diff --git a/src/resources/extensions/gsd/commands-handlers.ts b/src/resources/extensions/gsd/commands-handlers.ts index 25074d634..0272ef289 100644 --- a/src/resources/extensions/gsd/commands-handlers.ts +++ b/src/resources/extensions/gsd/commands-handlers.ts @@ -28,6 +28,11 @@ import { loadPrompt } from "./prompt-loader.js"; const UPDATE_REGISTRY_URL = "https://registry.npmjs.org/gsd-pi/latest"; const UPDATE_FETCH_TIMEOUT_MS = 5000; +function resolveInstallCommand(pkg: string): string { + if ('bun' in process.versions) return `bun add -g ${pkg}`; + return `npm install -g ${pkg}`; +} + async function fetchLatestVersionForCommand(): Promise { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), UPDATE_FETCH_TIMEOUT_MS); @@ -431,8 +436,9 @@ export async function handleUpdate(ctx: ExtensionCommandContext): Promise ctx.ui.notify(`Updating: v${current} → v${latest}...`, "info"); + const installCmd = resolveInstallCommand(`${NPM_PACKAGE}@latest`); try { - execSync(`npm install -g ${NPM_PACKAGE}@latest`, { + execSync(installCmd, { stdio: ["ignore", "pipe", "ignore"], }); ctx.ui.notify( @@ -441,7 +447,7 @@ export async function handleUpdate(ctx: ExtensionCommandContext): Promise ); } catch { ctx.ui.notify( - `Update failed. Try manually: npm install -g ${NPM_PACKAGE}@latest`, + `Update failed. Try manually: ${installCmd}`, "error", ); } diff --git a/src/tests/cross-platform-filesystem-safety.test.ts b/src/tests/cross-platform-filesystem-safety.test.ts index 84e5b2790..0125c13f5 100644 --- a/src/tests/cross-platform-filesystem-safety.test.ts +++ b/src/tests/cross-platform-filesystem-safety.test.ts @@ -77,13 +77,9 @@ const ALLOW_HARDCODED_TMP: Array<[string, string]> = [ /** Pattern 4 — shell commands with interpolated variables */ const ALLOW_SHELL_INTERPOLATION: Array<[string, string]> = [ - // NPM_PACKAGE is a compile-time constant ('gsd-pi'), not user input. - ["update-cmd.ts", "npm view ${NPM_PACKAGE}"], - ["update-cmd.ts", "npm install -g ${NPM_PACKAGE}"], - ["update-check.ts", "npm install -g ${NPM_PACKAGE_NAME}"], - // Same constant forwarded through commands-handlers. - ["resources/extensions/gsd/commands-handlers.ts", "npm view ${NPM_PACKAGE}"], - ["resources/extensions/gsd/commands-handlers.ts", "npm install -g ${NPM_PACKAGE}"], + // update-cmd.ts, update-check.ts, and commands-handlers.ts all pass a + // pre-built variable (installCmd) to execSync — no template literal inside + // the execSync call, so no entries are needed here. ]; function isAllowlisted( diff --git a/src/tests/update-cmd-diagnostics.test.ts b/src/tests/update-cmd-diagnostics.test.ts index 8f3c5c088..21bb2981b 100644 --- a/src/tests/update-cmd-diagnostics.test.ts +++ b/src/tests/update-cmd-diagnostics.test.ts @@ -1,6 +1,7 @@ /** * Regression test for #3445: gsd update must print both current and latest * versions for diagnostics, and bypass npm cache. + * Regression test for #4145: gsd update must use bun when installed via Bun. */ import { test } from "node:test"; import assert from "node:assert/strict"; @@ -32,3 +33,52 @@ test("update commands use the registry fetch helper instead of npm view (#3806)" ); assert.ok(!handlerSrc.includes("npm view "), "/gsd update should no longer shell out to npm view"); }); + +test("update-check exports resolveInstallCommand (#4145)", async () => { + const { resolveInstallCommand } = await import("../update-check.js"); + assert.equal(typeof resolveInstallCommand, "function", "resolveInstallCommand must be exported from update-check"); +}); + +test("resolveInstallCommand returns bun command when running under Bun (#4145)", async () => { + const { resolveInstallCommand } = await import("../update-check.js"); + const orig = (process.versions as Record).bun; + try { + (process.versions as Record).bun = "1.0.0"; + assert.equal(resolveInstallCommand("gsd-pi@latest"), "bun add -g gsd-pi@latest"); + } finally { + if (orig === undefined) { + delete (process.versions as Record).bun; + } else { + (process.versions as Record).bun = orig; + } + } +}); + +test("resolveInstallCommand returns npm command when not running under Bun (#4145)", async () => { + const { resolveInstallCommand } = await import("../update-check.js"); + const orig = (process.versions as Record).bun; + try { + delete (process.versions as Record).bun; + assert.equal(resolveInstallCommand("gsd-pi@latest"), "npm install -g gsd-pi@latest"); + } finally { + if (orig !== undefined) { + (process.versions as Record).bun = orig; + } + } +}); + +test("update-cmd uses resolveInstallCommand instead of hardcoded npm (#4145)", () => { + const src = readFileSync(join(__dirname, "..", "update-cmd.ts"), "utf-8"); + assert.ok( + src.includes("resolveInstallCommand"), + "update-cmd should use resolveInstallCommand for package manager detection", + ); +}); + +test("commands-handlers uses resolveInstallCommand instead of hardcoded npm (#4145)", () => { + const handlerSrc = readFileSync(join(__dirname, "..", "resources", "extensions", "gsd", "commands-handlers.ts"), "utf-8"); + assert.ok( + handlerSrc.includes("resolveInstallCommand"), + "/gsd update handler should use resolveInstallCommand for package manager detection", + ); +}); diff --git a/src/update-check.ts b/src/update-check.ts index d560c318b..204197c43 100644 --- a/src/update-check.ts +++ b/src/update-check.ts @@ -74,10 +74,18 @@ export async function fetchLatestVersionFromRegistry( } } +export function resolveInstallCommand(pkg: string): string { + if ('bun' in process.versions) { + return `bun add -g ${pkg}` + } + return `npm install -g ${pkg}` +} + function printUpdateBanner(current: string, latest: string): void { + const installCmd = resolveInstallCommand('gsd-pi') process.stderr.write( ` ${chalk.yellow('Update available:')} ${chalk.dim(`v${current}`)} → ${chalk.bold(`v${latest}`)}\n` + - ` ${chalk.dim('Run')} npm update -g gsd-pi ${chalk.dim('or')} /gsd update ${chalk.dim('to upgrade')}\n\n`, + ` ${chalk.dim('Run')} ${installCmd} ${chalk.dim('or')} /gsd update ${chalk.dim('to upgrade')}\n\n`, ) } @@ -184,7 +192,7 @@ export async function checkAndPromptForUpdates(options: UpdateCheckOptions = {}) const choice = await new Promise((resolve) => { process.stderr.write( - ` ${chalk.bold('[1]')} Update now ${chalk.dim(`npm install -g ${NPM_PACKAGE_NAME}@latest`)}\n` + + ` ${chalk.bold('[1]')} Update now ${chalk.dim(resolveInstallCommand(`${NPM_PACKAGE_NAME}@latest`))}\n` + ` ${chalk.bold('[2]')} Skip\n\n`, ) @@ -210,13 +218,14 @@ export async function checkAndPromptForUpdates(options: UpdateCheckOptions = {}) process.stdin.pause() if (choice === '1') { - process.stderr.write(`\n ${chalk.dim('Running:')} npm install -g ${NPM_PACKAGE_NAME}@latest\n\n`) + const installCmd = resolveInstallCommand(`${NPM_PACKAGE_NAME}@latest`) + process.stderr.write(`\n ${chalk.dim('Running:')} ${installCmd}\n\n`) try { - execSync(`npm install -g ${NPM_PACKAGE_NAME}@latest`, { stdio: 'inherit' }) + execSync(installCmd, { stdio: 'inherit' }) process.stderr.write(`\n ${chalk.green.bold(`✓ Updated to v${latestVersion}`)}\n\n`) return true } catch { - process.stderr.write(`\n ${chalk.yellow(`Update failed. You can run: npm install -g ${NPM_PACKAGE_NAME}@latest`)}\n\n`) + process.stderr.write(`\n ${chalk.yellow(`Update failed. You can run: ${installCmd}`)}\n\n`) } } else { process.stderr.write(` ${chalk.dim('Skipped. Run')} gsd update ${chalk.dim('anytime to upgrade.')}\n\n`) diff --git a/src/update-cmd.ts b/src/update-cmd.ts index 18dcd0c48..20d2ba3dc 100644 --- a/src/update-cmd.ts +++ b/src/update-cmd.ts @@ -1,5 +1,5 @@ import { execSync } from 'node:child_process' -import { compareSemver, fetchLatestVersionFromRegistry } from './update-check.js' +import { compareSemver, fetchLatestVersionFromRegistry, resolveInstallCommand } from './update-check.js' const NPM_PACKAGE = 'gsd-pi' @@ -29,13 +29,14 @@ export async function runUpdate(): Promise { process.stdout.write(`${dim}Updating:${reset} v${current} → ${bold}v${latest}${reset}\n`) + const installCmd = resolveInstallCommand(`${NPM_PACKAGE}@latest`) try { - execSync(`npm install -g ${NPM_PACKAGE}@latest`, { + execSync(installCmd, { stdio: 'inherit', }) process.stdout.write(`\n${green}${bold}Updated to v${latest}${reset}\n`) } catch { - process.stderr.write(`\n${yellow}Update failed. Try manually: npm install -g ${NPM_PACKAGE}@latest${reset}\n`) + process.stderr.write(`\n${yellow}Update failed. Try manually: ${installCmd}${reset}\n`) process.exit(1) } }