@logicseek/hello-cc 0.1.9 → 1.0.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.
- package/CHANGELOG.md +136 -0
- package/README.md +50 -3
- package/README.zh-CN.md +48 -3
- package/bin/hcc.mjs +3 -5656
- package/docs/README.md +8 -0
- package/docs/README.zh-CN.md +6 -0
- package/docs/architecture.md +29 -18
- package/docs/architecture.zh-CN.md +20 -10
- package/docs/commands.md +13 -4
- package/docs/commands.zh-CN.md +13 -4
- package/docs/guide.md +97 -3
- package/docs/guide.zh-CN.md +89 -2
- package/lib/cli/app.mjs +644 -0
- package/lib/cli/commands/coordination.mjs +249 -0
- package/lib/cli/commands/doctor.mjs +81 -0
- package/lib/cli/commands/gc.mjs +1002 -0
- package/lib/cli/commands/hook.mjs +205 -0
- package/lib/cli/commands/install.mjs +294 -0
- package/lib/cli/commands/lifecycle.mjs +101 -0
- package/lib/cli/commands/lock.mjs +197 -0
- package/lib/cli/commands/misc.mjs +76 -0
- package/lib/cli/commands/msg.mjs +152 -0
- package/lib/cli/commands/peer.mjs +249 -0
- package/lib/cli/commands/query.mjs +96 -0
- package/lib/cli/commands/run.mjs +463 -0
- package/lib/cli/commands/task.mjs +405 -0
- package/lib/cli/commands/team.mjs +131 -0
- package/lib/cli/commands/tmux.mjs +838 -0
- package/lib/cli/commands/up.mjs +135 -0
- package/lib/cli-args.mjs +16 -2
- package/lib/coordination-state.mjs +112 -51
- package/lib/core/coordination/automation.mjs +5 -8
- package/lib/core/coordination/clock-safety.mjs +323 -0
- package/lib/core/coordination/event-retention.mjs +17 -0
- package/lib/core/coordination/gc-plan.mjs +614 -0
- package/lib/core/coordination/lease-renewal.mjs +102 -0
- package/lib/core/coordination/lock-evidence.mjs +77 -0
- package/lib/core/coordination/optimistic-evidence.mjs +26 -0
- package/lib/core/coordination/tasks.mjs +156 -36
- package/lib/core/peers/evidence-runtime.mjs +312 -0
- package/lib/core/peers/evidence.mjs +103 -0
- package/lib/core/peers/hook-owner.mjs +32 -0
- package/lib/core/peers/liveness.mjs +44 -4
- package/lib/core/peers/peer-helpers.mjs +121 -0
- package/lib/core/peers/session.mjs +8 -2
- package/lib/core/peers/tmux-safety.mjs +290 -0
- package/lib/core/sessions/launch.mjs +13 -1
- package/lib/db/connection.mjs +104 -0
- package/lib/db/events.mjs +34 -0
- package/lib/db/migration-backup.mjs +225 -0
- package/lib/db/schema.mjs +102 -5
- package/lib/db/stores/peers.mjs +34 -0
- package/lib/integrations/peers/identity.mjs +62 -16
- package/lib/integrations/shims/script.mjs +111 -9
- package/lib/process/identity.mjs +222 -0
- package/lib/process/pty-lifecycle.mjs +149 -0
- package/lib/runtime/buffer-directory-lease.mjs +48 -0
- package/lib/runtime/buffer-evidence.mjs +336 -0
- package/lib/runtime/buffer-gc-protocol.mjs +167 -0
- package/lib/runtime/buffer-gc.mjs +270 -0
- package/lib/runtime/client.mjs +63 -7
- package/lib/runtime/fatal-shutdown.mjs +54 -0
- package/lib/runtime/project-path.mjs +139 -0
- package/lib/runtime/projects.mjs +320 -40
- package/lib/runtime/state.mjs +227 -27
- package/lib/shared/cleanup-error.mjs +54 -0
- package/lib/shared/clock-grace.mjs +106 -0
- package/lib/shared/errors.mjs +9 -0
- package/lib/shared/file-lock.mjs +377 -0
- package/lib/shared/json-file.mjs +6 -2
- package/lib/shared/redact.mjs +60 -0
- package/lib/shared/socket-lock-worker.mjs +250 -0
- package/lib/terminal/tmux.mjs +131 -27
- package/lib/tmux.mjs +4 -0
- package/lib/ui/help.mjs +53 -15
- package/lib/web/api-version.mjs +42 -0
- package/lib/web/auto-attach.mjs +185 -0
- package/lib/web/buffer-gc-runtime.mjs +175 -0
- package/lib/web/cookie-auth.mjs +119 -0
- package/lib/web/external-sessions.mjs +254 -0
- package/lib/web/http-routes.mjs +542 -0
- package/lib/web/http.mjs +135 -6
- package/lib/web/liveness-reaper.mjs +145 -0
- package/lib/web/peer-actions.mjs +132 -23
- package/lib/web/project-contexts.mjs +183 -0
- package/lib/web/pty-sessions.mjs +203 -0
- package/lib/web/runtime-main.mjs +717 -0
- package/lib/web/runtime.mjs +91 -36
- package/lib/web/session-serialize.mjs +0 -0
- package/lib/web/startup.mjs +335 -0
- package/lib/web/tls.mjs +365 -0
- package/lib/web/tmux-sessions.mjs +1361 -0
- package/lib/web/tmux-stream.mjs +166 -0
- package/lib/web/ui-template.mjs +152 -20
- package/package.json +6 -3
- package/scripts/check-free-identifiers.mjs +290 -0
- package/scripts/regression.mjs +5404 -273
- package/docs/architecture-split-review.md +0 -217
- package/docs/layout-split-stack-audit.md +0 -298
package/lib/web/runtime.mjs
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
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
|
-
|
|
151
|
-
|
|
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,335 @@
|
|
|
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 const WEB_RUNTIME_START_TIMEOUT_MS = 60_000;
|
|
35
|
+
|
|
36
|
+
export function createWebStartup(deps) {
|
|
37
|
+
const {
|
|
38
|
+
splitProcessArgs, sameResolvedPath,
|
|
39
|
+
redactedLogText, CLI_NAME, PRODUCT_NAME, now,
|
|
40
|
+
prepareLocalBus
|
|
41
|
+
} = deps;
|
|
42
|
+
|
|
43
|
+
function hccWebProcessMatches(line, ctx) {
|
|
44
|
+
const args = splitProcessArgs(line);
|
|
45
|
+
const hccIndex = args.findIndex((arg) => sameResolvedPath(arg, commandPath()) || arg.endsWith('/hcc.mjs'));
|
|
46
|
+
if (hccIndex < 0) return false;
|
|
47
|
+
const hccArgs = args.slice(hccIndex + 1);
|
|
48
|
+
const { global, rest } = splitGlobalArgs(hccArgs);
|
|
49
|
+
if (rest[0] !== 'web') return false;
|
|
50
|
+
return sameResolvedPath(global.root, ctx.root) ||
|
|
51
|
+
sameResolvedPath(global.db, ctx.dbPath);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function currentProcessAncestorPids(ppidByPid) {
|
|
55
|
+
const ancestors = new Set();
|
|
56
|
+
let pid = process.ppid;
|
|
57
|
+
while (Number.isFinite(pid) && pid > 0 && !ancestors.has(pid)) {
|
|
58
|
+
ancestors.add(pid);
|
|
59
|
+
pid = ppidByPid.get(pid);
|
|
60
|
+
}
|
|
61
|
+
return ancestors;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function stopOrphanWebRuntimes(ctx, keepPid = null) {
|
|
65
|
+
if (process.platform === 'win32') return;
|
|
66
|
+
let output = '';
|
|
67
|
+
try {
|
|
68
|
+
output = spawnSync('ps', ['-eo', 'pid=,ppid=,args='], {
|
|
69
|
+
encoding: 'utf8',
|
|
70
|
+
stdio: ['ignore', 'pipe', 'ignore']
|
|
71
|
+
}).stdout || '';
|
|
72
|
+
} catch {
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const rows = [];
|
|
77
|
+
const ppidByPid = new Map();
|
|
78
|
+
for (const line of output.split('\n')) {
|
|
79
|
+
const trimmed = line.trim();
|
|
80
|
+
if (!trimmed) continue;
|
|
81
|
+
const match = trimmed.match(/^(\d+)\s+(\d+)\s+(.*)$/);
|
|
82
|
+
if (!match) continue;
|
|
83
|
+
const pid = Number.parseInt(match[1], 10);
|
|
84
|
+
const ppid = Number.parseInt(match[2], 10);
|
|
85
|
+
if (!Number.isFinite(pid)) continue;
|
|
86
|
+
if (Number.isFinite(ppid)) ppidByPid.set(pid, ppid);
|
|
87
|
+
rows.push({ pid, args: match[3] });
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const ancestorPids = currentProcessAncestorPids(ppidByPid);
|
|
91
|
+
const pids = [];
|
|
92
|
+
for (const row of rows) {
|
|
93
|
+
if (row.pid === process.pid || row.pid === keepPid || ancestorPids.has(row.pid)) continue;
|
|
94
|
+
if (hccWebProcessMatches(row.args, ctx)) pids.push(row.pid);
|
|
95
|
+
}
|
|
96
|
+
if (!pids.length) return;
|
|
97
|
+
|
|
98
|
+
for (const pid of pids) {
|
|
99
|
+
try { process.kill(pid, 'SIGTERM'); } catch {}
|
|
100
|
+
}
|
|
101
|
+
await sleep(250);
|
|
102
|
+
for (const pid of pids) {
|
|
103
|
+
try {
|
|
104
|
+
process.kill(pid, 0);
|
|
105
|
+
process.kill(pid, 'SIGKILL');
|
|
106
|
+
} catch {}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function assertWebTokenForHost(host, hasToken) {
|
|
111
|
+
if (!isLoopbackHost(host) && !hasToken) {
|
|
112
|
+
throw new CliError('WEB_EXPOSED_WITHOUT_TOKEN',
|
|
113
|
+
`Refusing to expose the web console on ${host} without a token. A tokenless ` +
|
|
114
|
+
`terminal on a non-loopback address lets anyone on the network run commands as you. ` +
|
|
115
|
+
`Use --local to bind loopback only, or drop --no-token so a token is required.`);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function webExposureWarning(host, port) {
|
|
120
|
+
return `WARNING: hello-cc web is bound to ${host}:${port}, exposing a writable terminal ` +
|
|
121
|
+
`(remote code execution surface) to your network. Anyone who reaches this port with the ` +
|
|
122
|
+
`token can run commands as you. Prefer '--local' + 'ssh -L ${port}:127.0.0.1:${port}', ` +
|
|
123
|
+
`or put it behind a TLS reverse proxy.`;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Same-origin check for the WebSocket terminal upgrade. Browsers always send an
|
|
127
|
+
// Origin header on WebSocket handshakes, so a cross-site page attempting a
|
|
128
|
+
// cross-site WebSocket hijack (CSWSH) is rejected. Non-browser clients (the CLI,
|
|
129
|
+
// the `ws` library, regression tests) send no Origin and are allowed through to
|
|
130
|
+
// the token gate.
|
|
131
|
+
function webSocketOriginAllowed(req, options = {}) {
|
|
132
|
+
const origin = req.headers.origin;
|
|
133
|
+
if (!origin) return true;
|
|
134
|
+
return requestOriginMatches(req, options);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function proxyOriginForOpts(opts) {
|
|
138
|
+
const trustProxy = Boolean(opts['trust-proxy']);
|
|
139
|
+
const value = String(opts['proxy-origin'] || '');
|
|
140
|
+
if (!trustProxy && value) throw new CliError('BAD_ARGS', '--proxy-origin requires --trust-proxy');
|
|
141
|
+
if (!trustProxy) return '';
|
|
142
|
+
if (!value) throw new CliError('BAD_ARGS', '--trust-proxy requires --proxy-origin');
|
|
143
|
+
try {
|
|
144
|
+
const parsed = new URL(value);
|
|
145
|
+
if (!['http:', 'https:'].includes(parsed.protocol) || parsed.username || parsed.password ||
|
|
146
|
+
parsed.pathname !== '/' || parsed.search || parsed.hash) throw new Error('invalid origin');
|
|
147
|
+
return parsed.origin;
|
|
148
|
+
} catch {
|
|
149
|
+
throw new CliError('BAD_ARGS', '--proxy-origin must be an http(s) origin without a path, query, or credentials');
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async function startWebBackground(ctx, args) {
|
|
154
|
+
const opts = parseOpts(args, { booleans: ['local', 'no-token', 'no-guidance', 'no-discover', 'tls', 'trust-proxy'] });
|
|
155
|
+
validateOpts('web', opts, ['host', 'port', 'token', 'local', 'no-token', 'no-guidance', 'no-discover', 'tls', 'trust-proxy', 'proxy-origin']);
|
|
156
|
+
const requestedProxyOrigin = proxyOriginForOpts(opts);
|
|
157
|
+
if (requestedProxyOrigin) opts['proxy-origin'] = requestedProxyOrigin;
|
|
158
|
+
validateWebTokenOpts(opts);
|
|
159
|
+
const requestedHost = expectedWebHost(opts);
|
|
160
|
+
assertWebTokenForHost(requestedHost, !opts['no-token']);
|
|
161
|
+
if (!isLoopbackHost(requestedHost)) console.error(redactedLogText(webExposureWarning(requestedHost, intOpt(opts, 'port', 8787)) + (opts.tls ? '' : ' Consider --tls to encrypt this connection.')));
|
|
162
|
+
ensureTmuxAvailable({ autoInstall: true });
|
|
163
|
+
const setup = await prepareLocalBus(ctx, {
|
|
164
|
+
...opts,
|
|
165
|
+
installShims: process.env.HCC_SKIP_SHIM_INSTALL === '1' ? false : true
|
|
166
|
+
});
|
|
167
|
+
registerProject(ctx);
|
|
168
|
+
|
|
169
|
+
const existing = await readHealthyGlobalRuntime();
|
|
170
|
+
if (existing) {
|
|
171
|
+
if (webRuntimeMatchesRequest(existing, opts)) {
|
|
172
|
+
await stopOrphanWebRuntimes(ctx, existing.pid || null);
|
|
173
|
+
rememberRuntimeToken(existing, opts);
|
|
174
|
+
try {
|
|
175
|
+
await runtimeRequest(ctx, 'POST', '/api/projects', { root: ctx.root, db: ctx.dbPath }, existing);
|
|
176
|
+
} catch {}
|
|
177
|
+
writeRuntime(ctx, {
|
|
178
|
+
...existing,
|
|
179
|
+
api_version: API_VERSION,
|
|
180
|
+
root: ctx.root,
|
|
181
|
+
db: ctx.dbPath,
|
|
182
|
+
project_root: ctx.root,
|
|
183
|
+
global_runtime: true
|
|
184
|
+
});
|
|
185
|
+
return printWebRuntime(ctx, existing, { already: true, logFile: webLogPath(ctx), setup });
|
|
186
|
+
}
|
|
187
|
+
// TLS-2: an idempotent `hcc web` must not silently stop a TLS runtime and
|
|
188
|
+
// downgrade to plaintext (or vice versa) when only --tls/--trust-proxy
|
|
189
|
+
// differ. Refuse loudly instead; host/port/token mismatches below still
|
|
190
|
+
// take the normal stop-and-restart path (legitimate reconfiguration).
|
|
191
|
+
const runtimeTls = existing.tls === undefined
|
|
192
|
+
? /^https:/i.test(String(existing.base_url || ''))
|
|
193
|
+
: Boolean(existing.tls);
|
|
194
|
+
if (runtimeTls !== Boolean(opts.tls) ||
|
|
195
|
+
Boolean(existing.trust_proxy) !== Boolean(opts['trust-proxy']) ||
|
|
196
|
+
(existing.proxy_origin || '') !== (opts['proxy-origin'] || '')) {
|
|
197
|
+
throw new CliError('RUNTIME_CONFIG_CONFLICT',
|
|
198
|
+
`A ${runtimeTls ? 'TLS' : 'plaintext'} web runtime is already running${existing.trust_proxy ? ' with --trust-proxy' : ''} on port ${existing.port}. ` +
|
|
199
|
+
`Run ${CLI_NAME} down first, or re-run with matching flags (${opts.tls ? '--tls' : 'no --tls'}${opts['trust-proxy'] ? ', --trust-proxy' : ''}).`);
|
|
200
|
+
}
|
|
201
|
+
try { await runtimeRequest(ctx, 'POST', '/api/runtime/stop', {}, existing); } catch {}
|
|
202
|
+
await sleep(250);
|
|
203
|
+
}
|
|
204
|
+
await stopOrphanWebRuntimes(ctx);
|
|
205
|
+
|
|
206
|
+
try { fs.rmSync(runtimePath(ctx), { force: true }); } catch {}
|
|
207
|
+
try { fs.rmSync(globalRuntimePath(), { force: true }); } catch {}
|
|
208
|
+
|
|
209
|
+
const logFile = webLogPath(ctx);
|
|
210
|
+
fs.mkdirSync(path.dirname(logFile), { recursive: true });
|
|
211
|
+
// bg-05: rotate web.log once it grows past 5 MB (keep the previous .1).
|
|
212
|
+
try {
|
|
213
|
+
if (fs.statSync(logFile).size > 5 * 1024 * 1024) {
|
|
214
|
+
try { fs.rmSync(`${logFile}.1`, { force: true }); } catch {}
|
|
215
|
+
try { fs.renameSync(logFile, `${logFile}.1`); } catch {}
|
|
216
|
+
}
|
|
217
|
+
} catch {}
|
|
218
|
+
const redactedStart = redactedLogText(`${CLI_NAME} web ${redactCliArgs(args).join(' ')}`);
|
|
219
|
+
fs.appendFileSync(logFile, `\n[${new Date().toISOString()}] ${redactedStart}\n`, { mode: 0o600 });
|
|
220
|
+
// The runtime echoes token-bearing URLs into web.log; keep it owner-only so a
|
|
221
|
+
// co-tenant on the machine cannot read the token (net-02).
|
|
222
|
+
try { fs.chmodSync(logFile, 0o600); } catch {}
|
|
223
|
+
const logFd = fs.openSync(logFile, 'a');
|
|
224
|
+
|
|
225
|
+
const childArgs = [
|
|
226
|
+
commandPath(),
|
|
227
|
+
'--root', ctx.root,
|
|
228
|
+
'--db', ctx.dbPath,
|
|
229
|
+
'web',
|
|
230
|
+
...args
|
|
231
|
+
];
|
|
232
|
+
const childEnv = {
|
|
233
|
+
...process.env,
|
|
234
|
+
[WEB_CHILD_ENV]: '1',
|
|
235
|
+
HCC_ROOT: ctx.root,
|
|
236
|
+
HCC_DB: ctx.dbPath
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
let child;
|
|
240
|
+
try {
|
|
241
|
+
child = spawn(process.execPath, childArgs, {
|
|
242
|
+
cwd: ctx.cwd,
|
|
243
|
+
env: childEnv,
|
|
244
|
+
detached: true,
|
|
245
|
+
stdio: ['ignore', logFd, logFd]
|
|
246
|
+
});
|
|
247
|
+
} finally {
|
|
248
|
+
try { fs.closeSync(logFd); } catch {}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const runtime = await waitForStartedRuntime(ctx, child, logFile);
|
|
252
|
+
child.unref();
|
|
253
|
+
return printWebRuntime(ctx, runtime, { already: false, logFile, setup });
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
async function waitForStartedRuntime(ctx, child, logFile) {
|
|
257
|
+
let exitInfo = null;
|
|
258
|
+
child.once('exit', (code, signal) => {
|
|
259
|
+
exitInfo = { code, signal };
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
const deadline = Date.now() + WEB_RUNTIME_START_TIMEOUT_MS;
|
|
263
|
+
while (Date.now() < deadline) {
|
|
264
|
+
const runtime = await readHealthyGlobalRuntime();
|
|
265
|
+
if (runtime) return runtime;
|
|
266
|
+
if (exitInfo) {
|
|
267
|
+
const detail = redactedLogText(tailFile(logFile));
|
|
268
|
+
throw new CliError('RUNTIME_START_FAILED',
|
|
269
|
+
`${PRODUCT_NAME} runtime exited before it became healthy` +
|
|
270
|
+
` (code=${exitInfo.code ?? ''}${exitInfo.signal ? ` signal=${exitInfo.signal}` : ''}).` +
|
|
271
|
+
`${detail ? `\n\nLast log lines:\n${detail}` : ''}`,
|
|
272
|
+
{ log: logFile });
|
|
273
|
+
}
|
|
274
|
+
await sleep(150);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
try {
|
|
278
|
+
if (process.platform === 'win32') process.kill(child.pid, 'SIGTERM');
|
|
279
|
+
else process.kill(-child.pid, 'SIGTERM');
|
|
280
|
+
} catch {}
|
|
281
|
+
const detail = redactedLogText(tailFile(logFile));
|
|
282
|
+
throw new CliError('RUNTIME_START_TIMEOUT',
|
|
283
|
+
`${PRODUCT_NAME} runtime did not become healthy within ${WEB_RUNTIME_START_TIMEOUT_MS / 1000}s.` +
|
|
284
|
+
`${detail ? `\n\nLast log lines:\n${detail}` : ''}`,
|
|
285
|
+
{ log: logFile });
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function printWebRuntime(ctx, runtime, opts = {}) {
|
|
289
|
+
const logFile = opts.logFile || webLogPath(ctx);
|
|
290
|
+
const data = {
|
|
291
|
+
status: opts.already ? 'already_running' : 'started',
|
|
292
|
+
pid: runtime.pid || null,
|
|
293
|
+
root: ctx.root,
|
|
294
|
+
db: ctx.dbPath,
|
|
295
|
+
host: runtime.host || null,
|
|
296
|
+
port: runtime.port || null,
|
|
297
|
+
url: publicRuntimeUrl(runtime, ctx.root),
|
|
298
|
+
local_url: localRuntimeUrl(runtime, ctx.root),
|
|
299
|
+
runtime: runtimePath(ctx),
|
|
300
|
+
log: logFile,
|
|
301
|
+
stop: `${CLI_NAME} down`
|
|
302
|
+
};
|
|
303
|
+
return printResult(ctx, data, (r) => {
|
|
304
|
+
const lines = [
|
|
305
|
+
opts.already
|
|
306
|
+
? `${PRODUCT_NAME} web already running in background`
|
|
307
|
+
: `${PRODUCT_NAME} web started in background`,
|
|
308
|
+
`pid: ${r.pid}`,
|
|
309
|
+
`project: ${r.root}`,
|
|
310
|
+
`database: ${r.db}`,
|
|
311
|
+
`runtime: ${r.runtime}`,
|
|
312
|
+
`log: ${r.log}`,
|
|
313
|
+
`open: ${r.url}`
|
|
314
|
+
];
|
|
315
|
+
if (r.local_url !== r.url) lines.push(`local: ${r.local_url}`);
|
|
316
|
+
if (opts.setup?.shims?.installed?.length) {
|
|
317
|
+
lines.push(`shims: installed ${opts.setup.shims.installed.map((p) => path.basename(p)).join(', ')}`);
|
|
318
|
+
if (opts.setup.shims.pathUpdated && opts.setup.shims.rcFile) {
|
|
319
|
+
lines.push(`PATH updated in ${opts.setup.shims.rcFile}; open a new terminal or source it`);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
if (opts.setup?.warnings?.length) {
|
|
323
|
+
lines.push(...opts.setup.warnings.map((warning) => `warning: ${warning}`));
|
|
324
|
+
}
|
|
325
|
+
lines.push(`stop: ${r.stop}`);
|
|
326
|
+
return lines.join('\n');
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
return {
|
|
331
|
+
hccWebProcessMatches, currentProcessAncestorPids, stopOrphanWebRuntimes,
|
|
332
|
+
assertWebTokenForHost, webExposureWarning, webSocketOriginAllowed,
|
|
333
|
+
proxyOriginForOpts, startWebBackground, waitForStartedRuntime, printWebRuntime
|
|
334
|
+
};
|
|
335
|
+
}
|