diff --git a/.squad/agents/fenster/history.md b/.squad/agents/fenster/history.md index 32fc2f519..2fd4bc91c 100644 --- a/.squad/agents/fenster/history.md +++ b/.squad/agents/fenster/history.md @@ -749,3 +749,20 @@ Fixed 4 runtime bugs: - The @github/copilot SDK bundles node:sqlite imports in its minified output. Cannot fix at source — pre-flight checks with clear messages are the right pattern. - resolveSquad() returns the .squad/ directory path itself, not the parent. Callers must not re-join with '.squad'. - Ink re-renders on every React state change. Multiple high-frequency animation timers compound into excessive redraws. Keep intervals ≥120ms for animations, ≥1000ms for counters. +## Phase 3 CLI Config Fixes — 2026-03-07 + +**PR #233 (squad/phase3-cli-config → dev)** + +Fixed 4 bugs in a single branch: + +1. **#226 — aspire not wired:** Added routing block + help text for squad aspire in cli-entry.ts. The command existed (175 lines) but was never connected to the router. +2. **#229 — doctor not exported:** CLI routing was present but unDoctor/doctorCommand were not exported from the barrel. Added exports to cli/index.ts for SDK consumers. +3. **#201 — workflows opt-in:** FRAMEWORK_WORKFLOWS filter was already in place. Added --no-workflows flag to squad init and threaded includeWorkflows through RunInitOptions. +4. **#202 — config.json gitignore:** Both link.ts and init-remote.ts now auto-append .squad/config.json to .gitignore after writing config, using the same pattern as orchestration-log entries. + +## Learnings +- CLI command wiring requires both a routing block in cli-entry.ts AND help text in the help section. Easy to miss one. +- The .gitignore append pattern (check exists, check includes, append with header comment) is reusable across init, link, and init-remote. +- FRAMEWORK_WORKFLOWS already filters init to 4 safe workflows; the opt-in flag is about user control, not safety. +- `process.env.NODE_NO_WARNINGS = '1'` set at runtime does NOT suppress Node.js ExperimentalWarning -- the env var is only checked at process start. Use a `process.emit` override to filter warnings at runtime. +- CI failures in --version tests were caused by `node:sqlite` ExperimentalWarning leaking into terminal output (3 lines instead of 1). Fixed with process.emit hook in cli-entry.ts. \ No newline at end of file diff --git a/.squad/decisions/inbox/fenster-init-workflows-opt-in.md b/.squad/decisions/inbox/fenster-init-workflows-opt-in.md new file mode 100644 index 000000000..0566f40ef --- /dev/null +++ b/.squad/decisions/inbox/fenster-init-workflows-opt-in.md @@ -0,0 +1,5 @@ +### 2026-03-07: squad init --no-workflows flag for opt-in workflow installation +**By:** Fenster (Core Dev) +**What:** `squad init` now accepts `--no-workflows` to skip GitHub workflow installation. Default remains `true` (framework workflows are installed). The `RunInitOptions` interface accepts `includeWorkflows?: boolean`. +**Why:** Users in repos with existing CI/CD should be able to skip Squad's framework workflow installation. The 4 framework workflows are safe, but user control matters. +**Impact:** CLI help text updated, `RunInitOptions` extended, `initSquad` respects the flag. diff --git a/.squad/decisions/inbox/fenster-pr233-ci-fix.md b/.squad/decisions/inbox/fenster-pr233-ci-fix.md new file mode 100644 index 000000000..8b42d6547 --- /dev/null +++ b/.squad/decisions/inbox/fenster-pr233-ci-fix.md @@ -0,0 +1,32 @@ +# Decision: Runtime ExperimentalWarning suppression via process.emit hook + +**Date:** 2026-03-07 +**Author:** Fenster (Core Dev) +**Context:** PR #233 CI failure — 4 tests failed + +## Problem + +PR #233 (CLI wiring fixes for #226, #229, #201, #202) passed all 74 tests locally but failed 4 tests in CI: + +- `test/cli-p0-regressions.test.ts` — bare semver test (expected 1 line, got 3) +- `test/speed-gates.test.ts` — version outputs one line (expected 1, got 3) +- `test/ux-gates.test.ts` — no overflow beyond 80 chars (ExperimentalWarning line >80) +- `test/ux-gates.test.ts` — version bare semver (expected 1 line, got 3) + +Root cause: `node:sqlite` import triggers Node.js `ExperimentalWarning` that leaks to stderr. The existing `process.env.NODE_NO_WARNINGS = '1'` in cli-entry.ts was ineffective because Node only reads that env var at process startup, not when set at runtime. + +The warning likely didn't appear locally because the local Node.js version may have already suppressed it or the env var was set in the shell. + +## Decision + +Added a `process.emit` override in cli-entry.ts that intercepts `warning` events with `name === 'ExperimentalWarning'` and swallows them. This is placed: +- After `process.env.NODE_NO_WARNINGS = '1'` (which still helps child processes) +- Before the `await import('node:sqlite')` pre-flight check + +This is the standard Node.js pattern for runtime warning suppression when you can't control the process launch flags. + +## Impact + +- **cli-entry.ts**: 12 lines added (comment + override function) +- **Tests**: All 4 previously failing tests now pass; no regressions in structural tests (#624) +- **Behavior**: ExperimentalWarning messages no longer appear in CLI output; other warnings (DeprecationWarning, etc.) are unaffected diff --git a/packages/squad-cli/src/cli-entry.ts b/packages/squad-cli/src/cli-entry.ts index fe70dfa0b..4b93203e6 100644 --- a/packages/squad-cli/src/cli-entry.ts +++ b/packages/squad-cli/src/cli-entry.ts @@ -10,6 +10,39 @@ process.env.NODE_NO_WARNINGS = '1'; +// Suppress ExperimentalWarning (e.g. node:sqlite) from leaking to terminal. +// process.env.NODE_NO_WARNINGS only works when set BEFORE process starts; +// this runtime hook catches warnings emitted during dynamic imports below. +const _origEmit = process.emit; +// @ts-expect-error — narrowing emit signature for warning suppression +process.emit = function (evt: string, ...args: unknown[]) { + if (evt === 'warning' && (args[0] as { name?: string })?.name === 'ExperimentalWarning') { + return false; + } + return _origEmit.apply(this, [evt, ...args] as Parameters); +}; + +// Pre-flight: detect missing node:sqlite before the Copilot SDK tries to use it. +// The @github/copilot SDK lazily imports node:sqlite for session storage. +// Node.js <22.5.0 and some 22.x builds don't include the builtin. (#214) +try { + await import('node:sqlite'); +} catch { + // Module not available — install a shim so the SDK's lazy require succeeds. + // We register a minimal stub that throws a descriptive error only if + // DatabaseSync is actually instantiated (it may never be on short sessions). + const { register } = await import('node:module'); + // No shim to register — just surface a clear message at startup so users + // know what's happening instead of seeing an opaque stack trace. + const nodeVersion = process.versions.node; + console.warn( + `⚠ node:sqlite is not available in Node.js v${nodeVersion}.\n` + + ' The Copilot SDK uses node:sqlite for session storage.\n' + + ' Upgrade to Node.js ≥22.5.0 or launch with --experimental-sqlite.\n' + + ' Squad will attempt to continue, but session persistence may fail.\n', + ); +} + import fs from 'node:fs'; import path from 'node:path'; import { fatal, SquadError } from './cli/core/errors.js'; @@ -62,6 +95,7 @@ async function main(): Promise { console.log(` Flags: --global (init in personal squad directory)`); console.log(` ${BOLD}init${RESET} Initialize Squad (skip files that already exist)`); console.log(` Flags: --global (init in personal squad directory)`); + console.log(` --no-workflows (skip GitHub workflow installation)`); console.log(` Usage: init --mode remote `); console.log(` Creates .squad/config.json pointing to an external team root`); console.log(` ${BOLD}upgrade${RESET} Update Squad-owned files to latest version`); @@ -109,6 +143,8 @@ async function main(): Promise { console.log(` ${BOLD}build${RESET} Compile squad.config.ts into .squad/ markdown`); console.log(` Flags: --check (validate only), --dry-run (preview)`); console.log(` --watch (rebuild on change)`); + console.log(` ${BOLD}aspire${RESET} Launch .NET Aspire dashboard for observability`); + console.log(` Flags: --docker (force Docker), --port (dashboard port)`); console.log(` ${BOLD}help${RESET} Show this help message`); console.log(`\nFlags:`); @@ -154,7 +190,8 @@ async function main(): Promise { } const dest = hasGlobal ? resolveGlobalSquadPath() : process.cwd(); - runInit(dest).catch(err => { + const noWorkflows = args.includes('--no-workflows'); + runInit(dest, { includeWorkflows: !noWorkflows }).catch(err => { fatal(err.message); }); return; @@ -324,11 +361,11 @@ async function main(): Promise { if (cmd === 'nap') { const { runNap, formatNapReport } = await import('./cli/core/nap.js'); - const squadRoot = resolveSquad(process.cwd()); - if (!squadRoot) { + // resolveSquad() returns the .squad/ directory itself — use it directly (#207) + const squadDir = resolveSquad(process.cwd()); + if (!squadDir) { fatal('No squad found. Run "squad init" first.'); } - const squadDir = path.join(squadRoot, '.squad'); const deep = args.includes('--deep'); const dryRun = args.includes('--dry-run'); const result = await runNap({ squadDir, deep, dryRun }); @@ -354,6 +391,15 @@ async function main(): Promise { return; } + if (cmd === 'aspire') { + const { runAspire } = await import('./cli/commands/aspire.js'); + const useDocker = args.includes('--docker'); + const portIdx = args.indexOf('--port'); + const port = (portIdx !== -1 && args[portIdx + 1]) ? parseInt(args[portIdx + 1]!, 10) : undefined; + await runAspire({ docker: useDocker, port }); + return; + } + if (cmd === 'link') { const { runLink } = await import('./cli/commands/link.js'); const teamPath = args[1]; diff --git a/packages/squad-cli/src/cli/commands/init-remote.ts b/packages/squad-cli/src/cli/commands/init-remote.ts index 992ec19fa..36f947e50 100644 --- a/packages/squad-cli/src/cli/commands/init-remote.ts +++ b/packages/squad-cli/src/cli/commands/init-remote.ts @@ -38,4 +38,18 @@ export function writeRemoteConfig(projectDir: string, teamRepoPath: string): voi JSON.stringify(config, null, 2) + '\n', 'utf-8', ); + + // Ensure .squad/config.json is in .gitignore (machine-local path, never commit) + const gitignorePath = path.join(projectDir, '.gitignore'); + const ignoreEntry = '.squad/config.json'; + let existingIgnore = ''; + if (fs.existsSync(gitignorePath)) { + existingIgnore = fs.readFileSync(gitignorePath, 'utf-8'); + } + if (!existingIgnore.includes(ignoreEntry)) { + const block = (existingIgnore && !existingIgnore.endsWith('\n') ? '\n' : '') + + '# Squad: local config (machine-specific paths, never commit)\n' + + ignoreEntry + '\n'; + fs.appendFileSync(gitignorePath, block); + } } diff --git a/packages/squad-cli/src/cli/commands/link.ts b/packages/squad-cli/src/cli/commands/link.ts index 936cfada0..9ad1a869c 100644 --- a/packages/squad-cli/src/cli/commands/link.ts +++ b/packages/squad-cli/src/cli/commands/link.ts @@ -58,5 +58,19 @@ export function runLink(projectDir: string, teamRepoPath: string): void { 'utf-8', ); + // Ensure .squad/config.json is in .gitignore (machine-local path, never commit) + const gitignorePath = path.join(projectDir, '.gitignore'); + const ignoreEntry = '.squad/config.json'; + let existingIgnore = ''; + if (fs.existsSync(gitignorePath)) { + existingIgnore = fs.readFileSync(gitignorePath, 'utf-8'); + } + if (!existingIgnore.includes(ignoreEntry)) { + const block = (existingIgnore && !existingIgnore.endsWith('\n') ? '\n' : '') + + '# Squad: local config (machine-specific paths, never commit)\n' + + ignoreEntry + '\n'; + fs.appendFileSync(gitignorePath, block); + } + console.log(`✅ Linked to team root: ${relativePath}`); } diff --git a/packages/squad-cli/src/cli/core/init.ts b/packages/squad-cli/src/cli/core/init.ts index ea66efaee..a125d9633 100644 --- a/packages/squad-cli/src/cli/core/init.ts +++ b/packages/squad-cli/src/cli/core/init.ts @@ -75,6 +75,8 @@ export interface RunInitOptions { prompt?: string; /** If true, disable extraction from consult sessions (read-only consultations) */ extractionDisabled?: boolean; + /** If false, skip GitHub workflow installation (default: true) */ + includeWorkflows?: boolean; } /** @@ -111,7 +113,7 @@ export async function runInit(dest: string, options: RunInitOptions = {}): Promi ], configFormat: 'typescript', skipExisting: true, - includeWorkflows: true, + includeWorkflows: options.includeWorkflows !== false, includeTemplates: true, includeMcpConfig: true, projectType: projectType as any, diff --git a/packages/squad-cli/src/cli/index.ts b/packages/squad-cli/src/cli/index.ts index c85bd8b96..b2bfb89dd 100644 --- a/packages/squad-cli/src/cli/index.ts +++ b/packages/squad-cli/src/cli/index.ts @@ -34,6 +34,7 @@ export { export * from './core/workflows.js'; export * from './core/team-md.js'; export { runCopilot, type CopilotFlags } from './commands/copilot.js'; +export { runDoctor, doctorCommand, type DoctorCheck, type DoctorMode } from './commands/doctor.js'; export { runExport } from './commands/export.js'; export { runImport } from './commands/import.js'; export { splitHistory } from './core/history-split.js';