Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions packages/cli-kit/src/private/node/conf-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ export interface ConfSchema {
cache?: Cache
autoUpgradeEnabled?: boolean
skillInstallPromptDismissed?: boolean
skillUpdateAnnouncementPending?: boolean
}

let _instance: LocalStorage<ConfSchema> | undefined
Expand Down Expand Up @@ -306,6 +307,29 @@ export function setSkillInstallPromptDismissed(config: LocalStorage<ConfSchema>
config.set('skillInstallPromptDismissed', true)
}

/**
* Get whether a background Shopify skill update is waiting to be announced.
*
* @param config - The cli-kit local storage.
* @returns Whether an announcement is pending.
*/
export function getSkillUpdateAnnouncementPending(config: LocalStorage<ConfSchema> = cliKitStore()): boolean {
return config.get('skillUpdateAnnouncementPending') ?? false
}

/**
* Record whether a background Shopify skill update is waiting to be announced.
*
* @param pending - Whether an announcement is pending.
* @param config - The cli-kit local storage.
*/
export function setSkillUpdateAnnouncementPending(
pending: boolean,
config: LocalStorage<ConfSchema> = cliKitStore(),
): void {
config.set('skillUpdateAnnouncementPending', pending)
}

export function getConfigStoreForPartnerStatus() {
return new LocalStorage<Record<string, {status: true; checkedAt: string}>>({
projectName: 'shopify-cli-kit-partner-status',
Expand Down
1 change: 1 addition & 0 deletions packages/cli-kit/src/public/node/hooks/prerun.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export const hook: Hook.Prerun = async (options) => {
outputDebug(`Running command ${commandContent.command}`)
await analyticsMod.startAnalytics({commandContent, args, commandClass: options.Command})
notificationsMod.fetchNotificationsInBackground(options.Command.id)
skillsMod.announcePendingSkillUpdate()
await skillsMod.promptShopifySkillInstallIfNeeded({currentCommand: options.Command.id, args})
// eslint-disable-next-line no-void
void skillsMod.updateShopifySkillInBackground({currentCommand: options.Command.id})
Expand Down
71 changes: 69 additions & 2 deletions packages/cli-kit/src/public/node/skills.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
announcePendingSkillUpdate,
promptShopifySkillInstallIfNeeded,
shopifySkillIsInstalled,
updateShopifySkill,
Expand All @@ -8,7 +9,7 @@ import {inTemporaryDirectory, mkdir, readFile, writeFile} from './fs.js'
import {fetch, Response} from './http.js'
import {joinPath} from './path.js'
import {exec, terminalSupportsPrompting} from './system.js'
import {renderSelectPrompt} from './ui.js'
import {renderInfo, renderSelectPrompt} from './ui.js'
import {LocalStorage} from './local-storage.js'
import {ConfSchema} from '../../private/node/conf-store.js'
import {beforeEach, describe, expect, test, vi} from 'vitest'
Expand Down Expand Up @@ -114,6 +115,72 @@ describe('updateShopifySkill', () => {
await expect(updateShopifySkill({env, homeDir: cwd})).rejects.toThrow('Failed to check for Shopify skill updates')
})
})

test('records a pending announcement when updating with announceOnNextRun', async () => {
await inTemporaryDirectory(async (cwd) => {
// Given
const config = new LocalStorage<ConfSchema>({cwd})
await writeInstalledSkill(cwd, '# Old skill')
vi.mocked(fetch).mockResolvedValue(new Response('# New skill', {status: 200}))

// When
const result = await updateShopifySkill({announceOnNextRun: true, env, config, homeDir: cwd})

// Then
expect(result).toBe('updated')
expect(config.get('skillUpdateAnnouncementPending')).toBe(true)
})
})

test('does not record an announcement when nothing was updated', async () => {
await inTemporaryDirectory(async (cwd) => {
// Given
const config = new LocalStorage<ConfSchema>({cwd})
await writeInstalledSkill(cwd, '# Same skill')
vi.mocked(fetch).mockResolvedValue(new Response('# Same skill', {status: 200}))

// When
const result = await updateShopifySkill({announceOnNextRun: true, env, config, homeDir: cwd})

// Then
expect(result).toBe('already-up-to-date')
expect(config.get('skillUpdateAnnouncementPending')).toBeUndefined()
})
})
})

