diff --git a/docs-shopify.dev/generated/generated_docs_data_v2.json b/docs-shopify.dev/generated/generated_docs_data_v2.json index 5bbd950639d..e72cafeb48b 100644 --- a/docs-shopify.dev/generated/generated_docs_data_v2.json +++ b/docs-shopify.dev/generated/generated_docs_data_v2.json @@ -4758,6 +4758,16 @@ "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": [], + "value": "export interface skillupdate {\n\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..3056e6d69d1 100644 --- a/packages/cli-kit/src/public/node/hooks/prerun.ts +++ b/packages/cli-kit/src/public/node/hooks/prerun.ts @@ -31,6 +31,7 @@ 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}) + await skillsMod.updateShopifySkillIfNeeded({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..11dc5b51a1d 100644 --- a/packages/cli-kit/src/public/node/skills.test.ts +++ b/packages/cli-kit/src/public/node/skills.test.ts @@ -1,14 +1,32 @@ -import {promptShopifySkillInstallIfNeeded, shopifySkillIsInstalled} from './skills.js' -import {inTemporaryDirectory, mkdir} from './fs.js' +import { + promptShopifySkillInstallIfNeeded, + shopifySkillIsInstalled, + updateShopifySkill, + updateShopifySkillIfNeeded, +} from './skills.js' +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' vi.mock('./system.js') vi.mock('./ui.js') +vi.mock('./http.js', async (importOriginal) => { + const original = await importOriginal() + return {...original, fetch: vi.fn()} +}) + +async function writeInstalledSkill(homeDir: string, content: string): Promise { + const skillDir = joinPath(homeDir, '.agents', 'skills', 'shopify') + await mkdir(skillDir) + const skillPath = joinPath(skillDir, 'SKILL.md') + await writeFile(skillPath, content) + return skillPath +} const argv = ['/path/to/node', '/path/to/shopify', 'theme', 'list'] const env = {SHOPIFY_UNIT_TEST: 'false'} @@ -26,7 +44,7 @@ describe('shopifySkillIsInstalled', () => { test('returns true when the skill is installed in the home agents directory', async () => { await inTemporaryDirectory(async (homeDir) => { - await mkdir(joinPath(homeDir, '.agents', 'skills', 'shopify')) + await writeInstalledSkill(homeDir, '# Shopify skill') expect(shopifySkillIsInstalled(env, homeDir)).toBe(true) }) @@ -35,13 +53,69 @@ describe('shopifySkillIsInstalled', () => { test('returns true when the skill is installed in the XDG config directory', async () => { await inTemporaryDirectory(async (homeDir) => { const xdgConfigHome = joinPath(homeDir, 'xdg-config') - await mkdir(joinPath(xdgConfigHome, 'agents', 'skills', 'shopify')) + const skillDir = joinPath(xdgConfigHome, 'agents', 'skills', 'shopify') + await mkdir(skillDir) + await writeFile(joinPath(skillDir, 'SKILL.md'), '# Shopify skill') expect(shopifySkillIsInstalled({...env, XDG_CONFIG_HOME: xdgConfigHome}, homeDir)).toBe(true) }) }) }) +describe('updateShopifySkill', () => { + test('returns not-installed without fetching when the skill is missing', async () => { + await inTemporaryDirectory(async (cwd) => { + // When + const result = await updateShopifySkill({env, homeDir: cwd}) + + // Then + expect(result).toBe('not-installed') + expect(fetch).not.toHaveBeenCalled() + }) + }) + + test('writes the remote content over the installed skill when the source changed', async () => { + await inTemporaryDirectory(async (cwd) => { + // Given + const skillPath = await writeInstalledSkill(cwd, '# Old skill') + vi.mocked(fetch).mockResolvedValue(new Response('# New skill', {status: 200})) + + // When + const result = await updateShopifySkill({env, homeDir: cwd}) + + // Then + expect(result).toBe('updated') + await expect(readFile(skillPath)).resolves.toBe('# New skill') + }) + }) + + test('returns already-up-to-date and leaves the skill untouched when the content matches', async () => { + await inTemporaryDirectory(async (cwd) => { + // Given + const skillPath = await writeInstalledSkill(cwd, '# Same skill') + vi.mocked(fetch).mockResolvedValue(new Response('# Same skill', {status: 200})) + + // When + const result = await updateShopifySkill({env, homeDir: cwd}) + + // Then + expect(result).toBe('already-up-to-date') + await expect(readFile(skillPath)).resolves.toBe('# Same skill') + }) + }) + + test('throws when the source responds with an error', async () => { + await inTemporaryDirectory(async (cwd) => { + // Given + await writeInstalledSkill(cwd, '# Local skill') + vi.mocked(fetch).mockResolvedValue(new Response(null, {status: 500, statusText: 'Internal Server Error'})) + + // When / Then + await expect(updateShopifySkill({env, homeDir: cwd})).rejects.toThrow('Failed to check for Shopify skill updates') + }) + }) +}) + describe('promptShopifySkillInstallIfNeeded', () => { test('spawns a background skill install when the user accepts', async () => { await inTemporaryDirectory(async (cwd) => { @@ -98,7 +172,7 @@ describe('promptShopifySkillInstallIfNeeded', () => { await inTemporaryDirectory(async (cwd) => { // Given const config = new LocalStorage({cwd}) - await mkdir(joinPath(cwd, '.agents', 'skills', 'shopify')) + await writeInstalledSkill(cwd, '# Shopify skill') // When await promptShopifySkillInstallIfNeeded({currentCommand: 'theme:list', argv, env, config, homeDir: cwd}) @@ -195,3 +269,117 @@ describe('promptShopifySkillInstallIfNeeded', () => { }) }) }) + +describe('updateShopifySkillIfNeeded', () => { + test('updates the skill inline and announces it immediately', async () => { + await inTemporaryDirectory(async (cwd) => { + // Given + const config = new LocalStorage({cwd}) + const skillPath = await writeInstalledSkill(cwd, '# Old skill') + vi.mocked(fetch).mockResolvedValue(new Response('# New skill', {status: 200})) + + // When + await updateShopifySkillIfNeeded({currentCommand: 'theme:list', env, config, homeDir: cwd}) + + // Then + await expect(readFile(skillPath)).resolves.toBe('# New skill') + expect(renderInfo).toHaveBeenCalledWith({ + body: 'The Shopify skill for coding agents was updated to the latest version.', + }) + }) + }) + + test('does not announce anything when the skill is already up to date', async () => { + await inTemporaryDirectory(async (cwd) => { + // Given + const config = new LocalStorage({cwd}) + await writeInstalledSkill(cwd, '# Same skill') + vi.mocked(fetch).mockResolvedValue(new Response('# Same skill', {status: 200})) + + // When + await updateShopifySkillIfNeeded({currentCommand: 'theme:list', env, config, homeDir: cwd}) + + // Then + expect(fetch).toHaveBeenCalled() + expect(renderInfo).not.toHaveBeenCalled() + }) + }) + + test('swallows fetch failures and leaves the skill untouched', async () => { + await inTemporaryDirectory(async (cwd) => { + // Given + const config = new LocalStorage({cwd}) + const skillPath = await writeInstalledSkill(cwd, '# Local skill') + vi.mocked(fetch).mockRejectedValue(new Error('network down')) + + // When + await updateShopifySkillIfNeeded({currentCommand: 'theme:list', env, config, homeDir: cwd}) + + // Then + await expect(readFile(skillPath)).resolves.toBe('# Local skill') + expect(renderInfo).not.toHaveBeenCalled() + }) + }) + + test('checks at most once within the daily interval', async () => { + await inTemporaryDirectory(async (cwd) => { + // Given + const config = new LocalStorage({cwd}) + await writeInstalledSkill(cwd, '# Shopify skill') + vi.mocked(fetch).mockResolvedValue(new Response('# Shopify skill', {status: 200})) + + // When + await updateShopifySkillIfNeeded({currentCommand: 'theme:list', env, config, homeDir: cwd}) + await updateShopifySkillIfNeeded({currentCommand: 'theme:list', env, config, homeDir: cwd}) + + // Then + expect(fetch).toHaveBeenCalledTimes(1) + }) + }) + + test('does nothing when the skill is not installed', async () => { + await inTemporaryDirectory(async (cwd) => { + // Given + const config = new LocalStorage({cwd}) + + // When + await updateShopifySkillIfNeeded({currentCommand: 'theme:list', env, config, homeDir: cwd}) + + // Then + expect(fetch).not.toHaveBeenCalled() + }) + }) + + test('does nothing for skill commands', async () => { + await inTemporaryDirectory(async (cwd) => { + // Given + const config = new LocalStorage({cwd}) + await writeInstalledSkill(cwd, '# Shopify skill') + + // When + await updateShopifySkillIfNeeded({currentCommand: 'skill:update', env, config, homeDir: cwd}) + + // Then + expect(fetch).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 writeInstalledSkill(cwd, '# Shopify skill') + + // When + await updateShopifySkillIfNeeded({currentCommand: 'theme:list', env: skipEnv, config, homeDir: cwd}) + + // Then + expect(fetch).not.toHaveBeenCalled() + }) + }) +}) diff --git a/packages/cli-kit/src/public/node/skills.ts b/packages/cli-kit/src/public/node/skills.ts index 6afe4b6a2b8..7fbfd65ccb8 100644 --- a/packages/cli-kit/src/public/node/skills.ts +++ b/packages/cli-kit/src/public/node/skills.ts @@ -1,10 +1,12 @@ import {homeDirectory, isDevelopment} from './context/local.js' import {isTruthy} from './context/utilities.js' -import {fileExistsSync} from './fs.js' +import {AbortError} from './error.js' +import {fileExistsSync, readFile, writeFile} from './fs.js' +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, @@ -15,6 +17,12 @@ import { type SkillInstallPromptChoice = 'install' | 'later' | 'never' +// eslint-disable-next-line no-warning-comments +// TODO: Point at the skill's final hosting URL (or the main branch) before merging. The +// shopify skill only exists on the feature/shopify-validate-command branch until PR #8142 lands. +const SHOPIFY_SKILL_URL = + 'https://raw.githubusercontent.com/Shopify/cli/feature/shopify-validate-command/.agents/skills/shopify/SKILL.md' + /** * Options for {@link promptShopifySkillInstallIfNeeded}. */ @@ -49,12 +57,125 @@ export interface PromptShopifySkillInstallOptions { * @returns Whether the Shopify skill is installed in any known global location. */ export function shopifySkillIsInstalled(env: NodeJS.ProcessEnv = process.env, homeDir = homeDirectory()): boolean { + return installedShopifySkillPath(env, homeDir) !== undefined +} + +function installedShopifySkillPath(env: NodeJS.ProcessEnv, homeDir: string): string | undefined { const configHome = env.XDG_CONFIG_HOME ?? joinPath(homeDir, '.config') - const skillDirectories = [ - joinPath(homeDir, '.agents', 'skills', 'shopify'), - joinPath(configHome, 'agents', 'skills', 'shopify'), + const skillPaths = [ + joinPath(homeDir, '.agents', 'skills', 'shopify', 'SKILL.md'), + joinPath(configHome, 'agents', 'skills', 'shopify', 'SKILL.md'), ] - return skillDirectories.some(fileExistsSync) + return skillPaths.find(fileExistsSync) +} + +/** + * The outcome of a Shopify skill update check. + */ +export type ShopifySkillUpdateResult = 'updated' | 'already-up-to-date' | 'not-installed' + +/** + * Options for {@link updateShopifySkill}. + */ +export interface UpdateShopifySkillOptions { + /** The process environment. */ + env?: NodeJS.ProcessEnv + + /** The user's home directory, injectable for testing. */ + homeDir?: string +} + +/** + * Updates the installed Shopify skill when its remote source has changed. + * + * Fetches the skill source and compares it with the installed universal skill + * file, writing it over only when the content differs. The installed file is + * the only state involved, and updating it propagates to every agent through + * the symlinks created at install time. + * + * @param options - See {@link UpdateShopifySkillOptions}. + * @returns The outcome of the update check. + */ +export async function updateShopifySkill(options: UpdateShopifySkillOptions = {}): Promise { + const {env = process.env, homeDir = homeDirectory()} = options + + const skillPath = installedShopifySkillPath(env, homeDir) + if (!skillPath) return 'not-installed' + + // Time-box the request tightly: this check runs before commands, so a slow or + // broken network must never noticeably delay whatever the user actually asked for. + const response = await fetch(SHOPIFY_SKILL_URL, undefined, { + useNetworkLevelRetry: false, + useAbortSignal: true, + timeoutMs: 3000, + }) + if (!response.ok) { + throw new AbortError(`Failed to check for Shopify skill updates: ${response.status} ${response.statusText}`) + } + + const remoteContent = await response.text() + const localContent = await readFile(skillPath) + if (localContent === remoteContent) return 'already-up-to-date' + + await writeFile(skillPath, remoteContent) + return 'updated' +} + +/** + * Options for {@link updateShopifySkillIfNeeded}. + */ +export interface UpdateShopifySkillIfNeededOptions { + /** The command being run, used to avoid updating recursively from `skill` commands. */ + currentCommand: 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, checking the remote source at most + * once per day. The check runs inline: a tightly time-boxed fetch compares the + * source with the installed skill, rewrites it when it changed, and announces the + * update immediately. Failures are logged and retried a day later. + * + * 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 UpdateShopifySkillIfNeededOptions}. + */ +export async function updateShopifySkillIfNeeded(options: UpdateShopifySkillIfNeededOptions): Promise { + const {currentCommand, 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 + + // Check for skill updates at most once per day. + await runAtMinimumInterval( + 'skill-update', + {days: 1}, + async () => { + try { + const result = await updateShopifySkill({env, homeDir}) + if (result === 'updated') { + renderInfo({body: 'The Shopify skill for coding agents was updated to the latest version.'}) + } + // A skill update must never break the command the user actually ran; failures + // are logged and naturally retried when the daily interval elapses. + // eslint-disable-next-line no-catch-all/no-catch-all + } catch (error) { + outputDebug(`Failed to update the Shopify skill: ${(error as Error).message}`) + } + }, + config, + ) } /** @@ -132,13 +253,14 @@ 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) +} diff --git a/packages/cli/README.md b/packages/cli/README.md index 867e214ae50..7bd4d0982df 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,15 @@ FLAGS ``` +## `shopify skill update` + +Update the Shopify skill for coding agents when its source has changed. + +``` +USAGE + $ shopify skill update +``` + ## `shopify store auth` Authenticate an app against a store for store commands. diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index 3c241c98165..6cca9e77d34 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -6335,6 +6335,24 @@ "strict": true, "summary": "Install the Shopify skill for coding agents." }, + "skill:update": { + "aliases": [ + ], + "args": { + }, + "enableJsonFlag": false, + "flags": { + }, + "hasDynamicHelp": false, + "hiddenAliases": [ + ], + "id": "skill:update", + "pluginAlias": "@shopify/cli", + "pluginName": "@shopify/cli", + "pluginType": "core", + "strict": true, + "summary": "Update the Shopify skill for coding agents when its source has changed." + }, "store:auth": { "aliases": [ ], @@ -10029,4 +10047,4 @@ } }, "version": "4.5.0" -} +} \ No newline at end of file diff --git a/packages/cli/src/cli/commands/skill/update.test.ts b/packages/cli/src/cli/commands/skill/update.test.ts new file mode 100644 index 00000000000..28106d9d51e --- /dev/null +++ b/packages/cli/src/cli/commands/skill/update.test.ts @@ -0,0 +1,39 @@ +import SkillUpdate from './update.js' +import {updateShopifySkill} from '@shopify/cli-kit/node/skills' +import {mockAndCaptureOutput} from '@shopify/cli-kit/node/testing/output' +import {afterEach, describe, expect, test, vi} from 'vitest' + +vi.mock('@shopify/cli-kit/node/skills') + +afterEach(() => { + mockAndCaptureOutput().clear() +}) + +describe('skill update', () => { + test('reports when the skill was updated', async () => { + const outputMock = mockAndCaptureOutput() + vi.mocked(updateShopifySkill).mockResolvedValue('updated') + + await SkillUpdate.run([], import.meta.url) + + expect(outputMock.info()).toContain('The Shopify skill was updated to the latest version.') + }) + + test('reports when the skill is already up to date', async () => { + const outputMock = mockAndCaptureOutput() + vi.mocked(updateShopifySkill).mockResolvedValue('already-up-to-date') + + await SkillUpdate.run([], import.meta.url) + + expect(outputMock.info()).toContain('The Shopify skill is already up to date.') + }) + + test('points to skill install when the skill is not installed', async () => { + const outputMock = mockAndCaptureOutput() + vi.mocked(updateShopifySkill).mockResolvedValue('not-installed') + + await SkillUpdate.run([], import.meta.url) + + expect(outputMock.info()).toContain('Run `shopify skill install` to install it.') + }) +}) diff --git a/packages/cli/src/cli/commands/skill/update.ts b/packages/cli/src/cli/commands/skill/update.ts new file mode 100644 index 00000000000..402ba1ce987 --- /dev/null +++ b/packages/cli/src/cli/commands/skill/update.ts @@ -0,0 +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' + +export default class SkillUpdate extends Command { + static summary = 'Update the Shopify skill for coding agents when its source has changed.' + + async run(): Promise { + const result = await updateShopifySkill() + + switch (result) { + case 'not-installed': + outputInfo('The Shopify skill is not installed. Run `shopify skill install` to install it.') + break + case 'already-up-to-date': + outputInfo('The Shopify skill is already up to date.') + break + case 'updated': + outputInfo('The Shopify skill was updated to the latest version.') + break + } + } +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index d6e8ae4eb84..6b3eecd7c48 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -25,6 +25,7 @@ import ValidateFunctions from './cli/commands/validate/functions.js' import ValidateGraphql from './cli/commands/validate/graphql.js' import ValidateTheme from './cli/commands/validate/theme.js' import SkillInstall from './cli/commands/skill/install.js' +import SkillUpdate from './cli/commands/skill/update.js' import {createGlobalProxyAgent} from 'global-agent' import StoreCommands from '@shopify/store' import ThemeCommands from '@shopify/theme' @@ -178,6 +179,7 @@ export const COMMANDS: any = { 'validate:graphql': ValidateGraphql, 'validate:theme': ValidateTheme, 'skill:install': SkillInstall, + 'skill:update': SkillUpdate, } export default runShopifyCLI diff --git a/packages/e2e/data/snapshots/commands.txt b/packages/e2e/data/snapshots/commands.txt index 4b1f198da71..497fa78254e 100644 --- a/packages/e2e/data/snapshots/commands.txt +++ b/packages/e2e/data/snapshots/commands.txt @@ -97,7 +97,8 @@ │ └─ update ├─ search ├─ skill -│ └─ install +│ ├─ install +│ └─ update ├─ store │ ├─ auth │ │ └─ list @@ -134,4 +135,9 @@ │ ├─ rename │ └─ share ├─ upgrade +├─ validate +│ ├─ components +│ ├─ functions +│ ├─ graphql +│ └─ theme └─ version