@logicseek/hello-cc 1.0.2 → 1.1.0-rc.1

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.
Files changed (79) hide show
  1. package/CHANGELOG.md +111 -23
  2. package/README.md +10 -1
  3. package/README.zh-CN.md +8 -1
  4. package/docs/commands.md +28 -4
  5. package/docs/commands.zh-CN.md +20 -4
  6. package/docs/dsh.md +2 -2
  7. package/docs/dsh.zh-CN.md +3 -3
  8. package/docs/native.md +84 -10
  9. package/docs/native.zh-CN.md +66 -5
  10. package/docs/web-handoff.zh-CN.md +11 -3
  11. package/lib/cli/app.mjs +42 -1
  12. package/lib/cli/commands/coordination.mjs +7 -2
  13. package/lib/cli/commands/dsh.mjs +1 -1
  14. package/lib/cli/commands/gc.mjs +9 -4
  15. package/lib/cli/commands/hook.mjs +6 -1
  16. package/lib/cli/commands/install.mjs +48 -6
  17. package/lib/cli/commands/lifecycle.mjs +14 -3
  18. package/lib/cli/commands/native.mjs +16 -9
  19. package/lib/cli/commands/query.mjs +15 -10
  20. package/lib/cli/commands/run.mjs +7 -6
  21. package/lib/core/coordination/results.mjs +27 -10
  22. package/lib/db/connection.mjs +135 -10
  23. package/lib/db/schema.mjs +59 -0
  24. package/lib/discover.mjs +4 -4
  25. package/lib/guidance.mjs +3 -2
  26. package/lib/integrations/codex-interactions.mjs +13 -11
  27. package/lib/integrations/dsh-collaboration.mjs +6 -12
  28. package/lib/integrations/dsh-cordis.mjs +17 -13
  29. package/lib/integrations/dsh.mjs +12 -9
  30. package/lib/integrations/hooks.mjs +132 -40
  31. package/lib/integrations/mcp-elicitation.mjs +116 -0
  32. package/lib/integrations/mcp-url-elicitation.mjs +25 -0
  33. package/lib/integrations/native/interactions.mjs +2 -1
  34. package/lib/integrations/peers/identity.mjs +42 -7
  35. package/lib/process/identity.mjs +15 -14
  36. package/lib/runtime/buffer-evidence.mjs +2 -1
  37. package/lib/runtime/native/service.mjs +11 -5
  38. package/lib/runtime/native/store.mjs +70 -18
  39. package/lib/runtime/paths.mjs +17 -2
  40. package/lib/runtime/private-file.mjs +76 -0
  41. package/lib/runtime/private-state.mjs +546 -0
  42. package/lib/runtime/project-context.mjs +2 -1
  43. package/lib/runtime/project-path.mjs +148 -12
  44. package/lib/runtime/project-trust.mjs +79 -0
  45. package/lib/runtime/projects.mjs +6 -3
  46. package/lib/runtime/state-migration.mjs +697 -0
  47. package/lib/runtime/state.mjs +22 -11
  48. package/lib/shared/automatic-result-key.mjs +6 -0
  49. package/lib/shared/json-file.mjs +55 -6
  50. package/lib/ui/help.mjs +6 -4
  51. package/lib/web/codex-app-server.mjs +26 -5
  52. package/lib/web/codex-sessions.mjs +5 -3
  53. package/lib/web/cookie-auth.mjs +1 -1
  54. package/lib/web/external-sessions.mjs +54 -5
  55. package/lib/web/http-routes.mjs +13 -9
  56. package/lib/web/http.mjs +20 -2
  57. package/lib/web/native-sessions.mjs +14 -7
  58. package/lib/web/peer-actions.mjs +27 -27
  59. package/lib/web/project-contexts.mjs +2 -3
  60. package/lib/web/runtime-main.mjs +22 -5
  61. package/lib/web/runtime.mjs +18 -4
  62. package/lib/web/session-serialize.mjs +1 -1
  63. package/lib/web/startup.mjs +115 -22
  64. package/lib/web/task-results.mjs +20 -17
  65. package/lib/web/terminal-assets.mjs +18 -0
  66. package/lib/web/tls.mjs +84 -33
  67. package/lib/web/tmux-sessions.mjs +2 -2
  68. package/lib/web/tmux-stream.mjs +3 -4
  69. package/lib/web/ui-codex.mjs +161 -32
  70. package/lib/web/ui-interactions.mjs +137 -4
  71. package/lib/web/ui-native.mjs +31 -13
  72. package/lib/web/ui-template.mjs +10 -3
  73. package/package.json +1 -1
  74. package/scripts/mcp-form-live-fixture.mjs +81 -0
  75. package/scripts/native-installed-acceptance.mjs +413 -0
  76. package/scripts/regression.mjs +46 -29
  77. package/scripts/web-mcp-form-acceptance.mjs +119 -0
  78. package/scripts/web-mcp-form-live-acceptance.mjs +368 -0
  79. package/scripts/web-native-interaction-acceptance.mjs +77 -10
