diff --git a/manifests/install-assets/commonjs-scripts-package.json b/manifests/install-assets/commonjs-scripts-package.json new file mode 100644 index 000000000..5bbefffba --- /dev/null +++ b/manifests/install-assets/commonjs-scripts-package.json @@ -0,0 +1,3 @@ +{ + "type": "commonjs" +} diff --git a/scripts/lib/install-lifecycle.js b/scripts/lib/install-lifecycle.js index 8992011f5..ffbcd4901 100644 --- a/scripts/lib/install-lifecycle.js +++ b/scripts/lib/install-lifecycle.js @@ -1727,6 +1727,7 @@ function repairInstalledStates(options = {}) { } const rawPlan = createRepairPlanFromRecord(record, context); + require('./install/apply').assertOpenCodeScriptBoundary(rawPlan); const { migration, plan: desiredPlan, @@ -1811,6 +1812,9 @@ function repairInstalledStates(options = {}) { } for (const operation of repairOperations) { + if (operation.sourceRelativePath === 'manifests/install-assets/commonjs-scripts-package.json') { + require('./install/apply').assertOpenCodeScriptBoundary(desiredPlan, new Set(repairedPaths)); + } if (desiredPlan.target === 'opencode') { const { assertOpenCodeActivationUnchanged } = require('./install/apply'); assertOpenCodeActivationUnchanged(desiredPlan, operation, activationSnapshot); @@ -1996,7 +2000,18 @@ function uninstallInstalledStates(options = {}) { ); } - for (const operation of operations) { + const { isScriptBoundary, retainScriptBoundary } = require('./install/opencode-script-boundary'); + // Remove owned script bodies first. Keep their CommonJS scope while any + // retained or user-created JavaScript still relies on that boundary. + const orderedOperations = record.adapter.target === 'opencode' + ? [...operations.filter(operation => !isScriptBoundary(operation, record.targetRoot)), + ...operations.filter(operation => isScriptBoundary(operation, record.targetRoot))] + : operations; + for (const operation of orderedOperations) { + if (record.adapter.target === 'opencode' && retainScriptBoundary(operation, record.targetRoot)) { + retainedPaths.push(operation.destinationPath); + continue; + } const outcome = executeUninstallOperation(operation, record.targetRoot, { preserveDriftedCopies: true, target: record.adapter.target, diff --git a/scripts/lib/install-targets/opencode-home.js b/scripts/lib/install-targets/opencode-home.js index 6c687f438..726cef611 100644 --- a/scripts/lib/install-targets/opencode-home.js +++ b/scripts/lib/install-targets/opencode-home.js @@ -94,7 +94,7 @@ module.exports = createInstallTargetAdapter({ const modules = Array.isArray(input.modules) ? input.modules : (input.module ? [input.module] : []); - return modules.flatMap(module => (Array.isArray(module.paths) ? module.paths : []) + const operations = modules.flatMap(module => (Array.isArray(module.paths) ? module.paths : []) .filter(sourcePath => !isForeignPlatformPath(sourcePath, adapter.target)) .flatMap(sourcePath => { const operation = adapter.createScaffoldOperation(module.id, sourcePath, input); @@ -111,6 +111,18 @@ module.exports = createInstallTargetAdapter({ contentTransform: 'opencode-home-skills-path', })]; })); + const scriptOperation = operations.find(operation => ( + String(operation.sourceRelativePath).replace(/\\/g, '/').startsWith('scripts/') + )); + if (!scriptOperation) return operations; + // OpenCode's root is ESM, while ECC runtime scripts remain CommonJS. + // Record this boundary so install, repair and uninstall share ownership. + return [...operations, createManagedOperation({ + moduleId: scriptOperation.moduleId, + sourceRelativePath: 'manifests/install-assets/commonjs-scripts-package.json', + destinationPath: path.join(adapter.resolveRoot(input), 'scripts', 'package.json'), + strategy: 'preserve-relative-path', + })]; }, validate: defaultValidateOpencodeHome, }); diff --git a/scripts/lib/install/apply.js b/scripts/lib/install/apply.js index 818035358..fddd3298f 100644 --- a/scripts/lib/install/apply.js +++ b/scripts/lib/install/apply.js @@ -58,6 +58,7 @@ const { } = require('./stale-operations-reconciliation'); const { buildInstallIndex, rewriteRelativeLinks } = require('./link-rewrite'); const { transformInstallContent } = require('./content-transform'); +const { assertScriptBoundary } = require('./opencode-script-boundary'); function isMarkdownPath(filePath) { return /\.(md|mdx|markdown)$/i.test(String(filePath || '')); @@ -295,6 +296,22 @@ function readPreviousInstallState(plan) { return readInstallState(plan.installStatePath); } +function assertOpenCodeScriptBoundary(plan, writtenDestinations = new Set()) { + if (plan.adapter?.target !== 'opencode') return; + assertSafeInstallOperation(plan, { destinationPath: plan.installStatePath }); + assertScriptBoundary(plan, { + readFile: destinationPath => readInstalledFileNoFollow(plan, { destinationPath }), + previousOperations: readPreviousInstallState(plan)?.operations || [], + writtenDestinations, + expectedContent(operation) { + if (!operation || operation.kind !== 'copy-file') return null; + const source = fs.readFileSync(operation.sourcePath); + return operation.contentTransform + ? Buffer.from(transformInstallContent(operation, source.toString('utf8'))) : source; + }, + }); +} + function comparablePath(filePath) { const resolved = path.resolve(filePath); return process.platform === 'win32' ? resolved.toLowerCase() : resolved; @@ -690,6 +707,7 @@ function preflightQwenAgentOperations(plan) { function previewInstallPlan(plan) { preflightQwenAgentOperations(plan); + assertOpenCodeScriptBoundary(plan); assertOpenCodeHookDeactivationReady(plan); const migration = prepareStaleOperationsReconciliation( plan, @@ -757,6 +775,7 @@ function applyInstallPlanLocked(plan, dependencies = {}, settingsLockHeld = fals if (typeof beforeInstallStateRead === 'function') { beforeInstallStateRead({ plan }); } + assertOpenCodeScriptBoundary(plan); assertOpenCodeLeaseCoverage(plan, dependencies.opencodeLease); const legacyActivation = inspectLegacyOpenCodeDeactivation(plan); const activationSnapshot = assertOpenCodeHookDeactivationReady(plan); @@ -809,6 +828,9 @@ function applyInstallPlanLocked(plan, dependencies = {}, settingsLockHeld = fals beforeOperationWrite({ plan: appliedPlan, operation }); } assertNoNewUserOwnedFile(migration, operation, appliedPlan); + if (operation.sourceRelativePath === 'manifests/install-assets/commonjs-scripts-package.json') { + assertOpenCodeScriptBoundary(appliedPlan, writtenDestinations); + } assertOpenCodeActivationUnchanged(appliedPlan, operation, activationSnapshot); if ( @@ -1059,6 +1081,7 @@ function applyInstallPlanLocked(plan, dependencies = {}, settingsLockHeld = fals module.exports = { applyInstallPlan, + assertOpenCodeScriptBoundary, assertOpenCodeActivationUnchanged, assertOpenCodeLeaseCoverage, assertOpenCodeHookDeactivationReady, diff --git a/scripts/lib/install/opencode-script-boundary.js b/scripts/lib/install/opencode-script-boundary.js new file mode 100644 index 000000000..99f15e759 --- /dev/null +++ b/scripts/lib/install/opencode-script-boundary.js @@ -0,0 +1,72 @@ +'use strict'; + +const crypto = require('crypto'); +const fs = require('fs'); +const path = require('path'); + +const SOURCE = 'manifests/install-assets/commonjs-scripts-package.json'; +const key = file => process.platform === 'win32' ? path.resolve(file).toLowerCase() : path.resolve(file); +const markerPath = root => path.join(root, 'scripts', 'package.json'); +const isScriptBoundary = (operation, root) => operation.kind === 'copy-file' + && operation.sourceRelativePath === SOURCE && key(operation.destinationPath) === key(markerPath(root)); + +// A nested package owns its own module scope. Symlinks and uncertain trees are +// preserved conservatively rather than followed while deciding scope changes. +function scopedJavaScriptFiles(directory, { nested = false, depth = 0 } = {}) { + if (depth > 40) throw new Error('OpenCode scripts tree exceeds the safe inspection depth'); + const stat = fs.lstatSync(directory, { throwIfNoEntry: false }); + if (!stat) return []; + if (stat.isSymbolicLink() || !stat.isDirectory()) throw new Error(`Unsafe OpenCode scripts directory: ${directory}`); + const packageStat = fs.lstatSync(path.join(directory, 'package.json'), { throwIfNoEntry: false }); + if (nested && packageStat?.isFile()) return []; + const files = []; + for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { + const file = path.join(directory, entry.name); + if (entry.isSymbolicLink()) throw new Error(`Unsafe OpenCode scripts symlink: ${file}`); + if (entry.isDirectory()) files.push(...scopedJavaScriptFiles(file, { nested: true, depth: depth + 1 })); + else if (entry.isFile() && entry.name.toLowerCase().endsWith('.js')) files.push(file); + if (files.length > 12000) throw new Error('OpenCode scripts tree exceeds the safe inspection count'); + } + return files; +} + +function assertScriptBoundary(plan, { readFile, previousOperations = [], writtenDestinations = new Set(), expectedContent }) { + if (plan.adapter?.target !== 'opencode' + || !plan.operations.some(operation => isScriptBoundary(operation, plan.targetRoot))) return; + const marker = markerPath(plan.targetRoot); + const content = readFile(marker); + if (content !== null) { + const metadata = JSON.parse(content.toString('utf8')); + if (!metadata || typeof metadata !== 'object' || Array.isArray(metadata) + || (metadata.type !== undefined && metadata.type !== 'commonjs')) { + throw new Error(`Refusing OpenCode CommonJS runtime migration: preserve the existing user module boundary at ${marker}.`); + } + // Existing user-owned CommonJS metadata is skipped by the ownership guard; + // retaining its bytes does not change anyone's module interpretation. + return; + } + const previous = new Map(previousOperations.filter(operation => operation.ownership === 'managed') + .map(operation => [key(operation.destinationPath), operation])); + const written = new Set([...writtenDestinations].map(key)); + const planned = new Map(plan.operations.map(operation => [key(operation.destinationPath), operation])); + for (const file of scopedJavaScriptFiles(path.dirname(marker))) { + const operation = previous.get(key(file)); + const bytes = readFile(file); + const expected = written.has(key(file)) + ? expectedContent(planned.get(key(file))) : null; + const digest = bytes === null ? null : crypto.createHash('sha256').update(bytes).digest('hex'); + const expectedDigest = expected === null + ? operation?.contentSha256 : crypto.createHash('sha256').update(expected).digest('hex'); + if (!expectedDigest || digest !== expectedDigest) { + throw new Error(`Refusing OpenCode CommonJS runtime migration: unowned or edited JavaScript at ${file} would change module scope. Give user scripts their own package.json scope before retrying.`); + } + } +} + +function retainScriptBoundary(operation, targetRoot) { + if (!isScriptBoundary(operation, targetRoot)) return false; + try { return scopedJavaScriptFiles(path.dirname(operation.destinationPath)).length > 0; } + catch { return true; } +} + +module.exports = { assertScriptBoundary, isScriptBoundary, retainScriptBoundary }; diff --git a/tests/lib/opencode-script-boundary.test.js b/tests/lib/opencode-script-boundary.test.js new file mode 100644 index 000000000..324ee2e33 --- /dev/null +++ b/tests/lib/opencode-script-boundary.test.js @@ -0,0 +1,150 @@ +'use strict'; + +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { spawnSync } = require('child_process'); +const { createManifestInstallPlan, applyInstallPlan } = require('../../scripts/lib/install-executor'); +const { withHookConsent } = require('../../scripts/lib/install/hook-consent'); +const { repairInstalledStates, uninstallInstalledStates } = require('../../scripts/lib/install-lifecycle'); +const { readInstallState } = require('../../scripts/lib/install-state'); + +const repo = path.resolve(__dirname, '../..'); +const build = spawnSync(process.execPath, [path.join(repo, 'scripts/build-opencode.js')], { encoding: 'utf8' }); +assert.strictEqual(build.status, 0, build.stderr || build.stdout); +let passed = 0; +let failed = 0; +function test(name, fn) { + const dir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-opencode-boundary-'))); + const home = path.join(dir, 'home'); + const project = path.join(dir, 'project'); + fs.mkdirSync(home); + fs.mkdirSync(project); + const root = path.join(home, '.config/opencode'); + const scripts = path.join(root, 'scripts'); + const marker = path.join(scripts, 'package.json'); + const options = { repoRoot: repo, homeDir: home, projectRoot: project, env: {}, targets: ['opencode'] }; + const plan = () => withHookConsent(createManifestInstallPlan({ + sourceRoot: repo, homeDir: home, projectRoot: project, env: {}, target: 'opencode', + moduleIds: ['platform-configs', 'hooks-runtime'], + }), 'enabled'); + const write = (file, bytes) => { fs.mkdirSync(path.dirname(file), { recursive: true }); fs.writeFileSync(file, bytes); }; + const probe = file => { + const result = spawnSync(process.execPath, ['--input-type=module', '-e', + 'const m=await import(requirePath); console.log(m.default ?? m.value);'.replace('requirePath', JSON.stringify(require('url').pathToFileURL(file).href))], + { encoding: 'utf8', cwd: project }); + assert.strictEqual(result.status, 0, result.stderr); + return result.stdout.trim(); + }; + try { fn({ home, project, root, scripts, marker, options, plan, write, probe }); passed++; console.log(`PASS ${name}`); } + catch (error) { failed++; console.error(`FAIL ${name}: ${error.stack}`); } + finally { fs.rmSync(dir, { recursive: true, force: true }); } +} + +test('refuses unowned ESM scripts before copies or install-state writes', f => { + f.write(path.join(f.root, 'package.json'), '{"type":"module"}\n'); + const sentinel = path.join(f.scripts, 'user.js'); + f.write(sentinel, 'export const value = 42;\n'); + assert.strictEqual(f.probe(sentinel), '42'); + const plan = f.plan(); + assert.throws(() => applyInstallPlan(plan), /unowned or edited JavaScript/); + assert.ok(!fs.existsSync(f.marker)); + assert.ok(!fs.existsSync(plan.installStatePath)); + assert.ok(!fs.existsSync(path.join(f.root, 'plugins'))); + assert.strictEqual(f.probe(sentinel), '42'); +}); + +test('preserves an unowned CommonJS boundary without repair or uninstall adoption', f => { + const bytes = '{"type":"commonjs","user":"keep"}\n'; + f.write(f.marker, bytes); + const sentinel = path.join(f.scripts, 'user.js'); + f.write(sentinel, 'module.exports = 43;\n'); + const plan = f.plan(); + applyInstallPlan(plan); + const owned = () => readInstallState(plan.installStatePath).operations.some(operation => operation.destinationPath === f.marker); + assert.strictEqual(owned(), false); + assert.strictEqual(repairInstalledStates(f.options).summary.errorCount, 0); + assert.strictEqual(owned(), false); + assert.strictEqual(fs.readFileSync(f.marker, 'utf8'), bytes); + assert.strictEqual(f.probe(sentinel), '43'); + assert.strictEqual(uninstallInstalledStates(f.options).summary.errorCount, 0); + assert.strictEqual(fs.readFileSync(f.marker, 'utf8'), bytes); + assert.strictEqual(f.probe(sentinel), '43'); +}); + +test('refuses an existing user ESM package boundary without changing it', f => { + const bytes = '{"type":"module","user":"keep"}\n'; + f.write(f.marker, bytes); + const sentinel = path.join(f.scripts, 'user.js'); + f.write(sentinel, 'export const value = 44;\n'); + const plan = f.plan(); + assert.throws(() => applyInstallPlan(plan), /existing user module boundary/); + assert.strictEqual(fs.readFileSync(f.marker, 'utf8'), bytes); + assert.ok(!fs.existsSync(plan.installStatePath)); + assert.strictEqual(f.probe(sentinel), '44'); +}); + +test('a nested user ESM scope remains unchanged across install and uninstall', f => { + f.write(path.join(f.scripts, 'user/package.json'), '{"type":"module"}\n'); + const sentinel = path.join(f.scripts, 'user/index.js'); + f.write(sentinel, 'export const value = 45;\n'); + const explicitModule = path.join(f.scripts, 'user-module.mjs'); + f.write(explicitModule, 'export const value = 48;\n'); + applyInstallPlan(f.plan()); + assert.strictEqual(f.probe(sentinel), '45'); + assert.strictEqual(f.probe(explicitModule), '48'); + assert.strictEqual(uninstallInstalledStates(f.options).summary.errorCount, 0); + assert.ok(!fs.existsSync(f.marker)); + assert.strictEqual(f.probe(sentinel), '45'); + assert.strictEqual(f.probe(explicitModule), '48'); +}); + +test('partial uninstall retains an owned boundary while user JavaScript relies on it', f => { + const plan = f.plan(); + applyInstallPlan(plan); + const sentinel = path.join(f.scripts, 'user.js'); + f.write(sentinel, 'module.exports = 46;\n'); + const result = uninstallInstalledStates(f.options); + assert.strictEqual(result.results[0].status, 'partial'); + assert.ok(result.results[0].retainedPaths.includes(f.marker)); + assert.strictEqual(f.probe(sentinel), '46'); + assert.ok(readInstallState(plan.installStatePath).operations.some(operation => operation.destinationPath === f.marker)); + fs.unlinkSync(sentinel); + assert.strictEqual(uninstallInstalledStates(f.options).summary.errorCount, 0); + assert.ok(!fs.existsSync(f.marker)); + assert.ok(!fs.existsSync(plan.installStatePath)); +}); + +test('rechecks newly introduced user JavaScript before boundary publication', f => { + const sentinel = path.join(f.scripts, 'user.js'); + const plan = f.plan(); + assert.throws(() => applyInstallPlan(plan, { beforeOperationWrite({ operation }) { + if (operation.destinationPath === f.marker) f.write(sentinel, 'export const value = 47;\n'); + } }), /unowned or edited JavaScript/); + assert.ok(!fs.existsSync(f.marker)); + assert.strictEqual(f.probe(sentinel), '47'); +}); + +test('refuses a concurrent edit to a just-installed script before boundary publication', f => { + const plan = f.plan(); + const sentinel = path.join(f.scripts, 'hooks/skill-run-tracker.js'); + assert.throws(() => applyInstallPlan(plan, { beforeOperationWrite({ operation }) { + if (operation.destinationPath === f.marker) f.write(sentinel, 'export const value = 49;\n'); + } }), /unowned or edited JavaScript/); + assert.ok(!fs.existsSync(f.marker)); + assert.strictEqual(f.probe(sentinel), '49'); +}); + +test('repair restores a missing owned boundary and preserves runtime script ownership', f => { + const plan = f.plan(); + applyInstallPlan(plan); + fs.unlinkSync(f.marker); + assert.strictEqual(repairInstalledStates(f.options).summary.errorCount, 0); + assert.strictEqual(JSON.parse(fs.readFileSync(f.marker, 'utf8')).type, 'commonjs'); + assert.ok(readInstallState(plan.installStatePath).operations.some(operation => operation.destinationPath === f.marker)); +}); + +console.log(`Passed: ${passed}`); +console.log(`Failed: ${failed}`); +process.exitCode = failed ? 1 : 0; diff --git a/tests/opencode-plugin-hooks.test.js b/tests/opencode-plugin-hooks.test.js index 421f38113..dc3dcee23 100644 --- a/tests/opencode-plugin-hooks.test.js +++ b/tests/opencode-plugin-hooks.test.js @@ -111,8 +111,9 @@ function createModularInstallRoot(options = {}) { fs.writeFileSync( path.join(root, "package.json"), - JSON.stringify({ name: "ecc-universal", version: "0.0.0-test" }) + JSON.stringify({ name: "ecc-universal", version: "0.0.0-test", type: "module" }) ) + fs.writeFileSync(path.join(root, "scripts", "package.json"), JSON.stringify({ type: "commonjs" })) // The plugin's tools import @opencode-ai/plugin, which resolves through // node_modules — a real installation has it, an isolated temp dir would not. diff --git a/tests/scripts/opencode-modular-telemetry.test.js b/tests/scripts/opencode-modular-telemetry.test.js new file mode 100644 index 000000000..5a6d30949 --- /dev/null +++ b/tests/scripts/opencode-modular-telemetry.test.js @@ -0,0 +1,84 @@ +'use strict'; + +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); +const { spawnSync } = require('child_process'); +const { pathToFileURL } = require('url'); +const { createManifestInstallPlan, applyInstallPlan } = require('../../scripts/lib/install-executor'); +const { withHookConsent } = require('../../scripts/lib/install/hook-consent'); + +const repo = path.resolve(__dirname, '../..'); +const fixture = fs.mkdtempSync(path.join(repo, 'tests', '.opencode-modular-runtime-')); +const home = path.join(fixture, 'home'); +const project = path.join(fixture, 'project'); +fs.mkdirSync(home); +fs.mkdirSync(project); +let failed = 0; +try { + const build = spawnSync(process.execPath, [path.join(repo, 'scripts/build-opencode.js')], { encoding: 'utf8' }); + assert.strictEqual(build.status, 0, build.stderr || build.stdout); + const plan = withHookConsent(createManifestInstallPlan({ + sourceRoot: repo, homeDir: home, projectRoot: project, env: {}, + target: 'opencode', moduleIds: ['platform-configs', 'hooks-runtime'], + }), 'enabled'); + applyInstallPlan(plan); + assert.ok(!fs.existsSync(path.join(plan.targetRoot, 'skills')), 'Optional skills must stay absent'); + const marker = path.join(plan.targetRoot, 'scripts/package.json'); + assert.strictEqual(JSON.parse(fs.readFileSync(marker, 'utf8')).type, 'commonjs'); + assert.ok(plan.statePreview.operations.some(operation => operation.destinationPath === marker)); + const input = ` + import assert from 'node:assert/strict'; + import fs from 'node:fs'; + import path from 'node:path'; + import { pathToFileURL } from 'node:url'; + const root = process.argv[1]; + const logs = []; + const context = { client: { app: { log: ({ body }) => { logs.push(body); return Promise.resolve(); } } }, + $: () => { throw new Error('Offline fixture must not execute shell commands'); }, + directory: process.cwd(), worktree: process.cwd() }; + const loaded = []; + for (const name of fs.readdirSync(path.join(root, 'plugins')).filter(name => /\\.ts$/.test(name))) { + const mod = await import(pathToFileURL(path.join(root, 'plugins', name)).href); + for (const entry of new Set(Object.values(mod))) loaded.push(await entry(context)); + } + const live = loaded.filter(hooks => typeof hooks['tool.execute.after'] === 'function'); + assert.equal(live.length, 1, 'Local discovery must register exactly one live ECC plugin'); + await live[0]['tool.execute.after']({ tool: 'skill', args: { name: 'code-review', arguments: 'private prompt sentinel' } }, {}); + assert.ok(!logs.some(entry => /telemetry disabled/.test(entry.message))); + const runs = fs.readFileSync(path.join(process.env.ECC_AGENT_DATA_HOME, 'state/skill-runs.jsonl'), 'utf8'); + assert.equal(runs.trim().split('\\n').length, 1); + assert.equal(JSON.parse(runs).skill_id, 'code-review'); + assert.ok(!runs.includes('private prompt sentinel')); + const output = { env: {} }; + await live[0]['shell.env']({ cwd: process.cwd() }, output); + assert.equal(output.env.CLAUDE_PLUGIN_ROOT, fs.realpathSync(root)); + console.log('PASS: one live installed plugin, one private skill record, canonical root export'); + `; + const result = spawnSync(process.execPath, ['--loader', + pathToFileURL(path.join(repo, 'tests/fixtures/opencode-ts-loader.mjs')).href, + '--input-type=module', '-e', input, plan.targetRoot], { + cwd: project, encoding: 'utf8', timeout: 30000, + env: { ...process.env, HOME: home, USERPROFILE: home, + CLAUDE_CONFIG_DIR: path.join(home, '.claude'), ECC_AGENT_DATA_HOME: path.join(home, '.claude'), + XDG_CONFIG_HOME: path.join(home, '.config'), OPENCODE_CONFIG_DIR: plan.targetRoot, + CLAUDE_PLUGIN_ROOT: '', CLAUDE_CODE_PACKAGE_MANAGER: 'bun', ECC_HOOK_PROFILE: 'standard', ECC_DISABLED_HOOKS: '' }, + }); + assert.strictEqual(result.status, 0, result.stderr || result.stdout); + console.log(result.stdout.trim()); + const standalone = spawnSync(process.execPath, + [path.join(plan.targetRoot, 'scripts/hooks/post-bash-command-log.js'), 'audit'], { + cwd: project, encoding: 'utf8', input: JSON.stringify({ tool_name: 'Bash', tool_input: { command: 'echo standalone-offline' } }), + env: { ...process.env, HOME: home, USERPROFILE: home, CLAUDE_CONFIG_DIR: path.join(home, '.claude') }, + }); + assert.strictEqual(standalone.status, 0, standalone.stderr); + assert.match(fs.readFileSync(path.join(home, '.claude/bash-commands.log'), 'utf8'), /standalone-offline/); +} catch (error) { + failed = 1; + console.error(error.stack); +} finally { + fs.rmSync(fixture, { recursive: true, force: true }); +} +console.log(`Passed: ${1 - failed}`); +console.log(`Failed: ${failed}`); +process.exitCode = failed;