fix(sdk): decouple from build-from-source install, close #2441 #2453 (#2457)

* fix(sdk): decouple SDK from build-from-source install path, close #2441 and #2453

Ship sdk/dist prebuilt in the tarball and replace the npm-install-g
sub-install with a parent-package bin shim (bin/gsd-sdk.js). npm chmods
bin entries from a packed tarball correctly, eliminating the mode-644
failure (#2453) and the full class of NPM_CONFIG_PREFIX/ignore-scripts/
corepack/air-gapped failure modes that caused #2439 and #2441.

Changes:
- sdk/package.json: prepublishOnly runs `rm -rf dist && tsc && chmod +x
  dist/cli.js` (stale-build guard + execute-bit fix at publish time)
- package.json: add "gsd-sdk": "bin/gsd-sdk.js" bin entry; add sdk/dist
  to files so the prebuilt CLI ships in the tarball
- bin/gsd-sdk.js: new back-compat shim — resolves sdk/dist/cli.js relative
  to the package root and delegates via `node`, so all existing PATH call
  sites (slash commands, agents, hooks) continue to work unchanged (S1 shim)
- bin/install.js: replace installSdkIfNeeded() build-from-source + global-
  install dance with a dist-verify + chmod-in-place guard; delete
  resolveGsdSdk(), detectShellRc(), emitSdkFatal() helpers now unused
- .github/workflows/install-smoke.yml: add smoke-unpacked job that strips
  execute bit from sdk/dist/cli.js before install to reproduce the exact
  #2453 failure mode
- tests/bug-2441-sdk-decouple.test.cjs: new regression tests asserting all
  invariants (no npm install -g from sdk/, shim exists, sdk/dist in files,
  prepublishOnly has rm -rf + chmod)
- tests/bugs-1656-1657.test.cjs: update stale assertions that required
  build-from-source behavior (now asserts new prebuilt-dist invariants)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore(release): bump to 1.38.2, wire release.yml to build SDK dist

- Bump version 1.38.1 -> 1.38.2 for the #2441/#2453 fix shipped in 0f6903d.
- Add `build:sdk` script (`cd sdk && npm ci && npm run build`).
- `prepublishOnly` now runs hooks + SDK builds as a safety net.
- release.yml (rc + finalize): build SDK dist before `npm publish` so the
  published tarball always ships fresh `sdk/dist/` (kept gitignored).
- CHANGELOG: document 1.38.2 entry and `--sdk` flag semantics change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* ci: build SDK dist before tests and smoke jobs

sdk/dist/ is gitignored (built fresh at publish time via release.yml),
but both the test suite and install-smoke jobs run `bin/install.js`
or `npm pack` against the checked-out tree where dist doesn't exist yet.

- test.yml: `npm run build:sdk` before `npm run test:coverage`, so tests
  that spawn `bin/install.js` don't hit `installSdkIfNeeded()`'s fatal
  missing-dist check.
- install-smoke.yml (both smoke and smoke-unpacked): build SDK before
  pack/chmod so the published tarball contains dist and the unpacked
  install has a file to strip exec-bit from.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(sdk): lift SDK runtime deps to parent so tarball install can resolve them

The SDK's runtime deps (ws, @anthropic-ai/claude-agent-sdk) live in
sdk/package.json, but sdk/node_modules is NOT shipped in the parent
tarball — only sdk/dist, sdk/src, sdk/prompts, and sdk/package.json are.
When a user runs `npm install -g get-shit-done-cc`, npm installs the
parent's node_modules but never runs `npm install` inside the nested
sdk/ directory.

Result: `node sdk/dist/cli.js` fails with ERR_MODULE_NOT_FOUND for 'ws'.
The smoke tarball job caught this; the unpacked variant masked it
because `npm install -g <dir>` copies the entire workspace including
sdk/node_modules (left over from `npm run build:sdk`).

Fix: declare the same deps in the parent package.json so they land in
<pkg>/node_modules, which Node's resolution walks up to from
<pkg>/sdk/dist/cli.js. Keep them declared in sdk/package.json too so
the SDK remains a self-contained package for standalone dev.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(lockfile): regenerate package-lock.json cleanly

The previous `npm install` run left the lockfile internally inconsistent
(resolved esbuild@0.27.7 referenced but not fully written), causing
`npm ci` to fail in CI with "Missing from lock file" errors.

Clean regen via rm + npm install fixes all three failed jobs
(test, smoke, smoke-unpacked), which were all hitting the same
`npm ci` sync check.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(deps): remove unused esbuild + vitest from root devDependencies

Both were declared but never imported anywhere in the root package
(confirmed via grep of bin/, scripts/, tests/). They lived in sdk/
already, which is the only place they're actually used.

The transitive tree they pulled in (vitest → vite → esbuild 0.28 →
@esbuild/openharmony-arm64) was the root of the CI npm ci failures:
the openharmony platform package's `optional: true` flag was not being
applied correctly by npm 10 on Linux runners, causing EBADPLATFORM.

After removal: 800+ transitive packages → 155. Lockfile regenerated
cleanly. All 4170 tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(sdk): pretest:coverage builds sdk; tighten shim test assertions

Add "pretest:coverage": "npm run build:sdk" so npm run test:coverage
works in clean checkouts where sdk/dist/ hasn't been built yet.

Tighten the two loose shim assertions in bug-2441-sdk-decouple.test.cjs:
- forwards-to test now asserts path.resolve() is called with the
  'sdk','dist','cli.js' path segments, not just substring presence
- node-invocation test now asserts spawnSync(process.execPath, [...])
  pattern, ruling out matches in comments or the shebang line

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: address PR review — pretest:coverage + tighten shim tests

Review feedback from trek-e on PR 2457:

1. pretest:coverage + pretest hooks now run `npm run build:sdk` so
   `npm run test[:coverage]` in a clean checkout produces the required
   sdk/dist/ artifacts before running the installer-dependent tests.
   CI already does this explicitly; local contributors benefit.

2. Shim tests in bug-2441-sdk-decouple.test.cjs tightened from loose
   substring matches (which would pass on comments/shebangs alone) to
   regex assertions on the actual path.resolve call, spawnSync with
   process.execPath, process.argv.slice(2), and process.exit pattern.
   These now provide real regression protection for #2453-class bugs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: correct CHANGELOG entry and add [1.38.2] reference link

Two issues in the 1.38.2 CHANGELOG entry:
- installSdkIfNeeded() was described as deleted but it still exists in
  bin/install.js (repurposed to verify sdk/dist/cli.js and fix execute bit).
  Corrected the description to say 'repurposes' rather than 'deletes'.
- The reference-link block at the bottom of the file was missing a [1.38.2]
  compare URL and [Unreleased] still pointed to v1.37.1...HEAD. Added the
  [1.38.2] link and updated [Unreleased] to compare/v1.38.2...HEAD.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(sdk): double-cast WorkflowConfig to Record for strict tsc build

TypeScript error on main (introduced in #2611) blocks `npm run build`
in sdk/, which now runs as part of this PR's tarball build path. Apply
the double-cast via `unknown` as the compiler suggests.

Same fix as #2622; can be dropped if that lands first.

* test: remove bug-2598 test obsoleted by SDK decoupling

The bug-2598 test guards the Windows CVE-2024-27980 fix in the old
build-from-source path (npm spawnSync with shell:true + formatSpawnFailure
diagnostics). This PR removes that entire code path — installSdkIfNeeded
no longer spawns npm, it just verifies the prebuilt sdk/dist/cli.js
shipped in the tarball.

The test asserts `installSdkIfNeeded.toString()` contains a
formatSpawnFailure helper. After decoupling, no such helper exists
(nothing to format — there's no spawn). Keeping the test would assert
invariants of the rejected architecture.

The original #2598 defect (silent failure of npm spawn on Windows) is
structurally impossible in the shim path: bin/gsd-sdk.js invokes
`node sdk/dist/cli.js` directly via child_process.spawn with an
explicit argv array. No .cmd wrapper, no shell delegation.

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
This commit is contained in:
Jeremy McSpadden
2026-04-23 07:36:03 -05:00
committed by GitHub
parent a56707a07b
commit 0a049149e1
14 changed files with 1573 additions and 2677 deletions

32
bin/gsd-sdk.js Executable file
View File

@@ -0,0 +1,32 @@
#!/usr/bin/env node
/**
* bin/gsd-sdk.js — back-compat shim for external callers of `gsd-sdk`.
*
* When the parent package is installed globally (`npm install -g get-shit-done-cc`
* or `npx get-shit-done-cc`), npm creates a `gsd-sdk` symlink in the global bin
* directory pointing at this file. npm correctly chmods bin entries from a tarball,
* so the execute-bit problem that afflicted the sub-install approach (issue #2453)
* cannot occur here.
*
* This shim resolves sdk/dist/cli.js relative to its own location and delegates
* to it via `node`, so `gsd-sdk <args>` behaves identically to
* `node <packageDir>/sdk/dist/cli.js <args>`.
*
* Call sites (slash commands, agent prompts, hook scripts) continue to work without
* changes because `gsd-sdk` still resolves on PATH — it just comes from this shim
* in the parent package rather than from a separately installed @gsd-build/sdk.
*/
'use strict';
const path = require('path');
const { spawnSync } = require('child_process');
const cliPath = path.resolve(__dirname, '..', 'sdk', 'dist', 'cli.js');
const result = spawnSync(process.execPath, [cliPath, ...process.argv.slice(2)], {
stdio: 'inherit',
env: process.env,
});
process.exit(result.status ?? 1);

View File

@@ -6798,228 +6798,63 @@ function promptLocation(runtimes) {
}
/**
* Build `@gsd-build/sdk` from the in-repo `sdk/` source tree and install the
* resulting `gsd-sdk` binary globally so workflow commands that shell out to
* `gsd-sdk query …` succeed.
* Verify the prebuilt SDK dist is present and the gsd-sdk shim is wired up.
*
* We build from source rather than `npm install -g @gsd-build/sdk` because the
* npm-published package lags the source tree and shipping a stale SDK breaks
* every /gsd-* command that depends on newer query handlers.
* As of fix/2441-sdk-decouple, sdk/dist/ is shipped prebuilt inside the
* get-shit-done-cc npm tarball. The parent package declares a bin entry
* "gsd-sdk": "bin/gsd-sdk.js" so npm chmods the shim correctly when
* installing from a packed tarball — eliminating the mode-644 failure
* (issue #2453) and the build-from-source failure modes (#2439, #2441).
*
* Skip if --no-sdk. Skip if already on PATH (unless --sdk was explicit).
* Failures are FATAL — we exit non-zero so install does not complete with a
* silently broken SDK (issue #2439). Set GSD_ALLOW_OFF_PATH=1 to downgrade the
* post-install PATH verification to a warning (exit code 2) for users with an
* intentionally restricted PATH who will wire things up manually.
*/
/**
* Resolve `gsd-sdk` on PATH. Uses `command -v` via `sh -c` on POSIX (portable
* across sh/bash/zsh) and `where` on Windows. Returns trimmed path or null.
*/
function resolveGsdSdk() {
const { spawnSync } = require('child_process');
if (process.platform === 'win32') {
const r = spawnSync('where', ['gsd-sdk'], { encoding: 'utf-8' });
if (r.status === 0 && r.stdout && r.stdout.trim()) {
return r.stdout.trim().split('\n')[0].trim();
}
return null;
}
const r = spawnSync('sh', ['-c', 'command -v gsd-sdk'], { encoding: 'utf-8' });
if (r.status === 0 && r.stdout && r.stdout.trim()) {
return r.stdout.trim();
}
return null;
}
/**
* Best-effort detection of the user's shell rc file for PATH remediation hints.
*/
function detectShellRc() {
const path = require('path');
const shell = process.env.SHELL || '';
const home = process.env.HOME || '~';
if (/\/zsh$/.test(shell)) return { shell: 'zsh', rc: path.join(home, '.zshrc') };
if (/\/bash$/.test(shell)) return { shell: 'bash', rc: path.join(home, '.bashrc') };
if (/\/fish$/.test(shell)) return { shell: 'fish', rc: path.join(home, '.config', 'fish', 'config.fish') };
return { shell: 'sh', rc: path.join(home, '.profile') };
}
/**
* Emit a red fatal banner and exit. Prints actionable PATH remediation when
* the global install succeeded but the bin dir is not on PATH.
* This function verifies the invariant: sdk/dist/cli.js exists and is
* executable. If the execute bit is missing (possible in dev/clone setups
* where sdk/dist was committed without +x), we fix it in-place.
*
* If exitCode is 2, this is the "off-PATH" case and GSD_ALLOW_OFF_PATH respect
* is applied by the caller; we only print.
* --no-sdk skips the check entirely (back-compat).
* --sdk forces the check even if it would otherwise be skipped.
*/
function emitSdkFatal(reason, { globalBin, exitCode }) {
const { shell, rc } = detectShellRc();
const bar = '━'.repeat(72);
const redBold = `${red}${bold}`;
console.error('');
console.error(`${redBold}${bar}${reset}`);
console.error(`${redBold} ✗ GSD SDK install failed — /gsd-* commands will not work${reset}`);
console.error(`${redBold}${bar}${reset}`);
console.error(` ${red}Reason:${reset} ${reason}`);
if (globalBin) {
console.error('');
console.error(` ${yellow}gsd-sdk was installed to:${reset}`);
console.error(` ${cyan}${globalBin}${reset}`);
console.error('');
console.error(` ${yellow}Your shell's PATH does not include this directory.${reset}`);
console.error(` Add it by running:`);
if (shell === 'fish') {
console.error(` ${cyan}fish_add_path "${globalBin}"${reset}`);
console.error(` (or append to ${rc})`);
} else {
console.error(` ${cyan}echo 'export PATH="${globalBin}:$PATH"' >> ${rc}${reset}`);
console.error(` ${cyan}source ${rc}${reset}`);
}
console.error('');
console.error(` Then verify: ${cyan}command -v gsd-sdk${reset}`);
if (exitCode === 2) {
console.error('');
console.error(` ${dim}(GSD_ALLOW_OFF_PATH=1 set → exit ${exitCode} instead of hard failure)${reset}`);
}
} else {
console.error('');
console.error(` Build manually to retry:`);
console.error(` ${cyan}cd <install-dir>/sdk && npm install && npm run build && npm install -g .${reset}`);
}
console.error(`${redBold}${bar}${reset}`);
console.error('');
process.exit(exitCode);
}
function installSdkIfNeeded() {
if (hasNoSdk) {
console.log(`\n ${dim}Skipping GSD SDK install (--no-sdk)${reset}`);
console.log(`\n ${dim}Skipping GSD SDK check (--no-sdk)${reset}`);
return;
}
const { spawnSync } = require('child_process');
const path = require('path');
const fs = require('fs');
if (!hasSdk) {
const resolved = resolveGsdSdk();
if (resolved) {
console.log(` ${green}✓${reset} GSD SDK already installed (gsd-sdk on PATH at ${resolved})`);
return;
}
const sdkCliPath = path.resolve(__dirname, '..', 'sdk', 'dist', 'cli.js');
if (!fs.existsSync(sdkCliPath)) {
const bar = '━'.repeat(72);
const redBold = `${red}${bold}`;
console.error('');
console.error(`${redBold}${bar}${reset}`);
console.error(`${redBold} ✗ GSD SDK dist not found — /gsd-* commands will not work${reset}`);
console.error(`${redBold}${bar}${reset}`);
console.error(` ${red}Reason:${reset} sdk/dist/cli.js not found at ${sdkCliPath}`);
console.error('');
console.error(` This should not happen with a published tarball install.`);
console.error(` If you are running from a git clone, build the SDK first:`);
console.error(` ${cyan}cd sdk && npm install && npm run build${reset}`);
console.error(`${redBold}${bar}${reset}`);
console.error('');
process.exit(1);
}
// Locate the in-repo sdk/ directory relative to this installer file.
// For global npm installs this resolves inside the published package dir;
// for git-based installs (npx github:..., local clone) it resolves to the
// repo's sdk/ tree. Both contain the source tree because root package.json
// includes "sdk" in its `files` array.
const sdkDir = path.resolve(__dirname, '..', 'sdk');
const sdkPackageJson = path.join(sdkDir, 'package.json');
if (!fs.existsSync(sdkPackageJson)) {
emitSdkFatal(`SDK source tree not found at ${sdkDir}.`, { globalBin: null, exitCode: 1 });
}
console.log(`\n ${cyan}Building GSD SDK from source (${sdkDir})…${reset}`);
const npmCmd = process.platform === 'win32' ? 'npm.cmd' : 'npm';
// Windows: Node.js refuses to spawn .cmd/.bat files without `shell: true`
// after CVE-2024-27980 (fixed in Node ≥ 18.20.2 / ≥ 20.12.2 / ≥ 21.7.3).
// Without shell, spawnSync returns { status: null, error: EINVAL } and
// every `status !== 0` check trips — producing a silent build failure
// with no underlying diagnostic because stdio: 'inherit' never gets a
// child to stream (#2598).
const needsShell = process.platform === 'win32';
const spawnNpm = (args, opts = {}) =>
spawnSync(npmCmd, args, { ...opts, shell: opts.shell ?? needsShell });
// Format the underlying spawnSync failure so EINVAL / ENOENT / signal exits
// surface in the fatal banner instead of being swallowed. The #2598 silent
// failure happened precisely because `{ status: null, error: EINVAL }` was
// reduced to a generic "Failed to npm install" with no diagnostic — the real
// cause (CVE-2024-27980 on Windows) was invisible in the output.
const formatSpawnFailure = (result) => {
if (!result) return '';
if (result.error) return ` (${result.error.code || result.error.name || 'spawn error'}: ${result.error.message})`;
if (result.signal) return ` (signal: ${result.signal})`;
if (typeof result.status === 'number') return ` (exit status: ${result.status})`;
return '';
};
// 1. Install sdk build-time dependencies (tsc, etc.)
const installResult = spawnNpm(['install'], { cwd: sdkDir, stdio: 'inherit' });
if (installResult.status !== 0) {
emitSdkFatal(
`Failed to \`npm install\` in sdk/.${formatSpawnFailure(installResult)}`,
{ globalBin: null, exitCode: 1 },
);
}
// 2. Compile TypeScript → sdk/dist/
const buildResult = spawnNpm(['run', 'build'], { cwd: sdkDir, stdio: 'inherit' });
if (buildResult.status !== 0) {
emitSdkFatal(
`Failed to \`npm run build\` in sdk/.${formatSpawnFailure(buildResult)}`,
{ globalBin: null, exitCode: 1 },
);
}
// 3. Install the built package globally so `gsd-sdk` lands on PATH.
const globalResult = spawnNpm(['install', '-g', '.'], { cwd: sdkDir, stdio: 'inherit' });
if (globalResult.status !== 0) {
emitSdkFatal(
`Failed to \`npm install -g .\` from sdk/.${formatSpawnFailure(globalResult)}`,
{ globalBin: null, exitCode: 1 },
);
}
// 3a. Explicitly chmod dist/cli.js to 0o755 in the global install location.
// `tsc` emits files at process umask (typically 0o644 — non-executable), and
// `npm install -g` from a local directory does NOT chmod bin-script targets the
// way tarball extraction does. Without this, the `gsd-sdk` bin symlink points at
// a non-executable file and `command -v gsd-sdk` fails on every first install
// (root cause of #2453). Mirrors the pattern used for hook files in this installer.
// Ensure execute bit is set. tsc emits files at 0o644; git clone preserves
// whatever mode was committed. Fix in-place so node-invoked paths work too.
try {
const prefixRes = spawnNpm(['config', 'get', 'prefix'], { encoding: 'utf-8' });
if (prefixRes.status === 0) {
const npmPrefix = (prefixRes.stdout || '').trim();
const sdkPkg = JSON.parse(fs.readFileSync(path.join(sdkDir, 'package.json'), 'utf-8'));
const sdkName = sdkPkg.name; // '@gsd-build/sdk'
const globalModulesDir = process.platform === 'win32'
? path.join(npmPrefix, 'node_modules')
: path.join(npmPrefix, 'lib', 'node_modules');
const cliPath = path.join(globalModulesDir, sdkName, 'dist', 'cli.js');
try { fs.chmodSync(cliPath, 0o755); } catch (e) { if (process.platform !== 'win32') throw e; }
const stat = fs.statSync(sdkCliPath);
const isExecutable = !!(stat.mode & 0o111);
if (!isExecutable) {
fs.chmodSync(sdkCliPath, stat.mode | 0o111);
}
} catch (e) { /* Non-fatal: PATH verification in step 4 will catch any real failure */ }
// 4. Verify gsd-sdk is actually resolvable on PATH. npm's global bin dir is
// not always on the current shell's PATH (Homebrew prefixes, nvm setups,
// unconfigured npm prefix), so a zero exit status from `npm install -g`
// alone is not proof of a working binary (issue #2439 root cause).
const resolved = resolveGsdSdk();
if (resolved) {
console.log(` ${green}✓${reset} Built and installed GSD SDK from source (gsd-sdk resolved at ${resolved})`);
return;
} catch {
// Non-fatal: if chmod fails (e.g. read-only fs) the shim still works via
// `node sdkCliPath` invocation in bin/gsd-sdk.js.
}
// Off-PATH: resolve npm global bin dir for actionable remediation.
const prefixResult = spawnNpm(['config', 'get', 'prefix'], { encoding: 'utf-8' });
const prefix = prefixResult.status === 0 ? (prefixResult.stdout || '').trim() : null;
const globalBin = prefix
? (process.platform === 'win32' ? prefix : path.join(prefix, 'bin'))
: null;
const allowOffPath = process.env.GSD_ALLOW_OFF_PATH === '1';
emitSdkFatal(
'Built and installed GSD SDK, but `gsd-sdk` is not on your PATH.',
{ globalBin, exitCode: allowOffPath ? 2 : 1 },
);
console.log(` ${green}✓${reset} GSD SDK ready (sdk/dist/cli.js)`);
}
/**
@@ -7037,13 +6872,10 @@ function installAllRuntimes(runtimes, isGlobal, isInteractive) {
const primaryStatuslineResult = results.find(r => statuslineRuntimes.includes(r.runtime));
const finalize = (shouldInstallStatusline) => {
// Build @gsd-build/sdk from the in-repo sdk/ source and install it globally
// so `gsd-sdk` lands on PATH. Every /gsd-* command shells out to
// `gsd-sdk query …`; without this, commands fail with "command not found:
// gsd-sdk". The npm-published @gsd-build/sdk is kept intentionally frozen
// at an older version; we always build from source so users get the SDK
// that matches the installed GSD version.
// Runs by default; skip with --no-sdk. Idempotent when already present.
// Verify sdk/dist/cli.js is present and executable. The dist is shipped
// prebuilt in the tarball (fix/2441-sdk-decouple); gsd-sdk reaches users via
// the parent package's bin/gsd-sdk.js shim, so no sub-install is needed.
// Skip with --no-sdk.
installSdkIfNeeded();
const printSummaries = () => {