@logicseek/hello-cc 0.1.9 → 1.0.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.
Files changed (108) hide show
  1. package/CHANGELOG.md +59 -0
  2. package/README.md +18 -3
  3. package/README.zh-CN.md +17 -3
  4. package/bin/hcc.mjs +3 -5656
  5. package/docs/README.md +8 -0
  6. package/docs/README.zh-CN.md +6 -0
  7. package/docs/architecture.md +29 -18
  8. package/docs/architecture.zh-CN.md +20 -10
  9. package/docs/commands.md +13 -4
  10. package/docs/commands.zh-CN.md +13 -4
  11. package/docs/current-state-review-2026-08.md +341 -0
  12. package/docs/defect-review-2026-07.md +382 -0
  13. package/docs/guide.md +39 -3
  14. package/docs/guide.zh-CN.md +34 -2
  15. package/docs/superpowers/plans/2026-08-03-runtime-safety-plan.md +294 -0
  16. package/docs/superpowers/plans/2026-08-03-v1-data-identity-plan.md +400 -0
  17. package/docs/superpowers/plans/2026-08-03-v1-release-verification-plan.md +284 -0
  18. package/docs/superpowers/plans/2026-08-03-web-api-hardening-plan.md +303 -0
  19. package/docs/superpowers/plans/2026-08-04-runtime-buffer-gc-two-phase.md +71 -0
  20. package/docs/superpowers/specs/2026-08-03-runtime-safety-design.md +117 -0
  21. package/docs/superpowers/specs/2026-08-03-v1-data-identity-design.md +120 -0
  22. package/docs/superpowers/specs/2026-08-03-v1-release-verification-design.md +117 -0
  23. package/docs/superpowers/specs/2026-08-03-web-api-hardening-design.md +104 -0
  24. package/lib/cli/app.mjs +642 -0
  25. package/lib/cli/commands/coordination.mjs +247 -0
  26. package/lib/cli/commands/doctor.mjs +81 -0
  27. package/lib/cli/commands/gc.mjs +852 -0
  28. package/lib/cli/commands/hook.mjs +205 -0
  29. package/lib/cli/commands/install.mjs +294 -0
  30. package/lib/cli/commands/lifecycle.mjs +101 -0
  31. package/lib/cli/commands/lock.mjs +197 -0
  32. package/lib/cli/commands/misc.mjs +61 -0
  33. package/lib/cli/commands/msg.mjs +152 -0
  34. package/lib/cli/commands/peer.mjs +233 -0
  35. package/lib/cli/commands/query.mjs +96 -0
  36. package/lib/cli/commands/run.mjs +463 -0
  37. package/lib/cli/commands/task.mjs +405 -0
  38. package/lib/cli/commands/team.mjs +131 -0
  39. package/lib/cli/commands/tmux.mjs +838 -0
  40. package/lib/cli/commands/up.mjs +135 -0
  41. package/lib/cli-args.mjs +16 -2
  42. package/lib/coordination-state.mjs +112 -51
  43. package/lib/core/coordination/automation.mjs +5 -8
  44. package/lib/core/coordination/clock-safety.mjs +323 -0
  45. package/lib/core/coordination/event-retention.mjs +17 -0
  46. package/lib/core/coordination/gc-plan.mjs +614 -0
  47. package/lib/core/coordination/lease-renewal.mjs +102 -0
  48. package/lib/core/coordination/lock-evidence.mjs +77 -0
  49. package/lib/core/coordination/optimistic-evidence.mjs +26 -0
  50. package/lib/core/coordination/tasks.mjs +156 -36
  51. package/lib/core/peers/evidence-runtime.mjs +312 -0
  52. package/lib/core/peers/evidence.mjs +103 -0
  53. package/lib/core/peers/hook-owner.mjs +32 -0
  54. package/lib/core/peers/liveness.mjs +44 -4
  55. package/lib/core/peers/peer-helpers.mjs +121 -0
  56. package/lib/core/peers/session.mjs +8 -2
  57. package/lib/core/peers/tmux-safety.mjs +290 -0
  58. package/lib/core/sessions/launch.mjs +13 -1
  59. package/lib/db/connection.mjs +104 -0
  60. package/lib/db/events.mjs +34 -0
  61. package/lib/db/migration-backup.mjs +225 -0
  62. package/lib/db/schema.mjs +102 -5
  63. package/lib/db/stores/peers.mjs +34 -0
  64. package/lib/integrations/peers/identity.mjs +62 -16
  65. package/lib/integrations/shims/script.mjs +111 -9
  66. package/lib/process/identity.mjs +192 -0
  67. package/lib/process/pty-lifecycle.mjs +149 -0
  68. package/lib/runtime/buffer-directory-lease.mjs +47 -0
  69. package/lib/runtime/buffer-evidence.mjs +336 -0
  70. package/lib/runtime/buffer-gc-protocol.mjs +167 -0
  71. package/lib/runtime/buffer-gc.mjs +270 -0
  72. package/lib/runtime/client.mjs +60 -7
  73. package/lib/runtime/fatal-shutdown.mjs +54 -0
  74. package/lib/runtime/project-path.mjs +139 -0
  75. package/lib/runtime/projects.mjs +320 -40
  76. package/lib/runtime/state.mjs +227 -27
  77. package/lib/shared/cleanup-error.mjs +54 -0
  78. package/lib/shared/clock-grace.mjs +106 -0
  79. package/lib/shared/errors.mjs +9 -0
  80. package/lib/shared/file-lock.mjs +370 -0
  81. package/lib/shared/json-file.mjs +6 -2
  82. package/lib/shared/redact.mjs +60 -0
  83. package/lib/shared/socket-lock-worker.mjs +201 -0
  84. package/lib/terminal/tmux.mjs +78 -4
  85. package/lib/tmux.mjs +4 -0
  86. package/lib/ui/help.mjs +53 -15
  87. package/lib/web/api-version.mjs +42 -0
  88. package/lib/web/auto-attach.mjs +185 -0
  89. package/lib/web/buffer-gc-runtime.mjs +175 -0
  90. package/lib/web/cookie-auth.mjs +119 -0
  91. package/lib/web/external-sessions.mjs +254 -0
  92. package/lib/web/http-routes.mjs +542 -0
  93. package/lib/web/http.mjs +135 -6
  94. package/lib/web/liveness-reaper.mjs +145 -0
  95. package/lib/web/peer-actions.mjs +132 -23
  96. package/lib/web/project-contexts.mjs +178 -0
  97. package/lib/web/pty-sessions.mjs +203 -0
  98. package/lib/web/runtime-main.mjs +714 -0
  99. package/lib/web/runtime.mjs +91 -36
  100. package/lib/web/session-serialize.mjs +0 -0
  101. package/lib/web/startup.mjs +333 -0
  102. package/lib/web/tls.mjs +365 -0
  103. package/lib/web/tmux-sessions.mjs +1361 -0
  104. package/lib/web/tmux-stream.mjs +166 -0
  105. package/lib/web/ui-template.mjs +152 -20
  106. package/package.json +5 -2
  107. package/scripts/check-free-identifiers.mjs +290 -0
  108. package/scripts/regression.mjs +5397 -263
