@logicseek/hello-cc 0.1.5 → 0.1.7

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 (88) hide show
  1. package/CHANGELOG.md +130 -0
  2. package/README.md +21 -3
  3. package/README.zh-CN.md +21 -3
  4. package/assets/screenshots/web-console.png +0 -0
  5. package/bin/hcc.mjs +1943 -3519
  6. package/docs/README.md +2 -0
  7. package/docs/README.zh-CN.md +3 -1
  8. package/docs/architecture-split-review.md +217 -0
  9. package/docs/architecture.md +385 -0
  10. package/docs/architecture.zh-CN.md +364 -0
  11. package/docs/layout-split-stack-audit.md +298 -0
  12. package/lib/automation.mjs +8 -0
  13. package/lib/cli-args.mjs +1 -1
  14. package/lib/cli-runtime.mjs +45 -0
  15. package/lib/coordination-state.mjs +218 -0
  16. package/lib/core/coordination/automation.mjs +222 -0
  17. package/lib/core/coordination/locks.mjs +46 -0
  18. package/lib/core/coordination/messages.mjs +116 -0
  19. package/lib/core/coordination/tasks.mjs +240 -0
  20. package/lib/core/coordination/teams.mjs +73 -0
  21. package/lib/core/coordination/timeline.mjs +196 -0
  22. package/lib/core/peers/bindings.mjs +83 -0
  23. package/lib/core/peers/format.mjs +13 -0
  24. package/lib/core/peers/identity.mjs +61 -0
  25. package/lib/core/peers/liveness.mjs +90 -0
  26. package/lib/core/peers/reconcile.mjs +238 -0
  27. package/lib/core/peers/session.mjs +14 -0
  28. package/lib/core/sessions/launch.mjs +89 -0
  29. package/lib/db/schema.mjs +324 -0
  30. package/lib/db/stores/peers.mjs +231 -0
  31. package/lib/db-schema.mjs +7 -324
  32. package/lib/errors.mjs +1 -7
  33. package/lib/handoff.mjs +26 -0
  34. package/lib/help.mjs +3 -0
  35. package/lib/integrations/hooks.mjs +171 -0
  36. package/lib/integrations/peers/identity.mjs +208 -0
  37. package/lib/integrations/peers/processes.mjs +151 -0
  38. package/lib/integrations/providers.mjs +282 -0
  39. package/lib/integrations/shims/script.mjs +384 -0
  40. package/lib/integrations/shims.mjs +106 -0
  41. package/lib/json-file.mjs +1 -14
  42. package/lib/locks.mjs +10 -0
  43. package/lib/messages.mjs +3 -0
  44. package/lib/package-meta.mjs +6 -24
  45. package/lib/peer-bindings.mjs +14 -0
  46. package/lib/peer-format.mjs +4 -0
  47. package/lib/peer-identity.mjs +15 -0
  48. package/lib/project-context.mjs +6 -0
  49. package/lib/project-registry.mjs +7 -0
  50. package/lib/provider-commands.mjs +13 -0
  51. package/lib/release/package-meta.mjs +31 -0
  52. package/lib/release/release-notes.mjs +40 -0
  53. package/lib/release-notes.mjs +6 -40
  54. package/lib/runtime/client.mjs +42 -0
  55. package/lib/runtime/paths.mjs +45 -0
  56. package/lib/runtime/project-context.mjs +32 -0
  57. package/lib/runtime/projects.mjs +76 -0
  58. package/lib/runtime/state.mjs +128 -0
  59. package/lib/runtime-client.mjs +3 -0
  60. package/lib/runtime-paths.mjs +11 -45
  61. package/lib/runtime-state.mjs +11 -0
  62. package/lib/session-launch.mjs +17 -0
  63. package/lib/setup.mjs +17 -293
  64. package/lib/shared/errors.mjs +7 -0
  65. package/lib/shared/json-file.mjs +14 -0
  66. package/lib/shim-script.mjs +1 -384
  67. package/lib/state-render.mjs +5 -0
  68. package/lib/task-cli.mjs +45 -0
  69. package/lib/task-liveness.mjs +8 -0
  70. package/lib/task-store.mjs +3 -0
  71. package/lib/team-planning.mjs +7 -0
  72. package/lib/terminal/tmux.mjs +372 -0
  73. package/lib/timeline.mjs +12 -0
  74. package/lib/tmux.mjs +18 -0
  75. package/lib/ui/help.mjs +419 -0
  76. package/lib/ui/state-render.mjs +61 -0
  77. package/lib/web/http.mjs +55 -0
  78. package/lib/web/peer-actions.mjs +306 -0
  79. package/lib/web/runtime.mjs +154 -0
  80. package/lib/web/ui-template.mjs +2057 -0
  81. package/lib/web-http.mjs +7 -55
  82. package/lib/web-peer-actions.mjs +3 -0
  83. package/lib/web-runtime.mjs +17 -108
  84. package/lib/web-ui-template.mjs +3 -1909
  85. package/package.json +1 -1
  86. package/scripts/github-release.mjs +2 -2
  87. package/scripts/regression.mjs +2901 -79
  88. package/scripts/release-notes.mjs +2 -2
