diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ee101e0eb..d405c2504 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -14,6 +14,8 @@ bun install # install dependencies bin/dev-setup # activate dev mode ``` +`./setup` on its own installs production dependencies only. `bun install` (and `bin/dev-setup`, which runs it) adds the dev toolchain the tests and evals need: the Claude Agent SDK, TypeScript and Prettier. A later `./setup` never removes them. + > **Full clone vs shallow.** The README's user-facing install uses `--depth 1` for speed. As a contributor, use a full clone (no `--depth` flag) — you'll need history for `git log`, `git blame`, `git bisect`, and reviewing PRs against earlier versions. If you already have a `--depth 1` clone from following the README, promote it to a full clone with `git fetch --unshallow`. ### First free check (no API key, no browser) diff --git a/bin/dev-setup b/bin/dev-setup index 4079e5cc4..f77026715 100755 --- a/bin/dev-setup +++ b/bin/dev-setup @@ -22,11 +22,11 @@ if [ ! -f "$REPO_ROOT/.env" ]; then fi fi -# 2. Install dependencies -if [ ! -d "$REPO_ROOT/node_modules" ]; then - echo "Installing dependencies..." - (cd "$REPO_ROOT" && bun install) -fi +# 2. Install dependencies, devDependencies included. Always run: a checkout +# first installed by ./setup has production dependencies only, and an +# up-to-date install is a no-op. +echo "Installing dependencies..." +(cd "$REPO_ROOT" && bun install) # 3. Create .claude/skills/ inside the repo mkdir -p "$REPO_ROOT/.claude/skills" diff --git a/bin/gstack-browser-ensure b/bin/gstack-browser-ensure index bda9718f5..90037fd6d 100755 --- a/bin/gstack-browser-ensure +++ b/bin/gstack-browser-ensure @@ -73,7 +73,7 @@ if [ ! -d "$ROOT/node_modules/playwright" ]; then [ "$CHECK" -eq 1 ] && fail "playwright is not installed under $ROOT/node_modules" "cd $ROOT && ./setup" command -v bun >/dev/null 2>&1 || fail "playwright is not installed under $ROOT/node_modules and bun is not on PATH" "install Bun, then cd $ROOT && ./setup" log "gstack-browser-ensure: installing node modules (bun install)..." - (cd "$ROOT" && { bun install --frozen-lockfile >/dev/null 2>&1 || bun install >/dev/null; }) || fail "bun install failed under $ROOT" "cd $ROOT && bun install, then re-run $0" + (cd "$ROOT" && { bun install --production --frozen-lockfile >/dev/null 2>&1 || bun install --production >/dev/null; }) || fail "bun install failed under $ROOT" "cd $ROOT && bun install, then re-run $0" fi probe diff --git a/bun.lock b/bun.lock index 1ae228532..dc2f348d0 100644 --- a/bun.lock +++ b/bun.lock @@ -5,6 +5,7 @@ "": { "name": "gstack", "dependencies": { + "@anthropic-ai/sdk": "^0.78.0", "@huggingface/transformers": "^4.3.1", "@ngrok/ngrok": "^1.7.0", "cross-spawn": "^7.0.6", @@ -15,15 +16,14 @@ "playwright": "^1.62.1", "sharp": "^0.35.4", "socks": "^2.8.10", + "xterm": "^5.3.0", + "xterm-addon-fit": "^0.8.0", }, "devDependencies": { "@anthropic-ai/claude-agent-sdk": "0.2.141", - "@anthropic-ai/sdk": "^0.78.0", "@types/bun": "1.4.3", "prettier": "3.9.10", "typescript": "7.0.2", - "xterm": "^5.3.0", - "xterm-addon-fit": "^0.8.0", }, }, }, diff --git a/package.json b/package.json index 0b396bbf1..214a8209f 100644 --- a/package.json +++ b/package.json @@ -69,6 +69,7 @@ "eval:bg:release": "bun run scripts/eval-bg.ts release" }, "dependencies": { + "@anthropic-ai/sdk": "^0.78.0", "@huggingface/transformers": "^4.3.1", "@ngrok/ngrok": "^1.7.0", "cross-spawn": "^7.0.6", @@ -78,7 +79,9 @@ "marked": "^18.1.1", "playwright": "^1.62.1", "sharp": "^0.35.4", - "socks": "^2.8.10" + "socks": "^2.8.10", + "xterm": "^5.3.0", + "xterm-addon-fit": "^0.8.0" }, "engines": { "bun": ">=1.4.2" @@ -95,12 +98,9 @@ ], "devDependencies": { "@anthropic-ai/claude-agent-sdk": "0.2.141", - "@anthropic-ai/sdk": "^0.78.0", "@types/bun": "1.4.3", "prettier": "3.9.10", - "typescript": "7.0.2", - "xterm": "^5.3.0", - "xterm-addon-fit": "^0.8.0" + "typescript": "7.0.2" }, "overrides": { "@modelcontextprotocol/sdk": "1.31.0", diff --git a/setup b/setup index 10c0ae56c..db0244fee 100755 --- a/setup +++ b/setup @@ -1711,6 +1711,15 @@ bun_cmd() { "$BUN_CMD" "$@" } +# Users get production dependencies only. devDependencies are the test and eval +# toolchain (the Claude Agent SDK's native binaries alone are ~440 MB on Linux), and +# nothing setup, the build or an installed skill runs needs them. Bun never +# prunes packages that are already installed, so a contributor's full +# `bun install` (CONTRIBUTING.md, bin/dev-setup, CI) survives a later ./setup. +install_runtime_deps() { + bun_cmd install --production --frozen-lockfile 2>/dev/null || bun_cmd install --production +} + cleanup_copied_bun() { local rc=$? if [ "${BUN_CMD_WAS_COPIED:-0}" -eq 1 ]; then @@ -1928,7 +1937,7 @@ if [ "$NEEDS_BUILD" -eq 1 ]; then log "Building browse binary..." ( cd "$SOURCE_GSTACK_DIR" - bun_cmd install --frozen-lockfile 2>/dev/null || bun_cmd install + install_runtime_deps if [ "$CSO_BUILD_AVAILABLE" -eq 0 ]; then export GSTACK_SETUP_SKIP_CSO_BUILD=1 fi @@ -2079,7 +2088,7 @@ if [ "$NEEDS_AGENTS_GEN" -eq 1 ]; then log "Generating .agents/ skill docs..." ( cd "$SOURCE_GSTACK_DIR" - bun_cmd install --frozen-lockfile 2>/dev/null || bun_cmd install + install_runtime_deps bun_cmd run gen:skill-docs --host codex --model "$CODEX_GENERATION_MODEL" ${_DISABLED_CSV:+"--disabled-skills=$_DISABLED_CSV"} ${_PLAN_MODE_ARG:+"$_PLAN_MODE_ARG"} ) _prune_stale_generated "$SOURCE_GSTACK_DIR" "$AGENTS_DIR" "$CODEX_SKILLS" @@ -2092,7 +2101,7 @@ if [ "$INSTALL_FACTORY" -eq 1 ] && { [ "$NEEDS_BUILD" -eq 0 ] || [ -n "$_PLAN_MO log "Generating .factory/ skill docs..." ( cd "$SOURCE_GSTACK_DIR" - bun_cmd install --frozen-lockfile 2>/dev/null || bun_cmd install + install_runtime_deps bun_cmd run gen:skill-docs --host factory ${_DISABLED_CSV:+"--disabled-skills=$_DISABLED_CSV"} ${_PLAN_MODE_ARG:+"$_PLAN_MODE_ARG"} ) fi @@ -2102,7 +2111,7 @@ if [ "$INSTALL_OPENCODE" -eq 1 ] && { [ "$NEEDS_BUILD" -eq 0 ] || [ -n "$_PLAN_M log "Generating .opencode/ skill docs..." ( cd "$SOURCE_GSTACK_DIR" - bun_cmd install --frozen-lockfile 2>/dev/null || bun_cmd install + install_runtime_deps bun_cmd run gen:skill-docs --host opencode ${_DISABLED_CSV:+"--disabled-skills=$_DISABLED_CSV"} ${_PLAN_MODE_ARG:+"$_PLAN_MODE_ARG"} ) fi @@ -2112,7 +2121,7 @@ if [ "$INSTALL_CURSOR" -eq 1 ] && { [ "$NEEDS_BUILD" -eq 0 ] || [ -n "$_PLAN_MOD log "Generating .cursor/ skill docs..." ( cd "$SOURCE_GSTACK_DIR" - bun_cmd install --frozen-lockfile 2>/dev/null || bun_cmd install + install_runtime_deps bun_cmd run gen:skill-docs --host cursor ${_DISABLED_CSV:+"--disabled-skills=$_DISABLED_CSV"} ${_PLAN_MODE_ARG:+"$_PLAN_MODE_ARG"} ) fi @@ -2122,7 +2131,7 @@ if [ "$INSTALL_COPILOT" -eq 1 ] && { [ "$NEEDS_BUILD" -eq 0 ] || [ -n "$_PLAN_MO log "Generating .copilot/ skill docs..." ( cd "$SOURCE_GSTACK_DIR" - bun_cmd install --frozen-lockfile 2>/dev/null || bun_cmd install + install_runtime_deps bun_cmd run gen:skill-docs --host copilot ${_DISABLED_CSV:+"--disabled-skills=$_DISABLED_CSV"} ${_PLAN_MODE_ARG:+"$_PLAN_MODE_ARG"} ) fi @@ -2132,7 +2141,7 @@ if [ "$INSTALL_AGY" -eq 1 ] && { [ "$NEEDS_BUILD" -eq 0 ] || [ -n "$_PLAN_MODE_A log "Generating .agy/ skill docs..." ( cd "$SOURCE_GSTACK_DIR" - bun_cmd install --frozen-lockfile 2>/dev/null || bun_cmd install + install_runtime_deps bun_cmd run gen:skill-docs --host agy ${_DISABLED_CSV:+"--disabled-skills=$_DISABLED_CSV"} ${_PLAN_MODE_ARG:+"$_PLAN_MODE_ARG"} ) fi diff --git a/test/helpers/claude-overlay-fixture.ts b/test/helpers/claude-overlay-fixture.ts index 17a3c32a0..ded474ef6 100644 --- a/test/helpers/claude-overlay-fixture.ts +++ b/test/helpers/claude-overlay-fixture.ts @@ -21,7 +21,7 @@ export const MARKER = 'Effort-match the step'; export function failableBun(f: Fixture) { put(join(f.commands, 'bun'), `#!/usr/bin/env bash case "$*" in - 'install --frozen-lockfile') exit 0 ;; + 'install --production --frozen-lockfile') exit 0 ;; 'run build') echo 'Unexpected build in an install fixture' >&2; exit 90 ;; *gen:skill-docs*--out-dir*) if [ -f "$HOME/fail-render" ]; then echo 'render failed (fixture)' >&2; exit 7; fi ;; esac diff --git a/test/helpers/install-fixture.ts b/test/helpers/install-fixture.ts index 7d25bee85..2c08c54b5 100644 --- a/test/helpers/install-fixture.ts +++ b/test/helpers/install-fixture.ts @@ -172,7 +172,7 @@ export function makeFixture(): Fixture { mkdirSync(home, { recursive: true }); put(join(commands, 'bun'), `#!/usr/bin/env bash case "$*" in - 'install --frozen-lockfile') exit 0 ;; + 'install --production --frozen-lockfile') exit 0 ;; 'run build') echo 'Unexpected build in an install fixture' >&2; exit 90 ;; *) exec ${quote(process.execPath)} "$@" ;; esac @@ -248,7 +248,7 @@ export function fixture(layout: string) { }; write(join(commands, 'bun'), `#!/usr/bin/env bash case "$*" in - 'install --frozen-lockfile') exit 0 ;; + 'install --production --frozen-lockfile') exit 0 ;; 'build --help') echo 'Fixture Bun has no CSO compile flags'; exit 0 ;; 'run build') echo 'Unexpected build in registration fixture' >&2; exit 90 ;; *) exec ${quote(process.execPath)} "$@" ;; diff --git a/test/setup-production-deps.test.ts b/test/setup-production-deps.test.ts new file mode 100644 index 000000000..cdcfdac31 --- /dev/null +++ b/test/setup-production-deps.test.ts @@ -0,0 +1,171 @@ +import { afterEach, describe, expect, test } from 'bun:test'; +import { spawnSync } from 'child_process'; +import { builtinModules } from 'module'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { runBashScript } from './helpers/bash-script'; + +// ./setup installs production dependencies only (GSTA-97). devDependencies are +// the test and eval toolchain, chiefly the Claude Agent SDK's per-platform +// native binaries (~440 MB on Linux). This file pins the two halves of that: +// every install setup runs passes --production (contributors and CI install +// the full set themselves), and nothing setup, the build or an installed skill +// loads is a devDependency. + +const ROOT = path.resolve(import.meta.dir, '..'); +const read = (rel: string) => fs.readFileSync(path.join(ROOT, rel), 'utf-8'); +const PKG = JSON.parse(read('package.json')); +const bases: string[] = []; +afterEach(() => { for (const b of bases.splice(0)) fs.rmSync(b, { recursive: true, force: true }); }); + +function tempBase(): string { + const base = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-prod-deps-')); + bases.push(base); + return base; +} + +/** A bash function sliced out of a script, by its `name() {` line through the closing `}`. */ +function sliceFunction(source: string, name: string): string { + const start = source.indexOf(`\n${name}() {\n`); + if (start === -1) throw new Error(`${name}() not found`); + const end = source.indexOf('\n}\n', start); + return source.slice(start + 1, end + 3); +} + +/** A bun that records each argv line; `install` with --frozen-lockfile exits $FROZEN_RC. */ +function stubBun(base: string): { bun: string; calls: () => string[] } { + const log = path.join(base, 'calls'); + const bun = path.join(base, 'bun'); + fs.writeFileSync(bun, `#!/usr/bin/env bash\necho "$*" >> ${JSON.stringify(log)}\ncase "$*" in *--frozen-lockfile*) exit "\${FROZEN_RC:-0}" ;; esac\nexit 0\n`, { mode: 0o755 }); + return { bun, calls: () => (fs.existsSync(log) ? fs.readFileSync(log, 'utf-8').trim().split('\n') : []) }; +} + +describe.skipIf(process.platform === 'win32')('setup installs production dependencies only', () => { + const setup = read('setup'); + const script = (bun: string, call: string) => + `set -e\nBUN_CMD=${JSON.stringify(bun)}\n${sliceFunction(setup, 'bun_cmd')}\n${sliceFunction(setup, 'install_runtime_deps')}\n${call}\n`; + + test('install_runtime_deps passes --production with the frozen lockfile, and keeps it on the fallback', () => { + const base = tempBase(); + const { bun, calls } = stubBun(base); + expect(runBashScript(script(bun, 'install_runtime_deps'), { env: { ...process.env, FROZEN_RC: '0' } }).status).toBe(0); + expect(calls()).toEqual(['install --production --frozen-lockfile']); + + const base2 = tempBase(); + const stub2 = stubBun(base2); + expect(runBashScript(script(stub2.bun, 'install_runtime_deps'), { env: { ...process.env, FROZEN_RC: '1' } }).status).toBe(0); + expect(stub2.calls()).toEqual(['install --production --frozen-lockfile', 'install --production']); + }); + + test('every bun install in setup goes through install_runtime_deps', () => { + const body = setup.replace(sliceFunction(setup, 'install_runtime_deps'), ''); + const direct = body.split('\n').filter((l) => /\b(bun_cmd|"\$BUN_CMD"|bun)\s+install\b/.test(l) && !l.trim().startsWith('#')); + expect(direct).toEqual([]); + // The build, the Codex render and each host render install first. + expect(setup.match(/^\s+install_runtime_deps$/gm)?.length).toBeGreaterThanOrEqual(7); + // Negative control: the pre-GSTA-97 line is caught by the same filter. + expect(/\b(bun_cmd|"\$BUN_CMD"|bun)\s+install\b/.test(' bun_cmd install --frozen-lockfile 2>/dev/null || bun_cmd install')).toBe(true); + }); + + test('gstack-browser-ensure installs production dependencies too', () => { + const line = read('bin/gstack-browser-ensure').split('\n').find((l) => /bun install/.test(l) && !l.trim().startsWith('#') && !/log /.test(l))!; + expect(line).toContain('bun install --production --frozen-lockfile'); + expect(line).toContain('|| bun install --production >/dev/null'); + }); + + test('bin/dev-setup installs the full set even over a production-only node_modules', () => { + const devSetup = read('bin/dev-setup'); + const start = devSetup.indexOf('# 2. Install dependencies'); + const block = devSetup.slice(start, devSetup.indexOf('# 3.', start)); + const base = tempBase(); + const { bun, calls } = stubBun(base); + fs.mkdirSync(path.join(base, 'node_modules')); + const r = runBashScript(`set -e\nREPO_ROOT=${JSON.stringify(base)}\nbun() { ${JSON.stringify(bun)} "$@"; }\n${block}`); + expect(r.status).toBe(0); + expect(calls()).toEqual(['install']); + }); +}); + +/** Bare package names a source file loads (import/require/dynamic import), type-only imports excluded. */ +export function importedPackages(text: string): string[] { + const out = new Set(); + const patterns = [ + /^\s*import\s+(?!type\b)[^'"\n]*?from\s+['"]([^'"]+)['"]/gm, + /^\s*import\s+['"]([^'"]+)['"]/gm, + /\brequire\(\s*['"]([^'"]+)['"]\s*\)/g, + /\bimport\(\s*['"]([^'"]+)['"]\s*\)/g, + ]; + for (const re of patterns) { + for (const m of text.matchAll(re)) { + const spec = m[1]; + if (spec.startsWith('.') || spec.startsWith('/') || spec.startsWith('node:') || spec === 'bun' || spec.startsWith('bun:')) continue; + const name = spec.startsWith('@') ? spec.split('/').slice(0, 2).join('/') : spec.split('/')[0]; + if (builtinModules.includes(name)) continue; + out.add(name); + } + } + return [...out]; +} + +/** Every shipped-runtime file that loads a package which is not a production dependency. */ +export function nonProductionImports(pkg: { dependencies?: Record }, files: Record): string[] { + const deps = pkg.dependencies ?? {}; + const bad: string[] = []; + for (const [file, text] of Object.entries(files)) { + for (const name of importedPackages(text)) if (!(name in deps)) bad.push(`${file}: ${name}`); + } + return bad.sort(); +} + +/** The code setup, the build and installed skills run: shipped dirs plus the scripts setup and the build call by path. */ +function runtimeFiles(): Record { + const roots = ['bin/', 'lib/', 'browse/src/', 'design/src/', 'make-pdf/src/', 'hosts/', 'extension/', 'scripts/resolvers/']; + const tracked = spawnSync('git', ['ls-files'], { cwd: ROOT, encoding: 'utf-8', timeout: 30_000 }).stdout + .split('\n').filter((rel) => roots.some((r) => rel.startsWith(r))); + const ownPackage = (rel: string) => rel.startsWith('lib/diagram-render/'); + const invoked = new Set(['scripts/gen-skill-docs.ts']); + for (const src of ['setup', 'scripts/build.sh']) for (const m of read(src).matchAll(/scripts\/[A-Za-z0-9_./-]+\.ts/g)) invoked.add(m[0]); + const files: Record = {}; + for (const rel of [...tracked, ...invoked]) { + if (ownPackage(rel) || /(^|\/)(test|node_modules|dist)\//.test(rel) || /\.(test\.ts|md|json|png|svg|css|html|c|h)$/.test(rel)) continue; + const abs = path.join(ROOT, rel); + if (!fs.existsSync(abs) || !fs.statSync(abs).isFile() || fs.statSync(abs).size > 2_000_000) continue; + const text = fs.readFileSync(abs, 'utf-8'); + if (text.includes('\u0000')) continue; + files[rel] = text; + } + return files; +} + +describe('setup and runtime need only production dependencies', () => { + test('no shipped code loads a devDependency or an undeclared package', () => { + const files = runtimeFiles(); + expect(Object.keys(files).length).toBeGreaterThan(300); + expect(nonProductionImports(PKG, files)).toEqual([]); + }); + + test('negative controls: a devDependency import and a moved-back runtime package are caught', () => { + expect(nonProductionImports(PKG, { 'bin/x': "import { query } from '@anthropic-ai/claude-agent-sdk';" })).toEqual(['bin/x: @anthropic-ai/claude-agent-sdk']); + expect(nonProductionImports(PKG, { 'bin/x': "import type { Q } from '@anthropic-ai/claude-agent-sdk';\nimport fs from 'node:fs';" })).toEqual([]); + const { ['@anthropic-ai/sdk']: _sdk, ...rest } = PKG.dependencies; + const files = runtimeFiles(); + expect(nonProductionImports({ dependencies: rest }, files)).toContain('bin/gstack-distill-free-text: @anthropic-ai/sdk'); + }); + + test('packages the build copies out of node_modules are production dependencies', () => { + const scripts: Record = PKG.scripts; + const run = [...read('scripts/build.sh').matchAll(/"\$BUN_CMD" run ([A-Za-z0-9:_-]+)/g)].map((m) => m[1]); + expect(run).toContain('vendor:xterm'); + const copied = run.flatMap((name) => [...(scripts[name] ?? '').matchAll(/node_modules\/((?:@[^/\s]+\/)?[^/\s]+)\//g)].map((m) => m[1])); + expect(copied.sort()).toEqual(['xterm', 'xterm', 'xterm-addon-fit']); + for (const name of copied) expect(PKG.dependencies).toHaveProperty([name]); + }); + + test('the dev toolchain stays out of the user install', () => { + for (const name of ['@anthropic-ai/claude-agent-sdk', 'typescript', 'prettier', '@types/bun']) { + expect(PKG.devDependencies).toHaveProperty([name]); + expect(PKG.dependencies).not.toHaveProperty([name]); + } + }); +}); diff --git a/test/team-mode.test.ts b/test/team-mode.test.ts index efd4ff0f0..ad36946a2 100644 --- a/test/team-mode.test.ts +++ b/test/team-mode.test.ts @@ -485,7 +485,7 @@ describe('setup --team / --no-team / -q', () => { fs.writeFileSync(path.join(commands, 'bun'), `#!/usr/bin/env bash [ "$1 $2" = '--no-env-file --config=/dev/null' ] && shift 2 case "$*" in - 'install --frozen-lockfile') exit 0 ;; + 'install --production --frozen-lockfile') exit 0 ;; 'build --help') echo 'Fixture Bun has no CSO compile flags'; exit 0 ;; *'/bin/gstack-migrate-claude-code --install-dir '*) [[ "$#" -eq 5 && "$2" = --install-dir && "$4" = --skills-dir ]] || exit 90