describe('announcePendingSkillUpdate', () => {
test('renders the announcement once and clears it', async () => {
await inTemporaryDirectory(async (cwd) => {
// Given
const config = new LocalStorage<ConfSchema>({cwd})
config.set('skillUpdateAnnouncementPending', true)

// When
announcePendingSkillUpdate(config)
announcePendingSkillUpdate(config)

// Then
expect(renderInfo).toHaveBeenCalledTimes(1)
expect(renderInfo).toHaveBeenCalledWith({
body: 'The Shopify skill for coding agents was updated to the latest version.',
})
expect(config.get('skillUpdateAnnouncementPending')).toBe(false)
})
})

test('does nothing when no announcement is pending', async () => {
await inTemporaryDirectory(async (cwd) => {
// Given
const config = new LocalStorage<ConfSchema>({cwd})

// When
announcePendingSkillUpdate(config)

// Then
expect(renderInfo).not.toHaveBeenCalled()
})
})
})

describe('promptShopifySkillInstallIfNeeded', () => {
Expand Down Expand Up @@ -283,7 +350,7 @@ describe('updateShopifySkillInBackground', () => {
// Then
expect(exec).toHaveBeenCalledWith(
'/path/to/node',
['/path/to/shopify', 'skill', 'update'],
['/path/to/shopify', 'skill', 'update', '--background'],
expect.objectContaining({background: true}),
)
})
Expand Down
36 changes: 29 additions & 7 deletions packages/cli-kit/src/public/node/skills.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,14 @@ import {fetch} from './http.js'
import {outputDebug, outputInfo} from './output.js'
import {joinPath} from './path.js'
import {exec, terminalSupportsPrompting} from './system.js'
import {renderSelectPrompt} from './ui.js'
import {renderInfo, renderSelectPrompt} from './ui.js'
import {LocalStorage} from './local-storage.js'
import {
ConfSchema,
getSkillInstallPromptDismissed,
setSkillInstallPromptDismissed,
getSkillUpdateAnnouncementPending,
setSkillUpdateAnnouncementPending,
runAtMinimumInterval,
} from '../../private/node/conf-store.js'

Expand Down Expand Up @@ -78,9 +80,15 @@ export type ShopifySkillUpdateResult = 'updated' | 'already-up-to-date' | 'not-i
* Options for {@link updateShopifySkill}.
*/
export interface UpdateShopifySkillOptions {
/** Whether to announce a performed update on the next CLI run instead of the current output. */
announceOnNextRun?: boolean

/** The process environment. */
env?: NodeJS.ProcessEnv

/** The cli-kit local storage, injectable for testing. */
config?: LocalStorage<ConfSchema>

/** The user's home directory, injectable for testing. */
homeDir?: string
}
Expand All @@ -97,7 +105,7 @@ export interface UpdateShopifySkillOptions {
* @returns The outcome of the update check.
*/
export async function updateShopifySkill(options: UpdateShopifySkillOptions = {}): Promise<ShopifySkillUpdateResult> {
const {env = process.env, homeDir = homeDirectory()} = options
const {announceOnNextRun = false, env = process.env, config, homeDir = homeDirectory()} = options

const skillPath = installedShopifySkillPath(env, homeDir)
if (!skillPath) return 'not-installed'
Expand All @@ -112,9 +120,23 @@ export async function updateShopifySkill(options: UpdateShopifySkillOptions = {}
if (localContent === remoteContent) return 'already-up-to-date'

await writeFile(skillPath, remoteContent)
if (announceOnNextRun) setSkillUpdateAnnouncementPending(true, config)
return 'updated'
}

/**
* Announces a Shopify skill update performed in the background, once, on the next
* CLI run. Background updates run detached with their output discarded, so this is
* how the user learns a new skill version was installed.
*
* @param config - The cli-kit local storage, injectable for testing.
*/
export function announcePendingSkillUpdate(config?: LocalStorage<ConfSchema>): void {
if (!getSkillUpdateAnnouncementPending(config)) return
setSkillUpdateAnnouncementPending(false, config)
renderInfo({body: 'The Shopify skill for coding agents was updated to the latest version.'})
}

/**
* Options for {@link updateShopifySkillInBackground}.
*/
Expand Down Expand Up @@ -163,7 +185,7 @@ export async function updateShopifySkillInBackground(options: UpdateShopifySkill
'skill-update',
{days: 1},
async () => {
spawnShopifySkillCommandInBackground(nodeBinary, shopifyBinary, 'update', env)
spawnShopifySkillCommandInBackground(nodeBinary, shopifyBinary, ['update', '--background'], env)
},
config,
)
Expand Down Expand Up @@ -216,7 +238,7 @@ export async function promptShopifySkillInstallIfNeeded(options: PromptShopifySk

switch (choice) {
case 'install':
spawnShopifySkillCommandInBackground(nodeBinary, shopifyBinary, 'install', env)
spawnShopifySkillCommandInBackground(nodeBinary, shopifyBinary, ['install'], env)
outputInfo(
'Installing the Shopify skill in the background. Run `shopify skill install` to reinstall it at any time.',
)
Expand Down Expand Up @@ -252,15 +274,15 @@ function skipSkillMaintenance(currentCommand: string, env: NodeJS.ProcessEnv): b
function spawnShopifySkillCommandInBackground(
nodeBinary: string,
shopifyBinary: string,
subcommand: 'install' | 'update',
subcommand: string[],
env: NodeJS.ProcessEnv,
): void {
// eslint-disable-next-line no-void
void exec(nodeBinary, [shopifyBinary, 'skill', subcommand], {
void exec(nodeBinary, [shopifyBinary, 'skill', ...subcommand], {
background: true,
env: {...env, SHOPIFY_CLI_NO_ANALYTICS: '1'},
externalErrorHandler: async (error: unknown) => {
outputDebug(`Failed to run skill ${subcommand} in background: ${(error as Error).message}`)
outputDebug(`Failed to run skill ${subcommand.join(' ')} in background: ${(error as Error).message}`)
},
})
}
8 changes: 8 additions & 0 deletions packages/cli/oclif.manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -6342,6 +6342,14 @@
},
"enableJsonFlag": false,
"flags": {
"background": {
"allowNo": false,
"description": "Announce a performed update on the next CLI run instead of the current output.",
"env": "SHOPIFY_FLAG_BACKGROUND",
"hidden": true,
"name": "background",
"type": "boolean"
}
},
"hasDynamicHelp": false,
"hiddenAliases": [
Expand Down
16 changes: 16 additions & 0 deletions packages/cli/src/cli/commands/skill/update.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,4 +36,20 @@ describe('skill update', () => {

expect(outputMock.info()).toContain('Run `shopify skill install` to install it.')
})

test('defers the announcement to the next run when running in background', async () => {
vi.mocked(updateShopifySkill).mockResolvedValue('updated')

await SkillUpdate.run(['--background'], import.meta.url)

expect(updateShopifySkill).toHaveBeenCalledWith({announceOnNextRun: true})
})

test('announces directly when running in the foreground', async () => {
vi.mocked(updateShopifySkill).mockResolvedValue('updated')

await SkillUpdate.run([], import.meta.url)

expect(updateShopifySkill).toHaveBeenCalledWith({announceOnNextRun: false})
})
})
13 changes: 12 additions & 1 deletion packages/cli/src/cli/commands/skill/update.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,23 @@
import Command from '@shopify/cli-kit/node/base-command'
import {updateShopifySkill} from '@shopify/cli-kit/node/skills'
import {outputInfo} from '@shopify/cli-kit/node/output'
import {Flags} from '@oclif/core'

export default class SkillUpdate extends Command {
static summary = 'Update the Shopify skill for coding agents when its source has changed.'

static flags = {
background: Flags.boolean({
description: 'Announce a performed update on the next CLI run instead of the current output.',
env: 'SHOPIFY_FLAG_BACKGROUND',
default: false,
hidden: true,
}),
}

async run(): Promise<void> {
const result = await updateShopifySkill()
const {flags} = await this.parse(SkillUpdate)
const result = await updateShopifySkill({announceOnNextRun: flags.background})

switch (result) {
case 'not-installed':
Expand Down
Loading