diff --git a/.changeset/fix-1190-esm-patch-doctor-commit-hooks.md b/.changeset/fix-1190-esm-patch-doctor-commit-hooks.md new file mode 100644 index 000000000..035a6f94e --- /dev/null +++ b/.changeset/fix-1190-esm-patch-doctor-commit-hooks.md @@ -0,0 +1,5 @@ +--- +"@bradygaster/squad-cli": patch +--- + +Fix two upgrade-pipeline gaps from #1190: the postinstall ESM patcher now patches every search root instead of stopping at the first already-patched copy, and `squad upgrade` re-runs it against the repo-local node_modules so globally-installed CLIs no longer leave the consumer repo's vscode-jsonrpc/copilot-sdk unpatched. `squad doctor` now also verifies the pre-commit/post-commit hooks required by the two-layer/orphan state backends, instead of only the four sync hooks. diff --git a/packages/squad-cli/scripts/patch-esm-imports.mjs b/packages/squad-cli/scripts/patch-esm-imports.mjs index 7c0782b3c..b2f7f79ea 100644 --- a/packages/squad-cli/scripts/patch-esm-imports.mjs +++ b/packages/squad-cli/scripts/patch-esm-imports.mjs @@ -18,7 +18,7 @@ import { readFileSync, writeFileSync, existsSync } from 'fs'; import { join, dirname } from 'path'; -import { fileURLToPath } from 'url'; +import { fileURLToPath, pathToFileURL } from 'url'; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -36,8 +36,12 @@ const SEARCH_ROOTS = [ * This is the canonical fix: once the package has proper exports, Node's * ESM resolver handles every subpath ('vscode-jsonrpc/node', '/browser', etc.) * without needing per-file patches. + * + * Patches EVERY root that contains a copy, not just the first one found: + * stopping at the first (already-patched) copy left repo-local node_modules + * unpatched on global installs (#1190). */ -function patchVscodeJsonrpcExports() { +export function patchVscodeJsonrpcExports(searchRoots = SEARCH_ROOTS) { const exportsField = { '.': { types: './lib/common/api.d.ts', default: './lib/node/main.js' }, './node': { node: './lib/node/main.js', types: './lib/node/main.d.ts' }, @@ -45,7 +49,8 @@ function patchVscodeJsonrpcExports() { './browser': { types: './lib/browser/main.d.ts', browser: './lib/browser/main.js' }, }; - for (const root of SEARCH_ROOTS) { + let patchedAny = false; + for (const root of searchRoots) { const pkgPath = join(root, 'vscode-jsonrpc', 'package.json'); if (!existsSync(pkgPath)) continue; @@ -54,29 +59,30 @@ function patchVscodeJsonrpcExports() { const pkg = JSON.parse(raw); if (pkg.exports && pkg.exports['./node.js']) { - console.log('⏭️ vscode-jsonrpc already has complete exports field — skipping'); - return false; + console.log(`⏭️ vscode-jsonrpc already has complete exports field — skipping (${pkgPath})`); + continue; } pkg.exports = exportsField; writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n', 'utf8'); - console.log('✅ Patched vscode-jsonrpc/package.json with exports field (Node 22/24+ ESM fix)'); - return true; + console.log(`✅ Patched vscode-jsonrpc/package.json with exports field (${pkgPath})`); + patchedAny = true; } catch (err) { console.warn('⚠️ Failed to patch vscode-jsonrpc exports:', err.message); - return false; } } - return false; + return patchedAny; } /** * Layer 2 — Patch copilot-sdk session.js import (defense-in-depth). * Rewrites extensionless 'vscode-jsonrpc/node' to 'vscode-jsonrpc/node.js'. + * Same all-roots semantics as Layer 1 (#1190). */ -function patchCopilotSdkSessionJs() { - for (const root of SEARCH_ROOTS) { +export function patchCopilotSdkSessionJs(searchRoots = SEARCH_ROOTS) { + let patchedAny = false; + for (const root of searchRoots) { const sessionJsPath = join(root, '@github', 'copilot-sdk', 'dist', 'session.js'); if (!existsSync(sessionJsPath)) continue; @@ -90,19 +96,20 @@ function patchCopilotSdkSessionJs() { if (patched !== content) { writeFileSync(sessionJsPath, patched, 'utf8'); - console.log('✅ Patched @github/copilot-sdk session.js ESM imports'); - return true; + console.log(`✅ Patched @github/copilot-sdk session.js ESM imports (${sessionJsPath})`); + patchedAny = true; } - return false; } catch (err) { console.warn('⚠️ Failed to patch copilot-sdk session.js:', err.message); - return false; } } - return false; + return patchedAny; } -// Run both layers -patchVscodeJsonrpcExports(); -patchCopilotSdkSessionJs(); \ No newline at end of file +// Run both layers when executed directly (postinstall). `squad upgrade` imports +// the functions above and points them at the consumer repo's node_modules (#1190). +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + patchVscodeJsonrpcExports(); + patchCopilotSdkSessionJs(); +} \ No newline at end of file diff --git a/packages/squad-cli/src/cli/commands/doctor.ts b/packages/squad-cli/src/cli/commands/doctor.ts index 2dc991e49..cede58a91 100644 --- a/packages/squad-cli/src/cli/commands/doctor.ts +++ b/packages/squad-cli/src/cli/commands/doctor.ts @@ -516,7 +516,9 @@ function checkCopilotCli(): Promise { // ── git sync hooks check ───────────────────────────────────────────── const SQUAD_SYNC_HOOK_MARKER = '# --- squad-sync-hook ---'; -const REQUIRED_SYNC_HOOKS = ['pre-push', 'post-merge', 'post-rewrite', 'post-checkout'] as const; +// Must match the full set installed by install-hooks.ts: the four sync hooks +// plus pre-commit/post-commit, which guard and flush two-layer state (#1190). +const REQUIRED_SYNC_HOOKS = ['pre-push', 'post-merge', 'post-rewrite', 'post-checkout', 'pre-commit', 'post-commit'] as const; /** * Check that squad git sync hooks are installed when the state backend requires them. diff --git a/packages/squad-cli/src/cli/core/upgrade.ts b/packages/squad-cli/src/cli/core/upgrade.ts index fadd023af..a2f8ca5d6 100644 --- a/packages/squad-cli/src/cli/core/upgrade.ts +++ b/packages/squad-cli/src/cli/core/upgrade.ts @@ -6,6 +6,7 @@ import path from 'node:path'; import { execFileSync } from 'node:child_process'; +import { fileURLToPath, pathToFileURL } from 'node:url'; import { FSStorageProvider } from '@bradygaster/squad-sdk'; import { success, warn, info, dim, bold } from './output.js'; import { fatal } from './errors.js'; @@ -768,6 +769,49 @@ function refreshSquadTemplatesDir(dest: string, templatesDir: string): void { } } +/** + * Re-run the ESM import patcher against the project's own node_modules (#1190). + * + * npm runs postinstall with cwd inside the installed package dir, so a global + * `npm install -g` only ever patches the global copy — the consumer repo's + * node_modules stays unpatched and `squad doctor` keeps failing its + * vscode-jsonrpc / copilot-sdk checks there. Loading the patch script and + * pointing it at `/node_modules` closes that gap on every upgrade. + * + * Returns true when at least one file was patched. + */ +export async function ensureEsmImportsPatched(dest: string): Promise { + // Locate scripts/patch-esm-imports.mjs by walking up from the compiled file, + // same approach as getTemplatesDir() — works from dist/cli/core/ and bundles. + let dir = path.dirname(fileURLToPath(import.meta.url)); + let scriptPath: string | undefined; + for (let i = 0; i < 6; i++) { + const candidate = path.join(dir, 'scripts', 'patch-esm-imports.mjs'); + if (storage.existsSync(candidate)) { + scriptPath = candidate; + break; + } + const parent = path.dirname(dir); + if (parent === dir) break; + dir = parent; + } + if (!scriptPath) return false; + + try { + const patcher = await import(pathToFileURL(scriptPath).href) as { + patchVscodeJsonrpcExports: (searchRoots?: string[]) => boolean; + patchCopilotSdkSessionJs: (searchRoots?: string[]) => boolean; + }; + const roots = [path.join(dest, 'node_modules')]; + const patchedExports = patcher.patchVscodeJsonrpcExports(roots); + const patchedSession = patcher.patchCopilotSdkSessionJs(roots); + return patchedExports || patchedSession; + } catch (err) { + warn(`Could not patch ESM imports in ${path.join(dest, 'node_modules')}: ${err instanceof Error ? err.message : err}`); + return false; + } +} + /** * Run all ensure* checks and skill/template sync — shared by both code paths */ @@ -853,6 +897,13 @@ async function runEnsureChecks(dest: string, templatesDir: string, filesUpdated: success(`removed stale squad_state from ${tomb.path} (now lives in .mcp.json)`); filesUpdated.push('.copilot/mcp-config.json (tombstoned)'); } + + // #1190: patch ESM imports in the repo-local node_modules — postinstall only + // ever runs against the installed package's own directory tree. + if (await ensureEsmImportsPatched(dest)) { + success('patched ESM imports in repo-local node_modules (vscode-jsonrpc / copilot-sdk, see #449)'); + filesUpdated.push('node_modules (ESM patch)'); + } } /** Human-readable single-line description of an McpSpec for success() messages. */ diff --git a/test/cli/doctor.test.ts b/test/cli/doctor.test.ts index c3bc0f0c2..65e4b7a4b 100644 --- a/test/cli/doctor.test.ts +++ b/test/cli/doctor.test.ts @@ -356,7 +356,7 @@ describe('squad doctor', () => { await writeFile(join(squadDir, 'config.json'), JSON.stringify({ stateBackend: 'two-layer' })); const hooksDir = join(TEST_ROOT, '.git', 'hooks'); await mkdir(hooksDir, { recursive: true }); - for (const hookName of ['pre-push', 'post-merge', 'post-rewrite', 'post-checkout']) { + for (const hookName of ['pre-push', 'post-merge', 'post-rewrite', 'post-checkout', 'pre-commit', 'post-commit']) { await writeFile( join(hooksDir, hookName), `#!/bin/sh\n# --- squad-sync-hook ---\n# squad sync hook\n`, @@ -368,6 +368,30 @@ describe('squad doctor', () => { expect(result?.message).toContain('two-layer'); }); + it('reports FAIL when stateBackend=two-layer has sync hooks but no pre-commit/post-commit (#1190)', async () => { + const squadDir = join(TEST_ROOT, '.squad'); + await mkdir(squadDir, { recursive: true }); + execFileSync('git', ['init', '--quiet', '-b', 'main'], { cwd: TEST_ROOT }); + await writeFile(join(squadDir, 'config.json'), JSON.stringify({ stateBackend: 'two-layer' })); + const hooksDir = join(TEST_ROOT, '.git', 'hooks'); + await mkdir(hooksDir, { recursive: true }); + // The #1185 upgrade path installed only the four sync hooks — the commit + // hooks that actually write to the squad-state branch were never installed. + for (const hookName of ['pre-push', 'post-merge', 'post-rewrite', 'post-checkout']) { + await writeFile( + join(hooksDir, hookName), + `#!/bin/sh\n# --- squad-sync-hook ---\n# squad sync hook\n`, + ); + } + + const result = checkGitSyncHooks(TEST_ROOT, squadDir); + expect(result).toBeDefined(); + expect(result?.status).toBe('fail'); + expect(result?.message).toContain('pre-commit'); + expect(result?.message).toContain('post-commit'); + expect(result?.message).toContain('squad install-hooks'); + }); + it.each(['two-layer', 'orphan', 'git-notes'] as const)('reports PASS when stateBackend=%s has decisions.md on squad-state only', async (stateBackend) => { await scaffold(TEST_ROOT); execFileSync('git', ['init', '--quiet', '-b', 'main'], { cwd: TEST_ROOT }); @@ -437,7 +461,7 @@ describe('checkGitSyncHooks — git rev-parse --git-dir resolution', () => { // Install squad hooks in the actual .git/hooks dir (same as git rev-parse --git-dir → '.git') const hooksDir = join(repoDir, '.git', 'hooks'); await mkdir(hooksDir, { recursive: true }); - for (const hookName of ['pre-push', 'post-merge', 'post-rewrite', 'post-checkout']) { + for (const hookName of ['pre-push', 'post-merge', 'post-rewrite', 'post-checkout', 'pre-commit', 'post-commit']) { await writeFile( join(hooksDir, hookName), `#!/bin/sh\n# --- squad-sync-hook ---\n# squad sync hook\n`, diff --git a/test/cli/patch-esm-imports.test.ts b/test/cli/patch-esm-imports.test.ts new file mode 100644 index 000000000..07ca5ae7a --- /dev/null +++ b/test/cli/patch-esm-imports.test.ts @@ -0,0 +1,162 @@ +/** + * ESM import patcher — repo-local node_modules coverage (#1190) + * + * The postinstall patcher used to stop at the first search root that + * contained vscode-jsonrpc / copilot-sdk. On a global install that root is + * the global package's own node_modules (already patched), so the consumer + * repo's node_modules was never reached and `squad doctor` kept failing. + * These tests pin the all-roots behavior and the `squad upgrade` wiring. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdir, rm, readFile, writeFile } from 'fs/promises'; +import { join } from 'path'; +import { pathToFileURL } from 'url'; +import { existsSync } from 'fs'; +import { tmpdir } from 'os'; +import { randomBytes } from 'crypto'; +import { ensureEsmImportsPatched } from '@bradygaster/squad-cli/core/upgrade'; + +const SCRIPT_URL = pathToFileURL( + join(process.cwd(), 'packages', 'squad-cli', 'scripts', 'patch-esm-imports.mjs'), +).href; + +interface PatcherModule { + patchVscodeJsonrpcExports: (searchRoots?: string[]) => boolean; + patchCopilotSdkSessionJs: (searchRoots?: string[]) => boolean; +} + +const UNPATCHED_JSONRPC_PKG = JSON.stringify( + { name: 'vscode-jsonrpc', version: '8.2.1', main: './lib/node/main.js' }, + null, + 2, +); + +const PATCHED_JSONRPC_PKG = JSON.stringify( + { + name: 'vscode-jsonrpc', + version: '8.2.1', + main: './lib/node/main.js', + exports: { + '.': { types: './lib/common/api.d.ts', default: './lib/node/main.js' }, + './node': { node: './lib/node/main.js', types: './lib/node/main.d.ts' }, + './node.js': { node: './lib/node/main.js', types: './lib/node/main.d.ts' }, + './browser': { types: './lib/browser/main.d.ts', browser: './lib/browser/main.js' }, + }, + }, + null, + 2, +); + +const BROKEN_SESSION_JS = `import { createMessageConnection } from 'vscode-jsonrpc/node';\nexport { createMessageConnection };\n`; + +const TEST_ROOT = join(tmpdir(), `.test-patch-esm-${randomBytes(4).toString('hex')}`); + +async function writeJsonrpcPkg(root: string, content: string): Promise { + const pkgDir = join(root, 'vscode-jsonrpc'); + await mkdir(pkgDir, { recursive: true }); + const pkgPath = join(pkgDir, 'package.json'); + await writeFile(pkgPath, content + '\n'); + return pkgPath; +} + +async function writeSessionJs(root: string, content: string): Promise { + const distDir = join(root, '@github', 'copilot-sdk', 'dist'); + await mkdir(distDir, { recursive: true }); + const sessionPath = join(distDir, 'session.js'); + await writeFile(sessionPath, content); + return sessionPath; +} + +describe('patch-esm-imports — all-roots patching (#1190)', () => { + let patcher: PatcherModule; + + beforeEach(async () => { + patcher = await import(SCRIPT_URL) as PatcherModule; + if (existsSync(TEST_ROOT)) { + await rm(TEST_ROOT, { recursive: true, force: true }); + } + await mkdir(TEST_ROOT, { recursive: true }); + }); + + afterEach(async () => { + await rm(TEST_ROOT, { recursive: true, force: true }); + }); + + it('patches a later root even when an earlier root is already patched', async () => { + // Global-install layout: first search root already patched by postinstall, + // repo-local root (later in the list) still unpatched. + const globalRoot = join(TEST_ROOT, 'global', 'node_modules'); + const repoRoot = join(TEST_ROOT, 'repo', 'node_modules'); + await writeJsonrpcPkg(globalRoot, PATCHED_JSONRPC_PKG); + const repoPkgPath = await writeJsonrpcPkg(repoRoot, UNPATCHED_JSONRPC_PKG); + + const patched = patcher.patchVscodeJsonrpcExports([globalRoot, repoRoot]); + + expect(patched).toBe(true); + const repoPkg = JSON.parse(await readFile(repoPkgPath, 'utf-8')) as { exports?: Record }; + expect(repoPkg.exports?.['./node.js']).toBeDefined(); + }); + + it('patches session.js in a later root even when an earlier root is already patched', async () => { + const globalRoot = join(TEST_ROOT, 'global', 'node_modules'); + const repoRoot = join(TEST_ROOT, 'repo', 'node_modules'); + await writeSessionJs(globalRoot, 'import { x } from "vscode-jsonrpc/node.js";\n'); + const repoSessionPath = await writeSessionJs(repoRoot, BROKEN_SESSION_JS); + + const patched = patcher.patchCopilotSdkSessionJs([globalRoot, repoRoot]); + + expect(patched).toBe(true); + const session = await readFile(repoSessionPath, 'utf-8'); + expect(session).toContain('vscode-jsonrpc/node.js'); + expect(session).not.toMatch(/from\s+["']vscode-jsonrpc\/node["']/); + }); + + it('returns false when every root is already patched (idempotent)', async () => { + const root = join(TEST_ROOT, 'node_modules'); + await writeJsonrpcPkg(root, PATCHED_JSONRPC_PKG); + await writeSessionJs(root, 'import { x } from "vscode-jsonrpc/node.js";\n'); + + expect(patcher.patchVscodeJsonrpcExports([root])).toBe(false); + expect(patcher.patchCopilotSdkSessionJs([root])).toBe(false); + }); +}); + +describe('ensureEsmImportsPatched — squad upgrade wiring (#1190)', () => { + beforeEach(async () => { + if (existsSync(TEST_ROOT)) { + await rm(TEST_ROOT, { recursive: true, force: true }); + } + await mkdir(TEST_ROOT, { recursive: true }); + }); + + afterEach(async () => { + await rm(TEST_ROOT, { recursive: true, force: true }); + }); + + it('patches vscode-jsonrpc and session.js under the project node_modules', async () => { + const nodeModules = join(TEST_ROOT, 'node_modules'); + const pkgPath = await writeJsonrpcPkg(nodeModules, UNPATCHED_JSONRPC_PKG); + const sessionPath = await writeSessionJs(nodeModules, BROKEN_SESSION_JS); + + const patched = await ensureEsmImportsPatched(TEST_ROOT); + + expect(patched).toBe(true); + const pkg = JSON.parse(await readFile(pkgPath, 'utf-8')) as { exports?: Record }; + expect(pkg.exports?.['./node.js']).toBeDefined(); + expect(await readFile(sessionPath, 'utf-8')).toContain('vscode-jsonrpc/node.js'); + }); + + it('returns false on a second run (nothing left to patch)', async () => { + const nodeModules = join(TEST_ROOT, 'node_modules'); + await writeJsonrpcPkg(nodeModules, UNPATCHED_JSONRPC_PKG); + await writeSessionJs(nodeModules, BROKEN_SESSION_JS); + + expect(await ensureEsmImportsPatched(TEST_ROOT)).toBe(true); + expect(await ensureEsmImportsPatched(TEST_ROOT)).toBe(false); + }); + + it('returns false when the project has no node_modules', async () => { + expect(await ensureEsmImportsPatched(TEST_ROOT)).toBe(false); + }); +});