@logicseek/hello-cc 1.1.0-rc.8 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +107 -0
- package/README.md +2 -0
- package/README.zh-CN.md +2 -0
- package/docs/README.md +2 -0
- package/docs/README.zh-CN.md +2 -0
- package/docs/commands.md +16 -0
- package/docs/commands.zh-CN.md +14 -0
- package/docs/dsh.md +6 -0
- package/docs/dsh.zh-CN.md +5 -0
- package/docs/native.md +21 -1
- package/docs/native.zh-CN.md +19 -1
- package/docs/private-state.md +115 -0
- package/docs/private-state.zh-CN.md +92 -0
- package/lib/cli/app.mjs +46 -5
- package/lib/cli/commands/hook.mjs +31 -4
- package/lib/cli/commands/mcp.mjs +6 -0
- package/lib/cli/commands/native.mjs +17 -4
- package/lib/cli/commands/peer.mjs +3 -0
- package/lib/cli/commands/run.mjs +196 -62
- package/lib/cli/commands/tmux.mjs +28 -19
- package/lib/cli/commands/up.mjs +25 -17
- package/lib/cli-runtime.mjs +16 -1
- package/lib/core/coordination/event-retention.mjs +2 -0
- package/lib/core/sessions/codex-thread-root.mjs +144 -0
- package/lib/core/sessions/launch.mjs +4 -0
- package/lib/discover.mjs +95 -231
- package/lib/integrations/dsh-collaboration.mjs +9 -2
- package/lib/integrations/native/claude.mjs +6 -0
- package/lib/integrations/native/codex.mjs +4 -2
- package/lib/integrations/native/dsh-acp.mjs +1 -1
- package/lib/integrations/native/jsonrpc.mjs +2 -2
- package/lib/mcp/scope.mjs +50 -8
- package/lib/mcp/tools.mjs +15 -2
- package/lib/process/pinned-cwd.mjs +64 -0
- package/lib/process/pinned-guidance.mjs +1 -0
- package/lib/process/pinned-project-state.mjs +1 -0
- package/lib/process/tmux-cwd-handoff.mjs +277 -0
- package/lib/runtime/native/cli-submissions.mjs +220 -0
- package/lib/runtime/native/client.mjs +7 -1
- package/lib/runtime/native/launcher.mjs +20 -4
- package/lib/runtime/native/service.mjs +143 -50
- package/lib/runtime/native/store.mjs +303 -14
- package/lib/runtime/private-state.mjs +435 -24
- package/lib/runtime/project-path.mjs +53 -1
- package/lib/runtime/project-trust.mjs +6 -2
- package/lib/runtime/state-migration.mjs +66 -13
- package/lib/shared/redact.mjs +4 -1
- package/lib/terminal/tmux.mjs +13 -2
- package/lib/ui/help.mjs +1 -0
- package/lib/web/codex-app-server.mjs +3 -1
- package/lib/web/codex-sessions.mjs +68 -8
- package/lib/web/context-files.mjs +194 -41
- package/lib/web/http-routes.mjs +43 -12
- package/lib/web/native-sessions.mjs +3 -0
- package/lib/web/project-contexts.mjs +27 -5
- package/lib/web/project-files.mjs +108 -26
- package/lib/web/pty-sessions.mjs +15 -2
- package/lib/web/session-serialize.mjs +6 -1
- package/lib/web/startup.mjs +3 -1
- package/lib/web/tmux-sessions.mjs +153 -50
- package/lib/web/ui-interactions.mjs +7 -4
- package/native/bin/darwin-arm64/hcc-cwd-handoff +0 -0
- package/native/bin/darwin-x64/hcc-cwd-handoff +0 -0
- package/native/bin/linux-arm64/hcc-cwd-handoff +0 -0
- package/native/bin/linux-x64/hcc-cwd-handoff +0 -0
- package/native/cwd-handoff/README.md +45 -0
- package/native/cwd-handoff/hcc-cwd-handoff.c +693 -0
- package/package.json +8 -2
- package/scripts/build-cwd-handoff.mjs +47 -0
- package/scripts/pack-cwd-handoff.mjs +27 -0
- package/scripts/regression.mjs +113 -7
- package/scripts/verify-cwd-handoff-package.mjs +60 -0
- package/scripts/web-workbench-acceptance.mjs +79 -1
- package/scripts/web-workbench-installed-acceptance.mjs +11 -1
|
@@ -12,7 +12,11 @@ const FILE_READ_FLAGS = fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW;
|
|
|
12
12
|
const FILE_CREATE_FLAGS = fs.constants.O_WRONLY | fs.constants.O_CREAT |
|
|
13
13
|
fs.constants.O_EXCL | fs.constants.O_NOFOLLOW;
|
|
14
14
|
const MANIFEST_NAME = '.project-root.json';
|
|
15
|
-
const
|
|
15
|
+
const LEGACY_MANIFEST_VERSION = 1;
|
|
16
|
+
const MANIFEST_VERSION = 2;
|
|
17
|
+
const GENERATION_VERSION = 2;
|
|
18
|
+
const GENERATION_NAME = /^[a-f0-9]{32}$/;
|
|
19
|
+
const DECIMAL_IDENTITY = /^(0|[1-9][0-9]*)$/;
|
|
16
20
|
const AUTHORITY_KINDS = new Set(['pending', 'fresh', 'migrated', 'purging', 'reset']);
|
|
17
21
|
const AUTHORITY_TRANSITIONS = {
|
|
18
22
|
missing: new Set(['pending', 'migrated', 'purging']),
|
|
@@ -213,14 +217,117 @@ function optionalDirectory(target, description, {
|
|
|
213
217
|
}
|
|
214
218
|
|
|
215
219
|
function expectedManifest(root) {
|
|
220
|
+
const birthtimeNs = projectBirthtime(root);
|
|
221
|
+
if (!birthtimeNs) throw forbidden(`Project directory has no stable birth time: ${root.canonical}`);
|
|
216
222
|
return {
|
|
217
223
|
version: MANIFEST_VERSION,
|
|
218
224
|
canonicalRoot: root.canonical,
|
|
219
225
|
dev: root.stat.dev.toString(),
|
|
220
|
-
ino: root.stat.ino.toString()
|
|
226
|
+
ino: root.stat.ino.toString(),
|
|
227
|
+
birthtimeNs
|
|
221
228
|
};
|
|
222
229
|
}
|
|
223
230
|
|
|
231
|
+
function projectBirthtime(project) {
|
|
232
|
+
const birthtimeNs = project.stat?.birthtimeNs;
|
|
233
|
+
return typeof birthtimeNs === 'bigint' && birthtimeNs > 0n ? birthtimeNs.toString() : null;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function validBirthtime(value) {
|
|
237
|
+
return typeof value === 'string' && /^[1-9][0-9]*$/.test(value);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function generationParent(base) {
|
|
241
|
+
return `${base}.generations`;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function validGenerationMarker(value, canonical, id) {
|
|
245
|
+
return value?.version === GENERATION_VERSION && value.generation === id &&
|
|
246
|
+
value.canonicalRoot === canonical && DECIMAL_IDENTITY.test(value.dev) &&
|
|
247
|
+
DECIMAL_IDENTITY.test(value.ino) && validBirthtime(value.birthtimeNs) &&
|
|
248
|
+
AUTHORITY_KINDS.has(value.kind);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function generationBinding(project, home) {
|
|
252
|
+
if (!project.stat) return null;
|
|
253
|
+
const base = statePath(project.canonical, home);
|
|
254
|
+
const parent = generationParent(base);
|
|
255
|
+
let entries;
|
|
256
|
+
try {
|
|
257
|
+
fs.lstatSync(parent);
|
|
258
|
+
} catch (error) {
|
|
259
|
+
if (error?.code === 'ENOENT') return null;
|
|
260
|
+
throw inspectError(error, 'private project generations', parent);
|
|
261
|
+
}
|
|
262
|
+
inspectDirectory(parent, 'private project generations', { mode: 0o700 });
|
|
263
|
+
try { entries = fs.readdirSync(parent); }
|
|
264
|
+
catch (error) { throw inspectError(error, 'private project generations', parent); }
|
|
265
|
+
if (entries.length > 256) throw forbidden(`Too many private project generations: ${parent}`);
|
|
266
|
+
const names = new Set(entries);
|
|
267
|
+
const markers = new Set();
|
|
268
|
+
let matched = null;
|
|
269
|
+
for (const name of entries) {
|
|
270
|
+
if (!name.endsWith('.authority.json')) continue;
|
|
271
|
+
const id = name.slice(0, -'.authority.json'.length);
|
|
272
|
+
if (!GENERATION_NAME.test(id)) throw forbidden(`Invalid private project generation: ${parent}`);
|
|
273
|
+
markers.add(id);
|
|
274
|
+
const actual = readManifest(path.join(parent, name));
|
|
275
|
+
if (!validGenerationMarker(actual, project.canonical, id)) {
|
|
276
|
+
throw forbidden(`Invalid private project generation marker: ${path.join(parent, name)}`);
|
|
277
|
+
}
|
|
278
|
+
if (actual.dev === project.stat.dev.toString() && actual.ino === project.stat.ino.toString() &&
|
|
279
|
+
actual.birthtimeNs === projectBirthtime(project)) {
|
|
280
|
+
if (matched) throw forbidden(`Ambiguous private project generation: ${project.canonical}`);
|
|
281
|
+
matched = { directory: path.join(parent, id), marker: path.join(parent, name),
|
|
282
|
+
generation: id, authority: actual, parent };
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
for (const name of entries) {
|
|
286
|
+
if (GENERATION_NAME.test(name)) {
|
|
287
|
+
if (!markers.has(name)) throw forbidden(`Unbound private project generation: ${path.join(parent, name)}`);
|
|
288
|
+
const entry = fs.lstatSync(path.join(parent, name));
|
|
289
|
+
if (entry.isSymbolicLink() || !entry.isDirectory()) {
|
|
290
|
+
throw forbidden(`Invalid private project generation directory: ${path.join(parent, name)}`);
|
|
291
|
+
}
|
|
292
|
+
} else if (!name.endsWith('.authority.json')) {
|
|
293
|
+
throw forbidden(`Unexpected private project generation entry: ${path.join(parent, name)}`);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
const fence = readManifest(`${base}.authority.json`);
|
|
297
|
+
if (fence?.version !== GENERATION_VERSION ||
|
|
298
|
+
!['legacy-v1', 'legacy-v2'].includes(fence.fence) ||
|
|
299
|
+
fence.canonicalRoot !== project.canonical || !DECIMAL_IDENTITY.test(fence.dev) ||
|
|
300
|
+
!DECIMAL_IDENTITY.test(fence.ino) || !AUTHORITY_KINDS.has(fence.kind) ||
|
|
301
|
+
(fence.fence === 'legacy-v1' ? fence.birthtimeNs !== null
|
|
302
|
+
: !validBirthtime(fence.birthtimeNs))) {
|
|
303
|
+
throw forbidden(`Private project generation fence is missing or invalid: ${base}`);
|
|
304
|
+
}
|
|
305
|
+
return matched;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function projectStateBinding(project, home) {
|
|
309
|
+
return generationBinding(project, home) || {
|
|
310
|
+
directory: statePath(project.canonical, home),
|
|
311
|
+
marker: `${statePath(project.canonical, home)}.authority.json`,
|
|
312
|
+
generation: null,
|
|
313
|
+
parent: path.join(home, '.hello-cc', 'projects')
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function expectedGenerationManifest(project, generation) {
|
|
318
|
+
const birthtimeNs = projectBirthtime(project);
|
|
319
|
+
if (!birthtimeNs) throw forbidden(`Project directory has no stable birth time: ${project.canonical}`);
|
|
320
|
+
return { version: GENERATION_VERSION, canonicalRoot: project.canonical,
|
|
321
|
+
dev: project.stat.dev.toString(), ino: project.stat.ino.toString(),
|
|
322
|
+
birthtimeNs, generation };
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function expectedBindingManifest(project, binding) {
|
|
326
|
+
return binding.generation
|
|
327
|
+
? expectedGenerationManifest(project, binding.generation)
|
|
328
|
+
: expectedManifest(project);
|
|
329
|
+
}
|
|
330
|
+
|
|
224
331
|
function readManifest(manifestPath) {
|
|
225
332
|
let before;
|
|
226
333
|
try { before = fs.lstatSync(manifestPath, { bigint: true }); }
|
|
@@ -253,7 +360,7 @@ function readManifest(manifestPath) {
|
|
|
253
360
|
}
|
|
254
361
|
}
|
|
255
362
|
|
|
256
|
-
function ensureManifest(directory, root, create) {
|
|
363
|
+
function ensureManifest(directory, root, create, binding) {
|
|
257
364
|
const manifestPath = path.join(directory, MANIFEST_NAME);
|
|
258
365
|
let exists;
|
|
259
366
|
try { fs.lstatSync(manifestPath); exists = true; }
|
|
@@ -265,12 +372,12 @@ function ensureManifest(directory, root, create) {
|
|
|
265
372
|
if (!create || fs.readdirSync(directory).length !== 0) {
|
|
266
373
|
throw forbidden(`Project state directory has no binding manifest: ${directory}`);
|
|
267
374
|
}
|
|
268
|
-
const temporary = path.join(path.dirname(
|
|
375
|
+
const temporary = path.join(path.dirname(binding.parent),
|
|
269
376
|
`.${path.basename(directory)}.manifest-tmp-${randomBytes(16).toString('hex')}`);
|
|
270
377
|
let fd;
|
|
271
378
|
try {
|
|
272
379
|
fd = fs.openSync(temporary, FILE_CREATE_FLAGS, 0o600);
|
|
273
|
-
fs.writeFileSync(fd, `${JSON.stringify(
|
|
380
|
+
fs.writeFileSync(fd, `${JSON.stringify(expectedBindingManifest(root, binding))}\n`);
|
|
274
381
|
fs.fchmodSync(fd, 0o600);
|
|
275
382
|
fs.fsyncSync(fd);
|
|
276
383
|
fs.closeSync(fd);
|
|
@@ -285,9 +392,16 @@ function ensureManifest(directory, root, create) {
|
|
|
285
392
|
}
|
|
286
393
|
}
|
|
287
394
|
const actual = readManifest(manifestPath);
|
|
288
|
-
const expected =
|
|
395
|
+
const expected = expectedBindingManifest(root, binding);
|
|
396
|
+
if (!binding.generation && actual?.version === LEGACY_MANIFEST_VERSION) {
|
|
397
|
+
throw new CliError('STATE_BINDING_UPGRADE_REQUIRED',
|
|
398
|
+
`Historical private project state requires explicit offline identity upgrade: ${directory}`);
|
|
399
|
+
}
|
|
289
400
|
if (actual?.version !== expected.version || actual.canonicalRoot !== expected.canonicalRoot ||
|
|
290
|
-
actual.dev !== expected.dev || actual.ino !== expected.ino
|
|
401
|
+
actual.dev !== expected.dev || actual.ino !== expected.ino ||
|
|
402
|
+
(!binding.generation && actual.birthtimeNs !== expected.birthtimeNs) ||
|
|
403
|
+
(binding.generation && (actual.birthtimeNs !== expected.birthtimeNs ||
|
|
404
|
+
actual.generation !== expected.generation))) {
|
|
291
405
|
throw forbidden(`Project root identity differs from private state binding: ${root.canonical}. ` +
|
|
292
406
|
'Back up the old private store and authority marker, stop all writers, and review both root identities ' +
|
|
293
407
|
'before manual recovery; automatic rebinding is refused.');
|
|
@@ -298,7 +412,8 @@ function assertProjectUnchanged(root) {
|
|
|
298
412
|
let current;
|
|
299
413
|
try { current = canonicalProjectRoot(root.requested); }
|
|
300
414
|
catch { throw forbidden(`Project root changed during private state validation: ${root.requested}`); }
|
|
301
|
-
if (current.canonical !== root.canonical || !sameIdentity(current.stat, root.stat)
|
|
415
|
+
if (current.canonical !== root.canonical || !sameIdentity(current.stat, root.stat) ||
|
|
416
|
+
projectBirthtime(current) !== projectBirthtime(root)) {
|
|
302
417
|
throw forbidden(`Project root changed during private state validation: ${root.requested}`);
|
|
303
418
|
}
|
|
304
419
|
}
|
|
@@ -307,24 +422,188 @@ function assertProjectUnchanged(root) {
|
|
|
307
422
|
// path solely for hashing; state access always requires an existing directory.
|
|
308
423
|
export function privateProjectStateDir(root) {
|
|
309
424
|
const project = canonicalProjectRoot(root, { allowMissing: true });
|
|
310
|
-
return
|
|
425
|
+
return projectStateBinding(project, canonicalHome({ allowMissing: true })).directory;
|
|
311
426
|
}
|
|
312
427
|
|
|
313
428
|
export function privateProjectAuthorityPath(root) {
|
|
314
|
-
|
|
429
|
+
const project = canonicalProjectRoot(root, { allowMissing: true });
|
|
430
|
+
return projectStateBinding(project, canonicalHome({ allowMissing: true })).marker;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
export function privateProjectStateManifest(root) {
|
|
434
|
+
const project = canonicalProjectRoot(root);
|
|
435
|
+
return expectedBindingManifest(project, projectStateBinding(project, canonicalHome()));
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
export function privateProjectGenerationFromPath(value) {
|
|
439
|
+
if (typeof value !== 'string' || !value) return null;
|
|
440
|
+
return value.match(/(?:^|[/\\])[a-f0-9]{64}\.generations[/\\]([a-f0-9]{32})(?:[/\\]|$)/)?.[1] || null;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
function upgradeReceipt(project, kind) {
|
|
444
|
+
return createHash('sha256').update(JSON.stringify({
|
|
445
|
+
operation: 'private-binding-v1-to-v2',
|
|
446
|
+
canonicalRoot: project.canonical,
|
|
447
|
+
dev: project.stat.dev.toString(),
|
|
448
|
+
ino: project.stat.ino.toString(),
|
|
449
|
+
birthtimeNs: projectBirthtime(project),
|
|
450
|
+
kind
|
|
451
|
+
})).digest('hex');
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function inspectBindingUpgrade(project, home) {
|
|
455
|
+
inspectHomeAncestry(home);
|
|
456
|
+
const global = path.join(home, '.hello-cc');
|
|
457
|
+
const projects = path.join(global, 'projects');
|
|
458
|
+
if (!optionalDirectory(global, 'global state directory', {
|
|
459
|
+
create: false, mode: 0o700, allowReadOnlyPublic: true
|
|
460
|
+
}) || !optionalDirectory(projects, 'private projects directory', {
|
|
461
|
+
create: false, mode: 0o700
|
|
462
|
+
})) return { status: 'not-required', receipt: null };
|
|
463
|
+
const base = statePath(project.canonical, home);
|
|
464
|
+
const marker = `${base}.authority.json`;
|
|
465
|
+
let markerExists = false;
|
|
466
|
+
try { fs.lstatSync(marker); markerExists = true; }
|
|
467
|
+
catch (error) {
|
|
468
|
+
if (error?.code !== 'ENOENT') throw error;
|
|
469
|
+
}
|
|
470
|
+
if (!markerExists) {
|
|
471
|
+
try { fs.lstatSync(base); }
|
|
472
|
+
catch (storeError) {
|
|
473
|
+
if (storeError?.code === 'ENOENT') return { status: 'not-required', receipt: null };
|
|
474
|
+
throw inspectError(storeError, 'private project state', base);
|
|
475
|
+
}
|
|
476
|
+
throw forbidden(`Historical private project state has no authority marker: ${base}`);
|
|
477
|
+
}
|
|
478
|
+
const authority = readManifest(marker);
|
|
479
|
+
inspectDirectory(base, 'historical private project state', { mode: 0o700 });
|
|
480
|
+
if (authority.version === LEGACY_MANIFEST_VERSION && authority.kind === 'pending') {
|
|
481
|
+
let manifestPresent = false;
|
|
482
|
+
try { fs.lstatSync(path.join(base, MANIFEST_NAME)); manifestPresent = true; }
|
|
483
|
+
catch (error) {
|
|
484
|
+
if (error?.code !== 'ENOENT') throw inspectError(error, 'project state manifest', base);
|
|
485
|
+
}
|
|
486
|
+
if (!manifestPresent) {
|
|
487
|
+
throw new CliError('STATE_BINDING_MANUAL_RECOVERY_REQUIRED',
|
|
488
|
+
`Historical v1 initialization has no committed root manifest: ${base}. ` +
|
|
489
|
+
'Cold-drain writers, back up the root, private directory and authority marker, ' +
|
|
490
|
+
'and inspect any project-local legacy state before manual recovery. ' +
|
|
491
|
+
'Do not purge or fabricate a v2 marker.');
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
const manifest = readManifest(path.join(base, MANIFEST_NAME));
|
|
495
|
+
const identity = expectedManifest(project);
|
|
496
|
+
const kind = authority.kind ?? 'migrated';
|
|
497
|
+
if (!['fresh', 'migrated'].includes(kind) ||
|
|
498
|
+
authority.canonicalRoot !== project.canonical ||
|
|
499
|
+
authority.dev !== identity.dev || authority.ino !== identity.ino ||
|
|
500
|
+
manifest.canonicalRoot !== project.canonical ||
|
|
501
|
+
manifest.dev !== identity.dev || manifest.ino !== identity.ino) {
|
|
502
|
+
throw forbidden(`Cannot assert that current root owns historical private state: ${base}`);
|
|
503
|
+
}
|
|
504
|
+
if (authority.version === MANIFEST_VERSION && authority.fence === undefined &&
|
|
505
|
+
manifest.version === MANIFEST_VERSION && authority.birthtimeNs === identity.birthtimeNs &&
|
|
506
|
+
manifest.birthtimeNs === identity.birthtimeNs) {
|
|
507
|
+
return { status: 'already-current', receipt: null, stateDir: base };
|
|
508
|
+
}
|
|
509
|
+
const receipt = upgradeReceipt(project, kind);
|
|
510
|
+
const historical = authority.version === LEGACY_MANIFEST_VERSION &&
|
|
511
|
+
manifest.version === LEGACY_MANIFEST_VERSION;
|
|
512
|
+
const pending = authority.version === MANIFEST_VERSION &&
|
|
513
|
+
authority.fence === 'upgrade-pending' && authority.upgradeReceipt === receipt &&
|
|
514
|
+
authority.birthtimeNs === identity.birthtimeNs &&
|
|
515
|
+
(manifest.version === LEGACY_MANIFEST_VERSION ||
|
|
516
|
+
(manifest.version === MANIFEST_VERSION && manifest.birthtimeNs === identity.birthtimeNs));
|
|
517
|
+
if (!historical && !pending) {
|
|
518
|
+
throw forbidden(`Private binding upgrade state needs manual recovery: ${base}`);
|
|
519
|
+
}
|
|
520
|
+
return {
|
|
521
|
+
status: pending ? 'pending' : 'required', receipt, stateDir: base,
|
|
522
|
+
canonicalRoot: project.canonical,
|
|
523
|
+
rootIdentity: { dev: identity.dev, ino: identity.ino,
|
|
524
|
+
birthtimeNs: identity.birthtimeNs },
|
|
525
|
+
warning: 'Receipt records current filesystem metadata, not proof that this root is historical A. '
|
|
526
|
+
+ 'Proceed only after verifying A independently and cold-draining every old writer.'
|
|
527
|
+
};
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
export function inspectPrivateProjectBindingUpgrade(root) {
|
|
531
|
+
const project = canonicalProjectRoot(root);
|
|
532
|
+
return inspectBindingUpgrade(project, canonicalHome());
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
export function upgradePrivateProjectBinding(root, {
|
|
536
|
+
confirmedOffline = false, confirmedHistoricalRoot = false,
|
|
537
|
+
expectedReceipt = null, assertOffline = null
|
|
538
|
+
} = {}) {
|
|
539
|
+
if (!confirmedOffline || !confirmedHistoricalRoot ||
|
|
540
|
+
typeof expectedReceipt !== 'string' || !/^[a-f0-9]{64}$/.test(expectedReceipt) ||
|
|
541
|
+
typeof assertOffline !== 'function') {
|
|
542
|
+
throw new CliError('STATE_BINDING_UPGRADE_CONFIRMATION_REQUIRED',
|
|
543
|
+
'Private binding upgrade requires --offline, --yes, --assert-historical-root and an exact inspection receipt');
|
|
544
|
+
}
|
|
545
|
+
const project = canonicalProjectRoot(root);
|
|
546
|
+
const original = expectedManifest(project);
|
|
547
|
+
return withPrivateProjectStateTransition(root, ({ projects, home }) => {
|
|
548
|
+
assertFullProjectIdentity(project);
|
|
549
|
+
const inspected = inspectBindingUpgrade(project, home);
|
|
550
|
+
if (inspected.status === 'already-current' || inspected.status === 'not-required') return inspected;
|
|
551
|
+
if (inspected.receipt !== expectedReceipt ||
|
|
552
|
+
original.birthtimeNs !== inspected.rootIdentity.birthtimeNs) {
|
|
553
|
+
throw new CliError('STATE_BINDING_UPGRADE_RECEIPT_MISMATCH',
|
|
554
|
+
'Private binding inspection receipt or root identity changed; inspect again before upgrading');
|
|
555
|
+
}
|
|
556
|
+
if (assertOffline({ phase: 'before', stateDir: inspected.stateDir }) !== true) {
|
|
557
|
+
throw new CliError('STATE_BINDING_UPGRADE_OFFLINE_REQUIRED',
|
|
558
|
+
'Cannot establish cold-drained private state');
|
|
559
|
+
}
|
|
560
|
+
const marker = `${inspected.stateDir}.authority.json`;
|
|
561
|
+
const manifestPath = path.join(inspected.stateDir, MANIFEST_NAME);
|
|
562
|
+
let authority = readManifest(marker);
|
|
563
|
+
if (inspected.status === 'required') {
|
|
564
|
+
publishPrivateManifest(marker, { ...original, kind: authority.kind ?? 'migrated',
|
|
565
|
+
fence: 'upgrade-pending', upgradeReceipt: expectedReceipt }, projects, authority);
|
|
566
|
+
authority = readManifest(marker);
|
|
567
|
+
}
|
|
568
|
+
// Once the fence is durable, v1 readers reject the store. A crash in this
|
|
569
|
+
// gap leaves a retryable pending state; v2 readers also reject it.
|
|
570
|
+
const manifest = readManifest(manifestPath);
|
|
571
|
+
if (manifest.version === LEGACY_MANIFEST_VERSION) {
|
|
572
|
+
publishPrivateManifest(manifestPath, original, inspected.stateDir, manifest);
|
|
573
|
+
}
|
|
574
|
+
assertFullProjectIdentity(project);
|
|
575
|
+
if (assertOffline({ phase: 'after', stateDir: inspected.stateDir }) !== true) {
|
|
576
|
+
throw new CliError('STATE_BINDING_UPGRADE_OFFLINE_REQUIRED',
|
|
577
|
+
'Private binding remains fenced because offline evidence changed');
|
|
578
|
+
}
|
|
579
|
+
publishPrivateManifest(marker, { ...original, kind: authority.kind }, projects, authority);
|
|
580
|
+
assertFullProjectIdentity(project);
|
|
581
|
+
return { status: 'upgraded', stateDir: inspected.stateDir,
|
|
582
|
+
canonicalRoot: project.canonical, rootIdentity: inspected.rootIdentity };
|
|
583
|
+
});
|
|
315
584
|
}
|
|
316
585
|
|
|
317
586
|
export function inspectPrivateProjectAuthority(root) {
|
|
318
587
|
const project = canonicalProjectRoot(root);
|
|
319
|
-
const
|
|
588
|
+
const binding = projectStateBinding(project, canonicalHome());
|
|
589
|
+
const marker = binding.marker;
|
|
320
590
|
try { fs.lstatSync(marker); }
|
|
321
591
|
catch (error) {
|
|
322
592
|
if (error?.code === 'ENOENT') return null;
|
|
323
593
|
throw inspectError(error, 'project authority marker', marker);
|
|
324
594
|
}
|
|
325
595
|
const actual = readManifest(marker);
|
|
326
|
-
const expected =
|
|
327
|
-
if (
|
|
596
|
+
const expected = expectedBindingManifest(project, binding);
|
|
597
|
+
if (!binding.generation && actual?.version === LEGACY_MANIFEST_VERSION) {
|
|
598
|
+
throw new CliError('STATE_BINDING_UPGRADE_REQUIRED',
|
|
599
|
+
`Historical private project state requires explicit offline identity upgrade: ${marker}`);
|
|
600
|
+
}
|
|
601
|
+
const valid = binding.generation
|
|
602
|
+
? actual?.version === expected.version && actual?.generation === expected.generation &&
|
|
603
|
+
actual?.birthtimeNs === expected.birthtimeNs
|
|
604
|
+
: actual?.version === expected.version && actual?.birthtimeNs === expected.birthtimeNs &&
|
|
605
|
+
(actual.fence === undefined || actual.fence === 'legacy-v2');
|
|
606
|
+
if (!valid || actual.canonicalRoot !== expected.canonicalRoot ||
|
|
328
607
|
actual.dev !== expected.dev || actual.ino !== expected.ino) {
|
|
329
608
|
throw forbidden(`Project authority marker differs from project root: ${marker}. ` +
|
|
330
609
|
'Back up the old private store and authority marker, stop all writers, and review both root identities ' +
|
|
@@ -344,16 +623,141 @@ function fsyncDirectory(directory) {
|
|
|
344
623
|
finally { fs.closeSync(fd); }
|
|
345
624
|
}
|
|
346
625
|
|
|
626
|
+
function publishPrivateManifest(marker, contents, parent, expected = null) {
|
|
627
|
+
const temporary = path.join(path.dirname(parent),
|
|
628
|
+
`.${path.basename(marker)}.tmp-${randomBytes(16).toString('hex')}`);
|
|
629
|
+
let fd;
|
|
630
|
+
try {
|
|
631
|
+
fd = fs.openSync(temporary, FILE_CREATE_FLAGS, 0o600);
|
|
632
|
+
fs.writeFileSync(fd, `${JSON.stringify(contents)}\n`);
|
|
633
|
+
fs.fchmodSync(fd, 0o600);
|
|
634
|
+
fs.fsyncSync(fd);
|
|
635
|
+
fs.closeSync(fd);
|
|
636
|
+
fd = undefined;
|
|
637
|
+
if (expected && JSON.stringify(readManifest(marker)) !== JSON.stringify(expected)) {
|
|
638
|
+
throw forbidden(`Private project marker changed during publication: ${marker}`);
|
|
639
|
+
}
|
|
640
|
+
fs.renameSync(temporary, marker);
|
|
641
|
+
fsyncDirectory(parent);
|
|
642
|
+
} finally {
|
|
643
|
+
if (fd !== undefined) fs.closeSync(fd);
|
|
644
|
+
try { fs.unlinkSync(temporary); } catch { /* An inert private temp is safe to leave. */ }
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
function assertFullProjectIdentity(project, selected = null) {
|
|
649
|
+
const current = canonicalProjectRoot(project.requested);
|
|
650
|
+
if (current.canonical !== project.canonical || !sameIdentity(current.stat, project.stat) ||
|
|
651
|
+
projectBirthtime(current) !== projectBirthtime(project) ||
|
|
652
|
+
(selected && (selected.canonical !== project.canonical ||
|
|
653
|
+
selected.identity?.dev !== project.stat.dev.toString() ||
|
|
654
|
+
selected.identity?.ino !== project.stat.ino.toString() ||
|
|
655
|
+
selected.identity?.birthtimeNs !== projectBirthtime(project)))) {
|
|
656
|
+
throw forbidden(`Project root changed during private generation selection: ${project.requested}`);
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
// A replacement root gets a separate store only after an explicit selection.
|
|
661
|
+
// The v2 fence at the v1 pathname makes older binaries reject *both* roots,
|
|
662
|
+
// while retaining A's original directory and manifest byte-for-byte.
|
|
663
|
+
export function provisionPrivateProjectGeneration(root, { expectedIdentity = null } = {}) {
|
|
664
|
+
const project = canonicalProjectRoot(root);
|
|
665
|
+
// Refuse unknown creation times before fencing an old binding or creating
|
|
666
|
+
// generation records. Zero is an unavailable value, not a stable identity.
|
|
667
|
+
expectedManifest(project);
|
|
668
|
+
assertFullProjectIdentity(project, expectedIdentity);
|
|
669
|
+
const home = canonicalHome();
|
|
670
|
+
const base = statePath(project.canonical, home);
|
|
671
|
+
return withPrivateProjectStateTransition(project.requested, ({ projects }) => {
|
|
672
|
+
assertFullProjectIdentity(project, expectedIdentity);
|
|
673
|
+
const existing = generationBinding(project, home);
|
|
674
|
+
if (existing) return existing.directory;
|
|
675
|
+
const marker = `${base}.authority.json`;
|
|
676
|
+
let markerExists = false;
|
|
677
|
+
try { fs.lstatSync(marker); markerExists = true; }
|
|
678
|
+
catch (error) { if (error?.code !== 'ENOENT') throw inspectError(error, 'project authority marker', marker); }
|
|
679
|
+
if (!markerExists) {
|
|
680
|
+
try { fs.lstatSync(base); }
|
|
681
|
+
catch (error) { if (error?.code === 'ENOENT') return null; throw inspectError(error, 'private project state', base); }
|
|
682
|
+
throw forbidden(`Historical private project state has no authority marker: ${base}`);
|
|
683
|
+
}
|
|
684
|
+
let old;
|
|
685
|
+
old = readManifest(marker);
|
|
686
|
+
if (![LEGACY_MANIFEST_VERSION, GENERATION_VERSION].includes(old.version) ||
|
|
687
|
+
(old.version === GENERATION_VERSION &&
|
|
688
|
+
((old.fence && !['legacy-v1', 'legacy-v2'].includes(old.fence)) ||
|
|
689
|
+
(old.fence !== 'legacy-v1' && !validBirthtime(old.birthtimeNs)))) ||
|
|
690
|
+
old.canonicalRoot !== project.canonical ||
|
|
691
|
+
!DECIMAL_IDENTITY.test(old.dev) || !DECIMAL_IDENTITY.test(old.ino) ||
|
|
692
|
+
!['fresh', 'migrated'].includes(old.version === LEGACY_MANIFEST_VERSION
|
|
693
|
+
? old.kind ?? 'migrated' : old.kind)) {
|
|
694
|
+
throw forbidden(`Existing private project authority requires recovery: ${marker}`);
|
|
695
|
+
}
|
|
696
|
+
if (old.version === LEGACY_MANIFEST_VERSION) {
|
|
697
|
+
throw new CliError('STATE_BINDING_UPGRADE_REQUIRED',
|
|
698
|
+
`Historical v1 private state must be upgraded before selecting a replacement root: ${marker}. ` +
|
|
699
|
+
'Restore and independently confirm historical A at this canonical path, cold-drain every writer, ' +
|
|
700
|
+
'run migrate-state --inspect-private-binding, then migrate-state --upgrade-private-binding ' +
|
|
701
|
+
'--offline --yes --assert-historical-root --expect-receipt=SHA256, and only then select B. ' +
|
|
702
|
+
'Matching dev/ino alone does not prove the current root is A.');
|
|
703
|
+
}
|
|
704
|
+
if (old.fence === 'legacy-v1') {
|
|
705
|
+
throw new CliError('STATE_BINDING_MANUAL_RECOVERY_REQUIRED',
|
|
706
|
+
`A historical v1 store was already fenced without a saved root birth time: ${marker}. ` +
|
|
707
|
+
'Preserve both stores, cold-drain writers, and review the original A root and generation records ' +
|
|
708
|
+
'before manual recovery; further automatic provisioning is refused.');
|
|
709
|
+
}
|
|
710
|
+
if (old.dev === project.stat.dev.toString() && old.ino === project.stat.ino.toString()) {
|
|
711
|
+
if (old.birthtimeNs === projectBirthtime(project) && old.fence !== 'legacy-v1') {
|
|
712
|
+
return null; // The fully identified original v2 root is still selected.
|
|
713
|
+
}
|
|
714
|
+
if (old.birthtimeNs === null) {
|
|
715
|
+
throw forbidden(`Historical project identity is ambiguous after fencing: ${project.canonical}`);
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
inspectDirectory(base, 'historical private project state', { mode: 0o700 });
|
|
719
|
+
const oldManifest = readManifest(path.join(base, MANIFEST_NAME));
|
|
720
|
+
if (oldManifest.version !== MANIFEST_VERSION || oldManifest.canonicalRoot !== old.canonicalRoot ||
|
|
721
|
+
oldManifest.dev !== old.dev || oldManifest.ino !== old.ino ||
|
|
722
|
+
oldManifest.birthtimeNs !== old.birthtimeNs) {
|
|
723
|
+
throw forbidden(`Historical private project state does not match its authority: ${base}`);
|
|
724
|
+
}
|
|
725
|
+
if (!old.fence) {
|
|
726
|
+
publishPrivateManifest(marker, { ...old, fence: 'legacy-v2' }, projects, old);
|
|
727
|
+
}
|
|
728
|
+
const parent = generationParent(base);
|
|
729
|
+
optionalDirectory(parent, 'private project generations', {
|
|
730
|
+
create: true, mode: 0o700, tighten: true
|
|
731
|
+
});
|
|
732
|
+
fsyncDirectory(projects);
|
|
733
|
+
const id = randomBytes(16).toString('hex');
|
|
734
|
+
const generationMarker = path.join(parent, `${id}.authority.json`);
|
|
735
|
+
if (fs.existsSync(generationMarker) || fs.existsSync(path.join(parent, id))) {
|
|
736
|
+
throw forbidden(`Private project generation identifier already exists: ${id}`);
|
|
737
|
+
}
|
|
738
|
+
publishPrivateManifest(generationMarker,
|
|
739
|
+
{ ...expectedGenerationManifest(project, id), kind: 'pending' }, parent);
|
|
740
|
+
assertFullProjectIdentity(project, expectedIdentity);
|
|
741
|
+
return path.join(parent, id);
|
|
742
|
+
});
|
|
743
|
+
}
|
|
744
|
+
|
|
347
745
|
function writePrivateProjectAuthorityKind(project, home, projects, kind) {
|
|
348
746
|
if (!AUTHORITY_KINDS.has(kind)) throw forbidden(`Invalid project authority kind: ${kind}`);
|
|
349
|
-
const
|
|
747
|
+
const binding = projectStateBinding(project, home);
|
|
748
|
+
const marker = binding.marker;
|
|
350
749
|
const current = inspectPrivateProjectAuthority(project.canonical);
|
|
351
750
|
if (current === kind) return marker;
|
|
352
751
|
if (!AUTHORITY_TRANSITIONS[current ?? 'missing']?.has(kind)) {
|
|
353
752
|
throw forbidden(`Invalid project authority transition ${current ?? 'missing'} -> ${kind}: ${marker}`);
|
|
354
753
|
}
|
|
355
|
-
const
|
|
356
|
-
const
|
|
754
|
+
const previous = current === null ? null : readManifest(marker);
|
|
755
|
+
const manifest = binding.generation
|
|
756
|
+
? expectedGenerationManifest(project, binding.generation)
|
|
757
|
+
: { ...expectedManifest(project), ...(previous?.fence ? { fence: previous.fence } : {}) };
|
|
758
|
+
const contents = `${JSON.stringify({ ...manifest, kind })}\n`;
|
|
759
|
+
const temporary = path.join(path.dirname(binding.parent),
|
|
760
|
+
`.${path.basename(marker)}.tmp-${randomBytes(16).toString('hex')}`);
|
|
357
761
|
let fd;
|
|
358
762
|
try {
|
|
359
763
|
fd = fs.openSync(temporary, FILE_CREATE_FLAGS, 0o600);
|
|
@@ -374,7 +778,7 @@ function writePrivateProjectAuthorityKind(project, home, projects, kind) {
|
|
|
374
778
|
if (fd !== undefined) fs.closeSync(fd);
|
|
375
779
|
try { fs.unlinkSync(temporary); } catch { /* A private temp is inert. */ }
|
|
376
780
|
}
|
|
377
|
-
fsyncDirectory(
|
|
781
|
+
fsyncDirectory(binding.parent);
|
|
378
782
|
assertProjectUnchanged(project);
|
|
379
783
|
return marker;
|
|
380
784
|
}
|
|
@@ -395,12 +799,13 @@ export function withPrivateProjectStateTransition(root, fn) {
|
|
|
395
799
|
optionalDirectory(projects, 'private projects directory', {
|
|
396
800
|
create: true, mode: 0o700, tighten: true
|
|
397
801
|
});
|
|
398
|
-
const
|
|
399
|
-
return withFileLock(`${
|
|
802
|
+
const legacyDirectory = statePath(project.canonical, home);
|
|
803
|
+
return withFileLock(`${legacyDirectory}.transition`, () => {
|
|
400
804
|
inspectHomeAncestry(home);
|
|
401
805
|
inspectDirectory(global, 'global state directory', { mode: 0o700 });
|
|
402
806
|
inspectDirectory(projects, 'private projects directory', { mode: 0o700 });
|
|
403
807
|
assertProjectUnchanged(project);
|
|
808
|
+
const directory = projectStateBinding(project, home).directory;
|
|
404
809
|
const result = fn({
|
|
405
810
|
directory, projects, project, home,
|
|
406
811
|
authorityKind: () => inspectPrivateProjectAuthority(project.canonical),
|
|
@@ -448,7 +853,8 @@ function inspectPrivateProjectStateDir(project, home, create, setAuthorityKind =
|
|
|
448
853
|
inspectHomeAncestry(home);
|
|
449
854
|
const global = path.join(home, '.hello-cc');
|
|
450
855
|
const projects = path.join(global, 'projects');
|
|
451
|
-
const
|
|
856
|
+
const binding = projectStateBinding(project, home);
|
|
857
|
+
const directory = binding.directory;
|
|
452
858
|
|
|
453
859
|
// A pre-existing 0755 global directory can be read without mutation. Only
|
|
454
860
|
// create mode may tighten it, and writable-by-others directories are rejected.
|
|
@@ -483,6 +889,11 @@ function inspectPrivateProjectStateDir(project, home, create, setAuthorityKind =
|
|
|
483
889
|
if (error?.code !== 'ENOENT') throw inspectError(error, 'project state manifest', directory);
|
|
484
890
|
}
|
|
485
891
|
}
|
|
892
|
+
if (manifestExists && authority === null && !binding.generation &&
|
|
893
|
+
readManifest(path.join(directory, MANIFEST_NAME)).version === LEGACY_MANIFEST_VERSION) {
|
|
894
|
+
throw new CliError('STATE_BINDING_UPGRADE_REQUIRED',
|
|
895
|
+
`Markerless historical private state requires reviewed offline recovery: ${directory}`);
|
|
896
|
+
}
|
|
486
897
|
if (create && !manifestExists) {
|
|
487
898
|
// Resolver callers also check the legacy source. Repeat at the actual
|
|
488
899
|
// transition boundary: a concurrent purge can invalidate their earlier
|
|
@@ -506,9 +917,9 @@ function inspectPrivateProjectStateDir(project, home, create, setAuthorityKind =
|
|
|
506
917
|
create: Boolean(create), mode: 0o700, tighten: true
|
|
507
918
|
})) return null;
|
|
508
919
|
|
|
509
|
-
ensureManifest(directory, project, Boolean(create));
|
|
920
|
+
ensureManifest(directory, project, Boolean(create), binding);
|
|
510
921
|
if (create && (authority === null || authority === 'pending' || authority === 'reset')) {
|
|
511
|
-
fsyncDirectory(
|
|
922
|
+
fsyncDirectory(binding.parent);
|
|
512
923
|
setAuthorityKind('fresh');
|
|
513
924
|
}
|
|
514
925
|
assertProjectUnchanged(project);
|
|
@@ -526,7 +937,7 @@ export function ensurePrivateProjectStateDir(root, { create = false } = {}) {
|
|
|
526
937
|
// The purging marker is a durable intent record. If deletion is interrupted,
|
|
527
938
|
// normal state access stays closed and an explicit purge retry completes it.
|
|
528
939
|
export function purgePrivateProjectState(root) {
|
|
529
|
-
return withPrivateProjectStateTransition(root, ({ directory,
|
|
940
|
+
return withPrivateProjectStateTransition(root, ({ directory, authorityKind, setAuthorityKind }) => {
|
|
530
941
|
// The directory may be partially deleted after an earlier crash, so its
|
|
531
942
|
// manifest need not remain. Its private ownership and no-follow identity
|
|
532
943
|
// are still required before recursive deletion.
|
|
@@ -539,7 +950,7 @@ export function purgePrivateProjectState(root) {
|
|
|
539
950
|
authorityKind();
|
|
540
951
|
setAuthorityKind('purging');
|
|
541
952
|
fs.rmSync(directory, { recursive: true, force: true });
|
|
542
|
-
fsyncDirectory(
|
|
953
|
+
fsyncDirectory(path.dirname(directory));
|
|
543
954
|
setAuthorityKind('reset');
|
|
544
955
|
return directory;
|
|
545
956
|
});
|
|
@@ -221,13 +221,15 @@ function validateDatabaseTarget(stateDir, db) {
|
|
|
221
221
|
}
|
|
222
222
|
}
|
|
223
223
|
|
|
224
|
-
|
|
224
|
+
function databaseLocation({ root, db }) {
|
|
225
225
|
const requestedRoot = path.resolve(String(root || ''));
|
|
226
226
|
const canonicalRoot = canonicalProjectRoot(requestedRoot);
|
|
227
227
|
const rawDb = path.resolve(String(db || path.join(requestedRoot, '.hello-cc', 'mesh.db')));
|
|
228
228
|
const intendedStateDir = projectStateDir(canonicalRoot);
|
|
229
229
|
const requestedLegacyState = legacyProjectStateDir(requestedRoot);
|
|
230
230
|
const canonicalLegacyState = legacyProjectStateDir(canonicalRoot);
|
|
231
|
+
const legacySpelling = pathIsWithin(requestedLegacyState, rawDb) ||
|
|
232
|
+
pathIsWithin(canonicalLegacyState, rawDb);
|
|
231
233
|
let requestedDb = pathIsWithin(requestedLegacyState, rawDb)
|
|
232
234
|
? path.join(intendedStateDir, path.relative(requestedLegacyState, rawDb))
|
|
233
235
|
: pathIsWithin(canonicalLegacyState, rawDb)
|
|
@@ -246,6 +248,56 @@ export function resolveProjectDatabase({ root, db, createStateDir = false, creat
|
|
|
246
248
|
if (!pathIsWithin(intendedStateDir, requestedDb)) {
|
|
247
249
|
throw projectPathForbidden(`Database path must live under ${intendedStateDir}`);
|
|
248
250
|
}
|
|
251
|
+
return { canonicalRoot, intendedStateDir, requestedDb, legacySpelling };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Check an explicitly requested DB path without creating a state directory,
|
|
255
|
+
// claiming a generation, or tightening permissions. The mutating resolver
|
|
256
|
+
// repeats all checks after selection; this is an admission guard, not a grant.
|
|
257
|
+
export function prevalidateProjectDatabaseLocation({ root, db }) {
|
|
258
|
+
const location = databaseLocation({ root, db });
|
|
259
|
+
const { canonicalRoot, intendedStateDir, requestedDb, legacySpelling } = location;
|
|
260
|
+
// A direct private path from A must not be used to provision replacement B.
|
|
261
|
+
// A legacy spelling is remapped to B's new generation after selection.
|
|
262
|
+
if (!legacySpelling && intendedStateDir !== legacyProjectStateDir(canonicalRoot)) {
|
|
263
|
+
inspectPrivateProjectAuthority(canonicalRoot);
|
|
264
|
+
}
|
|
265
|
+
const canonicalCandidate = canonicalCandidateFromExistingParent(requestedDb);
|
|
266
|
+
if (!canonicalCandidate || !pathIsWithin(intendedStateDir, canonicalCandidate)) {
|
|
267
|
+
throw projectPathForbidden(`Database path escapes project state directory: ${requestedDb}`);
|
|
268
|
+
}
|
|
269
|
+
for (let current = intendedStateDir;;) {
|
|
270
|
+
let stat;
|
|
271
|
+
try { stat = fs.lstatSync(current); }
|
|
272
|
+
catch (error) {
|
|
273
|
+
if (error?.code !== 'ENOENT') {
|
|
274
|
+
throw projectPathForbidden(`Cannot inspect database parent: ${current}`);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
if (stat && (stat.isSymbolicLink() || !stat.isDirectory())) {
|
|
278
|
+
throw projectPathForbidden(`Database parent must be a real directory: ${current}`);
|
|
279
|
+
}
|
|
280
|
+
if (current === path.dirname(requestedDb)) break;
|
|
281
|
+
const next = path.join(current, path.relative(current, path.dirname(requestedDb)).split(path.sep)[0]);
|
|
282
|
+
if (next === current) throw projectPathForbidden(`Invalid database parent: ${requestedDb}`);
|
|
283
|
+
current = next;
|
|
284
|
+
}
|
|
285
|
+
try {
|
|
286
|
+
const target = fs.lstatSync(requestedDb);
|
|
287
|
+
if (target.isSymbolicLink() || !target.isFile()) {
|
|
288
|
+
throw projectPathForbidden(`Project database must be a regular file: ${requestedDb}`);
|
|
289
|
+
}
|
|
290
|
+
} catch (error) {
|
|
291
|
+
if (error?.code !== 'ENOENT') {
|
|
292
|
+
if (error instanceof CliError) throw error;
|
|
293
|
+
throw projectPathForbidden(`Cannot inspect project database: ${requestedDb}`);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
return location;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
export function resolveProjectDatabase({ root, db, createStateDir = false, createDatabaseParents = false }) {
|
|
300
|
+
const { canonicalRoot, intendedStateDir, requestedDb } = databaseLocation({ root, db });
|
|
249
301
|
const state = ensureStateDirectory(canonicalRoot, Boolean(createStateDir));
|
|
250
302
|
if (!state.exists) {
|
|
251
303
|
return { root: canonicalRoot, stateDir: state.stateDir, db: requestedDb };
|