@@ -0,0 +1,270 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ function eligibleBufferStat(stat) {
5
+ return stat.isFile() || stat.isFIFO();
6
+ }
7
+
8
+ function objectIdentity(stat) {
9
+ return `${stat.dev}:${stat.ino}`;
10
+ }
11
+
12
+ function sameDirectoryObject(left, right) {
13
+ return left.isDirectory() &&
14
+ right.isDirectory() &&
15
+ left.dev === right.dev &&
16
+ left.ino === right.ino;
17
+ }
18
+
19
+ function statIdentity(stat) {
20
+ return {
21
+ dev: stat.dev,
22
+ ino: stat.ino,
23
+ mode: stat.mode,
24
+ size: stat.size,
25
+ mtimeMs: stat.mtimeMs,
26
+ ctimeMs: stat.ctimeMs
27
+ };
28
+ }
29
+
30
+ function sameStatIdentity(left, right) {
31
+ return left.dev === right.dev &&
32
+ left.ino === right.ino &&
33
+ left.mode === right.mode &&
34
+ left.size === right.size &&
35
+ left.mtimeMs === right.mtimeMs &&
36
+ left.ctimeMs === right.ctimeMs;
37
+ }
38
+
39
+ function canonicalEntryPath(value) {
40
+ if (typeof value !== 'string' || value.length === 0) return null;
41
+ const resolved = path.resolve(value);
42
+ const suppliedParent = path.dirname(resolved);
43
+ try {
44
+ const suppliedStat = fs.statSync(suppliedParent);
45
+ const canonicalParent = fs.realpathSync(suppliedParent);
46
+ const canonicalStat = fs.lstatSync(canonicalParent);
47
+ if (canonicalStat.isSymbolicLink() || !sameDirectoryObject(suppliedStat, canonicalStat)) return null;
48
+ return path.join(canonicalParent, path.basename(resolved));
49
+ } catch {
50
+ return null;
51
+ }
52
+ }
53
+
54
+ function evidenceSnapshot(values) {
55
+ const paths = new Set();
56
+ const identities = new Set();
57
+ for (const value of values || []) {
58
+ const file = canonicalEntryPath(value);
59
+ if (!file) continue;
60
+ paths.add(file);
61
+ try {
62
+ const stat = fs.lstatSync(file);
63
+ if (!stat.isSymbolicLink() && eligibleBufferStat(stat)) identities.add(objectIdentity(stat));
64
+ } catch {}
65
+ }
66
+ return { paths, identities };
67
+ }
68
+
69
+ export function refreshBufferPlanEvidence(plan, {
70
+ protectedPaths = [],
71
+ unknownPaths = [],
72
+ gcCutoffs = []
73
+ } = {}) {
74
+ if (!plan || !Array.isArray(plan.deleteEntries)) {
75
+ throw new TypeError('valid buffer GC plan required');
76
+ }
77
+ const protectedEvidence = evidenceSnapshot(protectedPaths);
78
+ const unknownEvidence = evidenceSnapshot(unknownPaths);
79
+ const protectedEntries = [];
80
+ const unknownEntries = [];
81
+ const deleteEntries = [];
82
+ for (const entry of plan.deleteEntries) {
83
+ const identity = `${entry?.stat?.dev}:${entry?.stat?.ino}`;
84
+ if (protectedEvidence.paths.has(entry.path) || protectedEvidence.identities.has(identity)) {
85
+ protectedEntries.push(entry);
86
+ } else if (unknownEvidence.paths.has(entry.path) || unknownEvidence.identities.has(identity)) {
87
+ unknownEntries.push(entry);
88
+ } else {
89
+ deleteEntries.push(entry);
90
+ }
91
+ }
92
+ return {
93
+ ...plan,
94
+ evidenceGcCutoffs: [...new Set([
95
+ ...(plan.evidenceGcCutoffs || []),
96
+ ...gcCutoffs
97
+ ])].sort((left, right) => left - right),
98
+ protectedPaths: protectedEntries.map((entry) => entry.path),
99
+ unknownPaths: unknownEntries.map((entry) => entry.path),
100
+ deletePaths: deleteEntries.map((entry) => entry.path),
101
+ protectedEntries,
102
+ unknownEntries,
103
+ deleteEntries
104
+ };
105
+ }
106
+
107
+ function canonicalDirectories(values) {
108
+ const directories = [];
109
+ const seenPaths = new Set();
110
+ const seenIdentities = new Set();
111
+ for (const value of values || []) {
112
+ if (typeof value !== 'string' || value.length === 0) continue;
113
+ const resolved = path.resolve(value);
114
+ let suppliedStat;
115
+ try { suppliedStat = fs.lstatSync(resolved); } catch { continue; }
116
+ if (!suppliedStat.isDirectory() || suppliedStat.isSymbolicLink()) continue;
117
+
118
+ let directory;
119
+ let stat;
120
+ try {
121
+ directory = fs.realpathSync(resolved);
122
+ stat = fs.lstatSync(directory);
123
+ } catch {
124
+ continue;
125
+ }
126
+ if (stat.isSymbolicLink() || !sameDirectoryObject(suppliedStat, stat)) continue;
127
+ const identity = objectIdentity(stat);
128
+ if (seenPaths.has(directory) || seenIdentities.has(identity)) continue;
129
+ seenPaths.add(directory);
130
+ seenIdentities.add(identity);
131
+ directories.push({ path: directory, identity });
132
+ }
133
+ return directories;
134
+ }
135
+
136
+ export function planBufferFiles({
137
+ directories = [],
138
+ cutoffMs,
139
+ protectedPaths = [],
140
+ unknownPaths = [],
141
+ evidenceGcCutoffs = []
142
+ } = {}) {
143
+ if (!Number.isFinite(cutoffMs)) throw new TypeError('cutoffMs must be a finite number');
144
+
145
+ const protectedEvidence = evidenceSnapshot(protectedPaths);
146
+ const unknownEvidence = evidenceSnapshot(unknownPaths);
147
+ const seenIdentities = new Set();
148
+ const protectedEntries = [];
149
+ const unknownEntries = [];
150
+ const deleteEntries = [];
151
+
152
+ for (const directory of canonicalDirectories(directories)) {
153
+ let names;
154
+ try { names = fs.readdirSync(directory.path); } catch { continue; }
155
+ for (const name of names) {
156
+ const file = path.resolve(directory.path, name);
157
+ if (path.dirname(file) !== directory.path) continue;
158
+
159
+ let stat;
160
+ try { stat = fs.lstatSync(file); } catch { continue; }
161
+ if (stat.isSymbolicLink() || !eligibleBufferStat(stat) || stat.mtimeMs >= cutoffMs) continue;
162
+ const identity = objectIdentity(stat);
163
+ if (seenIdentities.has(identity)) continue;
164
+ seenIdentities.add(identity);
165
+
166
+ const entry = {
167
+ path: file,
168
+ stat: statIdentity(stat),
169
+ directory: { path: directory.path, identity: directory.identity }
170
+ };
171
+ if (protectedEvidence.paths.has(file) || protectedEvidence.identities.has(identity)) protectedEntries.push(entry);
172
+ else if (unknownEvidence.paths.has(file) || unknownEvidence.identities.has(identity)) unknownEntries.push(entry);
173
+ else deleteEntries.push(entry);
174
+ }
175
+ }
176
+
177
+ return {
178
+ cutoffMs,
179
+ evidenceGcCutoffs: [...evidenceGcCutoffs],
180
+ protectedPaths: protectedEntries.map((entry) => entry.path),
181
+ unknownPaths: unknownEntries.map((entry) => entry.path),
182
+ deletePaths: deleteEntries.map((entry) => entry.path),
183
+ protectedEntries,
184
+ unknownEntries,
185
+ deleteEntries
186
+ };
187
+ }
188
+
189
+ export function applyBufferPlan(plan) {
190
+ if (!plan || !Number.isFinite(plan.cutoffMs) || !Array.isArray(plan.deleteEntries)) {
191
+ throw new TypeError('valid buffer GC plan required');
192
+ }
193
+
194
+ const result = {
195
+ deleted: 0,
196
+ protected: plan.protectedEntries?.length || 0,
197
+ deferred: plan.unknownEntries?.length || 0
198
+ };
199
+
200
+ for (const entry of plan.deleteEntries) {
201
+ try {
202
+ const directory = fs.lstatSync(entry.directory.path);
203
+ if (directory.isSymbolicLink() ||
204
+ !directory.isDirectory() ||
205
+ objectIdentity(directory) !== entry.directory.identity ||
206
+ fs.realpathSync(entry.directory.path) !== entry.directory.path) {
207
+ result.deferred += 1;
208
+ continue;
209
+ }
210
+ const current = fs.lstatSync(entry.path);
211
+ if (current.isSymbolicLink() ||
212
+ !eligibleBufferStat(current) ||
213
+ current.mtimeMs >= plan.cutoffMs ||
214
+ !sameStatIdentity(entry.stat, statIdentity(current))) {
215
+ result.deferred += 1;
216
+ continue;
217
+ }
218
+ fs.unlinkSync(entry.path);
219
+ result.deleted += 1;
220
+ } catch {
221
+ result.deferred += 1;
222
+ }
223
+ }
224
+ return result;
225
+ }
226
+
227
+ export function bufferPlanGcCutoffs(plan, retentionSec) {
228
+ if (!plan || !Number.isFinite(plan.cutoffMs)) {
229
+ throw new TypeError('valid buffer GC plan required');
230
+ }
231
+ if (!Number.isSafeInteger(retentionSec) || retentionSec < 0) {
232
+ throw new TypeError('retentionSec must be a non-negative safe integer');
233
+ }
234
+ const entryCutoffs = plan.deleteEntries.map((entry) => {
235
+ const boundary = Math.max(0, Math.floor(Number(entry?.stat?.mtimeMs) / 1000) + retentionSec);
236
+ if (!Number.isSafeInteger(boundary)) {
237
+ throw new TypeError('buffer GC boundary must be a non-negative safe integer');
238
+ }
239
+ return boundary;
240
+ });
241
+ const evidenceCutoffs = plan.evidenceGcCutoffs || [];
242
+ if (!Array.isArray(evidenceCutoffs) || evidenceCutoffs.some((cutoff) =>
243
+ !Number.isSafeInteger(cutoff) || cutoff < 0)) {
244
+ throw new TypeError('buffer evidence GC cutoffs must be non-negative safe integers');
245
+ }
246
+ return [...new Set([...entryCutoffs, ...evidenceCutoffs])].sort((left, right) => left - right);
247
+ }
248
+
249
+ export function deferBufferPlan(plan) {
250
+ if (!plan || !Array.isArray(plan.deleteEntries)) {
251
+ throw new TypeError('valid buffer GC plan required');
252
+ }
253
+ return {
254
+ deleted: 0,
255
+ protected: plan.protectedEntries?.length || 0,
256
+ deferred: (plan.unknownEntries?.length || 0) + plan.deleteEntries.length
257
+ };
258
+ }
259
+
260
+ export function pruneBufferFiles(options = {}) {
261
+ const plan = planBufferFiles(options);
262
+ if (options.dryRun) {
263
+ return {
264
+ deleted: plan.deleteEntries.length,
265
+ protected: plan.protectedEntries.length,
266
+ deferred: plan.unknownEntries.length
267
+ };
268
+ }
269
+ return applyBufferPlan(plan);
270
+ }
@@ -1,32 +1,76 @@
1
1
  import { CliError } from '../shared/errors.mjs';
