@logicseek/hello-cc 0.1.8 → 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 +117 -0
  2. package/README.md +26 -4
  3. package/README.zh-CN.md +22 -3
  4. package/bin/hcc.mjs +3 -5622
  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 +16 -5
  10. package/docs/commands.zh-CN.md +16 -5
  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 +53 -5
  14. package/docs/guide.zh-CN.md +46 -3
  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 +15 -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 +139 -15
  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 +61 -8
  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 +232 -29
  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 +143 -7
  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 +5646 -262
@@ -1,19 +1,21 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
+ import https from 'node:https';
3
4
  import process from 'node:process';
4
5
  import { randomBytes } from 'node:crypto';
5
6
  import { intOpt } from '../cli-args.mjs';
7
+ import { withRuntimeApiVersionHeader } from './api-version.mjs';
6
8
  import { CliError } from '../shared/errors.mjs';
7
9
  import { sanitizePeerPart } from '../core/peers/format.mjs';
8
- import { globalWebTokenPath } from '../runtime/paths.mjs';
10
+ import { globalStateDir } from '../runtime/paths.mjs';
9
11
 
10
12
  export function runtimeConnectHost(host) {
11
13
  if (host === '0.0.0.0' || host === '::') return '127.0.0.1';
12
14
  return host;
13
15
  }
14
16
 
