From 747c1c14a0b45d8750061e513545b9131e58ca69 Mon Sep 17 00:00:00 2001 From: Alfonso Noriega Date: Thu, 23 Jul 2026 21:23:22 +0200 Subject: [PATCH] Announce background skill updates on the next CLI run Background skill updates run detached with their output discarded, so a successful update was invisible. The background spawn now passes a hidden --background flag to skill update, which records a pending announcement instead of printing; the prerun hook renders it once as an info banner on the next command and clears it. Foreground skill update runs keep printing directly. Assisted-By: devx/8a5f8cc7-d76e-49ff-a591-4193978ea1b9 --- .../cli-kit/src/private/node/conf-store.ts | 24 +++++++ .../cli-kit/src/public/node/hooks/prerun.ts | 1 + .../cli-kit/src/public/node/skills.test.ts | 71 ++++++++++++++++++- packages/cli-kit/src/public/node/skills.ts | 36 ++++++++-- packages/cli/oclif.manifest.json | 8 +++ .../cli/src/cli/commands/skill/update.test.ts | 16 +++++ packages/cli/src/cli/commands/skill/update.ts | 13 +++- 7 files changed, 159 insertions(+), 10 deletions(-) diff --git a/packages/cli-kit/src/private/node/conf-store.ts b/packages/cli-kit/src/private/node/conf-store.ts index f07b1c97b85..11e96f65f21 100644 --- a/packages/cli-kit/src/private/node/conf-store.ts +++ b/packages/cli-kit/src/private/node/conf-store.ts @@ -34,6 +34,7 @@ export interface ConfSchema { cache?: Cache autoUpgradeEnabled?: boolean skillInstallPromptDismissed?: boolean + skillUpdateAnnouncementPending?: boolean } let _instance: LocalStorage | undefined @@ -306,6 +307,29 @@ export function setSkillInstallPromptDismissed(config: LocalStorage 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 = 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 = cliKitStore(), +): void { + config.set('skillUpdateAnnouncementPending', pending) +} + export function getConfigStoreForPartnerStatus() { return new LocalStorage>({ projectName: 'shopify-cli-kit-partner-status', diff --git a/packages/cli-kit/src/public/node/hooks/prerun.ts b/packages/cli-kit/src/public/node/hooks/prerun.ts index 8d65b830e7b..6de6be2c868 100644 --- a/packages/cli-kit/src/public/node/hooks/prerun.ts +++ b/packages/cli-kit/src/public/node/hooks/prerun.ts @@ -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}) diff --git a/packages/cli-kit/src/public/node/skills.test.ts b/packages/cli-kit/src/public/node/skills.test.ts index 272a899bd24..2d0a73a67df 100644 --- a/packages/cli-kit/src/public/node/skills.test.ts +++ b/packages/cli-kit/src/public/node/skills.test.ts @@ -1,4 +1,5 @@ import { + announcePendingSkillUpdate, promptShopifySkillInstallIfNeeded, shopifySkillIsInstalled, updateShopifySkill, @@ -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' @@ -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({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({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({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({cwd}) + + // When + announcePendingSkillUpdate(config) + + // Then + expect(renderInfo).not.toHaveBeenCalled() + }) + }) }) describe('promptShopifySkillInstallIfNeeded', () => { @@ -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}), ) }) diff --git a/packages/cli-kit/src/public/node/skills.ts b/packages/cli-kit/src/public/node/skills.ts index b3d3009526b..aee8e5b6bb2 100644 --- a/packages/cli-kit/src/public/node/skills.ts +++ b/packages/cli-kit/src/public/node/skills.ts @@ -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' @@ -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 + /** The user's home directory, injectable for testing. */ homeDir?: string } @@ -97,7 +105,7 @@ export interface UpdateShopifySkillOptions { * @returns The outcome of the update check. */ export async function updateShopifySkill(options: UpdateShopifySkillOptions = {}): Promise { - 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' @@ -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): 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}. */ @@ -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, ) @@ -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.', ) @@ -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}`) }, }) } diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index 6cca9e77d34..ab35bfc4a04 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -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": [ diff --git a/packages/cli/src/cli/commands/skill/update.test.ts b/packages/cli/src/cli/commands/skill/update.test.ts index 28106d9d51e..fd178fb697e 100644 --- a/packages/cli/src/cli/commands/skill/update.test.ts +++ b/packages/cli/src/cli/commands/skill/update.test.ts @@ -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}) + }) }) diff --git a/packages/cli/src/cli/commands/skill/update.ts b/packages/cli/src/cli/commands/skill/update.ts index 402ba1ce987..fac5db78111 100644 --- a/packages/cli/src/cli/commands/skill/update.ts +++ b/packages/cli/src/cli/commands/skill/update.ts @@ -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 { - const result = await updateShopifySkill() + const {flags} = await this.parse(SkillUpdate) + const result = await updateShopifySkill({announceOnNextRun: flags.background}) switch (result) { case 'not-installed':