From fe1ebda32184e5d1056169904c52e8b126744f4f Mon Sep 17 00:00:00 2001 From: Alfonso Noriega Date: Thu, 23 Jul 2026 20:28:33 +0200 Subject: [PATCH 1/5] Keep the installed Shopify skill up to date daily Add a 'shopify skill update' command wrapping 'skills update shopify --global --yes', which compares the recorded install hash in the skills lock file against the remote source tree and only rewrites the skill when it has changed. The prerun hook spawns it detached in the background at most once per day whenever the skill is installed, so an outdated local skill converges to the remote source within a day. Opt out with SHOPIFY_CLI_NO_SKILL_AUTO_UPDATE. Assisted-By: devx/8a5f8cc7-d76e-49ff-a591-4193978ea1b9 --- .changeset/skill-auto-update-daily-check.md | 6 ++ .../generated/generated_docs_data_v2.json | 20 ++++ .../cli-kit/src/public/node/hooks/prerun.ts | 2 + .../cli-kit/src/public/node/skills.test.ts | 83 ++++++++++++++++- packages/cli-kit/src/public/node/skills.ts | 92 ++++++++++++++++--- packages/cli/README.md | 16 ++++ packages/cli/oclif.manifest.json | 34 ++++++- .../cli/src/cli/commands/skill/update.test.ts | 31 +++++++ packages/cli/src/cli/commands/skill/update.ts | 29 ++++++ packages/cli/src/index.ts | 2 + packages/e2e/data/snapshots/commands.txt | 8 +- 11 files changed, 306 insertions(+), 17 deletions(-) create mode 100644 .changeset/skill-auto-update-daily-check.md create mode 100644 packages/cli/src/cli/commands/skill/update.test.ts create mode 100644 packages/cli/src/cli/commands/skill/update.ts diff --git a/.changeset/skill-auto-update-daily-check.md b/.changeset/skill-auto-update-daily-check.md new file mode 100644 index 00000000000..adc989391e0 --- /dev/null +++ b/.changeset/skill-auto-update-daily-check.md @@ -0,0 +1,6 @@ +--- +'@shopify/cli-kit': patch +'@shopify/cli': patch +--- + +Add `shopify skill update` and refresh the installed Shopify skill in the background when its source has changed diff --git a/docs-shopify.dev/generated/generated_docs_data_v2.json b/docs-shopify.dev/generated/generated_docs_data_v2.json index 5bbd950639d..7ad4fc52b35 100644 --- a/docs-shopify.dev/generated/generated_docs_data_v2.json +++ b/docs-shopify.dev/generated/generated_docs_data_v2.json @@ -4758,6 +4758,26 @@ "value": "export interface skillinstall {\n /**\n * The package manager to use to download the latest version of the skills CLI.\n * @environment SHOPIFY_FLAG_PACKAGE_MANAGER\n */\n '--package-manager '?: string\n}" } }, + "skillupdate": { + "docs-shopify.dev/commands/interfaces/skill-update.interface.ts": { + "filePath": "docs-shopify.dev/commands/interfaces/skill-update.interface.ts", + "name": "skillupdate", + "description": "The following flags are available for the `skill update` command:", + "isPublicDocs": true, + "members": [ + { + "filePath": "docs-shopify.dev/commands/interfaces/skill-update.interface.ts", + "syntaxKind": "PropertySignature", + "name": "--package-manager ", + "value": "string", + "description": "The package manager to use to download the latest version of the skills CLI.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_PACKAGE_MANAGER" + } + ], + "value": "export interface skillupdate {\n /**\n * The package manager to use to download the latest version of the skills CLI.\n * @environment SHOPIFY_FLAG_PACKAGE_MANAGER\n */\n '--package-manager '?: string\n}" + } + }, "storeauthlist": { "docs-shopify.dev/commands/interfaces/store-auth-list.interface.ts": { "filePath": "docs-shopify.dev/commands/interfaces/store-auth-list.interface.ts", diff --git a/packages/cli-kit/src/public/node/hooks/prerun.ts b/packages/cli-kit/src/public/node/hooks/prerun.ts index 8290f72ebda..8d65b830e7b 100644 --- a/packages/cli-kit/src/public/node/hooks/prerun.ts +++ b/packages/cli-kit/src/public/node/hooks/prerun.ts @@ -31,6 +31,8 @@ export const hook: Hook.Prerun = async (options) => { await analyticsMod.startAnalytics({commandContent, args, commandClass: options.Command}) notificationsMod.fetchNotificationsInBackground(options.Command.id) await skillsMod.promptShopifySkillInstallIfNeeded({currentCommand: options.Command.id, args}) + // eslint-disable-next-line no-void + void skillsMod.updateShopifySkillInBackground({currentCommand: options.Command.id}) } export function parseCommandContent(cmdInfo: {id: string; aliases: string[]; pluginAlias?: string}): CommandContent { diff --git a/packages/cli-kit/src/public/node/skills.test.ts b/packages/cli-kit/src/public/node/skills.test.ts index dbeb023b9c0..b7b9c1640e9 100644 --- a/packages/cli-kit/src/public/node/skills.test.ts +++ b/packages/cli-kit/src/public/node/skills.test.ts @@ -1,4 +1,4 @@ -import {promptShopifySkillInstallIfNeeded, shopifySkillIsInstalled} from './skills.js' +import {promptShopifySkillInstallIfNeeded, shopifySkillIsInstalled, updateShopifySkillInBackground} from './skills.js' import {inTemporaryDirectory, mkdir} from './fs.js' import {joinPath} from './path.js' import {exec, terminalSupportsPrompting} from './system.js' @@ -195,3 +195,84 @@ describe('promptShopifySkillInstallIfNeeded', () => { }) }) }) + +describe('updateShopifySkillInBackground', () => { + test('spawns a background skill update when the skill is installed', async () => { + await inTemporaryDirectory(async (cwd) => { + // Given + const config = new LocalStorage({cwd}) + await mkdir(joinPath(cwd, '.agents', 'skills', 'shopify')) + + // When + await updateShopifySkillInBackground({currentCommand: 'theme:list', argv, env, config, homeDir: cwd}) + + // Then + expect(exec).toHaveBeenCalledWith( + '/path/to/node', + ['/path/to/shopify', 'skill', 'update'], + expect.objectContaining({background: true}), + ) + }) + }) + + test('spawns at most one update within the daily interval', async () => { + await inTemporaryDirectory(async (cwd) => { + // Given + const config = new LocalStorage({cwd}) + await mkdir(joinPath(cwd, '.agents', 'skills', 'shopify')) + + // When + await updateShopifySkillInBackground({currentCommand: 'theme:list', argv, env, config, homeDir: cwd}) + await updateShopifySkillInBackground({currentCommand: 'theme:list', argv, env, config, homeDir: cwd}) + + // Then + expect(exec).toHaveBeenCalledTimes(1) + }) + }) + + test('does nothing when the skill is not installed', async () => { + await inTemporaryDirectory(async (cwd) => { + // Given + const config = new LocalStorage({cwd}) + + // When + await updateShopifySkillInBackground({currentCommand: 'theme:list', argv, env, config, homeDir: cwd}) + + // Then + expect(exec).not.toHaveBeenCalled() + }) + }) + + test('does nothing for skill commands', async () => { + await inTemporaryDirectory(async (cwd) => { + // Given + const config = new LocalStorage({cwd}) + await mkdir(joinPath(cwd, '.agents', 'skills', 'shopify')) + + // When + await updateShopifySkillInBackground({currentCommand: 'skill:update', argv, env, config, homeDir: cwd}) + + // Then + expect(exec).not.toHaveBeenCalled() + }) + }) + + test.each([ + ['CI', {...env, CI: '1'}], + ['SHOPIFY_UNIT_TEST', {SHOPIFY_UNIT_TEST: 'true'}], + ['SHOPIFY_CLI_NO_SKILL_AUTO_UPDATE', {...env, SHOPIFY_CLI_NO_SKILL_AUTO_UPDATE: '1'}], + ['SHOPIFY_CLI_ENV=development', {...env, SHOPIFY_CLI_ENV: 'development'}], + ])('does nothing when %s is set', async (_name, skipEnv) => { + await inTemporaryDirectory(async (cwd) => { + // Given + const config = new LocalStorage({cwd}) + await mkdir(joinPath(cwd, '.agents', 'skills', 'shopify')) + + // When + await updateShopifySkillInBackground({currentCommand: 'theme:list', argv, env: skipEnv, config, homeDir: cwd}) + + // Then + expect(exec).not.toHaveBeenCalled() + }) + }) +}) diff --git a/packages/cli-kit/src/public/node/skills.ts b/packages/cli-kit/src/public/node/skills.ts index 6afe4b6a2b8..7660936c0e0 100644 --- a/packages/cli-kit/src/public/node/skills.ts +++ b/packages/cli-kit/src/public/node/skills.ts @@ -57,6 +57,60 @@ export function shopifySkillIsInstalled(env: NodeJS.ProcessEnv = process.env, ho return skillDirectories.some(fileExistsSync) } +/** + * Options for {@link updateShopifySkillInBackground}. + */ +export interface UpdateShopifySkillInBackgroundOptions { + /** The command being run, used to avoid updating recursively from `skill` commands. */ + currentCommand: string + + /** The process argv, used to re-invoke the current CLI binary. */ + argv?: string[] + + /** The process environment. */ + env?: NodeJS.ProcessEnv + + /** The cli-kit local storage, injectable for testing. */ + config?: LocalStorage + + /** The user's home directory, injectable for testing. */ + homeDir?: string +} + +/** + * Keeps an installed Shopify skill up to date by running `shopify skill update` + * in a detached background process at most once per day. The skills CLI compares + * the recorded install hash against the remote source and only rewrites the + * skill when it has changed, so unchanged sources are a cheap no-op. + * + * Skipped when the skill is not installed (the install prompt owns that case), + * for `skill` commands (to avoid recursion), in CI, in unit tests, in development + * mode, and when `SHOPIFY_CLI_NO_SKILL_AUTO_UPDATE` is set. + * + * @param options - See {@link UpdateShopifySkillInBackgroundOptions}. + */ +export async function updateShopifySkillInBackground(options: UpdateShopifySkillInBackgroundOptions): Promise { + const {currentCommand, argv = process.argv, env = process.env, config, homeDir} = options + + if (skipSkillMaintenance(currentCommand, env)) return + if (isTruthy(env.SHOPIFY_CLI_NO_SKILL_AUTO_UPDATE)) return + if (!shopifySkillIsInstalled(env, homeDir)) return + + const nodeBinary = argv[0] + const shopifyBinary = argv[1] + if (!nodeBinary || !shopifyBinary) return + + // Check for skill updates at most once per day. + await runAtMinimumInterval( + 'skill-update', + {days: 1}, + async () => { + spawnShopifySkillCommandInBackground(nodeBinary, shopifyBinary, 'update', env) + }, + config, + ) +} + /** * Asks the user whether to install the Shopify skill for coding agents when it * isn't installed yet. Selecting yes runs `shopify skill install` in a detached @@ -104,16 +158,7 @@ export async function promptShopifySkillInstallIfNeeded(options: PromptShopifySk switch (choice) { case 'install': - // Run the Shopify command the same way as the current execution, detached - // from the current process so the CLI can exit before the install finishes. - // eslint-disable-next-line no-void - void exec(nodeBinary, [shopifyBinary, 'skill', 'install'], { - background: true, - env: {...env, SHOPIFY_CLI_NO_ANALYTICS: '1'}, - externalErrorHandler: async (error: unknown) => { - outputDebug(`Failed to install the Shopify skill in background: ${(error as Error).message}`) - }, - }) + spawnShopifySkillCommandInBackground(nodeBinary, shopifyBinary, 'install', env) outputInfo( 'Installing the Shopify skill in the background. Run `shopify skill install` to reinstall it at any time.', ) @@ -132,13 +177,32 @@ export async function promptShopifySkillInstallIfNeeded(options: PromptShopifySk function skipSkillInstallPrompt(currentCommand: string, args: string[], env: NodeJS.ProcessEnv): boolean { return ( - currentCommand.startsWith('skill') || + skipSkillMaintenance(currentCommand, env) || args.includes('--json') || isTruthy(env.SHOPIFY_FLAG_JSON) || - isTruthy(env.CI) || - isTruthy(env.SHOPIFY_UNIT_TEST) || isTruthy(env.SHOPIFY_CLI_NO_SKILL_INSTALL_PROMPT) || - isDevelopment(env) || !terminalSupportsPrompting() ) } + +function skipSkillMaintenance(currentCommand: string, env: NodeJS.ProcessEnv): boolean { + return currentCommand.startsWith('skill') || isTruthy(env.CI) || isTruthy(env.SHOPIFY_UNIT_TEST) || isDevelopment(env) +} + +// Runs a `shopify skill` subcommand the same way as the current execution, detached +// from the current process so the CLI can exit before it finishes. +function spawnShopifySkillCommandInBackground( + nodeBinary: string, + shopifyBinary: string, + subcommand: 'install' | 'update', + env: NodeJS.ProcessEnv, +): void { + // eslint-disable-next-line no-void + 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}`) + }, + }) +} diff --git a/packages/cli/README.md b/packages/cli/README.md index 867e214ae50..600e466f62f 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -80,6 +80,7 @@ * [`shopify plugins update`](#shopify-plugins-update) * [`shopify search [query]`](#shopify-search-query) * [`shopify skill install`](#shopify-skill-install) +* [`shopify skill update`](#shopify-skill-update) * [`shopify store auth`](#shopify-store-auth) * [`shopify store auth list`](#shopify-store-auth-list) * [`shopify store bulk cancel`](#shopify-store-bulk-cancel) @@ -3416,6 +3417,21 @@ FLAGS ``` +## `shopify skill update` + +Update the Shopify skill for coding agents when its source has changed. + +``` +USAGE + $ shopify skill update [--package-manager npm|pnpm|yarn|bun] + +FLAGS + --package-manager=