15
- export function runtimeBaseUrl(host, port) {
16
- return `http://${runtimeConnectHost(host)}:${port}`;
17
+ export function runtimeBaseUrl(host, port, tls = false) {
18
+ return `${tls ? 'https' : 'http'}://${runtimeConnectHost(host)}:${port}`;
17
19
  }
18
20
 
19
21
  export function runtimeApiUrl(runtime, route) {
@@ -81,31 +83,87 @@ export function publicRuntimeUrl(runtime, projectRoot = null) {
81
83
  const host = runtime.host === '0.0.0.0' || runtime.host === '::'
82
84
  ? '<machine-ip>'
83
85
  : runtime.host || runtimeConnectHost(runtime.host || '127.0.0.1');
84
- return `http://${host}:${runtime.port}/${runtimeUrlQuery(runtime, projectRoot)}`;
86
+ const scheme = runtime.tls ? 'https' : 'http';
87
+ return `${scheme}://${host}:${runtime.port}/${runtimeUrlQuery(runtime, projectRoot)}`;
85
88
  }
86
89
 
87
90
  export function localRuntimeUrl(runtime, projectRoot = null) {
88
91
  const host = runtimeConnectHost(runtime.host || '127.0.0.1');
89
- return `http://${host}:${runtime.port}/${runtimeUrlQuery(runtime, projectRoot)}`;
90
- }
91
-
92
- export function readStoredWebToken() {
93
- const file = globalWebTokenPath();
94
- if (!fs.existsSync(file)) return '';
95
- try {
96
- return fs.readFileSync(file, 'utf8').trim();
97
- } catch {
98
- return '';
92
+ const scheme = runtime.tls ? 'https' : 'http';
93
+ return `${scheme}://${host}:${runtime.port}/${runtimeUrlQuery(runtime, projectRoot)}`;
94
+ }
95
+
96
+ // Issue a request to a runtime API route. Returns { ok, status, text }.
97
+ // Plaintext: the global fetch (mockable, no CA needed). TLS: the node https
98
+ // client with a CA agent so the CLI can trust its own self-signed cert (global
99
+ // fetch cannot take a per-call CA without undici's Agent). TLS sockets are not
100
+ // pooled so the CLI process can exit promptly.
101
+ // Both transports must serialize the request body identically: the global
102
+ // fetch rejects object bodies while https.request needs a string/buffer, so
103
+ // normalize once up front instead of letting the branches diverge.
104
+ export function normalizeRequestBody(body) {
105
+ if (body === null || body === undefined) return null;
106
+ return typeof body === 'string' ? body : JSON.stringify(body);
107
+ }
108
+
109
+ export async function runtimeHttpRequest(rt, route, { method = 'GET', headers = {}, body = null, timeoutMs = 8000, signal } = {}) {
110
+ const url = runtimeApiUrl(rt, route);
111
+ const requestBody = normalizeRequestBody(body);
112
+ const requestHeaders = withRuntimeApiVersionHeader(headers);
113
+ if (url.protocol !== 'https:') {
114
+ // xx-03: the plaintext branch must respect the deadline too — a stalled
115
+ // runtime must not hang the CLI forever. Combine the caller signal with a
116
+ // hard timeout.
117
+ const timeoutSignal = AbortSignal.timeout(timeoutMs);
118
+ const combinedSignal = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
119
+ const response = await fetch(url, {
120
+ method,
121
+ headers: requestHeaders,
122
+ body: requestBody === null ? undefined : requestBody,
123
+ signal: combinedSignal
124
+ });
125
+ return { ok: response.ok, status: response.status, text: await response.text() };
99
126
  }
100
- }
101
-
102
- export function writeStoredWebToken(token) {
103
- if (!token) return '';
104
- const file = globalWebTokenPath();
105
- fs.mkdirSync(path.dirname(file), { recursive: true });
106
- fs.writeFileSync(file, `${token}\n`, { mode: 0o600 });
107
- fs.chmodSync(file, 0o600);
108
- return file;
127
+ const tlsCa = rt?.tls_ca || rt?.tls_cert || (rt?.tls_ca_file
128
+ ? fs.readFileSync(rt.tls_ca_file, 'utf8')
129
+ : null);
130
+ const agent = new https.Agent({
131
+ ...(tlsCa ? { ca: tlsCa } : {}),
132
+ rejectUnauthorized: true,
133
+ keepAlive: false
134
+ });
135
+ const options = {
136
+ method,
137
+ hostname: url.hostname,
138
+ port: url.port || 443,
139
+ path: url.pathname + url.search,
140
+ headers: requestHeaders,
141
+ agent,
142
+ signal
143
+ };
144
+ return new Promise((resolve, reject) => {
145
+ let settled = false;
146
+ const cleanup = () => { try { agent.destroy(); } catch {} };
147
+ const done = (fn, value) => { if (settled) return; settled = true; cleanup(); clearTimeout(deadline); fn(value); };
148
+ // CLIENT-2: absolute wall-clock deadline (not just socket-idle) so a
149
+ // slow-drip response body cannot hold the promise open forever.
150
+ const deadline = setTimeout(() => done(reject, new Error('runtime request timeout')), timeoutMs);
151
+ const req = https.request(options, (res) => {
152
+ let text = '';
153
+ res.setEncoding('utf8');
154
+ res.on('data', (chunk) => { text += chunk; });
155
+ res.on('end', () => {
156
+ done(resolve, { ok: res.statusCode >= 200 && res.statusCode < 300, status: res.statusCode, text });
157
+ });
158
+ // CLIENT-2: a connection that errors mid-body must settle the promise.
159
+ res.on('error', (err) => done(reject, err));
160
+ res.on('aborted', () => done(reject, new Error('runtime response aborted')));
161
+ });
162
+ req.on('error', (err) => done(reject, err));
163
+ req.setTimeout(timeoutMs, () => done(reject, new Error('runtime request timeout')));
164
+ if (requestBody !== null) req.write(requestBody);
165
+ req.end();
166
+ });
109
167
  }
110
168
 
111
169
  export function validateWebTokenOpts(opts) {
@@ -118,15 +176,8 @@ export function makeWebToken(opts) {
118
176
  validateWebTokenOpts(opts);
119
177
  if (opts['no-token']) return '';
120
178
  const explicitToken = opts.token || process.env.HCC_WEB_TOKEN || '';
121
- if (explicitToken) {
122
- writeStoredWebToken(explicitToken);
123
- return explicitToken;
124
- }
125
- const stored = readStoredWebToken();
126
- if (stored) return stored;
127
- const generated = randomBytes(24).toString('base64url');
128
- writeStoredWebToken(generated);
129
- return generated;
179
+ if (explicitToken) return explicitToken;
180
+ return randomBytes(24).toString('base64url');
130
181
  }
131
182
 
132
183
  export function expectedWebHost(opts) {
@@ -136,6 +187,12 @@ export function expectedWebHost(opts) {
136
187
  export function webRuntimeMatchesRequest(runtime, opts) {
137
188
  if (!runtime) return false;
138
189
  if (opts['no-token'] && (opts.token || process.env.HCC_WEB_TOKEN)) return false;
190
+ const runtimeTls = runtime.tls === undefined
191
+ ? /^https:/i.test(String(runtime.base_url || ''))
192
+ : Boolean(runtime.tls);
193
+ if (runtimeTls !== Boolean(opts.tls)) return false;
194
+ if (Boolean(runtime.trust_proxy) !== Boolean(opts['trust-proxy'])) return false;
195
+ if ((runtime.proxy_origin || '') !== (opts['proxy-origin'] || '')) return false;
139
196
  const expectedHost = expectedWebHost(opts);
140
197
  if (runtime.host !== expectedHost) return false;
141
198
  const expectedPort = intOpt(opts, 'port', 8787);
@@ -147,8 +204,6 @@ export function webRuntimeMatchesRequest(runtime, opts) {
147
204
  }
148
205
 
149
206
  export function rememberRuntimeToken(runtime, opts) {
150
- if (!runtime?.token || opts['no-token']) return;
151
- const explicitToken = opts.token || process.env.HCC_WEB_TOKEN || '';
152
- if (explicitToken && runtime.token !== explicitToken) return;
153
- writeStoredWebToken(runtime.token);
207
+ // Generated tokens live only in the runtime pointer. Keeping this no-op
208
+ // compatibility export avoids reintroducing a long-lived bearer credential.
154
209
  }
Binary file
@@ -0,0 +1,333 @@
1
+ // Web startup helpers extracted from bin/hcc.mjs.
2
+ // hcc web process matching, orphan runtime reaping, exposure/token checks,
3
+ // background child launch, readiness polling, and runtime banner printing.
4
+
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ import process from 'node:process';
8
+ import { URL } from 'node:url';
9
+ import { spawn, spawnSync } from 'node:child_process';
10
+ import { setTimeout as sleep } from 'node:timers/promises';
11
+ import { CliError } from '../shared/errors.mjs';
12
+ import { redactCliArgs } from '../shared/redact.mjs';
13
+ import { intOpt, parseOpts, splitGlobalArgs, validateOpts } from '../cli-args.mjs';
14
+ import { printResult } from '../format.mjs';
15
+ import { commandPath, tailFile } from '../cli-runtime.mjs';
16
+ import { registerProject } from '../runtime/projects.mjs';
17
+ import { runtimePath, globalRuntimePath, webLogPath } from '../runtime/paths.mjs';
18
+ import { readHealthyGlobalRuntime, writeRuntime } from '../runtime/state.mjs';
19
+ import { runtimeRequest } from '../runtime/client.mjs';
20
+ import {
21
+ expectedWebHost,
22
+ isLoopbackHost,
23
+ localRuntimeUrl,
24
+ publicRuntimeUrl,
25
+ rememberRuntimeToken,
26
+ validateWebTokenOpts,
27
+ webRuntimeMatchesRequest
28
+ } from '../web/runtime.mjs';
29
+ import { requestOriginMatches } from '../web/http.mjs';
30
+ import { WEB_CHILD_ENV } from '../core/sessions/launch.mjs';
31
+ import { API_VERSION } from '../web/api-version.mjs';
32
+ import { ensureTmuxAvailable } from '../tmux.mjs';
33
+
34
+ export function createWebStartup(deps) {
35
+ const {
36
+ splitProcessArgs, sameResolvedPath,
37
+ redactedLogText, CLI_NAME, PRODUCT_NAME, now,
38
+ prepareLocalBus
39
+ } = deps;
40
+
41
+ function hccWebProcessMatches(line, ctx) {
42
+ const args = splitProcessArgs(line);
43
+ const hccIndex = args.findIndex((arg) => sameResolvedPath(arg, commandPath()) || arg.endsWith('/hcc.mjs'));
44
+ if (hccIndex < 0) return false;
45
+ const hccArgs = args.slice(hccIndex + 1);
46
+ const { global, rest } = splitGlobalArgs(hccArgs);
47
+ if (rest[0] !== 'web') return false;
48
+ return sameResolvedPath(global.root, ctx.root) ||
49
+ sameResolvedPath(global.db, ctx.dbPath);
50
+ }
51
+
52
+ function currentProcessAncestorPids(ppidByPid) {
53
+ const ancestors = new Set();
54
+ let pid = process.ppid;
55
+ while (Number.isFinite(pid) && pid > 0 && !ancestors.has(pid)) {
56
+ ancestors.add(pid);
57
+ pid = ppidByPid.get(pid);
58
+ }
59
+ return ancestors;
60
+ }
61
+
62
+ async function stopOrphanWebRuntimes(ctx, keepPid = null) {
63
+ if (process.platform === 'win32') return;
64
+ let output = '';
65
+ try {
66
+ output = spawnSync('ps', ['-eo', 'pid=,ppid=,args='], {
67
+ encoding: 'utf8',
68
+ stdio: ['ignore', 'pipe', 'ignore']
69
+ }).stdout || '';
70
+ } catch {
71
+ return;
72
+ }
73
+
74
+ const rows = [];
75
+ const ppidByPid = new Map();
76
+ for (const line of output.split('\n')) {
77
+ const trimmed = line.trim();
78
+ if (!trimmed) continue;
79
+ const match = trimmed.match(/^(\d+)\s+(\d+)\s+(.*)$/);
80
+ if (!match) continue;
81
+ const pid = Number.parseInt(match[1], 10);
82
+ const ppid = Number.parseInt(match[2], 10);
83
+ if (!Number.isFinite(pid)) continue;
84
+ if (Number.isFinite(ppid)) ppidByPid.set(pid, ppid);
85
+ rows.push({ pid, args: match[3] });
86
+ }
87
+
88
+ const ancestorPids = currentProcessAncestorPids(ppidByPid);
89
+ const pids = [];
90
+ for (const row of rows) {
91
+ if (row.pid === process.pid || row.pid === keepPid || ancestorPids.has(row.pid)) continue;
92
+ if (hccWebProcessMatches(row.args, ctx)) pids.push(row.pid);
93
+ }
94
+ if (!pids.length) return;
95
+
96
+ for (const pid of pids) {
97
+ try { process.kill(pid, 'SIGTERM'); } catch {}
98
+ }
99
+ await sleep(250);
100
+ for (const pid of pids) {
101
+ try {
102
+ process.kill(pid, 0);
103
+ process.kill(pid, 'SIGKILL');
104
+ } catch {}
105
+ }
106
+ }
107
+
108
+ function assertWebTokenForHost(host, hasToken) {
109
+ if (!isLoopbackHost(host) && !hasToken) {
110
+ throw new CliError('WEB_EXPOSED_WITHOUT_TOKEN',
111
+ `Refusing to expose the web console on ${host} without a token. A tokenless ` +
112
+ `terminal on a non-loopback address lets anyone on the network run commands as you. ` +
113
+ `Use --local to bind loopback only, or drop --no-token so a token is required.`);
114
+ }
115
+ }
116
+
117
+ function webExposureWarning(host, port) {
118
+ return `WARNING: hello-cc web is bound to ${host}:${port}, exposing a writable terminal ` +
119
+ `(remote code execution surface) to your network. Anyone who reaches this port with the ` +
120
+ `token can run commands as you. Prefer '--local' + 'ssh -L ${port}:127.0.0.1:${port}', ` +
121
+ `or put it behind a TLS reverse proxy.`;
122
+ }
123
+
124
+ // Same-origin check for the WebSocket terminal upgrade. Browsers always send an
125
+ // Origin header on WebSocket handshakes, so a cross-site page attempting a
126
+ // cross-site WebSocket hijack (CSWSH) is rejected. Non-browser clients (the CLI,
127
+ // the `ws` library, regression tests) send no Origin and are allowed through to
128
+ // the token gate.
129
+ function webSocketOriginAllowed(req, options = {}) {
130
+ const origin = req.headers.origin;
131
+ if (!origin) return true;
132
+ return requestOriginMatches(req, options);
133
+ }
134
+
135
+ function proxyOriginForOpts(opts) {
136
+ const trustProxy = Boolean(opts['trust-proxy']);
137
+ const value = String(opts['proxy-origin'] || '');
138
+ if (!trustProxy && value) throw new CliError('BAD_ARGS', '--proxy-origin requires --trust-proxy');
139
+ if (!trustProxy) return '';
140
+ if (!value) throw new CliError('BAD_ARGS', '--trust-proxy requires --proxy-origin');
141
+ try {
142
+ const parsed = new URL(value);
143
+ if (!['http:', 'https:'].includes(parsed.protocol) || parsed.username || parsed.password ||
144
+ parsed.pathname !== '/' || parsed.search || parsed.hash) throw new Error('invalid origin');
145
+ return parsed.origin;
146
+ } catch {
147
+ throw new CliError('BAD_ARGS', '--proxy-origin must be an http(s) origin without a path, query, or credentials');
148
+ }
149
+ }
150
+
151
+ async function startWebBackground(ctx, args) {
152
+ const opts = parseOpts(args, { booleans: ['local', 'no-token', 'no-guidance', 'no-discover', 'tls', 'trust-proxy'] });
153
+ validateOpts('web', opts, ['host', 'port', 'token', 'local', 'no-token', 'no-guidance', 'no-discover', 'tls', 'trust-proxy', 'proxy-origin']);
154
+ const requestedProxyOrigin = proxyOriginForOpts(opts);
155
+ if (requestedProxyOrigin) opts['proxy-origin'] = requestedProxyOrigin;
156
+ validateWebTokenOpts(opts);
157
+ const requestedHost = expectedWebHost(opts);
158
+ assertWebTokenForHost(requestedHost, !opts['no-token']);
159
+ if (!isLoopbackHost(requestedHost)) console.error(redactedLogText(webExposureWarning(requestedHost, intOpt(opts, 'port', 8787)) + (opts.tls ? '' : ' Consider --tls to encrypt this connection.')));
160
+ ensureTmuxAvailable({ autoInstall: true });
161
+ const setup = await prepareLocalBus(ctx, {
162
+ ...opts,
163
+ installShims: process.env.HCC_SKIP_SHIM_INSTALL === '1' ? false : true
164
+ });
165
+ registerProject(ctx);
166
+
167
+ const existing = await readHealthyGlobalRuntime();
168
+ if (existing) {
169
+ if (webRuntimeMatchesRequest(existing, opts)) {
170
+ await stopOrphanWebRuntimes(ctx, existing.pid || null);
171
+ rememberRuntimeToken(existing, opts);
172
+ try {
173
+ await runtimeRequest(ctx, 'POST', '/api/projects', { root: ctx.root, db: ctx.dbPath }, existing);
174
+ } catch {}
175
+ writeRuntime(ctx, {
176
+ ...existing,
177
+ api_version: API_VERSION,
178
+ root: ctx.root,
179
+ db: ctx.dbPath,
180
+ project_root: ctx.root,
181
+ global_runtime: true
182
+ });
183
+ return printWebRuntime(ctx, existing, { already: true, logFile: webLogPath(ctx), setup });
184
+ }
185
+ // TLS-2: an idempotent `hcc web` must not silently stop a TLS runtime and
186
+ // downgrade to plaintext (or vice versa) when only --tls/--trust-proxy
187
+ // differ. Refuse loudly instead; host/port/token mismatches below still
188
+ // take the normal stop-and-restart path (legitimate reconfiguration).
189
+ const runtimeTls = existing.tls === undefined
190
+ ? /^https:/i.test(String(existing.base_url || ''))
191
+ : Boolean(existing.tls);
192
+ if (runtimeTls !== Boolean(opts.tls) ||
193
+ Boolean(existing.trust_proxy) !== Boolean(opts['trust-proxy']) ||
194
+ (existing.proxy_origin || '') !== (opts['proxy-origin'] || '')) {
195
+ throw new CliError('RUNTIME_CONFIG_CONFLICT',
196
+ `A ${runtimeTls ? 'TLS' : 'plaintext'} web runtime is already running${existing.trust_proxy ? ' with --trust-proxy' : ''} on port ${existing.port}. ` +
197
+ `Run ${CLI_NAME} down first, or re-run with matching flags (${opts.tls ? '--tls' : 'no --tls'}${opts['trust-proxy'] ? ', --trust-proxy' : ''}).`);
198
+ }
199
+ try { await runtimeRequest(ctx, 'POST', '/api/runtime/stop', {}, existing); } catch {}
200
+ await sleep(250);
201
+ }
202
+ await stopOrphanWebRuntimes(ctx);
203
+
204
+ try { fs.rmSync(runtimePath(ctx), { force: true }); } catch {}
205
+ try { fs.rmSync(globalRuntimePath(), { force: true }); } catch {}
206
+
207
+ const logFile = webLogPath(ctx);
208
+ fs.mkdirSync(path.dirname(logFile), { recursive: true });
209
+ // bg-05: rotate web.log once it grows past 5 MB (keep the previous .1).
210
+ try {
211
+ if (fs.statSync(logFile).size > 5 * 1024 * 1024) {
212
+ try { fs.rmSync(`${logFile}.1`, { force: true }); } catch {}
213
+ try { fs.renameSync(logFile, `${logFile}.1`); } catch {}
214
+ }
215
+ } catch {}
216
+ const redactedStart = redactedLogText(`${CLI_NAME} web ${redactCliArgs(args).join(' ')}`);
217
+ fs.appendFileSync(logFile, `\n[${new Date().toISOString()}] ${redactedStart}\n`, { mode: 0o600 });
218
+ // The runtime echoes token-bearing URLs into web.log; keep it owner-only so a
219
+ // co-tenant on the machine cannot read the token (net-02).
220
+ try { fs.chmodSync(logFile, 0o600); } catch {}
221
+ const logFd = fs.openSync(logFile, 'a');
222
+
223
+ const childArgs = [
224
+ commandPath(),
225
+ '--root', ctx.root,
226
+ '--db', ctx.dbPath,
227
+ 'web',
228
+ ...args
229
+ ];
230
+ const childEnv = {
231
+ ...process.env,
232
+ [WEB_CHILD_ENV]: '1',
233
+ HCC_ROOT: ctx.root,
234
+ HCC_DB: ctx.dbPath
235
+ };
236
+
237
+ let child;
238
+ try {
239
+ child = spawn(process.execPath, childArgs, {
240
+ cwd: ctx.cwd,
241
+ env: childEnv,
242
+ detached: true,
243
+ stdio: ['ignore', logFd, logFd]
244
+ });
245
+ } finally {
246
+ try { fs.closeSync(logFd); } catch {}
247
+ }
248
+
249
+ const runtime = await waitForStartedRuntime(ctx, child, logFile);
250
+ child.unref();
251
+ return printWebRuntime(ctx, runtime, { already: false, logFile, setup });
252
+ }
253
+
254
+ async function waitForStartedRuntime(ctx, child, logFile) {
255
+ let exitInfo = null;
256
+ child.once('exit', (code, signal) => {
257
+ exitInfo = { code, signal };
258
+ });
259
+
260
+ const deadline = Date.now() + 15000;
261
+ while (Date.now() < deadline) {
262
+ const runtime = await readHealthyGlobalRuntime();
263
+ if (runtime) return runtime;
264
+ if (exitInfo) {
265
+ const detail = redactedLogText(tailFile(logFile));
266
+ throw new CliError('RUNTIME_START_FAILED',
267
+ `${PRODUCT_NAME} runtime exited before it became healthy` +
268
+ ` (code=${exitInfo.code ?? ''}${exitInfo.signal ? ` signal=${exitInfo.signal}` : ''}).` +
269
+ `${detail ? `\n\nLast log lines:\n${detail}` : ''}`,
270
+ { log: logFile });
271
+ }
272
+ await sleep(150);
273
+ }
274
+
275
+ try {
276
+ if (process.platform === 'win32') process.kill(child.pid, 'SIGTERM');
277
+ else process.kill(-child.pid, 'SIGTERM');
278
+ } catch {}
279
+ const detail = redactedLogText(tailFile(logFile));
280
+ throw new CliError('RUNTIME_START_TIMEOUT',
281
+ `${PRODUCT_NAME} runtime did not become healthy within 15s.` +
282
+ `${detail ? `\n\nLast log lines:\n${detail}` : ''}`,
283
+ { log: logFile });
284
+ }
285
+
286
+ function printWebRuntime(ctx, runtime, opts = {}) {
287
+ const logFile = opts.logFile || webLogPath(ctx);
288
+ const data = {
289
+ status: opts.already ? 'already_running' : 'started',
290
+ pid: runtime.pid || null,
291
+ root: ctx.root,
292
+ db: ctx.dbPath,
293
+ host: runtime.host || null,
294
+ port: runtime.port || null,
295
+ url: publicRuntimeUrl(runtime, ctx.root),
296
+ local_url: localRuntimeUrl(runtime, ctx.root),
297
+ runtime: runtimePath(ctx),
298
+ log: logFile,
299
+ stop: `${CLI_NAME} down`
300
+ };
301
+ return printResult(ctx, data, (r) => {
302
+ const lines = [
303
+ opts.already
304
+ ? `${PRODUCT_NAME} web already running in background`
305
+ : `${PRODUCT_NAME} web started in background`,
306
+ `pid: ${r.pid}`,
307
+ `project: ${r.root}`,
308
+ `database: ${r.db}`,
309
+ `runtime: ${r.runtime}`,
310
+ `log: ${r.log}`,
311
+ `open: ${r.url}`
312
+ ];
313
+ if (r.local_url !== r.url) lines.push(`local: ${r.local_url}`);
314
+ if (opts.setup?.shims?.installed?.length) {
315
+ lines.push(`shims: installed ${opts.setup.shims.installed.map((p) => path.basename(p)).join(', ')}`);
316
+ if (opts.setup.shims.pathUpdated && opts.setup.shims.rcFile) {
317
+ lines.push(`PATH updated in ${opts.setup.shims.rcFile}; open a new terminal or source it`);
318
+ }
319
+ }
320
+ if (opts.setup?.warnings?.length) {
321
+ lines.push(...opts.setup.warnings.map((warning) => `warning: ${warning}`));
322
+ }
323
+ lines.push(`stop: ${r.stop}`);
324
+ return lines.join('\n');
325
+ });
326
+ }
327
+
328
+ return {
329
+ hccWebProcessMatches, currentProcessAncestorPids, stopOrphanWebRuntimes,
330
+ assertWebTokenForHost, webExposureWarning, webSocketOriginAllowed,
331
+ proxyOriginForOpts, startWebBackground, waitForStartedRuntime, printWebRuntime
332
+ };
333
+ }