@runuai/host 0.9.14 → 0.9.43
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/README.md +22 -5
- package/db/migrations/0014_host_inventory_event_index.sql +1 -0
- package/db/migrations/0015_host_settings.sql +9 -0
- package/db/migrations/0016_task_environment.sql +2 -0
- package/db/migrations/meta/_journal.json +21 -0
- package/db/schema.ts +80 -30
- package/images/standard/Dockerfile +36 -10
- package/images/standard/README.md +63 -18
- package/images/standard/container/corepack-version +1 -0
- package/images/standard/container/uai-init +308 -38
- package/images/standard/container/uai-materialize-runtimes +1527 -0
- package/lib/agent-cli.ts +33 -2
- package/lib/agent.ts +46 -7
- package/lib/agents/claude.ts +13 -8
- package/lib/agents/codex.ts +11 -6
- package/lib/agents/cursor.ts +39 -29
- package/lib/agents/durable-proc.ts +20 -27
- package/lib/agents/factory.ts +9 -25
- package/lib/agents/grok.ts +43 -30
- package/lib/agents/kimi.ts +44 -29
- package/lib/agents/opencode.ts +43 -31
- package/lib/agents/proc.ts +149 -114
- package/lib/agents/transport.ts +62 -50
- package/lib/agents/types.ts +6 -4
- package/lib/apple-runtime-recycle.ts +236 -0
- package/lib/apple-uninstall-teardown.ts +224 -0
- package/lib/browser-testing.ts +233 -93
- package/lib/codex-auth.ts +40 -6
- package/lib/command-db.ts +20 -0
- package/lib/container-runtime.ts +1338 -0
- package/lib/db.ts +1 -0
- package/lib/docker-exec.ts +87 -5
- package/lib/engine-accounts.ts +68 -5
- package/lib/engine-login.ts +1952 -0
- package/lib/enrollment-state.ts +251 -0
- package/lib/env-file.ts +155 -0
- package/lib/env.ts +4 -0
- package/lib/git-diff.ts +98 -32
- package/lib/git-identity.ts +199 -87
- package/lib/github-tokens.ts +202 -91
- package/lib/host-cloud-url.ts +62 -0
- package/lib/host-config.ts +279 -0
- package/lib/host-logs.ts +962 -0
- package/lib/keyed-promise-tail.ts +23 -0
- package/lib/legacy-runtime-v1.fixture.ts +627 -0
- package/lib/managed-activation-watcher.ts +72 -0
- package/lib/managed-install-owner-watcher.ts +55 -0
- package/lib/managed-operation-drain.ts +49 -0
- package/lib/managed-runtime.ts +3644 -0
- package/lib/managed-update-scheduler.ts +125 -0
- package/lib/mcp-gateway.ts +450 -23
- package/lib/orchestrator.ts +3051 -200
- package/lib/preview-sidecar.ts +68 -14
- package/lib/release-manifest.ts +708 -0
- package/lib/release-trust.ts +28 -0
- package/lib/runtime-activation-tail.ts +232 -0
- package/lib/runtime-archive.ts +1086 -0
- package/lib/runtime-authority.ts +79 -0
- package/lib/runtime-guard.ts +36 -0
- package/lib/runtime-provider-state.ts +169 -0
- package/lib/runtime-state.ts +232 -12
- package/lib/skills.ts +24 -3
- package/lib/ssh.ts +18 -0
- package/lib/standard-image.ts +1104 -141
- package/lib/stopped-task-status-queue.ts +44 -0
- package/lib/task-container-cli.ts +269 -0
- package/lib/task-diff.ts +66 -46
- package/lib/task-environment/apple-container.ts +757 -0
- package/lib/task-environment/docker.ts +956 -0
- package/lib/task-environment/index.ts +364 -0
- package/lib/task-environment/legacy-adoption.ts +459 -0
- package/lib/task-environment/registry.ts +58 -0
- package/lib/task-environment/types.ts +408 -0
- package/lib/task-identity.ts +19 -0
- package/lib/task-inventory.ts +585 -0
- package/lib/tunnel-registry.ts +135 -19
- package/lib/tunnel-runtime.ts +235 -0
- package/package.json +1 -1
- package/scripts/agent/_common.sh +123 -3
- package/scripts/agent/task-down.sh +146 -38
- package/scripts/agent/task-status.sh +19 -3
- package/scripts/agent/task-up.sh +1405 -107
- package/scripts/install/darwin.ts +848 -50
- package/scripts/install/linux.ts +838 -35
- package/scripts/install/types.ts +43 -0
- package/scripts/install/util.ts +215 -8
- package/scripts/install/win.ts +12 -0
- package/src/apple-tunnel-route.ts +104 -0
- package/src/cli.ts +1464 -72
- package/src/event-outbox.ts +83 -4
- package/src/index.ts +766 -42
- package/src/main.ts +1398 -255
- package/src/paths.ts +17 -1
- package/src/protocol.ts +695 -1
- package/src/runtime-bootstrap.ts +165 -0
- package/src/ui/server.ts +46 -10
- package/src/ui/types.ts +37 -0
package/src/cli.ts
CHANGED
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
* `uai-host` CLI (ADR-028). Hand-rolled argv switch — no argparser dependency.
|
|
4
4
|
*
|
|
5
5
|
* run | install | uninstall | start | stop | restart
|
|
6
|
-
* status | logs [--follow] |
|
|
6
|
+
* status | logs [--follow] | runtime recheck
|
|
7
|
+
* enroll <token> | pair <token> | open
|
|
7
8
|
*
|
|
8
9
|
* `run` boots the service in-process; the install/start/... family dispatches
|
|
9
10
|
* to the per-OS installer (scripts/install/{darwin,linux,win}.ts) by
|
|
@@ -21,18 +22,88 @@ import "./load-env";
|
|
|
21
22
|
|
|
22
23
|
import { spawn, spawnSync } from "node:child_process";
|
|
23
24
|
import { createHash, randomBytes } from "node:crypto";
|
|
24
|
-
import { existsSync, mkdirSync, readFileSync
|
|
25
|
-
import { hostname } from "node:os";
|
|
26
|
-
import { dirname } from "node:path";
|
|
25
|
+
import { existsSync, mkdirSync, readFileSync } from "node:fs";
|
|
26
|
+
import { homedir, hostname } from "node:os";
|
|
27
|
+
import { dirname, join, resolve } from "node:path";
|
|
27
28
|
|
|
29
|
+
import { parse as parseDotenv } from "dotenv";
|
|
28
30
|
import { ulid } from "ulid";
|
|
29
31
|
|
|
32
|
+
import {
|
|
33
|
+
commitPendingEnrollment,
|
|
34
|
+
discardPendingEnrollment,
|
|
35
|
+
enrollmentTokenSha256,
|
|
36
|
+
ensurePendingEnrollment,
|
|
37
|
+
pendingEnrollmentMatches,
|
|
38
|
+
pendingEnrollmentPath,
|
|
39
|
+
removeCommittedPendingEnrollment,
|
|
40
|
+
writeEnvValuesAtomic,
|
|
41
|
+
type PendingEnrollment,
|
|
42
|
+
} from "../lib/enrollment-state";
|
|
43
|
+
import { parseHostCloudEndpoint } from "../lib/host-cloud-url";
|
|
44
|
+
import { env } from "../lib/env";
|
|
30
45
|
import {
|
|
31
46
|
persistTelemetryDisclosure,
|
|
32
47
|
telemetryNoticeIfActive,
|
|
33
48
|
} from "../lib/obs";
|
|
34
|
-
import {
|
|
35
|
-
|
|
49
|
+
import {
|
|
50
|
+
abortManagedRuntimeInstall,
|
|
51
|
+
acknowledgeManagedRuntimeActivation,
|
|
52
|
+
beginManagedRuntimeInstall,
|
|
53
|
+
completeManagedRuntimeInstall,
|
|
54
|
+
installManagedRuntimeArchive,
|
|
55
|
+
managedRuntimeInstallStartupState,
|
|
56
|
+
managedRuntimePaths,
|
|
57
|
+
nativeReleasePlatform,
|
|
58
|
+
reconcileManagedRuntimeActivation,
|
|
59
|
+
reconcileManagedRuntimeInstallLifecycle,
|
|
60
|
+
reconcileManagedRuntimeUninstall,
|
|
61
|
+
removeManagedRuntime,
|
|
62
|
+
resolveBundledRuntimeFromEnvironment,
|
|
63
|
+
rollbackManagedRuntime,
|
|
64
|
+
runManagedRuntimeInstallIntentMutation,
|
|
65
|
+
runManagedRuntimeInstallLifecycleMutation,
|
|
66
|
+
stopManagedRuntimeServiceForInstall,
|
|
67
|
+
updateManagedRuntime,
|
|
68
|
+
validateManagedRuntimeSelection,
|
|
69
|
+
validateProvisionalManagedRuntimeSelection,
|
|
70
|
+
MANAGED_UPDATE_RESTART_EXIT_CODE,
|
|
71
|
+
type ManagedServiceDefinitionState,
|
|
72
|
+
type ManagedServiceManagerState,
|
|
73
|
+
type ReconcileManagedRuntimeInstallLifecycleOptions,
|
|
74
|
+
} from "../lib/managed-runtime";
|
|
75
|
+
import { teardownAppleRuntimeStateForUninstall } from "../lib/apple-uninstall-teardown";
|
|
76
|
+
import {
|
|
77
|
+
readRuntimeProviderState,
|
|
78
|
+
runtimeProviderStatePath,
|
|
79
|
+
} from "../lib/runtime-provider-state";
|
|
80
|
+
import { ManagedActivationWatcher } from "../lib/managed-activation-watcher";
|
|
81
|
+
import { ManagedInstallOwnerWatcher } from "../lib/managed-install-owner-watcher";
|
|
82
|
+
import { ManagedUpdateScheduler } from "../lib/managed-update-scheduler";
|
|
83
|
+
import { PINNED_RELEASE_MANIFEST_KEYS } from "../lib/release-trust";
|
|
84
|
+
import {
|
|
85
|
+
hasActiveHostTasks,
|
|
86
|
+
hasInFlightHostTaskLifecycle,
|
|
87
|
+
hasNonTerminalHostTasks,
|
|
88
|
+
} from "../lib/runtime-state";
|
|
89
|
+
import {
|
|
90
|
+
ENROLLMENT_REPLAY_PROTOCOL_HEADER,
|
|
91
|
+
ENROLLMENT_REPLAY_PROTOCOL_VERSION,
|
|
92
|
+
} from "./protocol";
|
|
93
|
+
import {
|
|
94
|
+
envLocalPath,
|
|
95
|
+
packageVersion,
|
|
96
|
+
readPersistedHostId,
|
|
97
|
+
serviceLogPath,
|
|
98
|
+
uaiHome,
|
|
99
|
+
uiPortFilePath,
|
|
100
|
+
} from "./paths";
|
|
101
|
+
import {
|
|
102
|
+
CloudResponse,
|
|
103
|
+
ContainerRuntimeResponse,
|
|
104
|
+
StatusResponse,
|
|
105
|
+
TasksResponse,
|
|
106
|
+
} from "./ui/types";
|
|
36
107
|
import type { InstallContext, Installer } from "../scripts/install/types";
|
|
37
108
|
|
|
38
109
|
// --- tiny ANSI (no chalk) ---------------------------------------------------
|
|
@@ -64,17 +135,28 @@ async function main(): Promise<void> {
|
|
|
64
135
|
return cmdOpen();
|
|
65
136
|
case "logs":
|
|
66
137
|
return cmdLogs(follow);
|
|
138
|
+
case "runtime":
|
|
139
|
+
return cmdRuntime(rest);
|
|
140
|
+
case "update":
|
|
141
|
+
return cmdUpdate(rest);
|
|
142
|
+
case "rollback":
|
|
143
|
+
return cmdRollback(rest);
|
|
144
|
+
case "enrollment":
|
|
145
|
+
return cmdEnrollmentStatus(rest);
|
|
67
146
|
case "setup":
|
|
68
147
|
return cmdSetup(rest);
|
|
148
|
+
case "enroll":
|
|
149
|
+
return cmdEnroll(rest);
|
|
69
150
|
case "pair":
|
|
70
151
|
return cmdPair(rest[0]);
|
|
71
152
|
case "install":
|
|
72
|
-
return cmdInstall(
|
|
73
|
-
case "uninstall":
|
|
153
|
+
return cmdInstall(rest);
|
|
74
154
|
case "start":
|
|
75
155
|
case "stop":
|
|
76
156
|
case "restart":
|
|
77
157
|
return cmdInstaller(cmd, dryRun);
|
|
158
|
+
case "uninstall":
|
|
159
|
+
return cmdUninstall(rest);
|
|
78
160
|
case undefined:
|
|
79
161
|
case "help":
|
|
80
162
|
case "-h":
|
|
@@ -89,10 +171,202 @@ async function main(): Promise<void> {
|
|
|
89
171
|
|
|
90
172
|
// --- run --------------------------------------------------------------------
|
|
91
173
|
|
|
174
|
+
function timestampServiceLogs(): void {
|
|
175
|
+
const stamp = (): string => new Date().toISOString().slice(0, 19);
|
|
176
|
+
for (const level of ["log", "warn", "error"] as const) {
|
|
177
|
+
const original = console[level].bind(console);
|
|
178
|
+
console[level] = (...args: unknown[]) => original(`[${stamp()}]`, ...args);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
92
182
|
async function cmdRun(): Promise<void> {
|
|
183
|
+
// Timestamped service logs: the ADR-106 live smoke burned an hour dating
|
|
184
|
+
// undated scrollback. Applies to the service process only, not one-shot CLI
|
|
185
|
+
// commands.
|
|
186
|
+
timestampServiceLogs();
|
|
187
|
+
let installState = await managedRuntimeInstallStartupState();
|
|
188
|
+
if (installState === "attested") {
|
|
189
|
+
// Attestation is the durable commit decision. A supervisor restart after
|
|
190
|
+
// that point finishes the service/command journals; it must never enter the
|
|
191
|
+
// pre-attestation recovery gate or restore the old installation.
|
|
192
|
+
await reconcileInstallLifecycle(await loadInstaller());
|
|
193
|
+
installState = "none";
|
|
194
|
+
}
|
|
195
|
+
if (installState === "stale") {
|
|
196
|
+
const outcome = await waitForManagedInstaller();
|
|
197
|
+
if (outcome === "stopped") return;
|
|
198
|
+
if (outcome === "restart") {
|
|
199
|
+
process.exitCode = MANAGED_UPDATE_RESTART_EXIT_CODE;
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
installState = "live";
|
|
203
|
+
}
|
|
204
|
+
const runningVersion = packageVersion();
|
|
205
|
+
const managedPaths = managedRuntimePaths();
|
|
206
|
+
const provisionalInstall =
|
|
207
|
+
installState === "live" || installState === "unknown";
|
|
208
|
+
if (
|
|
209
|
+
existsSync(managedPaths.runtime) &&
|
|
210
|
+
!(provisionalInstall
|
|
211
|
+
? await validateProvisionalManagedRuntimeSelection(runningVersion)
|
|
212
|
+
: await validateManagedRuntimeSelection(runningVersion))
|
|
213
|
+
) {
|
|
214
|
+
throw new Error(
|
|
215
|
+
`managed runtime activation is still in progress; refusing to start stale runtime ${runningVersion}`,
|
|
216
|
+
);
|
|
217
|
+
}
|
|
93
218
|
// Boot the service in-process: main.ts's top-level code starts the WSS
|
|
94
219
|
// client + local UI server and keeps the event loop alive.
|
|
95
|
-
await import("./main");
|
|
220
|
+
const host = await import("./main");
|
|
221
|
+
// Close the none→intent race while the comparatively expensive service
|
|
222
|
+
// module imported. This is one lock-free marker read, not a steady-state
|
|
223
|
+
// poller; only a provisional install arms the recurring watcher.
|
|
224
|
+
try {
|
|
225
|
+
installState = await managedRuntimeInstallStartupState();
|
|
226
|
+
} catch (error) {
|
|
227
|
+
host.requestManagedHostRestart();
|
|
228
|
+
throw error;
|
|
229
|
+
}
|
|
230
|
+
if (installState === "stale" || installState === "attested") {
|
|
231
|
+
host.requestManagedHostRestart();
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
if (installState === "live" || installState === "unknown") {
|
|
235
|
+
watchManagedInstaller(host.requestManagedHostRestart);
|
|
236
|
+
}
|
|
237
|
+
if (
|
|
238
|
+
existsSync(managedPaths.runtime) &&
|
|
239
|
+
installState !== "live" &&
|
|
240
|
+
installState !== "unknown"
|
|
241
|
+
) {
|
|
242
|
+
try {
|
|
243
|
+
if (!(await acknowledgeManagedRuntimeActivation(runningVersion))) {
|
|
244
|
+
// Selection changed while the service module was loading. Its task
|
|
245
|
+
// admission remains closed by the marker; drain and let the supervisor
|
|
246
|
+
// start the newly selected stable command.
|
|
247
|
+
host.requestManagedHostRestart();
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
} catch (error) {
|
|
251
|
+
// A malformed or concurrently changed marker must not leave an imported
|
|
252
|
+
// service running indefinitely. Admission is still closed, so request a
|
|
253
|
+
// graceful stop before surfacing the settlement failure.
|
|
254
|
+
host.requestManagedHostRestart();
|
|
255
|
+
throw error;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
let localManagedUpdateRunning = false;
|
|
259
|
+
if (existsSync(managedPaths.current)) {
|
|
260
|
+
new ManagedActivationWatcher({
|
|
261
|
+
restartRequired: async () => {
|
|
262
|
+
if (localManagedUpdateRunning) return false;
|
|
263
|
+
return reconcileManagedRuntimeActivation(runningVersion);
|
|
264
|
+
},
|
|
265
|
+
isIdle: host.isIdleForManagedHostUpdate,
|
|
266
|
+
onReady: host.requestManagedHostRestart,
|
|
267
|
+
onError: (error) =>
|
|
268
|
+
console.warn(
|
|
269
|
+
`[host-agent] managed runtime activation check failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
270
|
+
),
|
|
271
|
+
}).start();
|
|
272
|
+
}
|
|
273
|
+
if (managedAutoupdateEnabled()) {
|
|
274
|
+
new ManagedUpdateScheduler({
|
|
275
|
+
isIdle: host.isIdleForManagedHostUpdate,
|
|
276
|
+
check: async () => {
|
|
277
|
+
localManagedUpdateRunning = true;
|
|
278
|
+
try {
|
|
279
|
+
const result = await updateManagedRuntime({
|
|
280
|
+
canActivate: host.isIdleForManagedHostUpdate,
|
|
281
|
+
});
|
|
282
|
+
if (result.status === "deferred") return "busy";
|
|
283
|
+
return result.status;
|
|
284
|
+
} finally {
|
|
285
|
+
localManagedUpdateRunning = false;
|
|
286
|
+
}
|
|
287
|
+
},
|
|
288
|
+
onUpdated: host.requestManagedHostRestart,
|
|
289
|
+
onError: (error) =>
|
|
290
|
+
console.warn(
|
|
291
|
+
`[host-agent] managed runtime update check failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
292
|
+
),
|
|
293
|
+
}).start();
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
async function waitForManagedInstaller(): Promise<"live" | "restart" | "stopped"> {
|
|
298
|
+
console.error(
|
|
299
|
+
red(
|
|
300
|
+
"managed host installation was interrupted; rerun the installer to resume safely",
|
|
301
|
+
),
|
|
302
|
+
);
|
|
303
|
+
return new Promise((resolve) => {
|
|
304
|
+
let stopped = false;
|
|
305
|
+
let timer: NodeJS.Timeout | undefined;
|
|
306
|
+
const finish = (state: "live" | "restart" | "stopped") => {
|
|
307
|
+
if (stopped) return;
|
|
308
|
+
stopped = true;
|
|
309
|
+
if (timer) clearTimeout(timer);
|
|
310
|
+
process.off("SIGTERM", onSignal);
|
|
311
|
+
process.off("SIGINT", onSignal);
|
|
312
|
+
resolve(state);
|
|
313
|
+
};
|
|
314
|
+
const onSignal = () => finish("stopped");
|
|
315
|
+
const poll = async () => {
|
|
316
|
+
try {
|
|
317
|
+
const state = await managedRuntimeInstallStartupState();
|
|
318
|
+
if (state === "live") {
|
|
319
|
+
finish("live");
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
if (state === "none" || state === "attested") {
|
|
323
|
+
// Another CLI settled the stale transaction. Exit nonzero so a
|
|
324
|
+
// Restart=on-failure supervisor launches a fresh process that can
|
|
325
|
+
// either boot normally or finish the attested commit before import.
|
|
326
|
+
finish("restart");
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
} catch (error) {
|
|
330
|
+
console.error(
|
|
331
|
+
red(
|
|
332
|
+
`managed host install gate could not be checked: ${
|
|
333
|
+
error instanceof Error ? error.message : String(error)
|
|
334
|
+
}`,
|
|
335
|
+
),
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
if (!stopped) timer = setTimeout(poll, 1_000);
|
|
339
|
+
};
|
|
340
|
+
process.once("SIGTERM", onSignal);
|
|
341
|
+
process.once("SIGINT", onSignal);
|
|
342
|
+
timer = setTimeout(poll, 1_000);
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function watchManagedInstaller(onStale: () => void): void {
|
|
347
|
+
new ManagedInstallOwnerWatcher({
|
|
348
|
+
check: () => managedRuntimeInstallStartupState(),
|
|
349
|
+
onRestart: (state) => {
|
|
350
|
+
console.error(
|
|
351
|
+
red(
|
|
352
|
+
state === "attested"
|
|
353
|
+
? "managed host install attested; draining so the supervisor can finalize it"
|
|
354
|
+
: "managed host installer exited before attestation; draining into the recovery gate",
|
|
355
|
+
),
|
|
356
|
+
);
|
|
357
|
+
onStale();
|
|
358
|
+
},
|
|
359
|
+
onUnsafe: (error) => {
|
|
360
|
+
console.error(
|
|
361
|
+
red(
|
|
362
|
+
`managed host install intent became unreadable; draining safely: ${
|
|
363
|
+
error instanceof Error ? error.message : String(error)
|
|
364
|
+
}`,
|
|
365
|
+
),
|
|
366
|
+
);
|
|
367
|
+
onStale();
|
|
368
|
+
},
|
|
369
|
+
}).start();
|
|
96
370
|
}
|
|
97
371
|
|
|
98
372
|
// --- status -----------------------------------------------------------------
|
|
@@ -108,9 +382,14 @@ async function cmdStatus(): Promise<void> {
|
|
|
108
382
|
}
|
|
109
383
|
let status, cloud, tasks;
|
|
110
384
|
try {
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
385
|
+
const [rawStatus, rawCloud, rawTasks] = await Promise.all([
|
|
386
|
+
api(port, "/api/status"),
|
|
387
|
+
api(port, "/api/cloud"),
|
|
388
|
+
api(port, "/api/tasks"),
|
|
389
|
+
]);
|
|
390
|
+
status = StatusResponse.parse(rawStatus);
|
|
391
|
+
cloud = CloudResponse.parse(rawCloud);
|
|
392
|
+
tasks = TasksResponse.parse(rawTasks);
|
|
114
393
|
} catch (err) {
|
|
115
394
|
console.log(
|
|
116
395
|
dim(
|
|
@@ -141,6 +420,22 @@ async function cmdStatus(): Promise<void> {
|
|
|
141
420
|
console.log(` agents ${yellow("mock")} ${dim("— echo only, no real CLI runs")}`);
|
|
142
421
|
console.log(` ${dim("unset UAI_AGENTS in " + envLocalPath() + ", then: uai-host restart")}`);
|
|
143
422
|
}
|
|
423
|
+
if (status.containerRuntime) {
|
|
424
|
+
const runtime = status.containerRuntime;
|
|
425
|
+
const label =
|
|
426
|
+
runtime.status === "ready"
|
|
427
|
+
? green(runtime.provider)
|
|
428
|
+
: runtime.status === "checking"
|
|
429
|
+
? yellow("checking")
|
|
430
|
+
: red("unavailable");
|
|
431
|
+
console.log(` runtime ${label} ${dim(`(${runtime.preference})`)}`);
|
|
432
|
+
if (runtime.status === "checking" && runtime.detail) {
|
|
433
|
+
console.log(` ${dim(runtime.detail)}`);
|
|
434
|
+
}
|
|
435
|
+
if (runtime.status === "no-runtime") {
|
|
436
|
+
console.log(` ${dim(runtime.message)}`);
|
|
437
|
+
}
|
|
438
|
+
}
|
|
144
439
|
console.log(` ui ${dim(`http://127.0.0.1:${port}`)}`);
|
|
145
440
|
console.log(` log ${dim(status.logPath)}`);
|
|
146
441
|
console.log(` tasks ${tasks.tasks.length === 0 ? dim("none") : tasks.tasks.length}`);
|
|
@@ -157,11 +452,395 @@ async function cmdStatus(): Promise<void> {
|
|
|
157
452
|
}
|
|
158
453
|
|
|
159
454
|
async function api(port: number, path: string): Promise<unknown> {
|
|
160
|
-
const res = await fetch(`http://127.0.0.1:${port}${path}
|
|
455
|
+
const res = await fetch(`http://127.0.0.1:${port}${path}`, {
|
|
456
|
+
signal: AbortSignal.timeout(1_000),
|
|
457
|
+
});
|
|
161
458
|
if (!res.ok) throw new Error(`${path} → ${res.status}`);
|
|
162
459
|
return (await res.json()) as unknown;
|
|
163
460
|
}
|
|
164
461
|
|
|
462
|
+
async function boundedResponseJson(
|
|
463
|
+
response: Response,
|
|
464
|
+
maxBytes = 16 * 1024,
|
|
465
|
+
): Promise<unknown> {
|
|
466
|
+
const declared = response.headers.get("content-length");
|
|
467
|
+
if (declared !== null) {
|
|
468
|
+
if (!/^(?:0|[1-9]\d*)$/.test(declared) || Number(declared) > maxBytes) {
|
|
469
|
+
throw new Error("cloud response exceeds its byte limit");
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
if (!response.body) throw new Error("cloud response has no body");
|
|
473
|
+
const reader = response.body.getReader();
|
|
474
|
+
const chunks: Uint8Array[] = [];
|
|
475
|
+
let total = 0;
|
|
476
|
+
try {
|
|
477
|
+
for (;;) {
|
|
478
|
+
const { done, value } = await reader.read();
|
|
479
|
+
if (done) break;
|
|
480
|
+
total += value.byteLength;
|
|
481
|
+
if (total > maxBytes) {
|
|
482
|
+
await reader.cancel("cloud response exceeds its byte limit");
|
|
483
|
+
throw new Error("cloud response exceeds its byte limit");
|
|
484
|
+
}
|
|
485
|
+
chunks.push(value);
|
|
486
|
+
}
|
|
487
|
+
} finally {
|
|
488
|
+
reader.releaseLock();
|
|
489
|
+
}
|
|
490
|
+
const bytes = new Uint8Array(total);
|
|
491
|
+
let offset = 0;
|
|
492
|
+
for (const chunk of chunks) {
|
|
493
|
+
bytes.set(chunk, offset);
|
|
494
|
+
offset += chunk.byteLength;
|
|
495
|
+
}
|
|
496
|
+
return JSON.parse(new TextDecoder().decode(bytes)) as unknown;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// --- runtime ---------------------------------------------------------------
|
|
500
|
+
|
|
501
|
+
async function cmdRuntime(rest: string[]): Promise<void> {
|
|
502
|
+
if (rest[0] === "begin-install") {
|
|
503
|
+
return cmdBeginRuntimeInstall(rest.slice(1));
|
|
504
|
+
}
|
|
505
|
+
if (rest[0] === "complete-install") {
|
|
506
|
+
return cmdCompleteRuntimeInstall(rest.slice(1));
|
|
507
|
+
}
|
|
508
|
+
if (rest[0] === "abort-install") {
|
|
509
|
+
return cmdAbortRuntimeInstall(rest.slice(1));
|
|
510
|
+
}
|
|
511
|
+
if (rest[0] === "stop-service") {
|
|
512
|
+
return cmdStopRuntimeInstallService(rest.slice(1));
|
|
513
|
+
}
|
|
514
|
+
if (rest.length === 1 && rest[0] === "reconcile-uninstall") {
|
|
515
|
+
await reconcileManagedRuntimeUninstall();
|
|
516
|
+
return;
|
|
517
|
+
}
|
|
518
|
+
if (rest[0] === "install-archive") {
|
|
519
|
+
return cmdInstallRuntimeArchive(rest.slice(1));
|
|
520
|
+
}
|
|
521
|
+
if (rest.length !== 1 || rest[0] !== "recheck") {
|
|
522
|
+
console.error(
|
|
523
|
+
red(
|
|
524
|
+
"usage: uai-host runtime recheck\n uai-host runtime install-archive --archive <file> --version <x.y.z> --size <bytes> --sha256 <hex>",
|
|
525
|
+
),
|
|
526
|
+
);
|
|
527
|
+
process.exitCode = 1;
|
|
528
|
+
return;
|
|
529
|
+
}
|
|
530
|
+
const port = readPort();
|
|
531
|
+
if (port == null) {
|
|
532
|
+
console.error(
|
|
533
|
+
red("service not running — start it before rechecking"),
|
|
534
|
+
);
|
|
535
|
+
process.exitCode = 1;
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
538
|
+
try {
|
|
539
|
+
const res = await fetch(`http://127.0.0.1:${port}/api/runtime/recheck`, {
|
|
540
|
+
method: "POST",
|
|
541
|
+
signal: AbortSignal.timeout(30_000),
|
|
542
|
+
headers: {
|
|
543
|
+
"x-uai-local-control": "runtime-recheck-v1",
|
|
544
|
+
},
|
|
545
|
+
});
|
|
546
|
+
if (!res.ok) throw new Error(`runtime recheck → ${res.status}`);
|
|
547
|
+
const runtime = ContainerRuntimeResponse.parse(
|
|
548
|
+
await boundedResponseJson(res),
|
|
549
|
+
);
|
|
550
|
+
if (runtime.status === "ready") {
|
|
551
|
+
console.log(
|
|
552
|
+
green("container runtime ready") + dim(` — ${runtime.provider}`),
|
|
553
|
+
);
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
556
|
+
if (runtime.status === "checking") {
|
|
557
|
+
console.log(
|
|
558
|
+
yellow("container runtime detected") +
|
|
559
|
+
dim(" — preparing images and recovering tasks; check status shortly"),
|
|
560
|
+
);
|
|
561
|
+
return;
|
|
562
|
+
}
|
|
563
|
+
console.error(red(runtime.message ?? "container runtime unavailable"));
|
|
564
|
+
process.exitCode = 1;
|
|
565
|
+
} catch (err) {
|
|
566
|
+
console.error(
|
|
567
|
+
red(err instanceof Error ? err.message : "runtime recheck failed"),
|
|
568
|
+
);
|
|
569
|
+
process.exitCode = 1;
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
async function cmdBeginRuntimeInstall(rest: string[]): Promise<void> {
|
|
574
|
+
if (rest.length !== 0) {
|
|
575
|
+
throw new Error("usage: uai-host runtime begin-install");
|
|
576
|
+
}
|
|
577
|
+
const installer = await loadInstaller();
|
|
578
|
+
const result = await beginManagedRuntimeInstall({
|
|
579
|
+
// The installer invokes this command directly with output redirection. Its
|
|
580
|
+
// parent is therefore the long-lived compound shell, not a short-lived
|
|
581
|
+
// command-substitution process. PID start identity protects against reuse.
|
|
582
|
+
ownerPid: process.ppid,
|
|
583
|
+
reconcileStandaloneDefinition: () => reconcileDefinitionInstall(installer),
|
|
584
|
+
definitionState: (managedCommand) =>
|
|
585
|
+
serviceDefinitionState(installer, managedCommand),
|
|
586
|
+
prepareRecovery: (installIntent) =>
|
|
587
|
+
prepareDefinitionInstallRollback(installer, installIntent),
|
|
588
|
+
resumeRecovery: (installIntent) =>
|
|
589
|
+
resumeDefinitionInstallRollback(installer, installIntent),
|
|
590
|
+
resumeStoppedService: (installIntent, priorManager) =>
|
|
591
|
+
resumeServiceForInstall(installer, installIntent, priorManager),
|
|
592
|
+
beforeFinalize: (installIntent) =>
|
|
593
|
+
completeDefinitionInstall(installer, installIntent),
|
|
594
|
+
});
|
|
595
|
+
console.log(
|
|
596
|
+
[
|
|
597
|
+
result.nonce,
|
|
598
|
+
result.legacyShim ? "legacy" : "none",
|
|
599
|
+
result.serviceOwnership,
|
|
600
|
+
result.serviceQuiescence,
|
|
601
|
+
result.serviceManager.loaded ? "loaded" : "unloaded",
|
|
602
|
+
result.serviceManager.active ? "active" : "inactive",
|
|
603
|
+
result.serviceManager.enabled ? "enabled" : "disabled",
|
|
604
|
+
].join(" "),
|
|
605
|
+
);
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
async function cmdStopRuntimeInstallService(rest: string[]): Promise<void> {
|
|
609
|
+
const installIntent = exactFlagValue(rest, "--install-intent");
|
|
610
|
+
if (!installIntent) {
|
|
611
|
+
throw new Error(
|
|
612
|
+
"usage: uai-host runtime stop-service --install-intent <nonce>",
|
|
613
|
+
);
|
|
614
|
+
}
|
|
615
|
+
const installer = await loadInstaller();
|
|
616
|
+
await stopManagedRuntimeServiceForInstall({
|
|
617
|
+
installIntent,
|
|
618
|
+
stopService: (intent, priorManager) =>
|
|
619
|
+
stopServiceForInstall(installer, intent, priorManager),
|
|
620
|
+
});
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
async function cmdCompleteRuntimeInstall(rest: string[]): Promise<void> {
|
|
624
|
+
const installIntent = exactFlagValue(rest, "--install-intent");
|
|
625
|
+
if (!installIntent) {
|
|
626
|
+
throw new Error(
|
|
627
|
+
"usage: uai-host runtime complete-install --install-intent <nonce>",
|
|
628
|
+
);
|
|
629
|
+
}
|
|
630
|
+
const installer = await loadInstaller();
|
|
631
|
+
await completeManagedRuntimeInstall({
|
|
632
|
+
installIntent,
|
|
633
|
+
beforeFinalize: (intent) => completeDefinitionInstall(installer, intent),
|
|
634
|
+
});
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
async function cmdAbortRuntimeInstall(rest: string[]): Promise<void> {
|
|
638
|
+
const installIntent = exactFlagValue(rest, "--install-intent");
|
|
639
|
+
if (!installIntent) {
|
|
640
|
+
throw new Error(
|
|
641
|
+
"usage: uai-host runtime abort-install --install-intent <nonce>",
|
|
642
|
+
);
|
|
643
|
+
}
|
|
644
|
+
const installer = await loadInstaller();
|
|
645
|
+
await abortManagedRuntimeInstall({
|
|
646
|
+
installIntent,
|
|
647
|
+
prepareRestore: (intent) =>
|
|
648
|
+
prepareDefinitionInstallRollback(installer, intent),
|
|
649
|
+
resumeRestore: (intent) =>
|
|
650
|
+
resumeDefinitionInstallRollback(installer, intent),
|
|
651
|
+
resumeStoppedService: (intent, priorManager) =>
|
|
652
|
+
resumeServiceForInstall(installer, intent, priorManager),
|
|
653
|
+
beforeFinalize: (intent) => completeDefinitionInstall(installer, intent),
|
|
654
|
+
});
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
async function cmdInstallRuntimeArchive(rest: string[]): Promise<void> {
|
|
658
|
+
const parsed = parseRuntimeInstallArgs(rest);
|
|
659
|
+
if (!parsed) {
|
|
660
|
+
console.error(
|
|
661
|
+
red(
|
|
662
|
+
"usage: uai-host runtime install-archive --archive <file> --version <x.y.z> --size <bytes> --sha256 <hex>",
|
|
663
|
+
),
|
|
664
|
+
);
|
|
665
|
+
process.exitCode = 1;
|
|
666
|
+
return;
|
|
667
|
+
}
|
|
668
|
+
const platform = nativeReleasePlatform();
|
|
669
|
+
const result = await installManagedRuntimeArchive({
|
|
670
|
+
archivePath: parsed.archive,
|
|
671
|
+
artifact: {
|
|
672
|
+
// Bootstrap authenticated the tuple in the cloud-rendered script. The
|
|
673
|
+
// local installer consumes only size/hash; update URLs come from the
|
|
674
|
+
// separately verified signed manifest.
|
|
675
|
+
url: "bootstrap-authenticated-artifact",
|
|
676
|
+
size: parsed.size,
|
|
677
|
+
sha256: parsed.sha256,
|
|
678
|
+
},
|
|
679
|
+
version: parsed.version,
|
|
680
|
+
platform,
|
|
681
|
+
allowDowngrade: parsed.allowDowngrade,
|
|
682
|
+
// Legacy conversion takes over the old service's live tasks, so it needs
|
|
683
|
+
// every task idle. A managed version update restarts only our own
|
|
684
|
+
// service, which running tasks survive (durable sessions) — it drains
|
|
685
|
+
// only in-flight lifecycle mutations.
|
|
686
|
+
canActivate: (context) =>
|
|
687
|
+
context.legacyConversion
|
|
688
|
+
? !hasActiveHostTasks()
|
|
689
|
+
: !hasInFlightHostTaskLifecycle(),
|
|
690
|
+
releaseRollbackUpdateHold: true,
|
|
691
|
+
installIntent: parsed.installIntent,
|
|
692
|
+
});
|
|
693
|
+
if (result.deferred) {
|
|
694
|
+
console.error(red("runtime activation deferred because host tasks are active"));
|
|
695
|
+
process.exitCode = 1;
|
|
696
|
+
return;
|
|
697
|
+
}
|
|
698
|
+
console.log(
|
|
699
|
+
green(result.changed ? `installed host runtime ${result.version}` : `host runtime ${result.version} is already installed`),
|
|
700
|
+
);
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
interface RuntimeInstallArgs {
|
|
704
|
+
archive: string;
|
|
705
|
+
version: string;
|
|
706
|
+
size: number;
|
|
707
|
+
sha256: string;
|
|
708
|
+
allowDowngrade: boolean;
|
|
709
|
+
installIntent?: string;
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
function parseRuntimeInstallArgs(rest: string[]): RuntimeInstallArgs | null {
|
|
713
|
+
const values = new Map<string, string>();
|
|
714
|
+
let allowDowngrade = false;
|
|
715
|
+
for (let index = 0; index < rest.length; index += 1) {
|
|
716
|
+
const arg = rest[index]!;
|
|
717
|
+
if (arg === "--allow-downgrade") {
|
|
718
|
+
if (allowDowngrade) return null;
|
|
719
|
+
allowDowngrade = true;
|
|
720
|
+
continue;
|
|
721
|
+
}
|
|
722
|
+
if (
|
|
723
|
+
![
|
|
724
|
+
"--archive",
|
|
725
|
+
"--version",
|
|
726
|
+
"--size",
|
|
727
|
+
"--sha256",
|
|
728
|
+
"--install-intent",
|
|
729
|
+
].includes(arg)
|
|
730
|
+
) {
|
|
731
|
+
return null;
|
|
732
|
+
}
|
|
733
|
+
if (values.has(arg)) return null;
|
|
734
|
+
const value = rest[index + 1];
|
|
735
|
+
if (!value || value.startsWith("--")) return null;
|
|
736
|
+
values.set(arg, value);
|
|
737
|
+
index += 1;
|
|
738
|
+
}
|
|
739
|
+
const archive = values.get("--archive");
|
|
740
|
+
const version = values.get("--version");
|
|
741
|
+
const sizeValue = values.get("--size");
|
|
742
|
+
const sha256 = values.get("--sha256");
|
|
743
|
+
const installIntent = values.get("--install-intent");
|
|
744
|
+
if (!archive || !version || !sizeValue || !sha256) return null;
|
|
745
|
+
if (!/^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)$/.test(version)) {
|
|
746
|
+
return null;
|
|
747
|
+
}
|
|
748
|
+
if (!/^[1-9]\d*$/.test(sizeValue) || !/^[a-f0-9]{64}$/.test(sha256)) {
|
|
749
|
+
return null;
|
|
750
|
+
}
|
|
751
|
+
const size = Number(sizeValue);
|
|
752
|
+
if (!Number.isSafeInteger(size)) return null;
|
|
753
|
+
if (installIntent && !/^[a-f0-9]{64}$/.test(installIntent)) return null;
|
|
754
|
+
return { archive, version, size, sha256, allowDowngrade, installIntent };
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
async function cmdUpdate(rest: string[]): Promise<void> {
|
|
758
|
+
if (rest.length !== 0) {
|
|
759
|
+
console.error(red("usage: uai-host update"));
|
|
760
|
+
process.exitCode = 1;
|
|
761
|
+
return;
|
|
762
|
+
}
|
|
763
|
+
// Running tasks survive the service restart (durable sessions); only a
|
|
764
|
+
// task mid-lifecycle blocks, and only until it drains.
|
|
765
|
+
if (hasInFlightHostTaskLifecycle()) {
|
|
766
|
+
console.error(red("refusing to update while a task is still starting; retry in a moment"));
|
|
767
|
+
process.exitCode = 1;
|
|
768
|
+
return;
|
|
769
|
+
}
|
|
770
|
+
const result = await updateManagedRuntime({
|
|
771
|
+
canActivate: () => !hasInFlightHostTaskLifecycle(),
|
|
772
|
+
releaseRollbackUpdateHold: true,
|
|
773
|
+
});
|
|
774
|
+
if (result.status === "not-managed") {
|
|
775
|
+
console.error(red("this host is not installed as a managed runtime"));
|
|
776
|
+
process.exitCode = 1;
|
|
777
|
+
return;
|
|
778
|
+
}
|
|
779
|
+
if (result.status === "current") {
|
|
780
|
+
console.log(green(`host runtime ${result.version} is current`));
|
|
781
|
+
return;
|
|
782
|
+
}
|
|
783
|
+
if (result.status === "deferred") {
|
|
784
|
+
console.error(red("host runtime update was deferred because the host became busy"));
|
|
785
|
+
process.exitCode = 1;
|
|
786
|
+
return;
|
|
787
|
+
}
|
|
788
|
+
console.log(
|
|
789
|
+
green(`updated host runtime ${result.fromVersion} → ${result.version}`) +
|
|
790
|
+
dim(" — activation staged; the service will restart after in-flight work drains"),
|
|
791
|
+
);
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
async function cmdRollback(rest: string[]): Promise<void> {
|
|
795
|
+
if (rest.length !== 0) {
|
|
796
|
+
console.error(red("usage: uai-host rollback"));
|
|
797
|
+
process.exitCode = 1;
|
|
798
|
+
return;
|
|
799
|
+
}
|
|
800
|
+
if (hasActiveHostTasks()) {
|
|
801
|
+
console.error(red("refusing to roll back while host tasks are active"));
|
|
802
|
+
process.exitCode = 1;
|
|
803
|
+
return;
|
|
804
|
+
}
|
|
805
|
+
const idle = () => !hasActiveHostTasks();
|
|
806
|
+
const result = await rollbackManagedRuntime(undefined, undefined, idle);
|
|
807
|
+
console.log(
|
|
808
|
+
green(`rolled back host runtime ${result.fromVersion} → ${result.version}`) +
|
|
809
|
+
dim(" — activation staged; the service will restart after in-flight work drains"),
|
|
810
|
+
);
|
|
811
|
+
console.log(
|
|
812
|
+
yellow("automatic runtime updates are paused; run `uai-host update` to resume them"),
|
|
813
|
+
);
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
function cmdEnrollmentStatus(rest: string[]): void {
|
|
817
|
+
if (rest.length !== 1 || rest[0] !== "status") {
|
|
818
|
+
console.error(red("usage: uai-host enrollment status"));
|
|
819
|
+
process.exitCode = 2;
|
|
820
|
+
return;
|
|
821
|
+
}
|
|
822
|
+
const identity = hostIdentity();
|
|
823
|
+
if (identity.kind === "complete") {
|
|
824
|
+
console.log("enrolled");
|
|
825
|
+
return;
|
|
826
|
+
}
|
|
827
|
+
if (identity.kind === "partial") {
|
|
828
|
+
console.error(red("incomplete host identity"));
|
|
829
|
+
process.exitCode = 2;
|
|
830
|
+
return;
|
|
831
|
+
}
|
|
832
|
+
console.log("unenrolled");
|
|
833
|
+
process.exitCode = 1;
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
function managedAutoupdateEnabled(): boolean {
|
|
837
|
+
return (
|
|
838
|
+
process.env.UAI_HOST_AUTOUPDATE !== "0" &&
|
|
839
|
+
PINNED_RELEASE_MANIFEST_KEYS.size > 0 &&
|
|
840
|
+
existsSync(managedRuntimePaths().current)
|
|
841
|
+
);
|
|
842
|
+
}
|
|
843
|
+
|
|
165
844
|
// --- open -------------------------------------------------------------------
|
|
166
845
|
|
|
167
846
|
async function cmdOpen(): Promise<void> {
|
|
@@ -222,6 +901,90 @@ function flagValue(rest: string[], name: string): string | undefined {
|
|
|
222
901
|
return eq ? eq.slice(name.length + 1) : undefined;
|
|
223
902
|
}
|
|
224
903
|
|
|
904
|
+
function exactFlagValue(rest: string[], name: string): string | undefined {
|
|
905
|
+
return rest.length === 2 && rest[0] === name && rest[1]
|
|
906
|
+
? rest[1]
|
|
907
|
+
: undefined;
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
type EnrollArgs =
|
|
911
|
+
| { ok: true; token: string; cloud?: string }
|
|
912
|
+
| { ok: false };
|
|
913
|
+
|
|
914
|
+
/** `enroll` spends a one-time token, so reject malformed/ambiguous argv
|
|
915
|
+
* instead of silently falling back to persisted configuration. */
|
|
916
|
+
function parseEnrollArgs(rest: string[]): EnrollArgs {
|
|
917
|
+
let token: string | undefined;
|
|
918
|
+
let cloud: string | undefined;
|
|
919
|
+
let sawCloud = false;
|
|
920
|
+
|
|
921
|
+
for (let index = 0; index < rest.length; index += 1) {
|
|
922
|
+
const arg = rest[index]!;
|
|
923
|
+
if (arg === "--cloud") {
|
|
924
|
+
if (sawCloud) return { ok: false };
|
|
925
|
+
sawCloud = true;
|
|
926
|
+
const value = rest[index + 1];
|
|
927
|
+
if (!value || value.startsWith("-")) return { ok: false };
|
|
928
|
+
cloud = value;
|
|
929
|
+
index += 1;
|
|
930
|
+
continue;
|
|
931
|
+
}
|
|
932
|
+
if (arg.startsWith("--cloud=")) {
|
|
933
|
+
if (sawCloud) return { ok: false };
|
|
934
|
+
sawCloud = true;
|
|
935
|
+
cloud = arg.slice("--cloud=".length);
|
|
936
|
+
if (!cloud) return { ok: false };
|
|
937
|
+
continue;
|
|
938
|
+
}
|
|
939
|
+
if (arg.startsWith("-") || token) return { ok: false };
|
|
940
|
+
token = arg;
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
return token ? { ok: true, token, ...(cloud ? { cloud } : {}) } : { ok: false };
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
type HostIdentity =
|
|
947
|
+
| { kind: "none" }
|
|
948
|
+
| { kind: "partial" }
|
|
949
|
+
| { kind: "complete"; hostId: string; hostToken: string };
|
|
950
|
+
|
|
951
|
+
/** Resolve both modern env identities and legacy `pair` identities whose id
|
|
952
|
+
* lives in `$UAI_DATA_DIR/host-id`. Exactly one unresolved half fails closed. */
|
|
953
|
+
function hostIdentity(): HostIdentity {
|
|
954
|
+
const hostToken = process.env.UAI_HOST_TOKEN;
|
|
955
|
+
const hostId = process.env.UAI_HOST_ID ?? (hostToken ? readPersistedHostId() : null);
|
|
956
|
+
if (!hostId && !hostToken) return { kind: "none" };
|
|
957
|
+
if (!hostId || !hostToken) return { kind: "partial" };
|
|
958
|
+
return { kind: "complete", hostId, hostToken };
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
function rejectPartialIdentity(): void {
|
|
962
|
+
console.error(
|
|
963
|
+
red("incomplete host identity — refusing to replace an existing enrollment"),
|
|
964
|
+
);
|
|
965
|
+
console.error(
|
|
966
|
+
dim("restore UAI_HOST_ID/UAI_HOST_TOKEN, or use `uai-host setup --force` for a deliberate reset."),
|
|
967
|
+
);
|
|
968
|
+
process.exitCode = 1;
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
/** The bridge credential is durable, so its destination trust anchor must be
|
|
972
|
+
* durable too. Process env is intentionally NOT consulted here: a pasted
|
|
973
|
+
* command can override it before load-env reads the host's real config. */
|
|
974
|
+
function persistedCloudUrl(): string | undefined {
|
|
975
|
+
for (const path of [envLocalPath(), join(uaiHome(), ".env")]) {
|
|
976
|
+
if (!existsSync(path)) continue;
|
|
977
|
+
try {
|
|
978
|
+
const value = parseDotenv(readFileSync(path)).UAI_CLOUD_URL;
|
|
979
|
+
if (value) return value;
|
|
980
|
+
} catch {
|
|
981
|
+
// The normal env loader already reports malformed/unreadable files. A
|
|
982
|
+
// missing trust anchor is handled by the caller without leaking secrets.
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
return undefined;
|
|
986
|
+
}
|
|
987
|
+
|
|
225
988
|
/**
|
|
226
989
|
* Claim this machine as a host: redeem a one-time enrollment token (minted in
|
|
227
990
|
* the web app) for a permanent bridge credential, then write the config. The
|
|
@@ -241,48 +1004,102 @@ async function cmdSetup(rest: string[]): Promise<void> {
|
|
|
241
1004
|
return;
|
|
242
1005
|
}
|
|
243
1006
|
|
|
244
|
-
let
|
|
1007
|
+
let cloudEndpoint: ReturnType<typeof parseHostCloudEndpoint>;
|
|
245
1008
|
try {
|
|
246
|
-
|
|
247
|
-
} catch {
|
|
248
|
-
console.error(
|
|
1009
|
+
cloudEndpoint = parseHostCloudEndpoint(cloud);
|
|
1010
|
+
} catch (error) {
|
|
1011
|
+
console.error(
|
|
1012
|
+
red(
|
|
1013
|
+
`invalid --cloud URL: ${error instanceof Error ? error.message : String(error)}`,
|
|
1014
|
+
),
|
|
1015
|
+
);
|
|
249
1016
|
process.exitCode = 1;
|
|
250
1017
|
return;
|
|
251
1018
|
}
|
|
1019
|
+
const normalizedCloud = cloudEndpoint.bridgeUrl;
|
|
252
1020
|
|
|
253
|
-
//
|
|
254
|
-
//
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
if (
|
|
259
|
-
|
|
1021
|
+
// An already-claimed machine ATTACHES the token to its existing identity
|
|
1022
|
+
// (ADR-098) — pasting a second org's enroll command Just Works, no new host
|
|
1023
|
+
// row, no --force. --force still re-enrolls from scratch (new identity,
|
|
1024
|
+
// orphans the old row) for the rare deliberate reset.
|
|
1025
|
+
const identity = hostIdentity();
|
|
1026
|
+
if (identity.kind === "partial" && !rest.includes("--force")) {
|
|
1027
|
+
rejectPartialIdentity();
|
|
1028
|
+
return;
|
|
1029
|
+
}
|
|
1030
|
+
if (identity.kind === "complete" && !rest.includes("--force")) {
|
|
1031
|
+
try {
|
|
1032
|
+
removeCommittedPendingEnrollment(pendingEnrollmentPath(uaiHome()), {
|
|
1033
|
+
hostId: identity.hostId,
|
|
1034
|
+
hostSecret: identity.hostToken,
|
|
1035
|
+
});
|
|
1036
|
+
} catch (err) {
|
|
1037
|
+
console.error(
|
|
1038
|
+
red(
|
|
1039
|
+
`cannot verify pending enrollment state: ${err instanceof Error ? err.message : String(err)}`,
|
|
1040
|
+
),
|
|
1041
|
+
);
|
|
1042
|
+
process.exitCode = 1;
|
|
1043
|
+
return;
|
|
1044
|
+
}
|
|
1045
|
+
console.log(
|
|
1046
|
+
dim("already enrolled — attaching this machine to the token's workspace instead."),
|
|
1047
|
+
);
|
|
1048
|
+
return attachToOrg(enroll, normalizedCloud, identity);
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
const pendingFile = pendingEnrollmentPath(uaiHome());
|
|
1052
|
+
if (rest.includes("--force")) discardPendingEnrollment(pendingFile);
|
|
1053
|
+
|
|
1054
|
+
const redeemUrl = `${cloudEndpoint.apiOrigin}/api/hosts/enroll/redeem`;
|
|
1055
|
+
|
|
1056
|
+
// Persist this host's proposed identity BEFORE spending the one-time token.
|
|
1057
|
+
// A retry after a lost response reuses the exact id/secret, and the cloud's
|
|
1058
|
+
// same-host replay returns the already-completed result without new access.
|
|
1059
|
+
const candidate: PendingEnrollment = {
|
|
1060
|
+
schemaVersion: 1,
|
|
1061
|
+
enrollmentTokenSha256: enrollmentTokenSha256(enroll),
|
|
1062
|
+
hostId: ulid().toLowerCase(),
|
|
1063
|
+
hostSecret: randomBytes(32).toString("hex"),
|
|
1064
|
+
cloudUrl: normalizedCloud,
|
|
1065
|
+
hostName: hostname(),
|
|
1066
|
+
createdAt: Date.now(),
|
|
1067
|
+
};
|
|
1068
|
+
let pending: PendingEnrollment;
|
|
1069
|
+
try {
|
|
1070
|
+
pending = ensurePendingEnrollment(pendingFile, candidate).pending;
|
|
1071
|
+
} catch (err) {
|
|
260
1072
|
console.error(
|
|
261
|
-
|
|
1073
|
+
red(
|
|
1074
|
+
`could not persist pending enrollment: ${err instanceof Error ? err.message : String(err)}`,
|
|
1075
|
+
),
|
|
262
1076
|
);
|
|
263
1077
|
process.exitCode = 1;
|
|
264
1078
|
return;
|
|
265
1079
|
}
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
const hostId =
|
|
277
|
-
const secret =
|
|
1080
|
+
if (!pendingEnrollmentMatches(pending, enroll, normalizedCloud)) {
|
|
1081
|
+
console.error(red("another enrollment is already pending on this machine"));
|
|
1082
|
+
console.error(
|
|
1083
|
+
dim(
|
|
1084
|
+
"retry the original enrollment command, or use `uai-host setup --force` for a deliberate reset.",
|
|
1085
|
+
),
|
|
1086
|
+
);
|
|
1087
|
+
process.exitCode = 1;
|
|
1088
|
+
return;
|
|
1089
|
+
}
|
|
1090
|
+
const hostId = pending.hostId;
|
|
1091
|
+
const secret = pending.hostSecret;
|
|
278
1092
|
const tokenHash = createHash("sha256").update(secret).digest("hex");
|
|
279
|
-
const name =
|
|
1093
|
+
const name = pending.hostName;
|
|
280
1094
|
|
|
281
|
-
console.log(
|
|
1095
|
+
console.log(
|
|
1096
|
+
dim(`enrolling ${name} (${hostId}) with ${cloudEndpoint.displayHost}…`),
|
|
1097
|
+
);
|
|
282
1098
|
let res: Response;
|
|
283
1099
|
try {
|
|
284
1100
|
res = await fetch(redeemUrl, {
|
|
285
1101
|
method: "POST",
|
|
1102
|
+
redirect: "error",
|
|
286
1103
|
headers: { "content-type": "application/json" },
|
|
287
1104
|
body: JSON.stringify({ enrollToken: enroll, hostId, tokenHash, name }),
|
|
288
1105
|
signal: AbortSignal.timeout(15_000),
|
|
@@ -291,32 +1108,70 @@ async function cmdSetup(rest: string[]): Promise<void> {
|
|
|
291
1108
|
console.error(
|
|
292
1109
|
red(`could not reach the cloud: ${err instanceof Error ? err.message : String(err)}`),
|
|
293
1110
|
);
|
|
1111
|
+
console.error(dim("the proposed identity is saved locally; retry this exact command."));
|
|
294
1112
|
process.exitCode = 1;
|
|
295
1113
|
return;
|
|
296
1114
|
}
|
|
297
1115
|
if (!res.ok) {
|
|
298
1116
|
let detail = `HTTP ${res.status}`;
|
|
299
1117
|
try {
|
|
300
|
-
const j = (await res
|
|
1118
|
+
const j = (await boundedResponseJson(res)) as {
|
|
1119
|
+
error?: { message?: string };
|
|
1120
|
+
};
|
|
301
1121
|
if (j.error?.message) detail = j.error.message;
|
|
302
1122
|
} catch {
|
|
303
1123
|
/* keep the HTTP status */
|
|
304
1124
|
}
|
|
305
1125
|
console.error(red(`enrollment failed: ${detail}`));
|
|
1126
|
+
const definitiveUnclaimed =
|
|
1127
|
+
(res.status === 404 || res.status === 410) &&
|
|
1128
|
+
res.headers.get(ENROLLMENT_REPLAY_PROTOCOL_HEADER) ===
|
|
1129
|
+
ENROLLMENT_REPLAY_PROTOCOL_VERSION;
|
|
1130
|
+
if (definitiveUnclaimed) {
|
|
1131
|
+
// A same-host-v1 cloud checks `redeemedAt` before expiry, so these are
|
|
1132
|
+
// proven unclaimed. Old/rolled-back handlers carry no header and may
|
|
1133
|
+
// return 410 after committing a response that was lost in transit.
|
|
1134
|
+
discardPendingEnrollment(pendingFile);
|
|
1135
|
+
console.error(dim("mint a fresh enrollment command in the web app and retry."));
|
|
1136
|
+
} else {
|
|
1137
|
+
// USED is intentionally retained for rolling compatibility: an older
|
|
1138
|
+
// cloud may have completed the first request but lack same-host replay.
|
|
1139
|
+
console.error(dim("the proposed identity is saved locally; retry this exact command."));
|
|
1140
|
+
}
|
|
1141
|
+
process.exitCode = 1;
|
|
1142
|
+
return;
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
let success: unknown;
|
|
1146
|
+
try {
|
|
1147
|
+
success = await boundedResponseJson(res);
|
|
1148
|
+
} catch (error) {
|
|
306
1149
|
console.error(
|
|
307
|
-
|
|
1150
|
+
red(
|
|
1151
|
+
`enrollment outcome could not be verified: ${error instanceof Error ? error.message : String(error)}`,
|
|
1152
|
+
),
|
|
308
1153
|
);
|
|
1154
|
+
console.error(dim("the proposed identity is saved locally; retry this exact command."));
|
|
1155
|
+
process.exitCode = 1;
|
|
1156
|
+
return;
|
|
1157
|
+
}
|
|
1158
|
+
if (
|
|
1159
|
+
!success ||
|
|
1160
|
+
typeof success !== "object" ||
|
|
1161
|
+
Array.isArray(success) ||
|
|
1162
|
+
(success as Record<string, unknown>).ok !== true ||
|
|
1163
|
+
(success as Record<string, unknown>).hostId !== pending.hostId
|
|
1164
|
+
) {
|
|
1165
|
+
console.error(red("enrollment outcome did not match the proposed host identity"));
|
|
1166
|
+
console.error(dim("the proposed identity is saved locally; retry this exact command."));
|
|
309
1167
|
process.exitCode = 1;
|
|
310
1168
|
return;
|
|
311
1169
|
}
|
|
312
1170
|
|
|
313
|
-
//
|
|
314
|
-
//
|
|
315
|
-
// hand rather than forcing the user to mint a fresh token.
|
|
1171
|
+
// Atomically commit all three values. The pending file is removed only after
|
|
1172
|
+
// the replacement is durable; a crash can leave both files, never neither.
|
|
316
1173
|
try {
|
|
317
|
-
|
|
318
|
-
upsertEnvLocal("UAI_HOST_TOKEN", secret);
|
|
319
|
-
upsertEnvLocal("UAI_CLOUD_URL", cloud);
|
|
1174
|
+
commitPendingEnrollment(pendingFile, envLocalPath(), pending);
|
|
320
1175
|
} catch (err) {
|
|
321
1176
|
console.error(
|
|
322
1177
|
red(
|
|
@@ -324,11 +1179,8 @@ async function cmdSetup(rest: string[]): Promise<void> {
|
|
|
324
1179
|
),
|
|
325
1180
|
);
|
|
326
1181
|
console.error(
|
|
327
|
-
dim("the
|
|
1182
|
+
dim("the identity remains in protected pending state — retry this exact enrollment command."),
|
|
328
1183
|
);
|
|
329
|
-
console.error(` UAI_HOST_ID=${hostId}`);
|
|
330
|
-
console.error(` UAI_HOST_TOKEN=${secret}`);
|
|
331
|
-
console.error(` UAI_CLOUD_URL=${cloud}`);
|
|
332
1184
|
process.exitCode = 1;
|
|
333
1185
|
return;
|
|
334
1186
|
}
|
|
@@ -340,6 +1192,171 @@ async function cmdSetup(rest: string[]): Promise<void> {
|
|
|
340
1192
|
console.log(cyan(" uai-host restart") + dim(" (already installed)"));
|
|
341
1193
|
}
|
|
342
1194
|
|
|
1195
|
+
// --- enroll (attach an org, ADR-098) -----------------------------------------
|
|
1196
|
+
|
|
1197
|
+
/**
|
|
1198
|
+
* Enroll this machine. Without an identity this delegates to fresh setup; with
|
|
1199
|
+
* one, it spends the token against the existing host rather than minting a new
|
|
1200
|
+
* identity. The bridge secret authenticates attach (same credential the WSS
|
|
1201
|
+
* bridge presents) and may only be sent over TLS or to a loopback dev cloud.
|
|
1202
|
+
*
|
|
1203
|
+
* uai-host enroll <token> [--cloud <wss-url>]
|
|
1204
|
+
*/
|
|
1205
|
+
async function cmdEnroll(rest: string[]): Promise<void> {
|
|
1206
|
+
const parsed = parseEnrollArgs(rest);
|
|
1207
|
+
if (!parsed.ok) {
|
|
1208
|
+
console.error(red("usage: uai-host enroll <token> [--cloud <wss-url>]"));
|
|
1209
|
+
process.exitCode = 1;
|
|
1210
|
+
return;
|
|
1211
|
+
}
|
|
1212
|
+
const identity = hostIdentity();
|
|
1213
|
+
if (identity.kind === "partial") {
|
|
1214
|
+
rejectPartialIdentity();
|
|
1215
|
+
return;
|
|
1216
|
+
}
|
|
1217
|
+
const cloud =
|
|
1218
|
+
parsed.cloud ??
|
|
1219
|
+
(identity.kind === "complete"
|
|
1220
|
+
? persistedCloudUrl()
|
|
1221
|
+
: process.env.UAI_CLOUD_URL);
|
|
1222
|
+
if (!cloud) {
|
|
1223
|
+
console.error(
|
|
1224
|
+
red(
|
|
1225
|
+
identity.kind === "complete"
|
|
1226
|
+
? "no persisted cloud URL — restore UAI_CLOUD_URL in the host config"
|
|
1227
|
+
: "no cloud URL — pass --cloud <wss-url>",
|
|
1228
|
+
),
|
|
1229
|
+
);
|
|
1230
|
+
process.exitCode = 1;
|
|
1231
|
+
return;
|
|
1232
|
+
}
|
|
1233
|
+
if (identity.kind === "none") {
|
|
1234
|
+
return cmdSetup(["--cloud", cloud, "--enroll", parsed.token]);
|
|
1235
|
+
}
|
|
1236
|
+
return attachToOrg(parsed.token, cloud, identity);
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
/** Shared by `enroll` and `setup`-on-an-enrolled-machine. */
|
|
1240
|
+
async function attachToOrg(
|
|
1241
|
+
enrollToken: string,
|
|
1242
|
+
cloud: string,
|
|
1243
|
+
identity: Extract<HostIdentity, { kind: "complete" }>,
|
|
1244
|
+
): Promise<void> {
|
|
1245
|
+
const { hostId, hostToken } = identity;
|
|
1246
|
+
|
|
1247
|
+
let cloudEndpoint: ReturnType<typeof parseHostCloudEndpoint>;
|
|
1248
|
+
try {
|
|
1249
|
+
cloudEndpoint = parseHostCloudEndpoint(cloud);
|
|
1250
|
+
} catch (error) {
|
|
1251
|
+
console.error(
|
|
1252
|
+
red(
|
|
1253
|
+
`invalid cloud URL: ${error instanceof Error ? error.message : String(error)}`,
|
|
1254
|
+
),
|
|
1255
|
+
);
|
|
1256
|
+
process.exitCode = 1;
|
|
1257
|
+
return;
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
const configuredCloud = persistedCloudUrl();
|
|
1261
|
+
if (!configuredCloud) {
|
|
1262
|
+
console.error(
|
|
1263
|
+
red("cannot verify the enrollment destination — UAI_CLOUD_URL is not persisted"),
|
|
1264
|
+
);
|
|
1265
|
+
console.error(dim("restore the original cloud URL before attaching this host."));
|
|
1266
|
+
process.exitCode = 1;
|
|
1267
|
+
return;
|
|
1268
|
+
}
|
|
1269
|
+
let configuredEndpoint: ReturnType<typeof parseHostCloudEndpoint>;
|
|
1270
|
+
try {
|
|
1271
|
+
configuredEndpoint = parseHostCloudEndpoint(configuredCloud);
|
|
1272
|
+
} catch (error) {
|
|
1273
|
+
console.error(
|
|
1274
|
+
red(
|
|
1275
|
+
`invalid configured UAI_CLOUD_URL: ${error instanceof Error ? error.message : String(error)}`,
|
|
1276
|
+
),
|
|
1277
|
+
);
|
|
1278
|
+
process.exitCode = 1;
|
|
1279
|
+
return;
|
|
1280
|
+
}
|
|
1281
|
+
if (cloudEndpoint.apiOrigin !== configuredEndpoint.apiOrigin) {
|
|
1282
|
+
console.error(
|
|
1283
|
+
red("refusing to send the host credential to a different cloud origin"),
|
|
1284
|
+
);
|
|
1285
|
+
console.error(dim(`configured cloud: ${configuredEndpoint.displayHost}`));
|
|
1286
|
+
process.exitCode = 1;
|
|
1287
|
+
return;
|
|
1288
|
+
}
|
|
1289
|
+
const attachUrl = `${cloudEndpoint.apiOrigin}/api/hosts/enroll/attach`;
|
|
1290
|
+
|
|
1291
|
+
console.log(
|
|
1292
|
+
dim(`attaching ${hostname()} (${hostId}) via ${cloudEndpoint.displayHost}…`),
|
|
1293
|
+
);
|
|
1294
|
+
let res: Response;
|
|
1295
|
+
try {
|
|
1296
|
+
res = await fetch(attachUrl, {
|
|
1297
|
+
method: "POST",
|
|
1298
|
+
redirect: "error",
|
|
1299
|
+
headers: { "content-type": "application/json" },
|
|
1300
|
+
body: JSON.stringify({ enrollToken, hostId, token: hostToken }),
|
|
1301
|
+
signal: AbortSignal.timeout(15_000),
|
|
1302
|
+
});
|
|
1303
|
+
} catch (err) {
|
|
1304
|
+
console.error(
|
|
1305
|
+
red(`could not reach the cloud: ${err instanceof Error ? err.message : String(err)}`),
|
|
1306
|
+
);
|
|
1307
|
+
process.exitCode = 1;
|
|
1308
|
+
return;
|
|
1309
|
+
}
|
|
1310
|
+
if (!res.ok) {
|
|
1311
|
+
let detail = `HTTP ${res.status}`;
|
|
1312
|
+
try {
|
|
1313
|
+
const j = (await boundedResponseJson(res)) as {
|
|
1314
|
+
error?: { message?: string };
|
|
1315
|
+
};
|
|
1316
|
+
if (j.error?.message) detail = j.error.message;
|
|
1317
|
+
} catch {
|
|
1318
|
+
/* keep the HTTP status */
|
|
1319
|
+
}
|
|
1320
|
+
console.error(red(`attach failed: ${detail}`));
|
|
1321
|
+
console.error(
|
|
1322
|
+
dim("the token may be expired or already used — mint a fresh one in the web app."),
|
|
1323
|
+
);
|
|
1324
|
+
process.exitCode = 1;
|
|
1325
|
+
return;
|
|
1326
|
+
}
|
|
1327
|
+
let body: unknown;
|
|
1328
|
+
try {
|
|
1329
|
+
body = await boundedResponseJson(res);
|
|
1330
|
+
} catch (error) {
|
|
1331
|
+
console.error(
|
|
1332
|
+
red(
|
|
1333
|
+
`attach outcome could not be verified: ${error instanceof Error ? error.message : String(error)}`,
|
|
1334
|
+
),
|
|
1335
|
+
);
|
|
1336
|
+
process.exitCode = 1;
|
|
1337
|
+
return;
|
|
1338
|
+
}
|
|
1339
|
+
const result = body as Record<string, unknown> | null;
|
|
1340
|
+
if (
|
|
1341
|
+
!result ||
|
|
1342
|
+
Array.isArray(result) ||
|
|
1343
|
+
result.ok !== true ||
|
|
1344
|
+
result.hostId !== hostId ||
|
|
1345
|
+
typeof result.orgId !== "string" ||
|
|
1346
|
+
result.orgId.length === 0 ||
|
|
1347
|
+
result.orgId.length > 128 ||
|
|
1348
|
+
(result.orgName !== null &&
|
|
1349
|
+
result.orgName !== undefined &&
|
|
1350
|
+
typeof result.orgName !== "string")
|
|
1351
|
+
) {
|
|
1352
|
+
console.error(red("attach outcome did not match this host identity"));
|
|
1353
|
+
process.exitCode = 1;
|
|
1354
|
+
return;
|
|
1355
|
+
}
|
|
1356
|
+
const where = result.orgName ? ` to ${result.orgName}` : "";
|
|
1357
|
+
console.log(green(`✓ enrolled${where}`) + dim(" — no restart needed."));
|
|
1358
|
+
}
|
|
1359
|
+
|
|
343
1360
|
// --- pair -------------------------------------------------------------------
|
|
344
1361
|
|
|
345
1362
|
async function cmdPair(token: string | undefined): Promise<void> {
|
|
@@ -358,30 +1375,85 @@ async function cmdPair(token: string | undefined): Promise<void> {
|
|
|
358
1375
|
}
|
|
359
1376
|
|
|
360
1377
|
function upsertEnvLocal(key: string, value: string): void {
|
|
361
|
-
|
|
362
|
-
// UAI_HOME (~/.uai on a fresh install) may not exist yet — create it before
|
|
363
|
-
// the first write, or writeFileSync ENOENTs (which on `setup` would orphan a
|
|
364
|
-
// just-redeemed host + burn the one-time token).
|
|
365
|
-
mkdirSync(dirname(path), { recursive: true });
|
|
366
|
-
const existing = existsSync(path) ? readFileSync(path, "utf8") : "";
|
|
367
|
-
const kept = existing
|
|
368
|
-
.split("\n")
|
|
369
|
-
.filter((l) => !l.startsWith(`${key}=`) && l.trim() !== "");
|
|
370
|
-
kept.push(`${key}=${value}`);
|
|
371
|
-
writeFileSync(path, kept.join("\n") + "\n", { mode: 0o600 });
|
|
1378
|
+
writeEnvValuesAtomic(envLocalPath(), { [key]: value });
|
|
372
1379
|
}
|
|
373
1380
|
|
|
374
1381
|
// --- install dispatch -------------------------------------------------------
|
|
375
1382
|
|
|
376
|
-
async function cmdInstall(
|
|
1383
|
+
async function cmdInstall(rest: string[]): Promise<void> {
|
|
1384
|
+
const parsed = parseInstallArgs(rest);
|
|
1385
|
+
if (!parsed) {
|
|
1386
|
+
console.error(
|
|
1387
|
+
red(
|
|
1388
|
+
"usage: uai-host install [--no-start] [--dry-run] [--install-intent <nonce>]",
|
|
1389
|
+
),
|
|
1390
|
+
);
|
|
1391
|
+
process.exitCode = 1;
|
|
1392
|
+
return;
|
|
1393
|
+
}
|
|
1394
|
+
const { dryRun, startOnInstall, installIntent } = parsed;
|
|
377
1395
|
// Disclosure BEFORE activation (review): install starts the service, and
|
|
378
1396
|
// the notice must precede the first telemetry-armed run.
|
|
379
1397
|
if (!dryRun) printTelemetryNotice();
|
|
1398
|
+
if (!dryRun) mkdirSync(uaiHome(), { recursive: true, mode: 0o700 });
|
|
380
1399
|
const installer = await loadInstaller();
|
|
381
|
-
|
|
1400
|
+
if (dryRun) {
|
|
1401
|
+
await installer.install(installContext(true, startOnInstall));
|
|
1402
|
+
} else if (installIntent) {
|
|
1403
|
+
await runManagedRuntimeInstallIntentMutation({
|
|
1404
|
+
installIntent,
|
|
1405
|
+
mutation: (identity) =>
|
|
1406
|
+
installer.install(installContext(false, startOnInstall, identity)),
|
|
1407
|
+
});
|
|
1408
|
+
} else {
|
|
1409
|
+
await runInstallLifecycleMutation(installer, () =>
|
|
1410
|
+
installer.install(installContext(false, startOnInstall)),
|
|
1411
|
+
);
|
|
1412
|
+
}
|
|
382
1413
|
if (!dryRun) {
|
|
383
|
-
console.log(
|
|
1414
|
+
console.log(
|
|
1415
|
+
green("installed") +
|
|
1416
|
+
dim(
|
|
1417
|
+
startOnInstall
|
|
1418
|
+
? " — service registered and started"
|
|
1419
|
+
: " — service registered but not started; start it: uai-host start",
|
|
1420
|
+
),
|
|
1421
|
+
);
|
|
1422
|
+
}
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
function parseInstallArgs(rest: string[]): {
|
|
1426
|
+
dryRun: boolean;
|
|
1427
|
+
startOnInstall: boolean;
|
|
1428
|
+
installIntent?: string;
|
|
1429
|
+
} | null {
|
|
1430
|
+
let dryRun = false;
|
|
1431
|
+
let noStart = false;
|
|
1432
|
+
let installIntent: string | undefined;
|
|
1433
|
+
for (let index = 0; index < rest.length; index += 1) {
|
|
1434
|
+
const arg = rest[index]!;
|
|
1435
|
+
if (arg === "--dry-run") {
|
|
1436
|
+
if (dryRun) return null;
|
|
1437
|
+
dryRun = true;
|
|
1438
|
+
continue;
|
|
1439
|
+
}
|
|
1440
|
+
if (arg === "--no-start") {
|
|
1441
|
+
if (noStart) return null;
|
|
1442
|
+
noStart = true;
|
|
1443
|
+
continue;
|
|
1444
|
+
}
|
|
1445
|
+
if (arg === "--install-intent") {
|
|
1446
|
+
if (installIntent) return null;
|
|
1447
|
+
const value = rest[index + 1];
|
|
1448
|
+
if (!value || !/^[a-f0-9]{64}$/.test(value)) return null;
|
|
1449
|
+
installIntent = value;
|
|
1450
|
+
index += 1;
|
|
1451
|
+
continue;
|
|
1452
|
+
}
|
|
1453
|
+
return null;
|
|
384
1454
|
}
|
|
1455
|
+
if (dryRun && installIntent) return null;
|
|
1456
|
+
return { dryRun, startOnInstall: !noStart, installIntent };
|
|
385
1457
|
}
|
|
386
1458
|
|
|
387
1459
|
/**
|
|
@@ -400,7 +1472,7 @@ function printTelemetryNotice(): void {
|
|
|
400
1472
|
}
|
|
401
1473
|
|
|
402
1474
|
async function cmdInstaller(
|
|
403
|
-
action: "
|
|
1475
|
+
action: "start" | "stop" | "restart",
|
|
404
1476
|
dryRun: boolean,
|
|
405
1477
|
): Promise<void> {
|
|
406
1478
|
// Upgrade path (review finding): operators of EXISTING installs meet
|
|
@@ -410,8 +1482,132 @@ async function cmdInstaller(
|
|
|
410
1482
|
printTelemetryNotice();
|
|
411
1483
|
}
|
|
412
1484
|
const installer = await loadInstaller();
|
|
413
|
-
|
|
414
|
-
|
|
1485
|
+
if (dryRun) {
|
|
1486
|
+
await installer[action](true);
|
|
1487
|
+
} else {
|
|
1488
|
+
await runInstallLifecycleMutation(installer, () => installer[action](false));
|
|
1489
|
+
}
|
|
1490
|
+
}
|
|
1491
|
+
|
|
1492
|
+
async function cmdUninstall(rest: string[]): Promise<void> {
|
|
1493
|
+
const dryRun = rest.includes("--dry-run");
|
|
1494
|
+
const purge = rest.includes("--purge");
|
|
1495
|
+
if (
|
|
1496
|
+
rest.some((arg) => arg !== "--dry-run" && arg !== "--purge") ||
|
|
1497
|
+
rest.filter((arg) => arg === "--dry-run").length > 1 ||
|
|
1498
|
+
rest.filter((arg) => arg === "--purge").length > 1
|
|
1499
|
+
) {
|
|
1500
|
+
console.error(red("usage: uai-host uninstall [--purge] [--dry-run]"));
|
|
1501
|
+
process.exitCode = 1;
|
|
1502
|
+
return;
|
|
1503
|
+
}
|
|
1504
|
+
const installer = await loadInstaller();
|
|
1505
|
+
const paths = managedRuntimePaths();
|
|
1506
|
+
if (dryRun) {
|
|
1507
|
+
await installer.uninstall(true);
|
|
1508
|
+
console.log(`$ rm -rf ${paths.root}`);
|
|
1509
|
+
if (purge) console.log(`$ rm -rf ${paths.data}`);
|
|
1510
|
+
return;
|
|
1511
|
+
}
|
|
1512
|
+
await reconcileInstallLifecycle(installer);
|
|
1513
|
+
await removeManagedRuntime({
|
|
1514
|
+
purgeData: purge,
|
|
1515
|
+
canRemove: () => !hasNonTerminalHostTasks(),
|
|
1516
|
+
beforeRemove: () => installer.uninstall(false),
|
|
1517
|
+
afterServiceTeardown: () => teardownBundledRuntimeForUninstall(purge),
|
|
1518
|
+
});
|
|
1519
|
+
console.log(
|
|
1520
|
+
green("uninstalled") +
|
|
1521
|
+
(purge
|
|
1522
|
+
? dim(" — host runtime and ~/.uai data removed")
|
|
1523
|
+
: dim(" — ~/.uai data preserved (use --purge to remove it)")),
|
|
1524
|
+
);
|
|
1525
|
+
}
|
|
1526
|
+
|
|
1527
|
+
/**
|
|
1528
|
+
* The service is already down, but the active signed generation and purge
|
|
1529
|
+
* target are still attached here — the last moment the bundled CLI exists to
|
|
1530
|
+
* clean up what Uai provisioned through it. Ownership-proven and fail-closed
|
|
1531
|
+
* (see lib/apple-uninstall-teardown.ts): an incomplete teardown THROWS, which
|
|
1532
|
+
* deliberately leaves the managed-removal transaction's teardown marker and
|
|
1533
|
+
* the signed runtime in place for a retry — never remove the escape route
|
|
1534
|
+
* while Uai resources may remain.
|
|
1535
|
+
*/
|
|
1536
|
+
async function teardownBundledRuntimeForUninstall(purge: boolean): Promise<void> {
|
|
1537
|
+
if (process.platform !== "darwin") return;
|
|
1538
|
+
// The durable provider claim marker is the authority for whether Apple
|
|
1539
|
+
// state can exist at all: it is written BEFORE any apple task provisioning
|
|
1540
|
+
// (runtime-provider-state.ts). Absent marker = nothing was ever
|
|
1541
|
+
// provisioned = nothing to tear down. A malformed marker throws from the
|
|
1542
|
+
// reader — uncertainty keeps the uninstall retryable. With the marker
|
|
1543
|
+
// present, a corrupt bundle must THROW rather than skip (review
|
|
1544
|
+
// 2026-08-18 round 3: the old `.catch(() => null)` silently removed the
|
|
1545
|
+
// signed runtime while Apple state remained — the escape-route failure).
|
|
1546
|
+
const providerState = readRuntimeProviderState(
|
|
1547
|
+
runtimeProviderStatePath(env.dataDir),
|
|
1548
|
+
);
|
|
1549
|
+
if (providerState?.statefulProvider !== "apple-container") return;
|
|
1550
|
+
let bundle: Awaited<ReturnType<typeof resolveBundledRuntimeFromEnvironment>>;
|
|
1551
|
+
try {
|
|
1552
|
+
bundle = await resolveBundledRuntimeFromEnvironment();
|
|
1553
|
+
} catch (error) {
|
|
1554
|
+
throw new Error(
|
|
1555
|
+
"Apple container state exists but the bundled runtime failed " +
|
|
1556
|
+
`verification (${error instanceof Error ? error.message : String(error)}); ` +
|
|
1557
|
+
"uninstall was left retryable — repair or reinstall the host runtime, then retry",
|
|
1558
|
+
);
|
|
1559
|
+
}
|
|
1560
|
+
const cliPath = bundle?.containerCliPath;
|
|
1561
|
+
if (!cliPath) {
|
|
1562
|
+
throw new Error(
|
|
1563
|
+
"Apple container state exists but no bundled CLI is available to tear it down; uninstall was left retryable",
|
|
1564
|
+
);
|
|
1565
|
+
}
|
|
1566
|
+
const cli = (args: string[], timeoutMs: number) =>
|
|
1567
|
+
new Promise<{ code: number | null; stdout: string; stderr: string }>(
|
|
1568
|
+
(resolveCli) => {
|
|
1569
|
+
let stdout = "";
|
|
1570
|
+
let stderr = "";
|
|
1571
|
+
let settled = false;
|
|
1572
|
+
const child = spawn(cliPath, args, {
|
|
1573
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
1574
|
+
});
|
|
1575
|
+
const settle = (code: number | null): void => {
|
|
1576
|
+
if (settled) return;
|
|
1577
|
+
settled = true;
|
|
1578
|
+
clearTimeout(timer);
|
|
1579
|
+
resolveCli({ code, stdout, stderr });
|
|
1580
|
+
};
|
|
1581
|
+
const timer = setTimeout(() => {
|
|
1582
|
+
child.kill("SIGKILL");
|
|
1583
|
+
const backstop = setTimeout(() => settle(null), 1_000);
|
|
1584
|
+
backstop.unref();
|
|
1585
|
+
}, timeoutMs);
|
|
1586
|
+
timer.unref();
|
|
1587
|
+
child.stdout?.setEncoding("utf8");
|
|
1588
|
+
child.stderr?.setEncoding("utf8");
|
|
1589
|
+
child.stdout?.on("data", (chunk: string) => {
|
|
1590
|
+
stdout += chunk;
|
|
1591
|
+
});
|
|
1592
|
+
child.stderr?.on("data", (chunk: string) => {
|
|
1593
|
+
stderr += chunk;
|
|
1594
|
+
});
|
|
1595
|
+
child.on("error", () => settle(null));
|
|
1596
|
+
child.on("close", (code) => settle(code));
|
|
1597
|
+
},
|
|
1598
|
+
);
|
|
1599
|
+
const result = await teardownAppleRuntimeStateForUninstall({ cli, purge });
|
|
1600
|
+
for (const removed of result.removedContainers) {
|
|
1601
|
+
console.log(dim(`removed container ${removed}`));
|
|
1602
|
+
}
|
|
1603
|
+
for (const removed of result.removedImages) {
|
|
1604
|
+
console.log(dim(`removed image ${removed}`));
|
|
1605
|
+
}
|
|
1606
|
+
if (result.problems.length > 0) {
|
|
1607
|
+
throw new Error(
|
|
1608
|
+
`Apple container teardown is incomplete — uninstall was left retryable: ${result.problems.join("; ")}`,
|
|
1609
|
+
);
|
|
1610
|
+
}
|
|
415
1611
|
}
|
|
416
1612
|
|
|
417
1613
|
async function loadInstaller(): Promise<Installer> {
|
|
@@ -433,19 +1629,204 @@ async function loadInstaller(): Promise<Installer> {
|
|
|
433
1629
|
}
|
|
434
1630
|
}
|
|
435
1631
|
|
|
436
|
-
function
|
|
1632
|
+
async function reconcileDefinitionInstall(
|
|
1633
|
+
installer: Installer,
|
|
1634
|
+
installIntent?: string,
|
|
1635
|
+
): Promise<void> {
|
|
1636
|
+
const reconcile = (
|
|
1637
|
+
installer as Installer & {
|
|
1638
|
+
reconcileDefinitionInstall?: (installIntent?: string) => Promise<void>;
|
|
1639
|
+
}
|
|
1640
|
+
).reconcileDefinitionInstall;
|
|
1641
|
+
if (!reconcile) {
|
|
1642
|
+
throw new Error(
|
|
1643
|
+
"service installer cannot reconcile an interrupted definition update",
|
|
1644
|
+
);
|
|
1645
|
+
}
|
|
1646
|
+
await reconcile.call(installer, installIntent);
|
|
1647
|
+
}
|
|
1648
|
+
|
|
1649
|
+
async function completeDefinitionInstall(
|
|
1650
|
+
installer: Installer,
|
|
1651
|
+
installIntent?: string,
|
|
1652
|
+
): Promise<void> {
|
|
1653
|
+
const complete = (
|
|
1654
|
+
installer as Installer & {
|
|
1655
|
+
completeDefinitionInstall?: (installIntent?: string) => Promise<void>;
|
|
1656
|
+
}
|
|
1657
|
+
).completeDefinitionInstall;
|
|
1658
|
+
if (!complete) {
|
|
1659
|
+
throw new Error(
|
|
1660
|
+
"service installer cannot complete its durable definition update",
|
|
1661
|
+
);
|
|
1662
|
+
}
|
|
1663
|
+
await complete.call(installer, installIntent);
|
|
1664
|
+
}
|
|
1665
|
+
|
|
1666
|
+
async function prepareDefinitionInstallRollback(
|
|
1667
|
+
installer: Installer,
|
|
1668
|
+
installIntent: string,
|
|
1669
|
+
): Promise<void> {
|
|
1670
|
+
const prepare = (
|
|
1671
|
+
installer as Installer & {
|
|
1672
|
+
prepareDefinitionInstallRollback?: (installIntent: string) => Promise<void>;
|
|
1673
|
+
}
|
|
1674
|
+
).prepareDefinitionInstallRollback;
|
|
1675
|
+
if (!prepare) {
|
|
1676
|
+
throw new Error(
|
|
1677
|
+
"service installer cannot prepare durable definition rollback",
|
|
1678
|
+
);
|
|
1679
|
+
}
|
|
1680
|
+
await prepare.call(installer, installIntent);
|
|
1681
|
+
}
|
|
1682
|
+
|
|
1683
|
+
async function resumeDefinitionInstallRollback(
|
|
1684
|
+
installer: Installer,
|
|
1685
|
+
installIntent: string,
|
|
1686
|
+
): Promise<void> {
|
|
1687
|
+
const resume = (
|
|
1688
|
+
installer as Installer & {
|
|
1689
|
+
resumeDefinitionInstallRollback?: (installIntent: string) => Promise<void>;
|
|
1690
|
+
}
|
|
1691
|
+
).resumeDefinitionInstallRollback;
|
|
1692
|
+
if (!resume) {
|
|
1693
|
+
throw new Error(
|
|
1694
|
+
"service installer cannot resume durable definition rollback",
|
|
1695
|
+
);
|
|
1696
|
+
}
|
|
1697
|
+
await resume.call(installer, installIntent);
|
|
1698
|
+
}
|
|
1699
|
+
|
|
1700
|
+
async function serviceDefinitionState(
|
|
1701
|
+
installer: Installer,
|
|
1702
|
+
managedCommand: string,
|
|
1703
|
+
): Promise<ManagedServiceDefinitionState> {
|
|
1704
|
+
const inspect = (
|
|
1705
|
+
installer as Installer & {
|
|
1706
|
+
definitionState?: (
|
|
1707
|
+
managedCommand: string,
|
|
1708
|
+
) => Promise<ManagedServiceDefinitionState>;
|
|
1709
|
+
}
|
|
1710
|
+
).definitionState;
|
|
1711
|
+
if (!inspect) {
|
|
1712
|
+
throw new Error("service installer cannot inspect definition lifecycle state");
|
|
1713
|
+
}
|
|
1714
|
+
return inspect.call(installer, managedCommand);
|
|
1715
|
+
}
|
|
1716
|
+
|
|
1717
|
+
async function stopServiceForInstall(
|
|
1718
|
+
installer: Installer,
|
|
1719
|
+
installIntent: string,
|
|
1720
|
+
priorManager: ManagedServiceManagerState,
|
|
1721
|
+
): Promise<void> {
|
|
1722
|
+
const stop = (
|
|
1723
|
+
installer as Installer & {
|
|
1724
|
+
stopForInstall?: (
|
|
1725
|
+
installIntent: string,
|
|
1726
|
+
priorManager: ManagedServiceManagerState,
|
|
1727
|
+
) => Promise<void>;
|
|
1728
|
+
}
|
|
1729
|
+
).stopForInstall;
|
|
1730
|
+
if (!stop) {
|
|
1731
|
+
throw new Error("service installer cannot stop an install-owned service");
|
|
1732
|
+
}
|
|
1733
|
+
await stop.call(installer, installIntent, priorManager);
|
|
1734
|
+
}
|
|
1735
|
+
|
|
1736
|
+
async function resumeServiceForInstall(
|
|
1737
|
+
installer: Installer,
|
|
1738
|
+
installIntent: string,
|
|
1739
|
+
priorManager: ManagedServiceManagerState,
|
|
1740
|
+
): Promise<void> {
|
|
1741
|
+
const resume = (
|
|
1742
|
+
installer as Installer & {
|
|
1743
|
+
resumeServiceForInstall?: (
|
|
1744
|
+
installIntent: string,
|
|
1745
|
+
priorManager: ManagedServiceManagerState,
|
|
1746
|
+
) => Promise<void>;
|
|
1747
|
+
}
|
|
1748
|
+
).resumeServiceForInstall;
|
|
1749
|
+
if (!resume) {
|
|
1750
|
+
throw new Error("service installer cannot resume an install-owned service");
|
|
1751
|
+
}
|
|
1752
|
+
await resume.call(installer, installIntent, priorManager);
|
|
1753
|
+
}
|
|
1754
|
+
|
|
1755
|
+
async function reconcileInstallLifecycle(installer: Installer): Promise<void> {
|
|
1756
|
+
await reconcileManagedRuntimeInstallLifecycle(
|
|
1757
|
+
installLifecycleCallbacks(installer),
|
|
1758
|
+
);
|
|
1759
|
+
}
|
|
1760
|
+
|
|
1761
|
+
async function runInstallLifecycleMutation<T>(
|
|
1762
|
+
installer: Installer,
|
|
1763
|
+
mutation: () => Promise<T>,
|
|
1764
|
+
): Promise<T> {
|
|
1765
|
+
return runManagedRuntimeInstallLifecycleMutation({
|
|
1766
|
+
...installLifecycleCallbacks(installer),
|
|
1767
|
+
mutation,
|
|
1768
|
+
});
|
|
1769
|
+
}
|
|
1770
|
+
|
|
1771
|
+
function installLifecycleCallbacks(
|
|
1772
|
+
installer: Installer,
|
|
1773
|
+
): ReconcileManagedRuntimeInstallLifecycleOptions {
|
|
1774
|
+
return {
|
|
1775
|
+
reconcileDefinition: (installIntent) =>
|
|
1776
|
+
reconcileDefinitionInstall(installer, installIntent),
|
|
1777
|
+
completeDefinition: (installIntent) =>
|
|
1778
|
+
completeDefinitionInstall(installer, installIntent),
|
|
1779
|
+
prepareDefinitionRollback: (installIntent) =>
|
|
1780
|
+
prepareDefinitionInstallRollback(installer, installIntent),
|
|
1781
|
+
resumeDefinitionRollback: (installIntent) =>
|
|
1782
|
+
resumeDefinitionInstallRollback(installer, installIntent),
|
|
1783
|
+
resumeStoppedService: (installIntent, priorManager) =>
|
|
1784
|
+
resumeServiceForInstall(installer, installIntent, priorManager),
|
|
1785
|
+
};
|
|
1786
|
+
}
|
|
1787
|
+
|
|
1788
|
+
function installContext(
|
|
1789
|
+
dryRun: boolean,
|
|
1790
|
+
startOnInstall = true,
|
|
1791
|
+
definitionInstallIntent?: { nonce: string; ownerPid: number },
|
|
1792
|
+
): InstallContext {
|
|
437
1793
|
const run = resolveRunCommand();
|
|
438
|
-
|
|
1794
|
+
const currentPath = process.env.PATH ?? "";
|
|
1795
|
+
const managedBin = pathForManagedCommand(run.execPath);
|
|
1796
|
+
const envPath = managedBin
|
|
1797
|
+
? [managedBin, ...currentPath.split(":").filter((entry) => entry !== managedBin)].join(":")
|
|
1798
|
+
: currentPath;
|
|
1799
|
+
const context: InstallContext & {
|
|
1800
|
+
definitionInstallIntent?: { nonce: string; ownerPid: number };
|
|
1801
|
+
} = {
|
|
1802
|
+
execPath: run.execPath,
|
|
1803
|
+
args: run.args,
|
|
1804
|
+
cwd: uaiHome(),
|
|
1805
|
+
dryRun,
|
|
1806
|
+
envPath,
|
|
1807
|
+
startOnInstall,
|
|
1808
|
+
definitionInstallIntent,
|
|
1809
|
+
};
|
|
1810
|
+
return context;
|
|
439
1811
|
}
|
|
440
1812
|
|
|
441
1813
|
/** The command that runs `uai-host run`: the installed binary if on PATH,
|
|
442
1814
|
* else `pnpm host-agent` from the repo (dev). */
|
|
443
1815
|
function resolveRunCommand(): { execPath: string; args: string[] } {
|
|
1816
|
+
const managed = managedRuntimePaths();
|
|
1817
|
+
if (existsSync(managed.current) && existsSync(managed.shim)) {
|
|
1818
|
+
return { execPath: managed.shim, args: ["run"] };
|
|
1819
|
+
}
|
|
444
1820
|
const uaiHost = which("uai-host");
|
|
445
1821
|
if (uaiHost) return { execPath: uaiHost, args: ["run"] };
|
|
446
1822
|
return { execPath: which("pnpm") ?? "pnpm", args: ["host-agent"] };
|
|
447
1823
|
}
|
|
448
1824
|
|
|
1825
|
+
function pathForManagedCommand(execPath: string): string | null {
|
|
1826
|
+
const managed = managedRuntimePaths();
|
|
1827
|
+
return resolve(execPath) === managed.shim ? dirname(managed.shim) : null;
|
|
1828
|
+
}
|
|
1829
|
+
|
|
449
1830
|
function which(cmd: string): string | null {
|
|
450
1831
|
const finder = process.platform === "win32" ? "where" : "which";
|
|
451
1832
|
const res = spawnSync(finder, [cmd], { encoding: "utf8" });
|
|
@@ -489,14 +1870,25 @@ function printHelp(): void {
|
|
|
489
1870
|
|
|
490
1871
|
run run the service in the foreground (debug)
|
|
491
1872
|
install [--dry-run] install as a per-user service for this OS
|
|
492
|
-
|
|
1873
|
+
[--no-start] register the service without launching it
|
|
1874
|
+
uninstall [--purge] remove service/runtime; preserve ~/.uai unless --purge
|
|
493
1875
|
start | stop control the installed service
|
|
494
1876
|
restart stop + start
|
|
1877
|
+
update accept signed updates (also resumes after rollback)
|
|
1878
|
+
rollback reactivate previous runtime; pause automatic updates
|
|
1879
|
+
uai-host-recover rollback
|
|
1880
|
+
roll back via previous runtime if current cannot start
|
|
1881
|
+
enrollment status report whether this machine has a complete identity
|
|
495
1882
|
status connection, service info, active tasks (same as the UI)
|
|
496
1883
|
logs [--follow] tail the service log
|
|
1884
|
+
runtime recheck probe Docker again and refresh cloud capabilities
|
|
497
1885
|
setup --cloud <wss-url> --enroll <token>
|
|
498
1886
|
claim this machine via an enrollment token from the web
|
|
499
|
-
app (mints + writes the host credential)
|
|
1887
|
+
app (mints + writes the host credential); on an
|
|
1888
|
+
already-enrolled machine, attaches the token's workspace
|
|
1889
|
+
enroll <token> [--cloud <wss-url>]
|
|
1890
|
+
first-time setup, or attach this machine to another
|
|
1891
|
+
workspace (token minted on that workspace's Hosts page)
|
|
500
1892
|
pair <token> store a host token directly (manual fallback)
|
|
501
1893
|
open open the local UI in your browser
|
|
502
1894
|
`);
|