diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b46c39c0..43e1abb78 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -154,6 +154,8 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Fixes +- Extension installation and interrupted-operation recovery now honor `CODEGRAPH_DIR` without creating or modifying the default data directory. + - Managed extensions remain trusted when a project is opened with different path capitalization on Windows and macOS. - Publisher forms preserve entered descriptions and source links while package metadata finishes loading. diff --git a/scripts/validation/extensions-recovery.cjs b/scripts/validation/extensions-recovery.cjs index 1f7575d3b..a83c841d0 100644 --- a/scripts/validation/extensions-recovery.cjs +++ b/scripts/validation/extensions-recovery.cjs @@ -5,7 +5,7 @@ const assert = require('node:assert/strict'); const { spawn } = require('node:child_process'); const { channel } = require('node:diagnostics_channel'); const { randomUUID, createHash } = require('node:crypto'); -const { CodeGraph } = require('../../dist'); +const { CodeGraph, getCodeGraphDir, codeGraphDirName } = require('../../dist'); const { ExtensionManager } = require('../../dist/plugins/manager'); const { recoverExtensions } = require('../../dist/plugins/recovery'); const { packageDigest } = require('../../dist/plugins/package'); @@ -16,9 +16,9 @@ function artifact(version = '1.0.0') { files: { 'index.cjs': `module.exports=()=>({frameworks:[{name:'routes',languages:['python'],detect:()=>true,resolve:()=>null,extract(file){return {nodes:[{id:'plugin:recovery-demo:'+file,kind:'route',name:'/${version}',qualifiedName:file+'::route',filePath:file,language:'python',startLine:1,endLine:1,startColumn:0,endColumn:1,updatedAt:0}],references:[]}}}]});` } })); } function config(root) { return fs.existsSync(path.join(root, 'codegraph.json')) ? fs.readFileSync(path.join(root, 'codegraph.json'), 'utf8') : null; } -function trust(root) { const file = path.join(root, '.codegraph/plugins/trust.json'); return fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : null; } +function trust(root) { const file = path.join(getCodeGraphDir(root), 'plugins/trust.json'); return fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : null; } function inspect(root) { - const dbPath = path.join(root, '.codegraph/codegraph.db'); + const dbPath = path.join(getCodeGraphDir(root), 'codegraph.db'); let routes = []; if (fs.existsSync(dbPath)) { const db = new DatabaseSync(dbPath, { readOnly: true }); @@ -96,10 +96,11 @@ if (process.argv[2] === '--child') { return root; } function clean(root) { - const folder = path.join(root, '.codegraph/plugins'); + if (codeGraphDirName() !== '.codegraph') assert.equal(fs.existsSync(path.join(root, '.codegraph')), false, 'override must not create default data directory'); + const folder = path.join(getCodeGraphDir(root), 'plugins'); assert.ok(!fs.existsSync(path.join(folder, 'transaction.json'))); assert.ok(!fs.existsSync(path.join(folder, 'operation.lock'))); - assert.ok(!fs.readdirSync(path.join(root, '.codegraph')).some(n => n.startsWith('extension-stage-'))); + assert.ok(!fs.readdirSync(getCodeGraphDir(root)).some(n => n.startsWith('extension-stage-'))); assert.ok(!fs.readdirSync(folder).some(n => n.startsWith('stage-'))); assert.equal(fs.readFileSync(path.join(root, 'keep.txt'), 'utf8'), 'unrelated bytes'); const entries = JSON.parse(config(root) || '{}').plugins || [], trusted = JSON.parse(trust(root) || '{}'); @@ -133,7 +134,7 @@ if (process.argv[2] === '--child') { for (const boundary of ['package_ready', 'trust_written']) { const root = await project(true), before = inspect(root); await killAt(root, 'update', boundary); await run(root); assert.deepEqual(inspect(root), before); clean(root); - const packages = fs.readdirSync(path.join(root, '.codegraph/plugins/packages')); assert.equal(packages.length, 1); + const packages = fs.readdirSync(path.join(getCodeGraphDir(root), 'plugins/packages')); assert.equal(packages.length, 1); receipt({ action: 'update', boundary, decision: 'rollback and discard new package/trust', passed: true }); } const first = await project(false, false); @@ -146,13 +147,13 @@ if (process.argv[2] === '--child') { receipt({ action: 'kill recovery itself', boundary: 'recovery_config', decision: 'idempotent rollback', passed: true }); const live = await project(true), c = child(live, 'update', 'config_written'); await Promise.race([c.reached, c.done.then(r => { throw new Error('Live-owner fixture exited early: ' + r.stderr); })]); - const lock = path.join(live, '.codegraph/plugins/operation.lock'); fs.utimesSync(lock, 1, 1); + const lock = path.join(getCodeGraphDir(live), 'plugins/operation.lock'); fs.utimesSync(lock, 1, 1); for (const action of ['install', 'inspect', 'index']) { const result = await run(live, action, 1); assert.match(result.stderr, /operation is active/); } assert.ok(processes.has(c.processChild)); c.processChild.kill('SIGKILL'); await c.done; await run(live); clean(live); receipt({ action: 'live aged owner', decision: 'mutation/read/index excluded; no timeout stealing', passed: true }); - const reused = await project(true), owner = path.join(reused, '.codegraph/plugins/operation.lock'); + const reused = await project(true), owner = path.join(getCodeGraphDir(reused), 'plugins/operation.lock'); fs.writeFileSync(owner, JSON.stringify({ format: 'codegraph-operation-lock-1', id: randomUUID(), pid: process.pid, startedAt: 1 })); await run(reused); clean(reused); fs.writeFileSync(owner, String(process.pid)); @@ -161,31 +162,31 @@ if (process.argv[2] === '--child') { fs.writeFileSync(owner, String(deadPid)); await run(reused); clean(reused); receipt({ action: 'PID reuse and legacy owners', decision: 'SQLite ownership authoritative; live legacy blocked, dead legacy reclaimed', passed: true }); const torn = await project(true); await killAt(torn, 'update', 'config_written'); - const journal = path.join(torn, '.codegraph/plugins/transaction.json'), original = fs.readFileSync(journal); - fs.writeFileSync(path.join(torn, '.codegraph/plugins/operation.lock'), '{'); + const journal = path.join(getCodeGraphDir(torn), 'plugins/transaction.json'), original = fs.readFileSync(journal); + fs.writeFileSync(path.join(getCodeGraphDir(torn), 'plugins/operation.lock'), '{'); fs.writeFileSync(journal, '{'); const state = inspect(torn); assert.match((await run(torn, 'inspect', 1)).stderr, /invalid transaction record/); assert.deepEqual(inspect(torn), state); fs.writeFileSync(journal, original); await run(torn); clean(torn); - fs.writeFileSync(path.join(torn, '.codegraph/plugins/operation.lock'), '{'); - assert.match((await run(torn, 'inspect', 1)).stderr, /torn\/unknown owner/); fs.unlinkSync(path.join(torn, '.codegraph/plugins/operation.lock')); + fs.writeFileSync(path.join(getCodeGraphDir(torn), 'plugins/operation.lock'), '{'); + assert.match((await run(torn, 'inspect', 1)).stderr, /torn\/unknown owner/); fs.unlinkSync(path.join(getCodeGraphDir(torn), 'plugins/operation.lock')); receipt({ action: 'torn records', decision: 'invalid journal/unknown owner fail closed; valid journal recovers torn owner', passed: true }); for (const boundary of ['config_written', 'graph_committed']) { const edited = await project(true); await killAt(edited, 'update', boundary); const cfg = JSON.parse(config(edited)); cfg.userNote = 'preserve this edit'; fs.writeFileSync(path.join(edited, 'codegraph.json'), JSON.stringify(cfg)); - const t = JSON.parse(trust(edited)); t['unrelated-user-key'] = 'preserve'; fs.writeFileSync(path.join(edited, '.codegraph/plugins/trust.json'), JSON.stringify(t)); + const t = JSON.parse(trust(edited)); t['unrelated-user-key'] = 'preserve'; fs.writeFileSync(path.join(getCodeGraphDir(edited), 'plugins/trust.json'), JSON.stringify(t)); await run(edited); assert.equal(JSON.parse(config(edited)).userNote, 'preserve this edit'); assert.equal(JSON.parse(trust(edited))['unrelated-user-key'], 'preserve'); clean(edited); receipt({ action: 'unrelated config/trust edits', boundary, decision: 'preserved', passed: true }); const conflict = await project(true); await killAt(conflict, 'update', boundary); const cfg2 = JSON.parse(config(conflict)); cfg2.plugins[0].options = { userEdit: true }; const editedRaw = JSON.stringify(cfg2); fs.writeFileSync(path.join(conflict, 'codegraph.json'), editedRaw); assert.match((await run(conflict, 'inspect', 1)).stderr, /plugins were edited externally/); assert.equal(config(conflict), editedRaw); - const r = JSON.parse(JSON.parse(fs.readFileSync(path.join(conflict, '.codegraph/plugins/transaction.json'))).payload); + const r = JSON.parse(JSON.parse(fs.readFileSync(path.join(getCodeGraphDir(conflict), 'plugins/transaction.json'))).payload); cfg2.plugins = JSON.parse(boundary === 'graph_committed' ? r.nextConfig : r.previousConfig).plugins; cfg2.resolvedByUser = true; fs.writeFileSync(path.join(conflict, 'codegraph.json'), JSON.stringify(cfg2)); await run(conflict); clean(conflict); assert.equal(JSON.parse(config(conflict)).resolvedByUser, true); receipt({ action: 'conflicting plugin edit', boundary, decision: 'no overwrite; actionable repair then recovery', passed: true }); } - const graphLocked = await project(true), graphLock = path.join(graphLocked, '.codegraph/codegraph.lock'); + const graphLocked = await project(true), graphLock = path.join(getCodeGraphDir(graphLocked), 'codegraph.lock'); fs.writeFileSync(graphLock, String(process.pid)); fs.utimesSync(graphLock, 1, 1); assert.match((await run(graphLocked, 'update', 1)).stderr, /legacy graph owner/); assert.equal(fs.readFileSync(graphLock, 'utf8'), String(process.pid)); fs.unlinkSync(graphLock); @@ -201,10 +202,10 @@ if (process.argv[2] === '--child') { receipt({ action: 'graph-affecting external config edit', decision: 'preserved and blocked with repair instructions', passed: true }); const tamper = await project(true); await killAt(tamper, 'update', 'graph_committed'); const entry = JSON.parse(config(tamper)).plugins[0]; - const entryFile = path.join(tamper, '.codegraph/plugins/packages', entry.integrity, 'index.cjs'), originalEntry = fs.readFileSync(entryFile); + const entryFile = path.join(getCodeGraphDir(tamper), 'plugins/packages', entry.integrity, 'index.cjs'), originalEntry = fs.readFileSync(entryFile); fs.appendFileSync(entryFile, '\n// external change'); assert.match((await run(tamper, 'inspect', 1)).stderr, /installed package managed:recovery-demo is missing or changed/); - assert.ok(fs.existsSync(path.join(tamper, '.codegraph/plugins/transaction.json'))); + assert.ok(fs.existsSync(path.join(getCodeGraphDir(tamper), 'plugins/transaction.json'))); fs.writeFileSync(entryFile, originalEntry); await run(tamper); clean(tamper); receipt({ action: 'changed installed package', decision: 'trust revalidated; blocked until bytes restored', passed: true }); const cached = await project(true), reader = await CodeGraph.open(cached); @@ -214,7 +215,7 @@ if (process.argv[2] === '--child') { assert.equal(reader.getNode(node.id).name, '/2.0.0'); } finally { reader.close(); } receipt({ action: 'long-lived reader after fresh-process recovery', decision: 'old cached node invalidated by commit marker', passed: true }); - fs.writeFileSync(path.join(out, 'result.json'), JSON.stringify({ platform: process.platform, arch: process.arch, node: process.version, termination: process.platform === 'win32' ? 'Node child.kill(SIGKILL) / TerminateProcess' : 'SIGKILL', rows, commands: commands.length }, null, 2)); + fs.writeFileSync(path.join(out, 'result.json'), JSON.stringify({ platform: process.platform, arch: process.arch, node: process.version, dataDirectory: codeGraphDirName(), termination: process.platform === 'win32' ? 'Node child.kill(SIGKILL) / TerminateProcess' : 'SIGKILL', rows, commands: commands.length }, null, 2)); } finally { for (const c of processes) c.kill('SIGKILL'); if (processes.size) await new Promise(resolve => setTimeout(resolve, 300)); diff --git a/scripts/validation/native-validation.cjs b/scripts/validation/native-validation.cjs index 5ca8394af..0e383d339 100644 --- a/scripts/validation/native-validation.cjs +++ b/scripts/validation/native-validation.cjs @@ -19,13 +19,13 @@ const env = { ...process.env, CODEGRAPH_TELEMETRY: '0', CODEGRAPH_WASM_RELAUNCHE const playwright = process.env.PLAYWRIGHT_MODULE || path.join(root, '.qa/browser/node_modules/playwright'); env.PLAYWRIGHT_MODULE = playwright; env.CHROME_PATH = process.env.CHROME_PATH || require(playwright).chromium.executablePath(); -async function run(name, command, args, timeout = 360000) { - const step = { name, command: [command, ...args], cwd: root, revision: version, started: new Date().toISOString(), status: 'running' }; +async function run(name, command, args, timeout = 360000, overrides = {}) { + const step = { name, command: [command, ...args], cwd: root, revision: version, overrides, started: new Date().toISOString(), status: 'running' }; metadata.steps.push(step); save(); console.log('START', name); const logFile = path.join(out, name + '.log'); const log = fs.openSync(logFile, 'w'); const started = Date.now(); const result = await new Promise(resolve => { - const child = spawn(command, args, { cwd: root, env, stdio: ['ignore', log, log], timeout }); + const child = spawn(command, args, { cwd: root, env: { ...env, ...overrides }, stdio: ['ignore', log, log], timeout }); let error; child.on('error', e => error = String(e)); child.on('close', (exit, signal) => resolve({ exit, signal, error })); @@ -42,12 +42,13 @@ async function run(name, command, args, timeout = 360000) { if (built) { await run('marketplace-build', ...npmCommand(['run', 'build', '--prefix', 'marketplace'])); await run('focused', process.execPath, ['node_modules/vitest/vitest.mjs', 'run', - '__tests__/extension-trust.test.ts', '__tests__/extension-releases.test.ts', '__tests__/extension-marketplace.test.ts', '__tests__/extension-author.test.ts', '__tests__/plugins.test.ts', '__tests__/extension-explore.test.ts', + '__tests__/foundation.test.ts', '__tests__/extension-trust.test.ts', '__tests__/extension-releases.test.ts', '__tests__/extension-marketplace.test.ts', '__tests__/extension-author.test.ts', '__tests__/plugins.test.ts', '__tests__/extension-explore.test.ts', '__tests__/db-reopen-on-replace.test.ts', '__tests__/status-json.test.ts', '__tests__/sync.test.ts', '__tests__/concurrent-locking.test.ts', '--maxWorkers=2', '--minWorkers=1', '--reporter=default', '--reporter=json', `--outputFile.json=${path.join(out, 'focused.json')}`]); await run('compiled-workers', process.execPath, ['--test', 'scripts/validation/extensions-runtime.test.cjs']); await run('native-paths', process.execPath, ['scripts/validation/native-paths.cjs']); await run('process-recovery', process.execPath, ['scripts/validation/extensions-recovery.cjs'], 600000); + await run('process-recovery-override', process.execPath, ['scripts/validation/extensions-recovery.cjs'], 600000, { CODEGRAPH_DIR: '.codegraph-native', RECOVERY_OUTPUT: path.join(out, 'recovery-override') }); await run('external-author', process.execPath, ['scripts/validation/extension-author.cjs'], 600000); await run('compatibility', process.execPath, ['scripts/validation/extensions-compatibility.cjs']); await run('browser', process.execPath, ['scripts/validation/marketplace-browser.cjs']); diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index 34f2e4fb1..d0c4de709 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -1,5 +1,6 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; +import { getCodeGraphDir } from '../directory'; import { createRequire } from 'node:module'; import { pathToFileURL } from 'node:url'; import { NODE_KINDS, EDGE_KINDS, LANGUAGES, type Node, type Edge } from '../types'; @@ -11,7 +12,7 @@ import { loadPluginEntries } from '../project-config'; import { version as engineVersion } from '../../package.json'; const importESM = new Function('url', 'return import(url)') as (url: string) => Promise; -export const pluginDirectory = (root: string): string => path.join(root, '.codegraph', 'plugins'); +export const pluginDirectory = (root: string): string => path.join(getCodeGraphDir(root), 'plugins'); function resolveEntry(root: string, entry: PluginEntry): ResolvedPlugin { let packageRoot: string; diff --git a/src/plugins/recovery.ts b/src/plugins/recovery.ts index 526d5a624..c3f368db6 100644 --- a/src/plugins/recovery.ts +++ b/src/plugins/recovery.ts @@ -1,6 +1,7 @@ /** Process-death recovery for managed extensions; no PID/age based lock stealing. */ import * as fs from 'node:fs'; import * as path from 'node:path'; +import { getCodeGraphDir } from '../directory'; import { randomUUID } from 'node:crypto'; import { AsyncLocalStorage } from 'node:async_hooks'; import { channel } from 'node:diagnostics_channel'; @@ -10,7 +11,7 @@ import { sha256, packageDigest, trustedPackageDigest, type ExtensionPackage } fr const context = new AsyncLocalStorage(); const transitions = channel('codegraph.extension.transaction'); -const dir = (root: string) => path.join(root, '.codegraph', 'plugins'); +const dir = (root: string) => path.join(getCodeGraphDir(root), 'plugins'); const recordPath = (root: string) => path.join(dir(root), 'transaction.json'); const ownerPath = (root: string) => path.join(dir(root), 'operation.lock'); const uuid = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/; @@ -102,7 +103,7 @@ function checkOwner(root: string, journal?: RecordV1): void { fail(root, 'torn/unknown owner record without a valid transaction; confirm no legacy installer is running before archiving the owner record'); } function checkGraphOwner(root: string, transactionPid?: number): void { - const lock = path.join(root, '.codegraph', 'codegraph.lock'), raw = read(lock)?.trim(); + const lock = path.join(getCodeGraphDir(root), 'codegraph.lock'), raw = read(lock)?.trim(); if (raw === undefined) return; if (transactionPid && raw === String(transactionPid)) return; // orphan proved by the coordinator if (/^[1-9]\d*$/.test(raw)) { @@ -113,7 +114,7 @@ function checkGraphOwner(root: string, transactionPid?: number): void { fail(root, 'unknown legacy graph lock; confirm its owner has stopped before archiving codegraph.lock'); } function marker(root: string, graphExisted: boolean): string | null { - const file = path.join(root, '.codegraph', 'codegraph.db'); + const file = path.join(getCodeGraphDir(root), 'codegraph.db'); if (!fs.existsSync(file)) return null; const { db } = createDatabase(file); try { @@ -129,7 +130,7 @@ function replaceObserved(root: string, file: string, observed: string | null, ta if (target === null) remove(file); else durableWrite(file, target); } function cleanup(root: string, r: RecordV1, committed: boolean): void { - const stage = path.join(root, '.codegraph', `extension-stage-${r.id}.db`); + const stage = path.join(getCodeGraphDir(root), `extension-stage-${r.id}.db`); for (const suffix of ['', '-wal', '-shm', '.lock']) remove(stage + suffix); fs.rmSync(path.join(dir(root), `stage-${r.id}`), { recursive: true, force: true }); if (!committed && r.package && !r.package.existed) { @@ -140,9 +141,9 @@ function cleanup(root: string, r: RecordV1, committed: boolean): void { // All modern graph writers use the coordinator. Only remove the killed // transaction's own old graph lock; never someone else's legacy lock. if (!committed && !r.graphExisted) { - for (const suffix of ['', '-wal', '-shm']) remove(path.join(root, '.codegraph', 'codegraph.db') + suffix); + for (const suffix of ['', '-wal', '-shm']) remove(path.join(getCodeGraphDir(root), 'codegraph.db') + suffix); } - const graphLock = path.join(root, '.codegraph', 'codegraph.lock'); + const graphLock = path.join(getCodeGraphDir(root), 'codegraph.lock'); if (read(graphLock)?.trim() === String(r.pid)) remove(graphLock); } /** Validate the version/trust side before removing the only recovery record. */ @@ -229,7 +230,7 @@ export class ExtensionTransaction { readonly record: RecordV1; constructor(readonly root: string, nextConfig: string, pkg?: { contents: ExtensionPackage; integrity: string }) { if (fs.existsSync(recordPath(root))) throw new Error('An extension transaction is already active'); - this.record = { format: 1, id: randomUUID(), pid: process.pid, graphExisted: fs.existsSync(path.join(root, '.codegraph', 'codegraph.db')), + this.record = { format: 1, id: randomUUID(), pid: process.pid, graphExisted: fs.existsSync(path.join(getCodeGraphDir(root), 'codegraph.db')), previousConfig: read(path.join(root, 'codegraph.json')), nextConfig, previousTrust: read(path.join(dir(root), 'trust.json')), nextTrust: read(path.join(dir(root), 'trust.json')), ...(pkg ? { package: { integrity: pkg.integrity, existed: fs.existsSync(path.join(dir(root), 'packages', pkg.integrity)) } } : {}) };