@@ -0,0 +1,306 @@
1
+ import path from 'node:path';
2
+ import process from 'node:process';
3
+ import { intOpt, required } from '../cli-args.mjs';
4
+ import { CliError } from '../shared/errors.mjs';
5
+ import {
6
+ lockLabel,
7
+ lockScope,
8
+ locksConflict,
9
+ scopedLockResource
10
+ } from '../core/coordination/locks.mjs';
11
+ import { normalizeStateResources } from '../ui/state-render.mjs';
12
+
13
+ const DEFAULT_ACTIVE_PEER_TTL = 600;
14
+ const DEFAULT_LOCK_TTL = 900;
15
+
16
+ function defaultNow() {
17
+ return Math.floor(Date.now() / 1000);
18
+ }
19
+
20
+ function iso(ts) {
21
+ if (!ts) return '';
22
+ return new Date(ts * 1000).toISOString();
23
+ }
24
+
25
+ function requireFn(value, name) {
26
+ if (typeof value !== 'function') throw new TypeError(`createWebPeerActions requires deps.${name}`);
27
+ return value;
28
+ }
29
+
30
+ export function createWebPeerActions(deps = {}) {
31
+ const activePeerTtl = Number(deps.activePeerTtl ?? DEFAULT_ACTIVE_PEER_TTL);
32
+ const addEvent = requireFn(deps.addEvent, 'addEvent');
33
+ const assertTaskOwnerForMutation = requireFn(deps.assertTaskOwnerForMutation, 'assertTaskOwnerForMutation');
34
+ const claimNextTasksForPeer = requireFn(deps.claimNextTasksForPeer, 'claimNextTasksForPeer');
35
+ const connect = requireFn(deps.connect, 'connect');
36
+ const defaultLockTtl = Number(deps.defaultLockTtl ?? DEFAULT_LOCK_TTL);
37
+ const detectBranch = requireFn(deps.detectBranch, 'detectBranch');
38
+ const now = typeof deps.now === 'function' ? deps.now : defaultNow;
39
+ const positiveIntOpt = requireFn(deps.positiveIntOpt, 'positiveIntOpt');
40
+ const queryInbox = requireFn(deps.queryInbox, 'queryInbox');
41
+ const statusSnapshot = requireFn(deps.statusSnapshot, 'statusSnapshot');
42
+ const statusSummary = requireFn(deps.statusSummary, 'statusSummary');
43
+ const takeOverTaskForPeer = requireFn(deps.takeOverTaskForPeer, 'takeOverTaskForPeer');
44
+ const touchPeer = requireFn(deps.touchPeer, 'touchPeer');
45
+ const tx = requireFn(deps.tx, 'tx');
46
+ const upsertPeer = requireFn(deps.upsertPeer, 'upsertPeer');
47
+
48
+ function webMutationPeer(peer, input = {}) {
49
+ const actorPeer = String(input.actorPeer || input.actor_peer || '').trim();
50
+ if (!actorPeer) {
51
+ throw new CliError('PEER_IDENTITY_REQUIRED', 'Web peer mutations require a managed session identity');
52
+ }
53
+ if (actorPeer !== peer) {
54
+ throw new CliError('PEER_IDENTITY_MISMATCH', `Web peer action for ${peer} cannot run as ${actorPeer}`, {
55
+ peer,
56
+ actor_peer: actorPeer
57
+ });
58
+ }
59
+ return actorPeer;
60
+ }
61
+
62
+ function webAuditPayload(peer, input = {}, extra = {}) {
63
+ const actorPeer = String(input.actorPeer || input.actor_peer || peer).trim() || peer;
64
+ return {
65
+ ...extra,
66
+ source: 'web',
67
+ actor_peer: actorPeer,
68
+ target_peer: peer
69
+ };
70
+ }
71
+
72
+ function webPeerRegister(projectCtx, peer, input = {}) {
73
+ webMutationPeer(peer, input);
74
+ const db = connect(projectCtx);
75
+ const row = {
76
+ id: peer,
77
+ kind: input.kind || 'other',
78
+ role: input.role || 'peer',
79
+ worktree: path.resolve(input.worktree || projectCtx.cwd),
80
+ branch: input.branch || detectBranch(projectCtx.cwd),
81
+ pid: intOpt(input, 'pid', process.ppid),
82
+ status: input.status || 'idle',
83
+ capabilities: Array.isArray(input.cap) ? input.cap.join(',') : (input.cap || input.capabilities || 'web')
84
+ };
85
+ try {
86
+ upsertPeer(db, row);
87
+ addEvent(db, 'peer.registered', peer, null, webAuditPayload(peer, input, row));
88
+ } finally {
89
+ db.close();
90
+ }
91
+ return { peer: row, summary: `registered ${row.id} (${row.kind}${row.role ? `, ${row.role}` : ''})` };
92
+ }
93
+
94
+ function webPeerHeartbeat(projectCtx, peer, input = {}) {
95
+ webMutationPeer(peer, input);
96
+ const ttl = intOpt(input, 'ttl', defaultLockTtl);
97
+ const status = input.status || null;
98
+ const renewLocks = input['renew-locks'] !== false && input.renew_locks !== false;
99
+ const db = connect(projectCtx);
100
+ const t = now();
101
+ let renewed = 0;
102
+ try {
103
+ touchPeer(db, peer, status);
104
+ if (renewLocks) {
105
+ renewed = db.prepare(`
106
+ UPDATE locks SET expires_at = ?
107
+ WHERE owner = ? AND expires_at > ?
108
+ `).run(t + ttl, peer, t).changes;
109
+ }
110
+ addEvent(db, 'peer.heartbeat', peer, null, webAuditPayload(peer, input, { status, renewed }));
111
+ } finally {
112
+ db.close();
113
+ }
114
+ return {
115
+ peer,
116
+ status,
117
+ renewed,
118
+ summary: `heartbeat ${peer}${renewed ? `, renewed locks: ${renewed}` : ''}`
119
+ };
120
+ }
121
+
122
+ function webPeerTaskNext(projectCtx, peer, input = {}) {
123
+ webMutationPeer(peer, input);
124
+ const db = connect(projectCtx);
125
+ try {
126
+ touchPeer(db, peer, 'working');
127
+ const count = positiveIntOpt(input, 'count', 1, { max: 50 });
128
+ const result = claimNextTasksForPeer(db, peer, { force: Boolean(input.force), count });
129
+ const task = result.current || result.tasks[0] || null;
130
+ if (!task) return { peer, task: null, tasks: [], summary: 'no pending task' };
131
+ return {
132
+ peer,
133
+ task,
134
+ tasks: result.tasks,
135
+ current: Boolean(result.current),
136
+ summary: result.current
137
+ ? `current task #${task.id}: ${task.title} (${task.status})`
138
+ : result.tasks.length === 1
139
+ ? `claimed task #${task.id}: ${task.title}`
140
+ : `claimed ${result.tasks.length} tasks`
141
+ };
142
+ } finally {
143
+ db.close();
144
+ }
145
+ }
146
+
147
+ function webPeerTaskTakeover(projectCtx, peer, input = {}) {
148
+ webMutationPeer(peer, input);
149
+ const id = intOpt(input, 'id', intOpt({ id: input.task }, 'id'));
150
+ if (!id) throw new CliError('BAD_REQUEST', 'task id required');
151
+ const reason = required(input, 'reason');
152
+ const policy = input.policy || 'any';
153
+ const staleAfter = positiveIntOpt(input, 'stale-after', intOpt(input, 'stale_after', activePeerTtl), { max: 86400 * 30 });
154
+ const db = connect(projectCtx);
155
+ try {
156
+ touchPeer(db, peer, 'working');
157
+ const task = takeOverTaskForPeer(db, peer, id, { reason, policy, staleAfter, source: 'web' });
158
+ return { peer, task, summary: `took over task #${task.id}: ${task.title}` };
159
+ } finally {
160
+ db.close();
161
+ }
162
+ }
163
+
164
+ function webPeerLockAcquire(projectCtx, peer, input = {}) {
165
+ webMutationPeer(peer, input);
166
+ const requested = scopedLockResource(required(input, 'resource'), input.scope);
167
+ const ttl = intOpt(input, 'ttl', defaultLockTtl);
168
+ const taskId = intOpt(input, 'task', intOpt(input, 'task_id', null));
169
+ const reason = input.reason || '';
170
+ const db = connect(projectCtx);
171
+ try {
172
+ touchPeer(db, peer, 'working');
173
+ const lock = tx(db, () => {
174
+ const t = now();
175
+ if (taskId) {
176
+ const task = db.prepare('SELECT * FROM tasks WHERE id = ?').get(taskId);
177
+ if (!task) throw new CliError('NOT_FOUND', `Task #${taskId} does not exist`);
178
+ assertTaskOwnerForMutation(db, peer, task, 'lock-acquire');
179
+ }
180
+ const activeLocks = db.prepare('SELECT * FROM locks WHERE expires_at > ?').all(t);
181
+ const conflict = activeLocks.find((row) => locksConflict(row, requested) && row.owner !== peer);
182
+ if (conflict) {
183
+ throw new CliError('LOCK_HELD', `Resource ${lockLabel(requested)} conflicts with lock ${lockLabel(conflict)} held by ${conflict.owner}`, {
184
+ resource: requested.base_resource,
185
+ scope: requested.scope,
186
+ lock_resource: conflict.resource,
187
+ lock_scope: lockScope(conflict),
188
+ owner: conflict.owner,
189
+ expires_at: iso(conflict.expires_at)
190
+ });
191
+ }
192
+ const existing = db.prepare('SELECT * FROM locks WHERE resource = ?').get(requested.resource);
193
+ db.prepare(`
194
+ INSERT INTO locks(resource, base_resource, scope, owner, task_id, reason, expires_at, created_at)
195
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
196
+ ON CONFLICT(resource) DO UPDATE SET
197
+ base_resource = excluded.base_resource,
198
+ scope = excluded.scope,
199
+ owner = excluded.owner,
200
+ task_id = excluded.task_id,
201
+ reason = excluded.reason,
202
+ expires_at = excluded.expires_at
203
+ `).run(requested.resource, requested.base_resource, requested.scope, peer, taskId, reason, t + ttl, existing ? existing.created_at : t);
204
+ addEvent(db, 'lock.acquired', peer, taskId, webAuditPayload(peer, input, { resource: requested.base_resource, lock_resource: requested.resource, scope: requested.scope, ttl, previous_owner: existing ? existing.owner : null }));
205
+ return db.prepare('SELECT * FROM locks WHERE resource = ?').get(requested.resource);
206
+ });
207
+ return { peer, lock, summary: `locked ${lockLabel(lock)} by ${lock.owner} until ${iso(lock.expires_at)}` };
208
+ } finally {
209
+ db.close();
210
+ }
211
+ }
212
+
213
+ function webPeerLockRelease(projectCtx, peer, input = {}) {
214
+ webMutationPeer(peer, input);
215
+ const requested = scopedLockResource(required(input, 'resource'), input.scope);
216
+ const force = false;
217
+ const db = connect(projectCtx);
218
+ try {
219
+ touchPeer(db, peer, null);
220
+ const result = tx(db, () => {
221
+ const existing = db.prepare('SELECT * FROM locks WHERE resource = ?').get(requested.resource);
222
+ if (!existing) return { released: false, ...requested };
223
+ if (existing.owner !== peer) {
224
+ throw new CliError('LOCK_OWNED', `Lock is owned by ${existing.owner}`, { owner: existing.owner });
225
+ }
226
+ db.prepare('DELETE FROM locks WHERE resource = ?').run(requested.resource);
227
+ addEvent(db, 'lock.released', peer, existing.task_id || null, webAuditPayload(peer, input, { resource: requested.base_resource, lock_resource: requested.resource, scope: requested.scope, force }));
228
+ return { released: true, ...requested };
229
+ });
230
+ return { peer, result, summary: result.released ? `released ${lockLabel(result)}` : `no lock for ${lockLabel(result)}` };
231
+ } finally {
232
+ db.close();
233
+ }
234
+ }
235
+
236
+ function webPeerInbox(projectCtx, peer, input = {}) {
237
+ const db = connect(projectCtx);
238
+ try {
239
+ const messages = queryInbox(db, peer, Boolean(input.all), intOpt(input, 'limit', 20));
240
+ return {
241
+ peer,
242
+ messages,
243
+ summary: messages.length ? `${messages.length} message${messages.length === 1 ? '' : 's'}` : 'no messages'
244
+ };
245
+ } finally {
246
+ db.close();
247
+ }
248
+ }
249
+
250
+ function webPeerAction(projectCtx, peer, action, input = {}) {
251
+ const normalized = String(action || '').replace(/_/g, '-');
252
+ if (!peer) throw new CliError('BAD_REQUEST', 'peer required');
253
+ if (normalized === 'status') {
254
+ const status = statusSummary(projectCtx, peer);
255
+ return { ok: true, action: normalized, peer, summary: `active=${status.active_peers}, stale=${status.stale_peers}, locks=${status.active_locks}, unread=${status.unread ?? 0}`, data: status };
256
+ }
257
+ if (normalized === 'state') {
258
+ const data = statusSnapshot(projectCtx, peer, {
259
+ resources: normalizeStateResources(input.resource || input.resources || []),
260
+ intent: input.intent || null,
261
+ scope: input.scope || null
262
+ });
263
+ return { ok: true, action: normalized, peer, summary: data.automation?.next_action?.reason || 'state loaded', data };
264
+ }
265
+ if (normalized === 'inbox') {
266
+ const data = webPeerInbox(projectCtx, peer, input);
267
+ return { ok: true, action: normalized, peer, summary: data.summary, data };
268
+ }
269
+ if (normalized === 'task-next') {
270
+ const data = webPeerTaskNext(projectCtx, peer, input);
271
+ return { ok: true, action: normalized, peer, summary: data.summary, data };
272
+ }
273
+ if (normalized === 'task-takeover') {
274
+ const data = webPeerTaskTakeover(projectCtx, peer, input);
275
+ return { ok: true, action: normalized, peer, summary: data.summary, data };
276
+ }
277
+ if (normalized === 'lock-acquire') {
278
+ const data = webPeerLockAcquire(projectCtx, peer, input);
279
+ return { ok: true, action: normalized, peer, summary: data.summary, data };
280
+ }
281
+ if (normalized === 'lock-release') {
282
+ const data = webPeerLockRelease(projectCtx, peer, input);
283
+ return { ok: true, action: normalized, peer, summary: data.summary, data };
284
+ }
285
+ if (normalized === 'heartbeat') {
286
+ const data = webPeerHeartbeat(projectCtx, peer, input);
287
+ return { ok: true, action: normalized, peer, summary: data.summary, data };
288
+ }
289
+ if (normalized === 'register') {
290
+ const data = webPeerRegister(projectCtx, peer, input);
291
+ return { ok: true, action: normalized, peer, summary: data.summary, data };
292
+ }
293
+ throw new CliError('BAD_REQUEST', `Unknown peer action: ${action}`);
294
+ }
295
+
296
+ return {
297
+ webPeerAction,
298
+ webPeerHeartbeat,
299
+ webPeerInbox,
300
+ webPeerLockAcquire,
301
+ webPeerLockRelease,
302
+ webPeerRegister,
303
+ webPeerTaskNext,
304
+ webPeerTaskTakeover
305
+ };
306
+ }
@@ -0,0 +1,154 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import process from 'node:process';
4
+ import { randomBytes } from 'node:crypto';
5
+ import { intOpt } from '../cli-args.mjs';
6
+ import { CliError } from '../shared/errors.mjs';
7
+ import { sanitizePeerPart } from '../core/peers/format.mjs';
8
+ import { globalWebTokenPath } from '../runtime/paths.mjs';
9
+
10
+ export function runtimeConnectHost(host) {
11
+ if (host === '0.0.0.0' || host === '::') return '127.0.0.1';
12
+ return host;
13
+ }
14
+
15
+ export function runtimeBaseUrl(host, port) {
16
+ return `http://${runtimeConnectHost(host)}:${port}`;
17
+ }
18
+
19
+ export function runtimeApiUrl(runtime, route) {
20
+ const baseUrl = typeof runtime === 'string' ? runtime : runtime?.base_url;
21
+ return new URL(route, baseUrl);
22
+ }
23
+
24
+ export function requestUrl(req) {
25
+ return new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`);
26
+ }
27
+
28
+ export function isLoopbackHost(host) {
29
+ return host === '127.0.0.1' || host === 'localhost' || host === '::1';
30
+ }
31
+
32
+ export function nextSessionId(existingIds, kind) {
33
+ const prefix = sanitizePeerPart(kind || 'shell', 'shell');
34
+ const ids = new Set();
35
+ if (existingIds instanceof Map) {
36
+ for (const value of existingIds.values()) {
37
+ if (value && typeof value === 'object' && value.id) ids.add(value.id);
38
+ else if (value) ids.add(String(value));
39
+ }
40
+ } else {
41
+ for (const value of existingIds || []) ids.add(String(value));
42
+ }
43
+ let i = 1;
44
+ while (ids.has(`${prefix}-${i}`)) i += 1;
45
+ return `${prefix}-${i}`;
46
+ }
47
+
48
+ export function listenServer(server, host, port, autoPort) {
49
+ return new Promise((resolve, reject) => {
50
+ function attempt(candidate, remaining) {
51
+ const onError = (err) => {
52
+ if (err.code === 'EADDRINUSE' && autoPort && remaining > 0 && candidate < 65535) {
53
+ attempt(candidate + 1, remaining - 1);
54
+ return;
55
+ }
56
+ if (err.code === 'EADDRINUSE') {
57
+ reject(new CliError('PORT_IN_USE', `Port ${candidate} is already in use on ${host}`, { host, port: candidate }));
58
+ return;
59
+ }
60
+ reject(new CliError('LISTEN_FAILED', `Cannot listen on ${host}:${candidate}: ${err.message}`, { host, port: candidate }));
61
+ };
62
+ server.once('error', onError);
63
+ server.listen(candidate, host, () => {
64
+ server.off('error', onError);
65
+ const address = server.address();
66
+ resolve(address && typeof address === 'object' ? address.port : candidate);
67
+ });
68
+ }
69
+ attempt(port, 20);
70
+ });
71
+ }
72
+
73
+ export function runtimeUrlQuery(runtime, projectRoot = null) {
74
+ const parts = [];
75
+ if (runtime.token) parts.push(`token=${encodeURIComponent(runtime.token)}`);
76
+ if (projectRoot) parts.push(`project=${encodeURIComponent(projectRoot)}`);
77
+ return parts.length ? `?${parts.join('&')}` : '';
78
+ }
79
+
80
+ export function publicRuntimeUrl(runtime, projectRoot = null) {
81
+ const host = runtime.host === '0.0.0.0' || runtime.host === '::'
82
+ ? '<machine-ip>'
83
+ : runtime.host || runtimeConnectHost(runtime.host || '127.0.0.1');
84
+ return `http://${host}:${runtime.port}/${runtimeUrlQuery(runtime, projectRoot)}`;
85
+ }
86
+
87
+ export function localRuntimeUrl(runtime, projectRoot = null) {
88
+ 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 '';
99
+ }
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;
109
+ }
110
+
111
+ export function validateWebTokenOpts(opts) {
112
+ if (opts['no-token'] && (opts.token || process.env.HCC_WEB_TOKEN)) {
113
+ throw new CliError('BAD_ARGS', '--no-token cannot be combined with --token or HCC_WEB_TOKEN');
114
+ }
115
+ }
116
+
117
+ export function makeWebToken(opts) {
118
+ validateWebTokenOpts(opts);
119
+ if (opts['no-token']) return '';
120
+ 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;
130
+ }
131
+
132
+ export function expectedWebHost(opts) {
133
+ return opts.host || (opts.local ? '127.0.0.1' : '0.0.0.0');
134
+ }
135
+
136
+ export function webRuntimeMatchesRequest(runtime, opts) {
137
+ if (!runtime) return false;
138
+ if (opts['no-token'] && (opts.token || process.env.HCC_WEB_TOKEN)) return false;
139
+ const expectedHost = expectedWebHost(opts);
140
+ if (runtime.host !== expectedHost) return false;
141
+ const expectedPort = intOpt(opts, 'port', 8787);
142
+ if (opts.port !== undefined && runtime.port !== expectedPort) return false;
143
+ const explicitToken = opts.token || process.env.HCC_WEB_TOKEN || '';
144
+ if (opts['no-token']) return !runtime.token;
145
+ if (explicitToken) return runtime.token === explicitToken;
146
+ return Boolean(runtime.token);
147
+ }
148
+
149
+ 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);
154
+ }