2
2
  import { readRuntime } from './state.mjs';
3
- import { runtimeApiUrl } from '../web/runtime.mjs';
3
+ import { runtimeApiUrl, runtimeHttpRequest } from '../web/runtime.mjs';
4
+ import { withRuntimeApiVersionHeader } from '../web/api-version.mjs';
4
5
 
5
6
  const DEFAULT_CLI_NAME = 'hcc';
6
7
 
8
+ function requestAbortScope(signal, timeoutMs) {
9
+ const controller = new AbortController();
10
+ const forwardAbort = () => controller.abort(signal?.reason);
11
+ if (signal?.aborted) forwardAbort();
12
+ else signal?.addEventListener('abort', forwardAbort, { once: true });
13
+
14
+ const timeout = Number(timeoutMs);
15
+ const timer = Number.isFinite(timeout) && timeout >= 0
16
+ ? setTimeout(() => controller.abort(new Error('runtime request deadline exceeded')), timeout)
17
+ : null;
18
+ return {
19
+ signal: controller.signal,
20
+ cleanup() {
21
+ if (timer) clearTimeout(timer);
22
+ signal?.removeEventListener('abort', forwardAbort);
23
+ }
24
+ };
25
+ }
26
+
27
+ function waitForAbort(promise, signal) {
28
+ if (!signal) return promise;
29
+ if (signal.aborted) return Promise.reject(signal.reason || new Error('runtime request aborted'));
30
+ return new Promise((resolve, reject) => {
31
+ const abort = () => reject(signal.reason || new Error('runtime request aborted'));
32
+ signal.addEventListener('abort', abort, { once: true });
33
+ Promise.resolve(promise).then(
34
+ (value) => {
35
+ signal.removeEventListener('abort', abort);
36
+ resolve(value);
37
+ },
38
+ (error) => {
39
+ signal.removeEventListener('abort', abort);
40
+ reject(error);
41
+ }
42
+ );
43
+ });
44
+ }
45
+
7
46
  export async function runtimeRequest(ctx, method, route, body = null, runtime = null, opts = {}) {
8
47
  const cliName = opts.cliName || DEFAULT_CLI_NAME;
9
48
  const rt = runtime || readRuntime(ctx, { cliName, localOnly: Boolean(opts.localOnly) });
10
49
  const url = runtimeApiUrl(rt, route);
11
- const headers = { 'Content-Type': 'application/json' };
50
+ const headers = withRuntimeApiVersionHeader({ 'Content-Type': 'application/json' });
12
51
  headers['X-HCC-Root'] = ctx.root;
13
52
  headers['X-HCC-DB'] = ctx.dbPath;
14
53
  if (rt.token) headers.Authorization = `Bearer ${rt.token}`;
15
54
  let res;
55
+ let text;
56
+ const abortScope = requestAbortScope(opts.signal, opts.timeoutMs);
16
57
  try {
17
- res = await fetch(url, {
58
+ res = await waitForAbort(runtimeHttpRequest(rt, route, {
18
59
  method,
19
60
  headers,
20
- body: body === null ? undefined : JSON.stringify(body)
21
- });
61
+ body: body === null ? null : JSON.stringify(body),
62
+ signal: abortScope.signal
63
+ }), abortScope.signal);
64
+ text = res.text;
22
65
  } catch (err) {
23
66
  throw new CliError('RUNTIME_UNREACHABLE', `Runtime is not reachable at ${rt.base_url}. Start ${cliName} web again.`, {
24
67
  runtime: rt.source || rt.base_url,
25
68
  message: err.message
26
69
  });
70
+ } finally {
71
+ abortScope.cleanup();
27
72
  }
28
73
  let json = null;
29
- const text = await res.text();
30
74
  if (text.trim()) {
31
75
  try {
32
76
  json = JSON.parse(text);
@@ -36,7 +80,16 @@ export async function runtimeRequest(ctx, method, route, body = null, runtime =
36
80
  }
37
81
  if (!res.ok) {
38
82
  const error = json && json.error ? json.error : { code: 'RUNTIME_ERROR', message: `Runtime request failed: ${res.status}` };
39
- throw new CliError(error.code || 'RUNTIME_ERROR', error.message || `Runtime request failed: ${res.status}`, error);
83
+ throw new CliError(error.code || 'RUNTIME_ERROR', error.message || `Runtime request failed: ${res.status}`, {
84
+ ...error,
85
+ status: res.status
86
+ });
40
87
  }
41
88
  return json || {};
42
89
  }
90
+
91
+ export function runtimeBufferGcUnavailable(error) {
92
+ return error?.code === 'RUNTIME_UNREACHABLE' ||
93
+ Number(error?.extra?.status) === 404 ||
94
+ Number(error?.extra?.status) === 426;
95
+ }
@@ -0,0 +1,54 @@
1
+ import { redactSecrets } from '../shared/redact.mjs';
2
+
3
+ function fatalReason(error) {
4
+ if (error instanceof Error) return redactSecrets(error.message || error.name);
5
+ if (typeof error === 'string') return redactSecrets(error);
6
+ return 'fatal runtime error';
7
+ }
8
+
9
+ export function createFatalShutdownController({
10
+ cleanup,
11
+ exit,
12
+ forceExit,
13
+ log,
14
+ schedule = setTimeout,
15
+ cancel = clearTimeout,
16
+ timeoutMs = 2_000
17
+ }) {
18
+ let state = 'idle';
19
+
20
+ function safeLog(entry) {
21
+ try { log(redactSecrets(entry)); } catch {}
22
+ }
23
+
24
+ async function fatal(error) {
25
+ if (state !== 'idle') {
26
+ forceExit(1);
27
+ return;
28
+ }
29
+
30
+ state = 'stopping';
31
+ const deadline = schedule(() => {
32
+ state = 'forced';
33
+ forceExit(1);
34
+ }, timeoutMs);
35
+ deadline?.unref?.();
36
+ safeLog({ code: 'FATAL_RUNTIME_ERROR', error });
37
+ try {
38
+ await cleanup(fatalReason(error));
39
+ } catch (cleanupError) {
40
+ safeLog({ code: 'FATAL_CLEANUP_ERROR', error: cleanupError });
41
+ }
42
+ if (state === 'forced') return;
43
+ state = 'stopped';
44
+ let exitReturned = false;
45
+ try {
46
+ exit(1);
47
+ exitReturned = true;
48
+ } finally {
49
+ if (exitReturned) cancel(deadline);
50
+ }
51
+ }
52
+
53
+ return { fatal };
54
+ }
@@ -0,0 +1,139 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { CliError } from '../shared/errors.mjs';
4
+
5
+ function pathIsWithin(parent, candidate) {
6
+ const relative = path.relative(parent, candidate);
7
+ return relative !== '' && relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
8
+ }
9
+
10
+ function projectPathForbidden(message) {
11
+ return new CliError('PROJECT_PATH_FORBIDDEN', message);
12
+ }
13
+
14
+ function canonicalProjectRoot(root) {
15
+ const requested = path.resolve(String(root || ''));
16
+ try {
17
+ const canonical = fs.realpathSync(requested);
18
+ if (!fs.statSync(canonical).isDirectory()) throw new Error('not a directory');
19
+ return canonical;
20
+ } catch {
21
+ throw new CliError('PROJECT_NOT_REGISTERED', `Project root does not exist: ${requested}`);
22
+ }
23
+ }
24
+
25
+ function ensureStateDirectory(root, createStateDir) {
26
+ const stateDir = path.join(root, '.hello-cc');
27
+ let stateStat;
28
+ try {
29
+ stateStat = fs.lstatSync(stateDir);
30
+ } catch (error) {
31
+ if (error?.code !== 'ENOENT') {
32
+ throw projectPathForbidden(`Cannot inspect project state directory: ${stateDir}`);
33
+ }
34
+ if (!createStateDir) return { stateDir, exists: false };
35
+ try {
36
+ fs.mkdirSync(stateDir, { mode: 0o700 });
37
+ stateStat = fs.lstatSync(stateDir);
38
+ } catch {
39
+ throw projectPathForbidden(`Cannot create project state directory: ${stateDir}`);
40
+ }
41
+ }
42
+
43
+ if (stateStat.isSymbolicLink() || !stateStat.isDirectory()) {
44
+ throw projectPathForbidden(`Project state directory must be a real directory: ${stateDir}`);
45
+ }
46
+ try {
47
+ const canonical = fs.realpathSync(stateDir);
48
+ if (canonical !== stateDir || !pathIsWithin(root, canonical)) {
49
+ throw projectPathForbidden(`Project state directory escapes its project root: ${stateDir}`);
50
+ }
51
+ return { stateDir: canonical, exists: true };
52
+ } catch (error) {
53
+ if (error instanceof CliError) throw error;
54
+ throw projectPathForbidden(`Cannot resolve project state directory: ${stateDir}`);
55
+ }
56
+ }
57
+
58
+ function validateDatabaseParent(stateDir, db) {
59
+ const parent = path.dirname(db);
60
+ const relative = path.relative(stateDir, parent);
61
+ const segments = relative === '' ? [] : relative.split(path.sep);
62
+ let current = stateDir;
63
+ for (const segment of segments) {
64
+ current = path.join(current, segment);
65
+ let stat;
66
+ try {
67
+ stat = fs.lstatSync(current);
68
+ } catch {
69
+ throw projectPathForbidden(`Database parent directory does not exist: ${current}`);
70
+ }
71
+ if (stat.isSymbolicLink() || !stat.isDirectory()) {
72
+ throw projectPathForbidden(`Database parent must contain only real directories: ${current}`);
73
+ }
74
+ }
75
+
76
+ try {
77
+ const canonicalParent = fs.realpathSync(parent);
78
+ if (canonicalParent !== stateDir && !pathIsWithin(stateDir, canonicalParent)) {
79
+ throw projectPathForbidden(`Database parent escapes project state directory: ${parent}`);
80
+ }
81
+ } catch (error) {
82
+ if (error instanceof CliError) throw error;
83
+ throw projectPathForbidden(`Cannot resolve database parent directory: ${parent}`);
84
+ }
85
+ }
86
+
87
+ function validateDatabaseTarget(stateDir, db) {
88
+ let stat;
89
+ try {
90
+ stat = fs.lstatSync(db);
91
+ } catch (error) {
92
+ if (error?.code === 'ENOENT') return db;
93
+ throw projectPathForbidden(`Cannot inspect project database: ${db}`);
94
+ }
95
+ if (stat.isSymbolicLink() || !stat.isFile()) {
96
+ throw projectPathForbidden(`Project database must be a regular file: ${db}`);
97
+ }
98
+ try {
99
+ const canonical = fs.realpathSync(db);
100
+ if (!pathIsWithin(stateDir, canonical)) {
101
+ throw projectPathForbidden(`Project database escapes project state directory: ${db}`);
102
+ }
103
+ return canonical;
104
+ } catch (error) {
105
+ if (error instanceof CliError) throw error;
106
+ throw projectPathForbidden(`Cannot resolve project database: ${db}`);
107
+ }
108
+ }
109
+
110
+ export function resolveProjectDatabase({ root, db, createStateDir = false }) {
111
+ const requestedRoot = path.resolve(String(root || ''));
112
+ const canonicalRoot = canonicalProjectRoot(requestedRoot);
113
+ const rawDb = path.resolve(String(db || path.join(requestedRoot, '.hello-cc', 'mesh.db')));
114
+ let requestedDb = pathIsWithin(requestedRoot, rawDb)
115
+ ? path.join(canonicalRoot, path.relative(requestedRoot, rawDb))
116
+ : rawDb;
117
+ const intendedStateDir = path.join(canonicalRoot, '.hello-cc');
118
+ if (!pathIsWithin(intendedStateDir, requestedDb)) {
119
+ try {
120
+ const canonicalParent = fs.realpathSync(path.dirname(requestedDb));
121
+ const canonicalCandidate = path.join(canonicalParent, path.basename(requestedDb));
122
+ if (pathIsWithin(intendedStateDir, canonicalCandidate)) requestedDb = canonicalCandidate;
123
+ } catch {}
124
+ }
125
+ if (!pathIsWithin(intendedStateDir, requestedDb)) {
126
+ throw projectPathForbidden(`Database path must live under ${intendedStateDir}`);
127
+ }
128
+ const state = ensureStateDirectory(canonicalRoot, Boolean(createStateDir));
129
+ if (!state.exists) {
130
+ return { root: canonicalRoot, stateDir: state.stateDir, db: requestedDb };
131
+ }
132
+
133
+ validateDatabaseParent(state.stateDir, requestedDb);
134
+ return {
135
+ root: canonicalRoot,
136
+ stateDir: state.stateDir,
137
+ db: validateDatabaseTarget(state.stateDir, requestedDb)
138
+ };
139
+ }