@@ -0,0 +1,697 @@
1
+ /**
2
+ * Explicit, offline migration of the legacy project-local state directory.
3
+ *
4
+ * The caller must first stop every producer (including external/unknown DB
5
+ * writers), obtain the user's confirmation, and supply a synchronous
6
+ * assertOffline callback that returns true both before and after the copy.
7
+ * Process pointers are checked here as additional evidence, not as proof that
8
+ * an unregistered writer cannot exist. No process is stopped by this module.
9
+ *
10
+ * A dedicated child process anchors its cwd in the validated legacy directory.
11
+ * Its source reads use relative names and no-follow opens. In particular, a
12
+ * rename/replacement of the project root cannot redirect subsequent reads.
13
+ * SQLite files and WALs are first copied into a private scratch directory;
14
+ * DatabaseSync then VACUUMs that copy into a checked, single-file snapshot.
15
+ * This keeps the legacy source byte-for-byte untouched by SQLite recovery.
16
+ * Hash-verified legacy DSH managed artifacts are archived inside the new
17
+ * private state; the returned rebuildCommand recreates path-bound artifacts.
18
+ */
19
+ import { spawnSync } from 'node:child_process';
20
+ import { createHash, randomBytes } from 'node:crypto';
21
+ import fs from 'node:fs';
22
+ import path from 'node:path';
23
+ import process from 'node:process';
24
+ import { fileURLToPath } from 'node:url';
25
+ import { DatabaseSync } from 'node:sqlite';
26
+ import { compareProcessIdentity, inspectProcessIdentity } from '../process/identity.mjs';
27
+ import { privateProjectStateDir, withPrivateProjectStateTransition,
28
+ privateStateBaseDir } from './private-state.mjs';
29
+ import { unsafeDirectoryAcl } from './project-trust.mjs';
30
+
31
+ const DIRECTORY_FLAGS = fs.constants.O_RDONLY | fs.constants.O_DIRECTORY | fs.constants.O_NOFOLLOW;
32
+ const READ_FLAGS = fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW;
33
+ const CREATE_FLAGS = fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_NOFOLLOW;
34
+ const MARKER = '.project-root.json';
35
+ const WORKER_ARG = '--internal-offline-state-migration-worker';
36
+ const MODULE_FILE = fileURLToPath(import.meta.url);
37
+
38
+ function failure(code, message, cause) {
39
+ const error = new Error(message, cause ? { cause } : undefined);
40
+ error.code = code;
41
+ return error;
42
+ }
43
+
44
+ function unsafe(message, cause) {
45
+ return failure('STATE_MIGRATION_UNSAFE', message, cause);
46
+ }
47
+
48
+ function uid() {
49
+ if (typeof process.getuid !== 'function' ||
50
+ fs.constants.O_NOFOLLOW === undefined || fs.constants.O_DIRECTORY === undefined) {
51
+ throw unsafe('Offline migration requires POSIX ownership and no-follow operations');
52
+ }
53
+ return BigInt(process.getuid());
54
+ }
55
+
56
+ function mode(stat) { return Number(stat.mode & 0o7777n); }
57
+ function sameFile(a, b) { return a.dev === b.dev && a.ino === b.ino; }
58
+ function sameContentStat(a, b) {
59
+ return sameFile(a, b) && a.size === b.size && a.mtimeNs === b.mtimeNs &&
60
+ a.ctimeNs === b.ctimeNs && a.nlink === b.nlink;
61
+ }
62
+
63
+ function statNoFollow(target) {
64
+ try { return fs.lstatSync(target, { bigint: true }); }
65
+ catch (error) { throw unsafe(`Cannot inspect migration source: ${target}`, error); }
66
+ }
67
+
68
+ function checkedDirectory(target, { exactMode = null, tighten = false } = {}) {
69
+ const before = statNoFollow(target);
70
+ if (before.isSymbolicLink() || !before.isDirectory() || before.uid !== uid()) {
71
+ throw unsafe(`Directory must be real and owned by the current user: ${target}`);
72
+ }
73
+ let fd;
74
+ try {
75
+ fd = fs.openSync(target, DIRECTORY_FLAGS);
76
+ let opened = fs.fstatSync(fd, { bigint: true });
77
+ if (!opened.isDirectory() || !sameFile(before, opened) || opened.uid !== uid() ||
78
+ unsafeDirectoryAcl(target, opened)) {
79
+ throw unsafe(`Directory changed during validation: ${target}`);
80
+ }
81
+ if (exactMode !== null && mode(opened) !== exactMode) {
82
+ if (!tighten || (mode(opened) & 0o022) !== 0) {
83
+ throw unsafe(`Directory must have mode ${exactMode.toString(8)}: ${target}`);
84
+ }
85
+ fs.fchmodSync(fd, exactMode);
86
+ opened = fs.fstatSync(fd, { bigint: true });
87
+ }
88
+ if (exactMode !== null && mode(opened) !== exactMode) {
89
+ throw unsafe(`Cannot secure directory: ${target}`);
90
+ }
91
+ const after = statNoFollow(target);
92
+ if (after.isSymbolicLink() || !sameFile(opened, after)) {
93
+ throw unsafe(`Directory changed during validation: ${target}`);
94
+ }
95
+ return opened;
96
+ } finally {
97
+ if (fd !== undefined) fs.closeSync(fd);
98
+ }
99
+ }
100
+
101
+ function checkedRegular(target) {
102
+ const stat = statNoFollow(target);
103
+ if (stat.isSymbolicLink() || !stat.isFile() || stat.uid !== uid() || stat.nlink !== 1n) {
104
+ throw unsafe(`Source must be a current-user-owned, single-link regular file: ${target}`);
105
+ }
106
+ return stat;
107
+ }
108
+
109
+ function signature(stat) {
110
+ return [stat.dev, stat.ino, stat.mode, stat.uid, stat.nlink, stat.size,
111
+ stat.mtimeNs, stat.ctimeNs].map(String).join(':');
112
+ }
113
+
114
+ function copyRegular(sourceName, destination) {
115
+ const before = checkedRegular(sourceName);
116
+ let input;
117
+ let output;
118
+ try {
119
+ input = fs.openSync(sourceName, READ_FLAGS);
120
+ const opened = fs.fstatSync(input, { bigint: true });
121
+ if (!sameContentStat(before, opened) || !opened.isFile()) {
122
+ throw unsafe(`Source file changed while opening: ${sourceName}`);
123
+ }
124
+ output = fs.openSync(destination, CREATE_FLAGS, 0o600);
125
+ const block = Buffer.allocUnsafe(128 * 1024);
126
+ for (;;) {
127
+ const size = fs.readSync(input, block, 0, block.length, null);
128
+ if (size === 0) break;
129
+ let offset = 0;
130
+ while (offset < size) offset += fs.writeSync(output, block, offset, size - offset);
131
+ }
132
+ fs.fchmodSync(output, 0o600);
133
+ fs.fsyncSync(output);
134
+ const afterOpened = fs.fstatSync(input, { bigint: true });
135
+ const afterPath = checkedRegular(sourceName);
136
+ if (!sameContentStat(before, afterOpened) || !sameContentStat(before, afterPath)) {
137
+ throw unsafe(`Source file changed while copying: ${sourceName}`);
138
+ }
139
+ } finally {
140
+ if (output !== undefined) fs.closeSync(output);
141
+ if (input !== undefined) fs.closeSync(input);
142
+ }
143
+ }
144
+
145
+ function privateMkdir(target) {
146
+ fs.mkdirSync(target, { mode: 0o700 });
147
+ return checkedDirectory(target, { exactMode: 0o700 });
148
+ }
149
+
150
+ function ensurePrivateDirectory(target) {
151
+ try { privateMkdir(target); }
152
+ catch (error) {
153
+ if (error?.code !== 'EEXIST') throw error;
154
+ checkedDirectory(target, { exactMode: 0o700, tighten: true });
155
+ }
156
+ }
157
+
158
+ function checkHomeAncestry(home) {
159
+ const parts = [];
160
+ for (let current = home;; current = path.dirname(current)) {
161
+ parts.push(current);
162
+ if (path.dirname(current) === current) break;
163
+ }
164
+ for (const target of parts.reverse()) {
165
+ const stat = statNoFollow(target);
166
+ if (stat.isSymbolicLink() || !stat.isDirectory() || unsafeDirectoryAcl(target, stat) ||
167
+ (stat.uid !== uid() && stat.uid !== 0n) ||
168
+ ((mode(stat) & 0o022) !== 0 && (mode(stat) & 0o1000) === 0)) {
169
+ throw unsafe(`Untrusted home ancestor: ${target}`);
170
+ }
171
+ const fd = fs.openSync(target, DIRECTORY_FLAGS);
172
+ try {
173
+ const opened = fs.fstatSync(fd, { bigint: true });
174
+ if (!opened.isDirectory() || !sameFile(stat, opened) || opened.uid !== stat.uid) {
175
+ throw unsafe(`Home ancestor changed during validation: ${target}`);
176
+ }
177
+ } finally { fs.closeSync(fd); }
178
+ }
179
+ }
180
+
181
+ function fsyncDirectory(target) {
182
+ const fd = fs.openSync(target, DIRECTORY_FLAGS);
183
+ try { fs.fsyncSync(fd); }
184
+ finally { fs.closeSync(fd); }
185
+ }
186
+
187
+ function readSmallJson(name) {
188
+ const before = checkedRegular(name);
189
+ if (before.size > 65536n) throw unsafe(`Process evidence is too large: ${name}`);
190
+ const fd = fs.openSync(name, READ_FLAGS);
191
+ try {
192
+ const opened = fs.fstatSync(fd, { bigint: true });
193
+ if (!sameContentStat(before, opened)) throw unsafe(`Process evidence changed: ${name}`);
194
+ const raw = fs.readFileSync(fd, 'utf8');
195
+ if (!sameContentStat(before, fs.fstatSync(fd, { bigint: true })) ||
196
+ !sameContentStat(before, checkedRegular(name))) {
197
+ throw unsafe(`Process evidence changed: ${name}`);
198
+ }
199
+ let value;
200
+ try { value = JSON.parse(raw); }
201
+ catch { throw unsafe(`Process evidence is not valid JSON: ${name}`); }
202
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
203
+ throw unsafe(`Process evidence is not an object: ${name}`);
204
+ }
205
+ return value;
206
+ } finally { fs.closeSync(fd); }
207
+ }
208
+
209
+ function assertRecordedProcessDead(pid, stored, label) {
210
+ if (!Number.isSafeInteger(pid) || pid <= 0) {
211
+ throw unsafe(`Cannot establish offline state for ${label}: missing PID`);
212
+ }
213
+ const current = inspectProcessIdentity(pid);
214
+ if (current.state === 'dead') return;
215
+ if (current.state === 'live' && stored &&
216
+ compareProcessIdentity(stored, current.identity) === 'dead') return;
217
+ throw unsafe(`Cannot establish offline state for ${label}: process is live or unknown`);
218
+ }
219
+
220
+ function checkPointer(name) {
221
+ const pointer = readSmallJson(name);
222
+ assertRecordedProcessDead(pointer.pid,
223
+ pointer.process_identity || pointer.processIdentity || null, name);
224
+ }
225
+
226
+ function checkBufferMetadata(name) {
227
+ const meta = readSmallJson(name);
228
+ const wrapperPid = meta.wrapper_pid ?? meta.wrapperPid;
229
+ const childPid = meta.pid;
230
+ if (wrapperPid == null && childPid == null) {
231
+ throw unsafe(`Cannot establish PTY owner for ${name}`);
232
+ }
233
+ if (wrapperPid != null) assertRecordedProcessDead(wrapperPid,
234
+ meta.wrapper_identity || meta.wrapperIdentity || null, name);
235
+ if (childPid != null) assertRecordedProcessDead(childPid,
236
+ meta.child_identity || meta.childIdentity || null, name);
237
+ }
238
+
239
+ function quoteIdentifier(value) { return `"${value.replaceAll('"', '""')}"`; }
240
+ function quoteSqlString(value) { return `'${value.replaceAll("'", "''")}'`; }
241
+
242
+ function quickCheckAndCounts(db, label) {
243
+ const results = db.prepare('PRAGMA quick_check').all();
244
+ if (results.length !== 1 || Object.values(results[0])[0] !== 'ok') {
245
+ throw unsafe(`SQLite quick_check failed: ${label}`);
246
+ }
247
+ const tables = db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name").all();
248
+ return tables.map(({ name }) => [name,
249
+ String(Object.values(db.prepare(`SELECT count(*) FROM ${quoteIdentifier(name)}`).get())[0])]);
250
+ }
251
+
252
+ function checkMeshPeers(db) {
253
+ const table = db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'peers'").get();
254
+ if (!table) return;
255
+ const columns = db.prepare('PRAGMA table_info(peers)').all().map((row) => row.name);
256
+ if (!columns.includes('pid')) return;
257
+ const hasFingerprint = columns.includes('pid_start_token') && columns.includes('pid_command_hash');
258
+ const rows = db.prepare(`SELECT pid${hasFingerprint ? ', pid_start_token, pid_command_hash' : ''} FROM peers WHERE pid IS NOT NULL`).all();
259
+ for (const row of rows) {
260
+ const stored = hasFingerprint && row.pid_start_token && row.pid_command_hash
261
+ ? { pid: Number(row.pid), startToken: row.pid_start_token, commandHash: row.pid_command_hash }
262
+ : null;
263
+ assertRecordedProcessDead(Number(row.pid), stored, 'mesh peer');
264
+ }
265
+ }
266
+
267
+ function snapshotDatabase(scratchDb, destination, { mesh = false } = {}) {
268
+ let source;
269
+ let target;
270
+ try {
271
+ source = new DatabaseSync(scratchDb, { timeout: 1000 });
272
+ const before = quickCheckAndCounts(source, scratchDb);
273
+ if (mesh) checkMeshPeers(source);
274
+ source.exec(`VACUUM INTO ${quoteSqlString(destination)}`);
275
+ source.close();
276
+ source = null;
277
+ const fd = fs.openSync(destination, fs.constants.O_RDWR | fs.constants.O_NOFOLLOW);
278
+ try {
279
+ const stat = fs.fstatSync(fd, { bigint: true });
280
+ if (!stat.isFile() || stat.uid !== uid() || stat.nlink !== 1n) {
281
+ throw unsafe(`SQLite snapshot is not a private regular file: ${destination}`);
282
+ }
283
+ fs.fchmodSync(fd, 0o600);
284
+ fs.fsyncSync(fd);
285
+ } finally { fs.closeSync(fd); }
286
+ target = new DatabaseSync(destination, { readOnly: true, timeout: 1000 });
287
+ const after = quickCheckAndCounts(target, destination);
288
+ if (JSON.stringify(before) !== JSON.stringify(after)) {
289
+ throw unsafe(`SQLite row counts changed in snapshot: ${destination}`);
290
+ }
291
+ } finally {
292
+ try { target?.close(); } catch {}
293
+ try { source?.close(); } catch {}
294
+ }
295
+ }
296
+
297
+ function isDatabase(name) { return /\.(?:db|sqlite)$/i.test(name); }
298
+ function sidecarBase(name) {
299
+ const match = name.match(/^(.*\.(?:db|sqlite))-(?:wal|shm)$/i);
300
+ return match?.[1] || null;
301
+ }
302
+
303
+ function isRuntimePointer(relativeDir, name) {
304
+ return name === 'runtime.json' && (relativeDir === '' || relativeDir === 'native');
305
+ }
306
+
307
+ function scanSource(stored, relativeDir = '') {
308
+ const current = fs.statSync('.', { bigint: true });
309
+ if (stored && !sameFile(current, stored)) throw unsafe('Anchored source directory changed');
310
+ const names = fs.readdirSync('.').sort();
311
+ for (const name of names) {
312
+ const relative = path.join(relativeDir, name);
313
+ const stat = statNoFollow(name);
314
+ if (stat.isSymbolicLink() || stat.uid !== uid() ||
315
+ (!stat.isDirectory() && (!stat.isFile() || stat.nlink !== 1n))) {
316
+ throw unsafe(`Unsupported or unsafe source entry: ${relative}`);
317
+ }
318
+ if (stat.isDirectory()) {
319
+ const before = checkedDirectory(name);
320
+ process.chdir(name);
321
+ try { scanSource(before, relative); }
322
+ finally { process.chdir('..'); }
323
+ if (!sameFile(fs.statSync('.', { bigint: true }), current)) {
324
+ throw unsafe(`Source parent changed during traversal: ${relativeDir}`);
325
+ }
326
+ } else if (isRuntimePointer(relativeDir, name)) {
327
+ checkPointer(name);
328
+ } else if (relativeDir === 'bufs' && name.endsWith('.meta')) {
329
+ checkBufferMetadata(name);
330
+ }
331
+ }
332
+ if (JSON.stringify(names) !== JSON.stringify(fs.readdirSync('.').sort())) {
333
+ throw unsafe(`Source directory changed during traversal: ${relativeDir || '.'}`);
334
+ }
335
+ }
336
+
337
+ function copyDirectory(stage, scratch, summary, relativeDir = '', inventory = new Map(), expected = null) {
338
+ const current = fs.statSync('.', { bigint: true });
339
+ if (expected && !sameFile(current, expected)) {
340
+ throw unsafe(`Anchored source directory changed before copy: ${relativeDir || '.'}`);
341
+ }
342
+ const names = fs.readdirSync('.').sort();
343
+ const namesSet = new Set(names);
344
+ for (const name of names) {
345
+ if (name === MARKER && relativeDir === '') throw unsafe('Legacy state contains a reserved migration marker');
346
+ const relative = path.join(relativeDir, name);
347
+ const stat = statNoFollow(name);
348
+ if (stat.isSymbolicLink() || stat.uid !== uid() ||
349
+ (!stat.isDirectory() && (!stat.isFile() || stat.nlink !== 1n))) {
350
+ throw unsafe(`Unsupported or unsafe source entry: ${relative}`);
351
+ }
352
+ inventory.set(relative, signature(stat));
353
+ if (sidecarBase(name) && !namesSet.has(sidecarBase(name))) {
354
+ throw unsafe(`Orphaned SQLite sidecar: ${relative}`);
355
+ }
356
+ if (/\.(?:db|sqlite)-journal$/i.test(name)) {
357
+ throw unsafe(`Rollback journal needs manual recovery before migration: ${relative}`);
358
+ }
359
+ }
360
+
361
+ for (const name of names) {
362
+ const relative = path.join(relativeDir, name);
363
+ const stat = statNoFollow(name);
364
+ if (inventory.get(relative) !== signature(stat)) {
365
+ throw unsafe(`Source entry changed before copying: ${relative}`);
366
+ }
367
+ if (stat.isDirectory()) {
368
+ const before = checkedDirectory(name);
369
+ if (inventory.get(relative) !== signature(before)) {
370
+ throw unsafe(`Source directory changed before entering: ${relative}`);
371
+ }
372
+ privateMkdir(path.join(stage, relative));
373
+ privateMkdir(path.join(scratch, relative));
374
+ process.chdir(name);
375
+ try { copyDirectory(stage, scratch, summary, relative, inventory, before); }
376
+ finally { process.chdir('..'); }
377
+ if (!sameFile(current, fs.statSync('.', { bigint: true }))) {
378
+ throw unsafe(`Source parent changed during copy: ${relativeDir || '.'}`);
379
+ }
380
+ if (!sameFile(before, statNoFollow(name))) throw unsafe(`Source directory changed: ${relative}`);
381
+ continue;
382
+ }
383
+ if (isRuntimePointer(relativeDir, name)) {
384
+ checkPointer(name);
385
+ summary.omittedPointers += 1;
386
+ continue;
387
+ }
388
+ if (relativeDir === 'bufs' && name.endsWith('.meta')) checkBufferMetadata(name);
389
+ if (sidecarBase(name)) {
390
+ if (name.endsWith('-wal') && !fs.existsSync(path.join(scratch, relative))) {
391
+ copyRegular(name, path.join(scratch, relative));
392
+ }
393
+ continue;
394
+ }
395
+ if (isDatabase(name)) {
396
+ const scratchDb = path.join(scratch, relative);
397
+ copyRegular(name, scratchDb);
398
+ // The WAL is copied before opening scratch, even when it sorts after DB.
399
+ const wal = `${name}-wal`;
400
+ if (namesSet.has(wal) && !fs.existsSync(path.join(scratch, relativeDir, wal))) {
401
+ copyRegular(wal, path.join(scratch, relativeDir, wal));
402
+ }
403
+ snapshotDatabase(scratchDb, path.join(stage, relative), {
404
+ mesh: relativeDir === '' && name === 'mesh.db'
405
+ });
406
+ summary.databases += 1;
407
+ continue;
408
+ }
409
+ copyRegular(name, path.join(stage, relative));
410
+ summary.files += 1;
411
+ }
412
+ if (JSON.stringify(names) !== JSON.stringify(fs.readdirSync('.').sort())) {
413
+ throw unsafe(`Source directory changed during copy: ${relativeDir || '.'}`);
414
+ }
415
+ return inventory;
416
+ }
417
+
418
+ function confirmInventory(inventory, relativeDir = '', expected = null) {
419
+ const current = fs.statSync('.', { bigint: true });
420
+ if (expected && !sameFile(current, expected)) {
421
+ throw unsafe(`Anchored source directory changed before verification: ${relativeDir || '.'}`);
422
+ }
423
+ const names = fs.readdirSync('.').sort();
424
+ for (const name of names) {
425
+ const relative = path.join(relativeDir, name);
426
+ const stat = statNoFollow(name);
427
+ if (inventory.get(relative) !== signature(stat)) {
428
+ throw unsafe(`Legacy state changed during migration: ${relative}`);
429
+ }
430
+ if (stat.isDirectory()) {
431
+ const before = checkedDirectory(name);
432
+ process.chdir(name);
433
+ try { confirmInventory(inventory, relativeDir ? path.join(relativeDir, name) : name, before); }
434
+ finally { process.chdir('..'); }
435
+ if (!sameFile(current, fs.statSync('.', { bigint: true })) ||
436
+ !sameFile(before, statNoFollow(name))) {
437
+ throw unsafe(`Legacy directory changed during verification: ${relative}`);
438
+ }
439
+ } else if (isRuntimePointer(relativeDir, name)) checkPointer(name);
440
+ else if (relativeDir === 'bufs' && name.endsWith('.meta')) checkBufferMetadata(name);
441
+ }
442
+ if (names.length !== [...inventory.keys()].filter((item) => path.dirname(item) === (relativeDir || '.')).length) {
443
+ throw unsafe(`Legacy directory contents changed: ${relativeDir || '.'}`);
444
+ }
445
+ }
446
+
447
+ function fsyncTree(directory) {
448
+ for (const name of fs.readdirSync(directory)) {
449
+ const child = path.join(directory, name);
450
+ const stat = statNoFollow(child);
451
+ if (stat.isDirectory()) fsyncTree(child);
452
+ else if (stat.isFile() && !stat.isSymbolicLink()) {
453
+ const fd = fs.openSync(child, READ_FLAGS);
454
+ try { fs.fsyncSync(fd); }
455
+ finally { fs.closeSync(fd); }
456
+ } else throw unsafe(`Unexpected staged entry: ${child}`);
457
+ }
458
+ fsyncDirectory(directory);
459
+ }
460
+
461
+ function archiveManagedDsh(stage, canonicalRoot) {
462
+ const directory = path.join(stage, 'dsh');
463
+ const directoryStat = existing(directory);
464
+ if (!directoryStat) return null;
465
+ if (!directoryStat.isDirectory() || directoryStat.isSymbolicLink()) {
466
+ throw unsafe('Staged DSH directory is not a real directory');
467
+ }
468
+ const names = ['hooks.json', 'cordis.patch.yml', 'managed.json'];
469
+ const present = names.filter((name) => existing(path.join(directory, name)));
470
+ if (present.length === 0) return null;
471
+ if (!present.includes('managed.json')) {
472
+ throw unsafe('DSH artifacts lack a managed manifest; preserve them manually before migration');
473
+ }
474
+ const manifest = readSmallJson(path.join(directory, 'managed.json'));
475
+ const mode = manifest.mode === undefined ? 'hooks' : manifest.mode;
476
+ if (manifest.managedBy !== 'hello-cc/dsh' || manifest.schema !== 1 ||
477
+ manifest.root !== canonicalRoot || !['hooks', 'cordis', 'off'].includes(mode) ||
478
+ !manifest.files || typeof manifest.files !== 'object' || Array.isArray(manifest.files) ||
479
+ typeof manifest.hccBin !== 'string' || !path.isAbsolute(manifest.hccBin) ||
480
+ typeof manifest.nodeBin !== 'string' || !path.isAbsolute(manifest.nodeBin)) {
481
+ throw unsafe('DSH managed manifest cannot be verified');
482
+ }
483
+ for (const name of present.filter((value) => value !== 'managed.json')) {
484
+ const file = path.join(directory, name);
485
+ checkedRegular(file);
486
+ const fd = fs.openSync(file, READ_FLAGS);
487
+ let bytes;
488
+ try { bytes = fs.readFileSync(fd); }
489
+ finally { fs.closeSync(fd); }
490
+ const expected = manifest.files[name];
491
+ const actual = createHash('sha256').update(bytes).digest('hex');
492
+ if (typeof expected !== 'string' || !/^[a-f0-9]{64}$/.test(expected) || expected !== actual) {
493
+ throw unsafe(`DSH managed artifact differs from its manifest: ${name}`);
494
+ }
495
+ if (name === 'hooks.json') {
496
+ let hooks;
497
+ try { hooks = JSON.parse(bytes.toString('utf8')); }
498
+ catch { throw unsafe('DSH managed hooks are invalid JSON'); }
499
+ if (hooks?._helloCc?.managedBy !== 'hello-cc/dsh' || hooks?._helloCc?.schema !== 1) {
500
+ throw unsafe('DSH hooks do not have the expected hello-cc ownership marker');
501
+ }
502
+ }
503
+ }
504
+ const archiveName = `.legacy-managed-${randomBytes(8).toString('hex')}`;
505
+ const archive = path.join(directory, archiveName);
506
+ privateMkdir(archive);
507
+ for (const name of present) fs.renameSync(path.join(directory, name), path.join(archive, name));
508
+ fsyncDirectory(archive);
509
+ fsyncDirectory(directory);
510
+ return { mode, archiveName, rebuildCommand: `hcc dsh setup --mode ${mode}` };
511
+ }
512
+
513
+ function worker({ source, sourceDev, sourceIno, stage, stageDev, stageIno, globalPointer, canonicalRoot }) {
514
+ process.umask(0o077);
515
+ const sourceStat = checkedDirectory(source, { exactMode: 0o700 });
516
+ const stageStat = checkedDirectory(stage, { exactMode: 0o700 });
517
+ if (String(sourceStat.dev) !== sourceDev || String(sourceStat.ino) !== sourceIno ||
518
+ String(stageStat.dev) !== stageDev || String(stageStat.ino) !== stageIno) {
519
+ throw unsafe('Migration source or staging directory changed before worker start');
520
+ }
521
+ process.chdir(source);
522
+ if (!sameFile(fs.statSync('.', { bigint: true }), sourceStat)) {
523
+ throw unsafe('Could not anchor the legacy state directory');
524
+ }
525
+ if (existing(globalPointer)) checkPointer(globalPointer);
526
+ scanSource(sourceStat);
527
+ const scratch = path.join(stage, '.sqlite-work');
528
+ privateMkdir(scratch);
529
+ const summary = { databases: 0, files: 0, omittedPointers: 0 };
530
+ const inventory = copyDirectory(stage, scratch, summary, '', new Map(), sourceStat);
531
+ confirmInventory(inventory, '', sourceStat);
532
+ scanSource(sourceStat);
533
+ if (existing(globalPointer)) checkPointer(globalPointer);
534
+ fs.rmSync(scratch, { recursive: true, force: false });
535
+ summary.dsh = archiveManagedDsh(stage, canonicalRoot);
536
+ fsyncTree(stage);
537
+ return summary;
538
+ }
539
+
540
+ function assertCallerOffline(assertOffline, phase) {
541
+ if (typeof assertOffline !== 'function' || assertOffline({ phase }) !== true) {
542
+ throw failure('STATE_MIGRATION_OFFLINE_REQUIRED',
543
+ `Caller must assert all legacy state producers are stopped (${phase})`);
544
+ }
545
+ }
546
+
547
+ function sourceIdentity(root) {
548
+ const canonicalRoot = fs.realpathSync(root);
549
+ const rootStat = fs.statSync(canonicalRoot, { bigint: true });
550
+ if (!rootStat.isDirectory()) throw unsafe(`Project root is not a directory: ${root}`);
551
+ const source = path.join(canonicalRoot, '.hello-cc');
552
+ const sourceStat = checkedDirectory(source, { exactMode: 0o700 });
553
+ return { canonicalRoot, rootStat, source, sourceStat };
554
+ }
555
+
556
+ function markerBody(project) {
557
+ return JSON.stringify({
558
+ version: 1,
559
+ canonicalRoot: project.canonicalRoot,
560
+ dev: String(project.rootStat.dev),
561
+ ino: String(project.rootStat.ino)
562
+ }) + '\n';
563
+ }
564
+
565
+ function writeMarker(stage, project) {
566
+ const marker = path.join(stage, MARKER);
567
+ const body = markerBody(project);
568
+ const fd = fs.openSync(marker, CREATE_FLAGS, 0o600);
569
+ try {
570
+ fs.writeFileSync(fd, body);
571
+ fs.fchmodSync(fd, 0o600);
572
+ fs.fsyncSync(fd);
573
+ } finally { fs.closeSync(fd); }
574
+ fsyncDirectory(stage);
575
+ }
576
+
577
+ function writeAuthorityMarker(projects, setAuthorityKind) {
578
+ const marker = setAuthorityKind('migrated');
579
+ fsyncDirectory(projects);
580
+ return marker;
581
+ }
582
+
583
+ function existing(target) {
584
+ try { return fs.lstatSync(target, { bigint: true }); }
585
+ catch (error) { if (error?.code === 'ENOENT') return null; throw error; }
586
+ }
587
+
588
+ /**
589
+ * Migrate only after explicit human confirmation and the caller's own offline
590
+ * assertion. Returns the new path and copied-file counts. Existing private
591
+ * state is never overwritten. Source runtime pointers are verified but omitted
592
+ * because their endpoints and tokens refer to the old location.
593
+ * A returned dsh.rebuildCommand must be run before using a migrated DSH setup.
594
+ */
595
+ export function migrateLegacyProjectState(root, { confirmedOffline = false, assertOffline } = {}) {
596
+ if (confirmedOffline !== true) {
597
+ throw failure('STATE_MIGRATION_OFFLINE_REQUIRED', 'Explicit offline migration confirmation is required');
598
+ }
599
+ assertCallerOffline(assertOffline, 'before');
600
+ const project = sourceIdentity(root);
601
+ const base = privateStateBaseDir();
602
+ const destination = privateProjectStateDir(project.canonicalRoot);
603
+ const projects = path.dirname(destination);
604
+ if (project.source === base || projects.startsWith(`${project.source}${path.sep}`)) {
605
+ throw unsafe('Legacy source overlaps private destination');
606
+ }
607
+ checkHomeAncestry(path.dirname(base));
608
+ ensurePrivateDirectory(base);
609
+ ensurePrivateDirectory(projects);
610
+ return withPrivateProjectStateTransition(project.canonicalRoot, ({ setAuthorityKind }) => {
611
+ const projectsStat = checkedDirectory(projects, { exactMode: 0o700 });
612
+ if (existing(destination)) {
613
+ throw failure('STATE_MIGRATION_EXISTS', `Private project state already exists: ${destination}`);
614
+ }
615
+ const stage = path.join(projects, `.${path.basename(destination)}.migration-${randomBytes(12).toString('hex')}`);
616
+ const stageStat = privateMkdir(stage);
617
+ let committed = false;
618
+ try {
619
+ const result = spawnSync(process.execPath, [MODULE_FILE, WORKER_ARG, JSON.stringify({
620
+ source: project.source,
621
+ sourceDev: String(project.sourceStat.dev),
622
+ sourceIno: String(project.sourceStat.ino),
623
+ stage,
624
+ stageDev: String(stageStat.dev),
625
+ stageIno: String(stageStat.ino),
626
+ globalPointer: path.join(base, 'runtime.json'),
627
+ canonicalRoot: project.canonicalRoot
628
+ })], { encoding: 'utf8', maxBuffer: 1024 * 1024 });
629
+ let message;
630
+ try { message = JSON.parse(result.stdout); } catch {}
631
+ if (result.error || result.status !== 0 || !message?.ok) {
632
+ throw failure(message?.code || 'STATE_MIGRATION_FAILED',
633
+ message?.error || result.error?.message || result.stderr?.trim() || 'Migration worker failed', result.error);
634
+ }
635
+ assertCallerOffline(assertOffline, 'after');
636
+ const sourceNow = checkedDirectory(project.source, { exactMode: 0o700 });
637
+ const rootNow = fs.statSync(project.canonicalRoot, { bigint: true });
638
+ if (!sameFile(sourceNow, project.sourceStat) || !sameFile(rootNow, project.rootStat)) {
639
+ throw unsafe('Project root or legacy state changed before commit');
640
+ }
641
+ if (!sameFile(checkedDirectory(stage, { exactMode: 0o700 }), stageStat)) {
642
+ throw unsafe('Migration staging directory changed before commit');
643
+ }
644
+ if (!sameFile(checkedDirectory(projects, { exactMode: 0o700 }), projectsStat)) {
645
+ throw unsafe('Private projects directory changed before commit');
646
+ }
647
+ writeMarker(stage, project);
648
+ // Persist authority before exposing the destination. If the rename fails,
649
+ // the marker makes future access fail closed until an explicit retry.
650
+ writeAuthorityMarker(projects, setAuthorityKind);
651
+ if (existing(destination)) {
652
+ throw failure('STATE_MIGRATION_EXISTS', `Private project state appeared during migration: ${destination}`);
653
+ }
654
+ fsyncDirectory(projects);
655
+ fs.renameSync(stage, destination);
656
+ try {
657
+ fsyncDirectory(projects);
658
+ } catch (error) {
659
+ try {
660
+ fs.renameSync(destination, stage);
661
+ fsyncDirectory(projects);
662
+ } catch (rollbackError) {
663
+ throw failure('STATE_MIGRATION_COMMIT_UNCERTAIN',
664
+ `Cannot confirm or roll back migration commit at ${destination}`, rollbackError);
665
+ }
666
+ throw failure('STATE_MIGRATION_FAILED', 'Could not durably commit private project state', error);
667
+ }
668
+ committed = true;
669
+ const summary = message.summary;
670
+ if (summary.dsh) {
671
+ summary.dsh.archivePath = path.join(destination, 'dsh', summary.dsh.archiveName);
672
+ }
673
+ return { stateDir: destination, sourceDir: project.source, ...summary };
674
+ } finally {
675
+ if (!committed) {
676
+ try {
677
+ const current = existing(stage);
678
+ if (current && current.isDirectory() && sameFile(current, stageStat)) {
679
+ fs.rmSync(stage, { recursive: true, force: false });
680
+ fsyncDirectory(projects);
681
+ }
682
+ } catch { /* Preserve the original error; an uncommitted stage is inert. */ }
683
+ }
684
+ }
685
+ });
686
+ }
687
+
688
+ if (process.argv[1] === MODULE_FILE && process.argv[2] === WORKER_ARG) {
689
+ try {
690
+ const summary = worker(JSON.parse(process.argv[3]));
691
+ process.stdout.write(JSON.stringify({ ok: true, summary }));
692
+ } catch (error) {
693
+ process.stdout.write(JSON.stringify({ ok: false, code: error?.code || 'STATE_MIGRATION_FAILED',
694
+ error: error?.message || 'Migration failed' }));
695
+ process.exitCode = 1;
696
+ }
697
+ }