@moondeck/station 0.0.1 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (330) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +149 -2
  3. package/bin/uai-host.mjs +130 -0
  4. package/db/migrations/0000_host_tasks.sql +12 -0
  5. package/db/migrations/0001_host_ui.sql +11 -0
  6. package/db/migrations/0002_host_github_tokens.sql +8 -0
  7. package/db/migrations/0003_host_ssh_keys.sql +8 -0
  8. package/db/migrations/0004_host_owner_name.sql +1 -0
  9. package/db/migrations/0005_host_env_vars.sql +5 -0
  10. package/db/migrations/0006_host_project_env.sql +9 -0
  11. package/db/migrations/0007_host_github_token_kind.sql +7 -0
  12. package/db/migrations/0008_host_mcp_connections.sql +18 -0
  13. package/db/migrations/0009_host_agent_sessions.sql +15 -0
  14. package/db/migrations/0010_host_engine_accounts.sql +13 -0
  15. package/db/migrations/0011_codex_thread_id.sql +5 -0
  16. package/db/migrations/0012_agent_session_attach_key.sql +4 -0
  17. package/db/migrations/0013_github_credential_generations.sql +11 -0
  18. package/db/migrations/0014_host_inventory_event_index.sql +1 -0
  19. package/db/migrations/0015_host_settings.sql +9 -0
  20. package/db/migrations/0016_task_environment.sql +2 -0
  21. package/db/migrations/0017_engine_account_scope.sql +7 -0
  22. package/db/migrations/0018_engine_account_position.sql +3 -0
  23. package/db/migrations/0019_engine_account_store_ref.sql +3 -0
  24. package/db/migrations/0020_user_credential_store_ref.sql +6 -0
  25. package/db/migrations/0021_project_env_store_ref.sql +3 -0
  26. package/db/migrations/0022_task_project_ids.sql +5 -0
  27. package/db/migrations/0023_project_env_org.sql +4 -0
  28. package/db/migrations/0024_task_env_mode.sql +9 -0
  29. package/db/migrations/0025_project_env_authority.sql +147 -0
  30. package/db/migrations/0026_project_env_replica.sql +150 -0
  31. package/db/migrations/0027_cloud_credential_foundation.sql +51 -0
  32. package/db/migrations/0028_leased_engine_runtimes.sql +11 -0
  33. package/db/migrations/0029_task_shared_files_mode.sql +5 -0
  34. package/db/migrations/0030_credential_cleanup_restops.sql +12 -0
  35. package/db/migrations/0031_first_turn_receipts.sql +9 -0
  36. package/db/migrations/meta/_journal.json +230 -0
  37. package/db/schema.ts +871 -0
  38. package/images/standard/Dockerfile +347 -0
  39. package/images/standard/README.md +167 -0
  40. package/images/standard/container/code-server-settings.json +63 -0
  41. package/images/standard/container/corepack-version +1 -0
  42. package/images/standard/container/uai-init +704 -0
  43. package/images/standard/container/uai-materialize-runtimes +1527 -0
  44. package/images/standard/container/uai-task-forwarder.mjs +303 -0
  45. package/images/standard/tool-versions +2 -0
  46. package/lib/__fixtures__/skill-index/SKILL.md +10 -0
  47. package/lib/__fixtures__/skill-index/skills/accessibility-checklist.md +7 -0
  48. package/lib/__fixtures__/skill-index/skills/brand-voice.md +9 -0
  49. package/lib/__fixtures__/skill-index/skills/design-tokens.md +9 -0
  50. package/lib/agent-cli.ts +1507 -0
  51. package/lib/agent-errors.ts +22 -0
  52. package/lib/agent.ts +672 -0
  53. package/lib/agents/attach-compatibility.ts +51 -0
  54. package/lib/agents/claude.ts +918 -0
  55. package/lib/agents/codex-token-usage.fixture.json +130 -0
  56. package/lib/agents/codex.ts +1392 -0
  57. package/lib/agents/cursor-period-usage.ts +253 -0
  58. package/lib/agents/cursor.ts +562 -0
  59. package/lib/agents/dispatch.ts +78 -0
  60. package/lib/agents/durable-proc.ts +334 -0
  61. package/lib/agents/factory.ts +57 -0
  62. package/lib/agents/grok.ts +381 -0
  63. package/lib/agents/kimi.ts +329 -0
  64. package/lib/agents/machine-durable.ts +604 -0
  65. package/lib/agents/mock.ts +133 -0
  66. package/lib/agents/mode.ts +64 -0
  67. package/lib/agents/opencode.ts +508 -0
  68. package/lib/agents/output-source.ts +3 -0
  69. package/lib/agents/proc.ts +221 -0
  70. package/lib/agents/rate-limit.ts +337 -0
  71. package/lib/agents/registry.ts +182 -0
  72. package/lib/agents/session-state.ts +197 -0
  73. package/lib/agents/skill-index-preamble.fixture.ts +26 -0
  74. package/lib/agents/transport.ts +741 -0
  75. package/lib/agents/types.ts +254 -0
  76. package/lib/agents/uai.ts +106 -0
  77. package/lib/agents/usage.ts +44 -0
  78. package/lib/apple-one-off.ts +277 -0
  79. package/lib/apple-runtime-recycle.ts +237 -0
  80. package/lib/apple-uninstall-teardown.ts +224 -0
  81. package/lib/assignment-evidence.ts +94 -0
  82. package/lib/attachments.ts +48 -0
  83. package/lib/browser-testing.ts +1537 -0
  84. package/lib/capture-flow.ts +332 -0
  85. package/lib/cloud-credential-cleanup-adapter.ts +123 -0
  86. package/lib/cloud-credential-host.ts +512 -0
  87. package/lib/cloud-credential-leases.ts +2587 -0
  88. package/lib/cloud-credential-runtime.ts +1357 -0
  89. package/lib/cloud-state.ts +56 -0
  90. package/lib/codex-auth.ts +456 -0
  91. package/lib/command-db.ts +310 -0
  92. package/lib/compose-network-recovery.ts +283 -0
  93. package/lib/connection-secret-redaction.ts +25 -0
  94. package/lib/container-runtime.ts +1470 -0
  95. package/lib/credential-cleanup-inventory.ts +490 -0
  96. package/lib/credential-consumers/engine-anchor.ts +1880 -0
  97. package/lib/credential-consumers/engines.ts +797 -0
  98. package/lib/credential-consumers/files.ts +934 -0
  99. package/lib/credential-consumers/github.ts +702 -0
  100. package/lib/credential-consumers/mcp.ts +389 -0
  101. package/lib/credential-consumers/task-files.ts +127 -0
  102. package/lib/credential-consumers/task-secrets.ts +10 -0
  103. package/lib/credential-store-pickup.ts +149 -0
  104. package/lib/credential-store-scope.ts +112 -0
  105. package/lib/credential-store.ts +932 -0
  106. package/lib/credentials-activation-authority.ts +709 -0
  107. package/lib/credentials-purge.ts +1006 -0
  108. package/lib/custom-connection-capture.ts +395 -0
  109. package/lib/custom-connection-launch.ts +384 -0
  110. package/lib/custom-connection-runtime.ts +141 -0
  111. package/lib/db.ts +80 -0
  112. package/lib/disk-pressure.ts +42 -0
  113. package/lib/docker-exec.ts +156 -0
  114. package/lib/engine-accounts.ts +1970 -0
  115. package/lib/engine-connect.ts +321 -0
  116. package/lib/engine-credential-env.ts +52 -0
  117. package/lib/engine-login.ts +2933 -0
  118. package/lib/engines.ts +1342 -0
  119. package/lib/enrollment-state.ts +251 -0
  120. package/lib/env-file.ts +246 -0
  121. package/lib/env-schema.ts +937 -0
  122. package/lib/env.ts +195 -0
  123. package/lib/first-turn-receipts.ts +62 -0
  124. package/lib/fs-aliases.ts +49 -0
  125. package/lib/git-clone-credentials.ts +334 -0
  126. package/lib/git-diff.ts +530 -0
  127. package/lib/git-identity.ts +715 -0
  128. package/lib/github-git-auth.ts +207 -0
  129. package/lib/github-origin.ts +28 -0
  130. package/lib/github-tokens.ts +2439 -0
  131. package/lib/host-cloud-url.ts +62 -0
  132. package/lib/host-config.ts +279 -0
  133. package/lib/host-env.ts +424 -0
  134. package/lib/host-logs.ts +965 -0
  135. package/lib/internal-connections.ts +5 -0
  136. package/lib/keyed-promise-tail.ts +23 -0
  137. package/lib/launch-mode.ts +25 -0
  138. package/lib/lease.ts +239 -0
  139. package/lib/legacy-host.ts +769 -0
  140. package/lib/legacy-runtime-retirement.ts +424 -0
  141. package/lib/legacy-runtime-v1.fixture.ts +627 -0
  142. package/lib/machine-exec.ts +238 -0
  143. package/lib/machine-gateway-tunnel.ts +344 -0
  144. package/lib/machine-keys.ts +65 -0
  145. package/lib/machine-project-env-loader.ts +152 -0
  146. package/lib/machine-provider-aws.ts +923 -0
  147. package/lib/machine-provider-local.ts +338 -0
  148. package/lib/machine-provider.ts +90 -0
  149. package/lib/managed-activation-watcher.ts +72 -0
  150. package/lib/managed-install-owner-watcher.ts +55 -0
  151. package/lib/managed-operation-drain.ts +57 -0
  152. package/lib/managed-runtime.ts +4296 -0
  153. package/lib/managed-update-scheduler.ts +125 -0
  154. package/lib/mcp-capture.ts +865 -0
  155. package/lib/mcp-config-lock.ts +2 -0
  156. package/lib/mcp-connections.ts +959 -0
  157. package/lib/mcp-gateway.ts +2594 -0
  158. package/lib/obs.ts +512 -0
  159. package/lib/openrouter-budget.ts +247 -0
  160. package/lib/orchestrator.ts +11409 -0
  161. package/lib/person-capture.ts +327 -0
  162. package/lib/preview-ports.ts +85 -0
  163. package/lib/preview-sidecar.ts +331 -0
  164. package/lib/project-env-authority.ts +1319 -0
  165. package/lib/project-env-feature.ts +74 -0
  166. package/lib/project-env-legacy-tags.ts +130 -0
  167. package/lib/project-env-loader.ts +242 -0
  168. package/lib/project-env-replica.ts +2094 -0
  169. package/lib/project-env-schema-capture.ts +342 -0
  170. package/lib/project-env-staging.ts +148 -0
  171. package/lib/project-env-task-policy.ts +176 -0
  172. package/lib/project-env-task-replica.ts +1752 -0
  173. package/lib/release-feed.ts +61 -0
  174. package/lib/release-manifest.ts +710 -0
  175. package/lib/release-trust.ts +46 -0
  176. package/lib/repo-clone.ts +38 -0
  177. package/lib/routing/shadow.ts +261 -0
  178. package/lib/runner-boot.ts +10 -0
  179. package/lib/runtime-activation-tail.ts +279 -0
  180. package/lib/runtime-archive.ts +1216 -0
  181. package/lib/runtime-authority.ts +166 -0
  182. package/lib/runtime-guard.ts +36 -0
  183. package/lib/runtime-provider-state.ts +169 -0
  184. package/lib/runtime-reconnect.ts +86 -0
  185. package/lib/runtime-state.ts +1033 -0
  186. package/lib/safe-http.ts +364 -0
  187. package/lib/secrets-proxy/address.ts +95 -0
  188. package/lib/secrets-proxy/audit.ts +30 -0
  189. package/lib/secrets-proxy/ca-store.ts +200 -0
  190. package/lib/secrets-proxy/ca.ts +470 -0
  191. package/lib/secrets-proxy/container-steps.ts +138 -0
  192. package/lib/secrets-proxy/der.ts +273 -0
  193. package/lib/secrets-proxy/engine.ts +1633 -0
  194. package/lib/secrets-proxy/host.ts +93 -0
  195. package/lib/secrets-proxy/hostname.ts +57 -0
  196. package/lib/secrets-proxy/placeholders.ts +196 -0
  197. package/lib/secrets-proxy/policy.ts +150 -0
  198. package/lib/secrets-proxy/readiness.ts +148 -0
  199. package/lib/secrets-proxy/runtime.ts +582 -0
  200. package/lib/secrets-proxy/scrub.ts +559 -0
  201. package/lib/secrets-proxy/sidecar-view.ts +139 -0
  202. package/lib/secrets-proxy/task-scripts.ts +262 -0
  203. package/lib/secrets-proxy/task-trust.ts +605 -0
  204. package/lib/secrets-proxy/trust-watch.ts +80 -0
  205. package/lib/secrets.ts +71 -0
  206. package/lib/shared-files-s3.ts +976 -0
  207. package/lib/shared-files.ts +156 -0
  208. package/lib/sidecar-assign.ts +49 -0
  209. package/lib/sidecar-guard.ts +58 -0
  210. package/lib/sidecar-resume.ts +178 -0
  211. package/lib/sidecar.ts +69 -0
  212. package/lib/signin-box-credentials.ts +465 -0
  213. package/lib/signin-box.ts +114 -0
  214. package/lib/skill-index-example.fixture.ts +56 -0
  215. package/lib/skill-layout.ts +417 -0
  216. package/lib/skills.ts +489 -0
  217. package/lib/ssh.ts +401 -0
  218. package/lib/standard-image-cleanup.ts +117 -0
  219. package/lib/standard-image-pin.ts +207 -0
  220. package/lib/standard-image.ts +1697 -0
  221. package/lib/stopped-task-status-queue.ts +120 -0
  222. package/lib/task-container-cli.ts +269 -0
  223. package/lib/task-credential-cleanup.ts +752 -0
  224. package/lib/task-diff-sandbox.ts +464 -0
  225. package/lib/task-diff.ts +91 -0
  226. package/lib/task-env-forwarder.ts +228 -0
  227. package/lib/task-env-source.ts +249 -0
  228. package/lib/task-environment/anchor.ts +31 -0
  229. package/lib/task-environment/apple-container.ts +860 -0
  230. package/lib/task-environment/docker.ts +1344 -0
  231. package/lib/task-environment/index.ts +466 -0
  232. package/lib/task-environment/legacy-adoption.ts +467 -0
  233. package/lib/task-environment/machine-environment.ts +61 -0
  234. package/lib/task-environment/machine-task-up.ts +1227 -0
  235. package/lib/task-environment/machine.ts +974 -0
  236. package/lib/task-environment/registry.ts +59 -0
  237. package/lib/task-environment/self-identity.ts +20 -0
  238. package/lib/task-environment/self.ts +566 -0
  239. package/lib/task-environment/types.ts +642 -0
  240. package/lib/task-environment/workspace-files.ts +685 -0
  241. package/lib/task-environment/workspace-runtime.fixture.ts +50 -0
  242. package/lib/task-files-machine-retirement-outbox.ts +592 -0
  243. package/lib/task-files-machine-retirement.ts +116 -0
  244. package/lib/task-files-readiness.ts +127 -0
  245. package/lib/task-gateway-port.ts +22 -0
  246. package/lib/task-gateway-protocol.ts +190 -0
  247. package/lib/task-gateway-recovery.ts +47 -0
  248. package/lib/task-gateway-state.ts +512 -0
  249. package/lib/task-identity.ts +19 -0
  250. package/lib/task-inventory.ts +682 -0
  251. package/lib/task-machine-credentials-activation.ts +95 -0
  252. package/lib/task-machine-swap-safety.ts +729 -0
  253. package/lib/task-person-credentials.ts +76 -0
  254. package/lib/task-project-ops-host.ts +147 -0
  255. package/lib/task-project-ops.ts +1062 -0
  256. package/lib/task-secret-staging.ts +16 -0
  257. package/lib/task-status.ts +46 -0
  258. package/lib/task-token.ts +46 -0
  259. package/lib/token-broker-adopt.ts +344 -0
  260. package/lib/token-broker-lambda.ts +105 -0
  261. package/lib/token-broker-local.ts +392 -0
  262. package/lib/token-broker-revision.ts +310 -0
  263. package/lib/token-broker-ssm.ts +525 -0
  264. package/lib/token-broker.ts +218 -0
  265. package/lib/token-lanes/openai-oauth.ts +123 -0
  266. package/lib/token-lanes/registry.ts +45 -0
  267. package/lib/token-lanes/types.ts +163 -0
  268. package/lib/token-lanes/xai-oauth.ts +165 -0
  269. package/lib/transcript.ts +128 -0
  270. package/lib/tunnel-registry.ts +220 -0
  271. package/lib/tunnel-runtime.ts +235 -0
  272. package/lib/ulid.ts +16 -0
  273. package/lib/user-bus-env.ts +116 -0
  274. package/lib/user-credentials-store.ts +435 -0
  275. package/lib/vnode-pressure.ts +142 -0
  276. package/package.json +115 -8
  277. package/runner/runner.mjs +442 -0
  278. package/scripts/agent/_common.sh +633 -0
  279. package/scripts/agent/env-hook.sh +626 -0
  280. package/scripts/agent/moondeck-cli.sh +5 -0
  281. package/scripts/agent/task-down.sh +291 -0
  282. package/scripts/agent/task-status.sh +70 -0
  283. package/scripts/agent/task-up.sh +3761 -0
  284. package/scripts/agent/uai-env-exec.sh +26 -0
  285. package/scripts/install/darwin.ts +1048 -0
  286. package/scripts/install/linux.ts +958 -0
  287. package/scripts/install/types.ts +78 -0
  288. package/scripts/install/util.ts +254 -0
  289. package/scripts/install/win.ts +143 -0
  290. package/src/activation-respawn.ts +155 -0
  291. package/src/apple-tunnel-route.ts +104 -0
  292. package/src/bridge-diagnostics.ts +39 -0
  293. package/src/capture-wire.ts +102 -0
  294. package/src/cli.ts +2585 -0
  295. package/src/cloud-credentials.ts +2144 -0
  296. package/src/cloud-origins.ts +23 -0
  297. package/src/credential-location.ts +248 -0
  298. package/src/credential-wire-validation.ts +25 -0
  299. package/src/custom-connections.ts +237 -0
  300. package/src/engine-account-paths.ts +11 -0
  301. package/src/engine-models.ts +39 -0
  302. package/src/engine-usage.ts +104 -0
  303. package/src/enrollment-next-steps.ts +24 -0
  304. package/src/event-outbox.ts +185 -0
  305. package/src/index.ts +2907 -0
  306. package/src/load-env.ts +97 -0
  307. package/src/machine-tunnel-route.ts +156 -0
  308. package/src/main.ts +6363 -0
  309. package/src/never-proxied-domains.ts +31 -0
  310. package/src/outbound-frame.ts +31 -0
  311. package/src/paths.ts +81 -0
  312. package/src/project-env-latest.ts +130 -0
  313. package/src/project-env-policy.ts +396 -0
  314. package/src/project-env-wording.ts +53 -0
  315. package/src/protocol.ts +5550 -0
  316. package/src/runtime-bootstrap.ts +165 -0
  317. package/src/station-identity.ts +96 -0
  318. package/src/task-env-names.ts +28 -0
  319. package/src/task-secrets.ts +75 -0
  320. package/src/tunnel-dial.ts +102 -0
  321. package/src/ui/server.ts +852 -0
  322. package/src/ui/types.ts +173 -0
  323. package/src/workspace-path-policy.ts +41 -0
  324. package/ui/app.js +1102 -0
  325. package/ui/index.html +90 -0
  326. package/ui/style.css +685 -0
  327. package/ui/uai-favicon.svg +21 -0
  328. package/ui/uai-logo-black.svg +9 -0
  329. package/ui/uai-wheel.svg +9 -0
  330. package/bin/moondeck-station.mjs +0 -3
package/src/cli.ts ADDED
@@ -0,0 +1,2585 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * `uai-host` CLI (ADR-028). Hand-rolled argv switch — no argparser dependency.
4
+ *
5
+ * run | install | uninstall | start | stop | restart
6
+ * status | logs [--follow] | runtime recheck
7
+ * enroll <token> | pair <token> | open
8
+ *
9
+ * `run` boots the service in-process; the install/start/... family dispatches
10
+ * to the per-OS installer (scripts/install/{darwin,linux,win}.ts) by
11
+ * process.platform. `status`/`open` read the local UI's own JSON API.
12
+ */
13
+
14
+ // MUST be first: load-env populates process.env from .env.local before any
15
+ // import below pulls in lib/env, which freezes its config snapshot (uaiHome,
16
+ // dataDir, hostKeyPath, …) at module-eval. `./paths` imports lib/env, so
17
+ // without this the `run` path boots before UAI_DATA_DIR/UAI_HOME are loaded and
18
+ // resolves the data dir — and the master key that decrypts every secret store
19
+ // — from the wrong base. (main.ts loads it too, but `await import("./main")`
20
+ // runs far too late.)
21
+ import "./load-env";
22
+
23
+ import {
24
+ spawn,
25
+ spawnSync,
26
+ type SpawnSyncReturns,
27
+ } from "node:child_process";
28
+ import { createHash, randomBytes } from "node:crypto";
29
+ import Database from "better-sqlite3";
30
+ import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
31
+ import { homedir, hostname } from "node:os";
32
+ import { dirname, join, resolve } from "node:path";
33
+
34
+ import { parse as parseDotenv } from "dotenv";
35
+ import { ulid } from "ulid";
36
+
37
+ import {
38
+ commitPendingEnrollment,
39
+ discardPendingEnrollment,
40
+ enrollmentTokenSha256,
41
+ ensurePendingEnrollment,
42
+ pendingEnrollmentMatches,
43
+ pendingEnrollmentPath,
44
+ removeCommittedPendingEnrollment,
45
+ writeEnvValuesAtomic,
46
+ type PendingEnrollment,
47
+ } from "../lib/enrollment-state";
48
+ import { parseHostCloudEndpoint } from "../lib/host-cloud-url";
49
+ import { env } from "../lib/env";
50
+ import {
51
+ persistTelemetryDisclosure,
52
+ telemetryNoticeIfActive,
53
+ } from "../lib/obs";
54
+ import {
55
+ abortManagedRuntimeInstall,
56
+ acknowledgeManagedRuntimeActivation,
57
+ acknowledgeManagedRuntimeActivationWithCapabilityFloor,
58
+ beginManagedRuntimeInstall,
59
+ completeManagedRuntimeInstall,
60
+ installManagedRuntimeArchive,
61
+ managedRuntimeInstallStartupState,
62
+ managedRuntimePaths,
63
+ nativeReleasePlatform,
64
+ reconcileManagedRuntimeActivationWithCapabilityFloor,
65
+ reconcileManagedRuntimeInstallLifecycle,
66
+ reconcileManagedRuntimeUninstall,
67
+ removeManagedRuntime,
68
+ resolveBundledRuntimeFromEnvironment,
69
+ rollbackManagedRuntime,
70
+ runManagedRuntimeInstallIntentMutation,
71
+ runManagedRuntimeInstallLifecycleMutation,
72
+ stopManagedRuntimeServiceForInstall,
73
+ updateManagedRuntime,
74
+ validateManagedRuntimeSelection,
75
+ validateProvisionalManagedRuntimeSelection,
76
+ MANAGED_UPDATE_RESTART_EXIT_CODE,
77
+ type ManagedServiceDefinitionState,
78
+ type ManagedServiceManagerState,
79
+ type ReconcileManagedRuntimeInstallLifecycleOptions,
80
+ } from "../lib/managed-runtime";
81
+ import { PROJECT_ENV_BINDINGS_RUNTIME_CAPABILITY } from "../lib/project-env-feature";
82
+ import { teardownAppleRuntimeStateForUninstall } from "../lib/apple-uninstall-teardown";
83
+ import {
84
+ readRuntimeProviderState,
85
+ runtimeProviderStatePath,
86
+ } from "../lib/runtime-provider-state";
87
+ import { ManagedActivationWatcher } from "../lib/managed-activation-watcher";
88
+ import { ManagedInstallOwnerWatcher } from "../lib/managed-install-owner-watcher";
89
+ import { ManagedUpdateScheduler } from "../lib/managed-update-scheduler";
90
+ import { PINNED_RELEASE_MANIFEST_KEYS } from "../lib/release-trust";
91
+ import { sidecarMode } from "../lib/sidecar";
92
+ import { systemUserBusProbe } from "../lib/user-bus-env";
93
+ import { LEGACY_HOST, STATION, STATION_DEFAULT_CLOUD_URL } from "./station-identity";
94
+ import { enrollmentNextSteps } from "./enrollment-next-steps";
95
+ import {
96
+ inspectLegacyHost,
97
+ legacyHostRunningReason,
98
+ packageNameOfCommand,
99
+ uninstallLegacyHost,
100
+ type LegacyHostSeams,
101
+ } from "../lib/legacy-host";
102
+
103
+ /**
104
+ * `run`/`install`/`start` refused because the old host runs here: EX_CONFIG.
105
+ * Never 3, which a sidecar exits with when its lease is denied (main.ts) and
106
+ * its unit treats as "stay down" (RestartPreventExitStatus=3).
107
+ */
108
+ const LEGACY_HOST_RUNNING_EXIT_CODE = 78;
109
+ import {
110
+ hasActiveHostTasks,
111
+ hasInFlightHostTaskLifecycle,
112
+ hasNonTerminalHostTasks,
113
+ } from "../lib/runtime-state";
114
+ import {
115
+ ENROLLMENT_REPLAY_PROTOCOL_HEADER,
116
+ ENROLLMENT_REPLAY_PROTOCOL_VERSION,
117
+ } from "./protocol";
118
+ import {
119
+ envLocalPath,
120
+ packageVersion,
121
+ readPersistedHostId,
122
+ serviceLogPath,
123
+ uaiHome,
124
+ uiPortFilePath,
125
+ } from "./paths";
126
+ import {
127
+ CloudResponse,
128
+ ContainerRuntimeResponse,
129
+ StatusResponse,
130
+ TasksResponse,
131
+ } from "./ui/types";
132
+ import type { InstallContext, Installer } from "../scripts/install/types";
133
+ import { PAYLOAD_AMI_REGIONS } from "../lib/machine-provider-aws";
134
+
135
+ // --- tiny ANSI (no chalk) ---------------------------------------------------
136
+
137
+ const useColor = Boolean(process.stdout.isTTY) && !process.env.NO_COLOR;
138
+ const paint =
139
+ (code: number) =>
140
+ (s: string): string =>
141
+ useColor ? `\x1b[${code}m${s}\x1b[0m` : s;
142
+ const bold = paint(1);
143
+ const dim = paint(2);
144
+ const red = paint(31);
145
+ const green = paint(32);
146
+ const yellow = paint(33);
147
+ const cyan = paint(36);
148
+
149
+ // --- dispatch ---------------------------------------------------------------
150
+
151
+ async function main(): Promise<void> {
152
+ const [cmd, ...rest] = process.argv.slice(2);
153
+ const dryRun = rest.includes("--dry-run");
154
+ const follow = rest.includes("--follow") || rest.includes("-f");
155
+ switch (cmd) {
156
+ case "run":
157
+ return cmdRun(rest);
158
+ case "status":
159
+ return cmdStatus();
160
+ case "open":
161
+ return cmdOpen();
162
+ case "logs":
163
+ return cmdLogs(follow);
164
+ case "runtime":
165
+ return cmdRuntime(rest);
166
+ case "tune":
167
+ return cmdTune();
168
+ case "aws":
169
+ if (rest[0] === "setup") return cmdAwsSetup(rest.slice(1));
170
+ console.error(red("usage: moondeck-station aws setup [--region <region>] [--rotate]"));
171
+ process.exitCode = 1;
172
+ return;
173
+ case "update":
174
+ return cmdUpdate(rest);
175
+ case "rollback":
176
+ return cmdRollback(rest);
177
+ case "enrollment":
178
+ return cmdEnrollmentStatus(rest);
179
+ case "setup":
180
+ return cmdSetup(rest);
181
+ case "enroll":
182
+ return cmdEnroll(rest);
183
+ case "pair":
184
+ return cmdPair(rest[0]);
185
+ case "install":
186
+ if (!rest.includes("--dry-run") && (await refuseWhileLegacyHostRuns())) return;
187
+ return cmdInstall(rest);
188
+ case "start":
189
+ case "restart":
190
+ if (!dryRun && (await refuseWhileLegacyHostRuns())) return;
191
+ return cmdInstaller(cmd, dryRun);
192
+ case "stop":
193
+ return cmdInstaller(cmd, dryRun);
194
+ case "uninstall-legacy":
195
+ return cmdUninstallLegacy(rest);
196
+ case "uninstall":
197
+ return cmdUninstall(rest);
198
+ case "version":
199
+ case "-v":
200
+ case "--version":
201
+ // A bare version line: the sidecar's prepare step compares it with the
202
+ // pinned runner version, and any other flag prints the usage instead.
203
+ console.log(packageVersion());
204
+ return;
205
+ case undefined:
206
+ case "help":
207
+ case "-h":
208
+ case "--help":
209
+ return printHelp();
210
+ default:
211
+ console.error(red(`unknown command: ${cmd}`));
212
+ printHelp();
213
+ process.exitCode = 1;
214
+ }
215
+ }
216
+
217
+ // --- run --------------------------------------------------------------------
218
+
219
+ function timestampServiceLogs(): void {
220
+ const stamp = (): string => new Date().toISOString().slice(0, 19);
221
+ for (const level of ["log", "warn", "error"] as const) {
222
+ const original = console[level].bind(console);
223
+ console[level] = (...args: unknown[]) => original(`[${stamp()}]`, ...args);
224
+ }
225
+ }
226
+
227
+ async function cmdRun(rest: string[] = []): Promise<void> {
228
+ // ADR-133 sidecar mode: `uai-host run --task <id>` on the task's own
229
+ // machine. The flag becomes the environment the rest of the process asks
230
+ // (lib/sidecar.ts) — set before main.ts loads, since its boot reads it.
231
+ const sidecarTask = flagValue(rest, "--task");
232
+ if (sidecarTask) process.env.UAI_SIDECAR_TASK = sidecarTask;
233
+ // ADR-136 sign-in box: `uai-host run --signin-box` on the small always-on
234
+ // machine behind the "Uai Cloud" row. Same package, one flag
235
+ // (lib/signin-box.ts); set before main.ts loads for the same reason.
236
+ if (rest.includes("--signin-box")) process.env.UAI_SIGNIN_BOX = "1";
237
+ // A station never runs beside the old host (plan §4). Task-machine and
238
+ // warm-pool sidecars (UAI_SIDECAR_POOL) and the sign-in box run on cloud
239
+ // machines, never beside one.
240
+ if (!sidecarMode() && !rest.includes("--signin-box") && (await refuseWhileLegacyHostRuns())) {
241
+ return;
242
+ }
243
+ // Timestamped service logs: the ADR-106 live smoke burned an hour dating
244
+ // undated scrollback. Applies to the service process only, not one-shot CLI
245
+ // commands.
246
+ timestampServiceLogs();
247
+ let installState = await managedRuntimeInstallStartupState();
248
+ if (installState === "attested") {
249
+ // Attestation is the durable commit decision. A supervisor restart after
250
+ // that point finishes the service/command journals; it must never enter the
251
+ // pre-attestation recovery gate or restore the old installation.
252
+ await reconcileInstallLifecycle(await loadInstaller());
253
+ installState = "none";
254
+ }
255
+ if (installState === "stale") {
256
+ const outcome = await waitForManagedInstaller();
257
+ if (outcome === "stopped") return;
258
+ if (outcome === "restart") {
259
+ process.exitCode = MANAGED_UPDATE_RESTART_EXIT_CODE;
260
+ return;
261
+ }
262
+ installState = "live";
263
+ }
264
+ const runningVersion = packageVersion();
265
+ const managedPaths = managedRuntimePaths();
266
+ const provisionalInstall =
267
+ installState === "live" || installState === "unknown";
268
+ if (
269
+ existsSync(managedPaths.runtime) &&
270
+ !(provisionalInstall
271
+ ? await validateProvisionalManagedRuntimeSelection(runningVersion)
272
+ : await validateManagedRuntimeSelection(runningVersion))
273
+ ) {
274
+ throw new Error(
275
+ `managed runtime activation is still in progress; refusing to start stale runtime ${runningVersion}`,
276
+ );
277
+ }
278
+ // Boot the service in-process: main.ts's top-level code starts the WSS
279
+ // client + local UI server and keeps the event loop alive.
280
+ const host = await import("./main");
281
+ // Close the none→intent race while the comparatively expensive service
282
+ // module imported. This is one lock-free marker read, not a steady-state
283
+ // poller; only a provisional install arms the recurring watcher.
284
+ try {
285
+ installState = await managedRuntimeInstallStartupState();
286
+ } catch (error) {
287
+ host.requestManagedHostRestart();
288
+ throw error;
289
+ }
290
+ if (installState === "stale" || installState === "attested") {
291
+ host.requestManagedHostRestart();
292
+ return;
293
+ }
294
+ if (installState === "live" || installState === "unknown") {
295
+ watchManagedInstaller(host.requestManagedHostRestart);
296
+ }
297
+ if (
298
+ existsSync(managedPaths.runtime) &&
299
+ installState !== "live" &&
300
+ installState !== "unknown"
301
+ ) {
302
+ const activationWasPending = existsSync(managedPaths.activationPending);
303
+ try {
304
+ const acknowledged = activationWasPending
305
+ ? await acknowledgeManagedRuntimeActivationWithCapabilityFloor(
306
+ runningVersion,
307
+ PROJECT_ENV_BINDINGS_RUNTIME_CAPABILITY,
308
+ host.completeProjectEnvBindingsAfterManagedRuntimeFloor,
309
+ )
310
+ : await acknowledgeManagedRuntimeActivation(runningVersion);
311
+ if (!acknowledged) {
312
+ // Selection changed while the service module was loading. Its task
313
+ // admission remains closed by the marker; drain and let the supervisor
314
+ // start the newly selected stable command.
315
+ host.requestManagedHostRestart();
316
+ return;
317
+ }
318
+ if (activationWasPending) {
319
+ host.advertiseProjectEnvBindingsAfterManagedRuntimeActivation();
320
+ console.log(
321
+ `[host-agent] managed runtime ${runningVersion} activated (pending activation acknowledged)`,
322
+ );
323
+ await logLaunchdJobAfterActivation();
324
+ }
325
+ } catch (error) {
326
+ // A malformed or concurrently changed marker must not leave an imported
327
+ // service running indefinitely. Admission is still closed, so request a
328
+ // graceful stop before surfacing the settlement failure.
329
+ host.requestManagedHostRestart();
330
+ throw error;
331
+ }
332
+ }
333
+ let localManagedUpdateRunning = false;
334
+ if (existsSync(managedPaths.current)) {
335
+ new ManagedActivationWatcher({
336
+ restartRequired: async () => {
337
+ if (localManagedUpdateRunning) return false;
338
+ let capabilityCompleted = false;
339
+ const restartRequired =
340
+ await reconcileManagedRuntimeActivationWithCapabilityFloor(
341
+ runningVersion,
342
+ PROJECT_ENV_BINDINGS_RUNTIME_CAPABILITY,
343
+ () => {
344
+ host.completeProjectEnvBindingsAfterManagedRuntimeFloor();
345
+ capabilityCompleted = true;
346
+ },
347
+ );
348
+ if (capabilityCompleted) {
349
+ host.advertiseProjectEnvBindingsAfterManagedRuntimeActivation();
350
+ }
351
+ return restartRequired;
352
+ },
353
+ isIdle: host.isIdleForManagedHostUpdate,
354
+ onReady: host.requestManagedHostRestart,
355
+ onError: (error) =>
356
+ console.warn(
357
+ `[host-agent] managed runtime activation check failed: ${error instanceof Error ? error.message : String(error)}`,
358
+ ),
359
+ }).start();
360
+ }
361
+ if (managedAutoupdateEnabled()) {
362
+ new ManagedUpdateScheduler({
363
+ isIdle: host.isIdleForManagedHostUpdate,
364
+ check: async () => {
365
+ localManagedUpdateRunning = true;
366
+ try {
367
+ const result = await updateManagedRuntime({
368
+ canActivate: host.isIdleForManagedHostUpdate,
369
+ });
370
+ if (result.status === "deferred") return "busy";
371
+ return result.status;
372
+ } finally {
373
+ localManagedUpdateRunning = false;
374
+ }
375
+ },
376
+ onUpdated: host.requestManagedHostRestart,
377
+ onError: (error) =>
378
+ console.warn(
379
+ `[host-agent] managed runtime update check failed: ${error instanceof Error ? error.message : String(error)}`,
380
+ ),
381
+ }).start();
382
+ }
383
+ }
384
+
385
+ /**
386
+ * The boot after an activation is the moment a launchd respawn problem is
387
+ * visible — `last exit code = 75: EX_TEMPFAIL` with a `pended nondemand
388
+ * spawn` is what the 2026-09-06 stalls looked like in `launchctl print`, and
389
+ * nothing in the service log said so. Read-only, macOS, and only when launchd
390
+ * actually spawned this process (pid 1 is its parent): a terminal `uai-host
391
+ * run`, the desktop supervisor, or a test spawning the CLI never runs
392
+ * `launchctl` here.
393
+ */
394
+ async function logLaunchdJobAfterActivation(): Promise<void> {
395
+ if (process.platform !== "darwin" || process.ppid !== 1) return;
396
+ try {
397
+ const { describeLaunchdJob, formatLaunchdJobState, launchdJobTarget } =
398
+ await import("../scripts/install/darwin");
399
+ console.log(
400
+ `[host-agent] launchd job ${launchdJobTarget()} after activation: ${formatLaunchdJobState(describeLaunchdJob())}`,
401
+ );
402
+ } catch (error) {
403
+ console.warn(
404
+ `[host-agent] could not read the launchd job state after activation: ${
405
+ error instanceof Error ? error.message : String(error)
406
+ }`,
407
+ );
408
+ }
409
+ }
410
+
411
+ async function waitForManagedInstaller(): Promise<"live" | "restart" | "stopped"> {
412
+ console.error(
413
+ red(
414
+ "managed host installation was interrupted; rerun the installer to resume safely",
415
+ ),
416
+ );
417
+ return new Promise((resolve) => {
418
+ let stopped = false;
419
+ let timer: NodeJS.Timeout | undefined;
420
+ const finish = (state: "live" | "restart" | "stopped") => {
421
+ if (stopped) return;
422
+ stopped = true;
423
+ if (timer) clearTimeout(timer);
424
+ process.off("SIGTERM", onSignal);
425
+ process.off("SIGINT", onSignal);
426
+ resolve(state);
427
+ };
428
+ const onSignal = () => finish("stopped");
429
+ const poll = async () => {
430
+ try {
431
+ const state = await managedRuntimeInstallStartupState();
432
+ if (state === "live") {
433
+ finish("live");
434
+ return;
435
+ }
436
+ if (state === "none" || state === "attested") {
437
+ // Another CLI settled the stale transaction. Exit nonzero so a
438
+ // Restart=on-failure supervisor launches a fresh process that can
439
+ // either boot normally or finish the attested commit before import.
440
+ finish("restart");
441
+ return;
442
+ }
443
+ } catch (error) {
444
+ console.error(
445
+ red(
446
+ `managed host install gate could not be checked: ${
447
+ error instanceof Error ? error.message : String(error)
448
+ }`,
449
+ ),
450
+ );
451
+ }
452
+ if (!stopped) timer = setTimeout(poll, 1_000);
453
+ };
454
+ process.once("SIGTERM", onSignal);
455
+ process.once("SIGINT", onSignal);
456
+ timer = setTimeout(poll, 1_000);
457
+ });
458
+ }
459
+
460
+ function watchManagedInstaller(onStale: () => void): void {
461
+ new ManagedInstallOwnerWatcher({
462
+ check: () => managedRuntimeInstallStartupState(),
463
+ onRestart: (state) => {
464
+ console.error(
465
+ red(
466
+ state === "attested"
467
+ ? "managed host install attested; draining so the supervisor can finalize it"
468
+ : "managed host installer exited before attestation; draining into the recovery gate",
469
+ ),
470
+ );
471
+ onStale();
472
+ },
473
+ onUnsafe: (error) => {
474
+ console.error(
475
+ red(
476
+ `managed host install intent became unreadable; draining safely: ${
477
+ error instanceof Error ? error.message : String(error)
478
+ }`,
479
+ ),
480
+ );
481
+ onStale();
482
+ },
483
+ }).start();
484
+ }
485
+
486
+ // --- status -----------------------------------------------------------------
487
+
488
+ async function cmdStatus(): Promise<void> {
489
+ const port = readPort();
490
+ if (port == null) {
491
+ console.log(
492
+ dim("service not running (no host.port). Start it: moondeck-station start (or: moondeck-station run)"),
493
+ );
494
+ process.exitCode = 1;
495
+ return;
496
+ }
497
+ let status, cloud, tasks;
498
+ try {
499
+ const [rawStatus, rawCloud, rawTasks] = await Promise.all([
500
+ api(port, "/api/status"),
501
+ api(port, "/api/cloud"),
502
+ api(port, "/api/tasks"),
503
+ ]);
504
+ status = StatusResponse.parse(rawStatus);
505
+ cloud = CloudResponse.parse(rawCloud);
506
+ tasks = TasksResponse.parse(rawTasks);
507
+ } catch (err) {
508
+ console.log(dim(localApiFailure(port, err)));
509
+ process.exitCode = 1;
510
+ return;
511
+ }
512
+
513
+ const stateColor =
514
+ cloud.state === "connected"
515
+ ? green
516
+ : cloud.state === "reconnecting"
517
+ ? yellow
518
+ : red;
519
+ console.log(bold("Moondeck Station") + dim(` — ${status.hostName} · v${status.version}`));
520
+ console.log(
521
+ ` cloud ${stateColor(cloud.state)} ${dim(status.cloudUrl)}` +
522
+ (cloud.reconnectCount ? dim(` (reconnects: ${cloud.reconnectCount})`) : ""),
523
+ );
524
+ if (cloud.lastError) console.log(` ${dim("last error: " + cloud.lastError)}`);
525
+ console.log(` service ${dim(`pid ${status.pid} · up ${fmtDuration(status.uptime * 1000)}`)}`);
526
+ // Only worth a line when it's the surprising answer. A mock host looks
527
+ // identical to a real one everywhere else — connected, tasks running, output
528
+ // streaming — so this is the only place it can be caught before the work is.
529
+ if (status.agentMode === "mock") {
530
+ console.log(` agents ${yellow("mock")} ${dim("— echo only, no real CLI runs")}`);
531
+ console.log(` ${dim("unset UAI_AGENTS in " + envLocalPath() + ", then: moondeck-station restart")}`);
532
+ }
533
+ if (status.containerRuntime) {
534
+ const runtime = status.containerRuntime;
535
+ const label =
536
+ runtime.status === "ready"
537
+ ? green(runtime.provider)
538
+ : runtime.status === "checking"
539
+ ? yellow("checking")
540
+ : red("unavailable");
541
+ console.log(` runtime ${label} ${dim(`(${runtime.preference})`)}`);
542
+ if (runtime.status === "checking" && runtime.detail) {
543
+ console.log(` ${dim(runtime.detail)}`);
544
+ }
545
+ if (runtime.status === "no-runtime") {
546
+ console.log(` ${dim(runtime.message)}`);
547
+ }
548
+ }
549
+ console.log(` ui ${dim(`http://127.0.0.1:${port}`)}`);
550
+ console.log(` log ${dim(status.logPath)}`);
551
+ console.log(` tasks ${tasks.tasks.length === 0 ? dim("none") : tasks.tasks.length}`);
552
+ for (const t of tasks.tasks) {
553
+ const slugs = t.projectSlugs.length ? t.projectSlugs.join(",") : "scratchpad";
554
+ const age = t.ageMs != null ? fmtDuration(t.ageMs) : "—";
555
+ const mem = t.memoryBytes != null ? fmtBytes(t.memoryBytes) : "—";
556
+ console.log(
557
+ ` ${cyan((t.ownerEmail ?? "unknown").padEnd(22))} ${dim(slugs.padEnd(16))} ` +
558
+ `${(t.status ?? "—").padEnd(9)} ${dim(`up ${age}`.padEnd(11))} ${dim(mem)}`,
559
+ );
560
+ console.log(` ${dim(t.taskId)}`);
561
+ }
562
+ }
563
+
564
+ /** What `uai-host status` gives each local endpoint. A healthy runner
565
+ * answers in well under a second; one second was too tight once
566
+ * `/api/tasks` waited on `docker stats` (2026-10-30). */
567
+ const LOCAL_API_TIMEOUT_MS = 5_000;
568
+
569
+ /** A local endpoint that did not answer in time: the service is there. */
570
+ class LocalApiTimeout extends Error {}
571
+ /** Nothing accepted the connection: the service is not there. */
572
+ class LocalApiUnreachable extends Error {}
573
+
574
+ async function api(port: number, path: string): Promise<unknown> {
575
+ try {
576
+ const res = await fetch(`http://127.0.0.1:${port}${path}`, {
577
+ signal: AbortSignal.timeout(LOCAL_API_TIMEOUT_MS),
578
+ });
579
+ if (!res.ok) throw new Error(`${path} → ${res.status}`);
580
+ return (await res.json()) as unknown;
581
+ } catch (err) {
582
+ if (err instanceof Error && (err.name === "TimeoutError" || err.name === "AbortError")) {
583
+ throw new LocalApiTimeout(
584
+ `${path} did not answer within ${String(LOCAL_API_TIMEOUT_MS / 1000)}s`,
585
+ );
586
+ }
587
+ const code = (err as { cause?: { code?: unknown } } | null)?.cause?.code;
588
+ if (code === "ECONNREFUSED") {
589
+ throw new LocalApiUnreachable(`${path}: connection refused`);
590
+ }
591
+ throw err;
592
+ }
593
+ }
594
+
595
+ /** One line for a status read that failed: no service, a slow one, or one
596
+ * that answered wrong are three different things to the operator. */
597
+ function localApiFailure(port: number, err: unknown): string {
598
+ const detail = err instanceof Error ? err.message : String(err);
599
+ if (err instanceof LocalApiUnreachable) {
600
+ return `service unreachable on 127.0.0.1:${port} (${detail}). Start it: moondeck-station start`;
601
+ }
602
+ if (err instanceof LocalApiTimeout) {
603
+ return `service on 127.0.0.1:${port} is up but slow to answer (${detail}); try again, or check its log`;
604
+ }
605
+ return `service on 127.0.0.1:${port} answered unexpectedly (${detail})`;
606
+ }
607
+
608
+ async function boundedResponseJson(
609
+ response: Response,
610
+ maxBytes = 16 * 1024,
611
+ ): Promise<unknown> {
612
+ const declared = response.headers.get("content-length");
613
+ if (declared !== null) {
614
+ if (!/^(?:0|[1-9]\d*)$/.test(declared) || Number(declared) > maxBytes) {
615
+ throw new Error("cloud response exceeds its byte limit");
616
+ }
617
+ }
618
+ if (!response.body) throw new Error("cloud response has no body");
619
+ const reader = response.body.getReader();
620
+ const chunks: Uint8Array[] = [];
621
+ let total = 0;
622
+ try {
623
+ for (;;) {
624
+ const { done, value } = await reader.read();
625
+ if (done) break;
626
+ total += value.byteLength;
627
+ if (total > maxBytes) {
628
+ await reader.cancel("cloud response exceeds its byte limit");
629
+ throw new Error("cloud response exceeds its byte limit");
630
+ }
631
+ chunks.push(value);
632
+ }
633
+ } finally {
634
+ reader.releaseLock();
635
+ }
636
+ const bytes = new Uint8Array(total);
637
+ let offset = 0;
638
+ for (const chunk of chunks) {
639
+ bytes.set(chunk, offset);
640
+ offset += chunk.byteLength;
641
+ }
642
+ return JSON.parse(new TextDecoder().decode(bytes)) as unknown;
643
+ }
644
+
645
+ // --- runtime ---------------------------------------------------------------
646
+
647
+ // --- aws setup (ADR-125 BYO-AWS: mint the host's scoped AWS identity) --------
648
+
649
+ /** Mirrors scripts/machine-ami/byo-aws-quickcreate.yaml — keep in sync. */
650
+ const AWS_MACHINE_POLICY = {
651
+ Version: "2012-10-17",
652
+ Statement: [
653
+ {
654
+ Sid: "UaiMachines",
655
+ Effect: "Allow",
656
+ Action: [
657
+ "ec2:RunInstances",
658
+ "ec2:StartInstances",
659
+ "ec2:StopInstances",
660
+ "ec2:TerminateInstances",
661
+ "ec2:CreateTags",
662
+ "ec2:DescribeInstances",
663
+ "ec2:DescribeImages",
664
+ "ec2:DescribeSubnets",
665
+ "ec2:DescribeVpcs",
666
+ "ec2:DescribeSecurityGroups",
667
+ "ec2:CreateSecurityGroup",
668
+ "ec2:AuthorizeSecurityGroupIngress",
669
+ "ec2:CreateSnapshot",
670
+ "ec2:DeleteSnapshot",
671
+ "ec2:CreateVolume",
672
+ "ec2:DescribeSnapshots",
673
+ "ec2:DescribeVolumes",
674
+ ],
675
+ Resource: "*",
676
+ },
677
+ ],
678
+ };
679
+ const AWS_SETUP_USER = "uai-host";
680
+
681
+ /** Run the aws CLI with the operator's AMBIENT (admin) credentials: any
682
+ * AWS_* that load-env pulled from .env.local is a previously-minted SCOPED
683
+ * key that cannot administer IAM, and letting it shadow the shell's admin
684
+ * credentials makes every failure read as a permissions mystery. */
685
+ function awsAdmin(
686
+ args: string[],
687
+ region: string | null,
688
+ ): SpawnSyncReturns<string> {
689
+ const env = { ...process.env };
690
+ const fromFile = (() => {
691
+ try {
692
+ return parseDotenv(readFileSync(envLocalPath(), "utf8"));
693
+ } catch {
694
+ return {} as Record<string, string>;
695
+ }
696
+ })();
697
+ if (
698
+ env.AWS_ACCESS_KEY_ID &&
699
+ env.AWS_ACCESS_KEY_ID === fromFile.AWS_ACCESS_KEY_ID
700
+ ) {
701
+ delete env.AWS_ACCESS_KEY_ID;
702
+ delete env.AWS_SECRET_ACCESS_KEY;
703
+ delete env.AWS_SESSION_TOKEN;
704
+ }
705
+ return spawnSync(
706
+ "aws",
707
+ [...(region ? ["--region", region] : []), "--output", "json", ...args],
708
+ { encoding: "utf8", env },
709
+ );
710
+ }
711
+
712
+ async function cmdAwsSetup(rest: string[]): Promise<void> {
713
+ const rotate = rest.includes("--rotate");
714
+ const regionFlagAt = rest.indexOf("--region");
715
+ let region = regionFlagAt >= 0 ? (rest[regionFlagAt + 1] ?? "") : "";
716
+
717
+ const version = spawnSync("aws", ["--version"], { encoding: "utf8" });
718
+ if (version.status !== 0) {
719
+ console.error(red("the aws CLI is not installed (or not on PATH)"));
720
+ console.error("install it: https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html");
721
+ process.exitCode = 1;
722
+ return;
723
+ }
724
+
725
+ if (!region) {
726
+ const configured = spawnSync("aws", ["configure", "get", "region"], {
727
+ encoding: "utf8",
728
+ });
729
+ region = (configured.stdout ?? "").trim();
730
+ }
731
+ if (!region) {
732
+ console.error(red("no AWS region — pass --region <region> (e.g. --region us-west-2)"));
733
+ process.exitCode = 1;
734
+ return;
735
+ }
736
+ if (!PAYLOAD_AMI_REGIONS.includes(region)) {
737
+ console.error(
738
+ red(`task machines are not available in ${region} yet — Moondeck's machine image is published in ${PAYLOAD_AMI_REGIONS.join(", ")}`),
739
+ );
740
+ console.error(dim("pass --region with one of those; more regions are coming."));
741
+ process.exitCode = 1;
742
+ return;
743
+ }
744
+
745
+ const identity = awsAdmin(["sts", "get-caller-identity"], region);
746
+ if (identity.status !== 0) {
747
+ console.error(red("no working AWS admin credentials in this shell"));
748
+ console.error(
749
+ "sign in first (aws configure, aws sso login, or exported AWS_* vars), then re-run",
750
+ );
751
+ console.error(dim((identity.stderr ?? "").trim().slice(0, 200)));
752
+ process.exitCode = 1;
753
+ return;
754
+ }
755
+ const account = (() => {
756
+ try {
757
+ return (JSON.parse(identity.stdout) as { Account?: string }).Account ?? "?";
758
+ } catch {
759
+ return "?";
760
+ }
761
+ })();
762
+
763
+ const existingEnv = (() => {
764
+ try {
765
+ return parseDotenv(readFileSync(envLocalPath(), "utf8"));
766
+ } catch {
767
+ return {} as Record<string, string>;
768
+ }
769
+ })();
770
+ if (existingEnv.AWS_ACCESS_KEY_ID && !rotate) {
771
+ console.log(
772
+ `already configured (key ${existingEnv.AWS_ACCESS_KEY_ID.slice(0, 8)}…, region ${existingEnv.UAI_AWS_REGION ?? "?"})`,
773
+ );
774
+ console.log(`re-run with ${bold("--rotate")} to mint a fresh key`);
775
+ return;
776
+ }
777
+
778
+ console.log(`account ${account}, region ${region} — creating the ${AWS_SETUP_USER} identity`);
779
+
780
+ const created = awsAdmin(
781
+ ["iam", "create-user", "--user-name", AWS_SETUP_USER],
782
+ region,
783
+ );
784
+ if (
785
+ created.status !== 0 &&
786
+ !(created.stderr ?? "").includes("EntityAlreadyExists")
787
+ ) {
788
+ console.error(red(`could not create the ${AWS_SETUP_USER} IAM user: ${(created.stderr ?? "").trim().slice(0, 300)}`));
789
+ process.exitCode = 1;
790
+ return;
791
+ }
792
+
793
+ // put-user-policy overwrites: re-running upgrades the policy in place.
794
+ const policy = awsAdmin(
795
+ [
796
+ "iam",
797
+ "put-user-policy",
798
+ "--user-name",
799
+ AWS_SETUP_USER,
800
+ "--policy-name",
801
+ "uai-machines",
802
+ "--policy-document",
803
+ JSON.stringify(AWS_MACHINE_POLICY),
804
+ ],
805
+ region,
806
+ );
807
+ if (policy.status !== 0) {
808
+ console.error(red(`could not attach the machine policy: ${(policy.stderr ?? "").trim().slice(0, 300)}`));
809
+ process.exitCode = 1;
810
+ return;
811
+ }
812
+
813
+ const keyResult = awsAdmin(
814
+ ["iam", "create-access-key", "--user-name", AWS_SETUP_USER],
815
+ region,
816
+ );
817
+ if (keyResult.status !== 0) {
818
+ console.error(red(`could not create an access key: ${(keyResult.stderr ?? "").trim().slice(0, 300)}`));
819
+ if ((keyResult.stderr ?? "").includes("LimitExceeded")) {
820
+ console.error(
821
+ `the user already has two keys — list them with: aws iam list-access-keys --user-name ${AWS_SETUP_USER}\n` +
822
+ `and delete one with: aws iam delete-access-key --user-name ${AWS_SETUP_USER} --access-key-id <id>`,
823
+ );
824
+ }
825
+ process.exitCode = 1;
826
+ return;
827
+ }
828
+ let keyId = "";
829
+ let secret = "";
830
+ try {
831
+ const parsed = JSON.parse(keyResult.stdout) as {
832
+ AccessKey?: { AccessKeyId?: string; SecretAccessKey?: string };
833
+ };
834
+ keyId = parsed.AccessKey?.AccessKeyId ?? "";
835
+ secret = parsed.AccessKey?.SecretAccessKey ?? "";
836
+ } catch {
837
+ // handled below
838
+ }
839
+ if (!keyId || !secret) {
840
+ console.error(red("create-access-key returned no key material"));
841
+ process.exitCode = 1;
842
+ return;
843
+ }
844
+
845
+ writeEnvValuesAtomic(envLocalPath(), {
846
+ UAI_MACHINE_TASKS: "1",
847
+ UAI_MACHINE_PROVIDER: "aws",
848
+ UAI_AWS_REGION: region,
849
+ AWS_ACCESS_KEY_ID: keyId,
850
+ AWS_SECRET_ACCESS_KEY: secret,
851
+ });
852
+
853
+ // Rotation hygiene: the key the env file carried before this run belongs
854
+ // to the scoped user; a minted replacement leaves it orphaned AND holding
855
+ // the 2-key limit. Best-effort delete, loudly skippable.
856
+ const previousKey = existingEnv.AWS_ACCESS_KEY_ID;
857
+ if (rotate && previousKey && previousKey !== keyId) {
858
+ const deleted = awsAdmin(
859
+ [
860
+ "iam",
861
+ "delete-access-key",
862
+ "--user-name",
863
+ AWS_SETUP_USER,
864
+ "--access-key-id",
865
+ previousKey,
866
+ ],
867
+ region,
868
+ );
869
+ if (deleted.status === 0) {
870
+ console.log(`rotated: deleted the previous key ${previousKey.slice(0, 8)}…`);
871
+ } else {
872
+ console.log(
873
+ yellow(`minted a new key but could not delete ${previousKey.slice(0, 8)}… — remove it manually if unused`),
874
+ );
875
+ }
876
+ }
877
+
878
+ console.log(`${bold("done")} — scoped credentials written to ${envLocalPath()}`);
879
+ console.log(` UAI_MACHINE_PROVIDER=aws UAI_AWS_REGION=${region} (machine tasks enabled)`);
880
+ console.log(`restart the host to activate: ${cyan("moondeck-station restart")}`);
881
+ }
882
+
883
+ // --- tune (ADR-118 follow-up: vnode/file-table ceilings) ---------------------
884
+
885
+ const TUNE_DAEMON_LABEL = STATION.tuneDaemonLabel;
886
+ const TUNE_DAEMON_PLIST = `/Library/LaunchDaemons/${TUNE_DAEMON_LABEL}.plist`;
887
+ /** Floors, not targets — an operator-raised ceiling is never lowered. Each
888
+ * apple-container task VM pins host vnodes for its life (~12k after ADR-118,
889
+ * far more during dependency churn), and the boot defaults sit low enough
890
+ * that two heavy tasks have filled the table live (2026-08-25). ~2M vnodes
891
+ * is roughly half a GB of kernel memory IF fully populated. */
892
+ const TUNE_MAXVNODES_FLOOR = 2_097_152;
893
+ const TUNE_MAXFILES_FLOOR = 2_097_152;
894
+ const TUNE_MAXFILESPERPROC_FLOOR = 1_048_576;
895
+
896
+ function readSysctlNumber(name: string): number | null {
897
+ const res = spawnSync("/usr/sbin/sysctl", ["-n", name], {
898
+ encoding: "utf8",
899
+ });
900
+ const value = Number((res.stdout ?? "").trim());
901
+ return res.status === 0 && Number.isFinite(value) ? value : null;
902
+ }
903
+
904
+ function tunePlistContent(settings: Array<[string, number]>): string {
905
+ const args = ["/usr/sbin/sysctl", ...settings.map(([k, v]) => `${k}=${v}`)]
906
+ .map((a) => ` <string>${a}</string>`)
907
+ .join("\n");
908
+ return `<?xml version="1.0" encoding="UTF-8"?>
909
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
910
+ <plist version="1.0">
911
+ <dict>
912
+ <key>Label</key>
913
+ <string>${TUNE_DAEMON_LABEL}</string>
914
+ <key>ProgramArguments</key>
915
+ <array>
916
+ ${args}
917
+ </array>
918
+ <key>RunAtLoad</key>
919
+ <true/>
920
+ </dict>
921
+ </plist>
922
+ `;
923
+ }
924
+
925
+ async function cmdTune(): Promise<void> {
926
+ if (process.platform !== "darwin") {
927
+ console.error(red("tune is macOS-only (apple-container task VMs)"));
928
+ process.exitCode = 1;
929
+ return;
930
+ }
931
+ const current = {
932
+ maxvnodes: readSysctlNumber("kern.maxvnodes"),
933
+ maxfiles: readSysctlNumber("kern.maxfiles"),
934
+ maxfilesperproc: readSysctlNumber("kern.maxfilesperproc"),
935
+ };
936
+ if (
937
+ current.maxvnodes === null ||
938
+ current.maxfiles === null ||
939
+ current.maxfilesperproc === null
940
+ ) {
941
+ console.error(red("could not read the current kernel limits via sysctl"));
942
+ process.exitCode = 1;
943
+ return;
944
+ }
945
+ // Order matters at apply time: maxfilesperproc must stay <= maxfiles.
946
+ const settings: Array<[string, number]> = [
947
+ ["kern.maxfiles", Math.max(current.maxfiles, TUNE_MAXFILES_FLOOR)],
948
+ [
949
+ "kern.maxfilesperproc",
950
+ Math.max(current.maxfilesperproc, TUNE_MAXFILESPERPROC_FLOOR),
951
+ ],
952
+ ["kern.maxvnodes", Math.max(current.maxvnodes, TUNE_MAXVNODES_FLOOR)],
953
+ ];
954
+ const desiredPlist = tunePlistContent(settings);
955
+ const plistCurrent = existsSync(TUNE_DAEMON_PLIST)
956
+ ? readFileSync(TUNE_DAEMON_PLIST, "utf8")
957
+ : null;
958
+ const changes = settings.filter(
959
+ ([key, value]) => value !== current[key.slice("kern.".length) as keyof typeof current],
960
+ );
961
+ if (changes.length === 0 && plistCurrent === desiredPlist) {
962
+ const summary = settings.map(([k, v]) => `${k}=${v}`).join(" ");
963
+ console.log(`already tuned (${summary}); boot persistence in place`);
964
+ return;
965
+ }
966
+
967
+ if (typeof process.getuid !== "function" || process.getuid() !== 0) {
968
+ console.log(`${bold("moondeck-station tune")} will (needs sudo):`);
969
+ for (const [key, value] of changes) {
970
+ console.log(
971
+ ` raise ${key}: ${current[key.slice("kern.".length) as keyof typeof current]} -> ${value}`,
972
+ );
973
+ }
974
+ console.log(
975
+ ` install ${TUNE_DAEMON_PLIST} so the limits survive reboots`,
976
+ );
977
+ console.log(`\nrun: ${cyan("sudo moondeck-station tune")}`);
978
+ process.exitCode = 1;
979
+ return;
980
+ }
981
+
982
+ for (const [key, value] of settings) {
983
+ const res = spawnSync("/usr/sbin/sysctl", [`${key}=${value}`], {
984
+ encoding: "utf8",
985
+ });
986
+ if (res.status !== 0) {
987
+ console.error(
988
+ red(`sysctl ${key}=${value} failed: ${(res.stderr ?? "").trim()}`),
989
+ );
990
+ process.exitCode = 1;
991
+ return;
992
+ }
993
+ }
994
+ writeFileSync(TUNE_DAEMON_PLIST, desiredPlist, { mode: 0o644 });
995
+ spawnSync("/usr/sbin/chown", ["root:wheel", TUNE_DAEMON_PLIST]);
996
+ // Re-bootstrap so launchd owns the daemon under its current definition.
997
+ spawnSync("/bin/launchctl", ["bootout", `system/${TUNE_DAEMON_LABEL}`]);
998
+ const bootstrap = spawnSync(
999
+ "/bin/launchctl",
1000
+ ["bootstrap", "system", TUNE_DAEMON_PLIST],
1001
+ { encoding: "utf8" },
1002
+ );
1003
+ if (bootstrap.status !== 0) {
1004
+ console.error(
1005
+ red(
1006
+ `limits applied, but launchctl bootstrap failed (${(bootstrap.stderr ?? "").trim()}) — they will not survive a reboot`,
1007
+ ),
1008
+ );
1009
+ process.exitCode = 1;
1010
+ return;
1011
+ }
1012
+ const summary = settings.map(([k, v]) => `${k}=${v}`).join(" ");
1013
+ console.log(`tuned: ${summary} (persisted via ${TUNE_DAEMON_PLIST})`);
1014
+ }
1015
+
1016
+ async function cmdRuntime(rest: string[]): Promise<void> {
1017
+ if (rest[0] === "begin-install") {
1018
+ return cmdBeginRuntimeInstall(rest.slice(1));
1019
+ }
1020
+ if (rest[0] === "complete-install") {
1021
+ return cmdCompleteRuntimeInstall(rest.slice(1));
1022
+ }
1023
+ if (rest[0] === "abort-install") {
1024
+ return cmdAbortRuntimeInstall(rest.slice(1));
1025
+ }
1026
+ if (rest[0] === "stop-service") {
1027
+ return cmdStopRuntimeInstallService(rest.slice(1));
1028
+ }
1029
+ if (rest.length === 1 && rest[0] === "reconcile-uninstall") {
1030
+ await reconcileManagedRuntimeUninstall();
1031
+ return;
1032
+ }
1033
+ if (rest[0] === "install-archive") {
1034
+ return cmdInstallRuntimeArchive(rest.slice(1));
1035
+ }
1036
+ if (rest.length !== 1 || rest[0] !== "recheck") {
1037
+ console.error(
1038
+ red(
1039
+ "usage: moondeck-station runtime recheck\n moondeck-station runtime install-archive --archive <file> --version <x.y.z> --size <bytes> --sha256 <hex>",
1040
+ ),
1041
+ );
1042
+ process.exitCode = 1;
1043
+ return;
1044
+ }
1045
+ const port = readPort();
1046
+ if (port == null) {
1047
+ console.error(
1048
+ red("service not running — start it before rechecking"),
1049
+ );
1050
+ process.exitCode = 1;
1051
+ return;
1052
+ }
1053
+ try {
1054
+ const res = await fetch(`http://127.0.0.1:${port}/api/runtime/recheck`, {
1055
+ method: "POST",
1056
+ signal: AbortSignal.timeout(30_000),
1057
+ headers: {
1058
+ "x-uai-local-control": "runtime-recheck-v1",
1059
+ },
1060
+ });
1061
+ if (!res.ok) throw new Error(`runtime recheck → ${res.status}`);
1062
+ const runtime = ContainerRuntimeResponse.parse(
1063
+ await boundedResponseJson(res),
1064
+ );
1065
+ if (runtime.status === "ready") {
1066
+ console.log(
1067
+ green("container runtime ready") + dim(` — ${runtime.provider}`),
1068
+ );
1069
+ return;
1070
+ }
1071
+ if (runtime.status === "checking") {
1072
+ console.log(
1073
+ yellow("container runtime detected") +
1074
+ dim(" — preparing images and recovering tasks; check status shortly"),
1075
+ );
1076
+ return;
1077
+ }
1078
+ console.error(red(runtime.message ?? "container runtime unavailable"));
1079
+ process.exitCode = 1;
1080
+ } catch (err) {
1081
+ console.error(
1082
+ red(err instanceof Error ? err.message : "runtime recheck failed"),
1083
+ );
1084
+ process.exitCode = 1;
1085
+ }
1086
+ }
1087
+
1088
+ async function cmdBeginRuntimeInstall(rest: string[]): Promise<void> {
1089
+ if (rest.length !== 0) {
1090
+ throw new Error("usage: moondeck-station runtime begin-install");
1091
+ }
1092
+ const installer = await loadInstaller();
1093
+ const result = await beginManagedRuntimeInstall({
1094
+ // The installer invokes this command directly with output redirection. Its
1095
+ // parent is therefore the long-lived compound shell, not a short-lived
1096
+ // command-substitution process. PID start identity protects against reuse.
1097
+ ownerPid: process.ppid,
1098
+ reconcileStandaloneDefinition: () => reconcileDefinitionInstall(installer),
1099
+ definitionState: (managedCommand) =>
1100
+ serviceDefinitionState(installer, managedCommand),
1101
+ prepareRecovery: (installIntent) =>
1102
+ prepareDefinitionInstallRollback(installer, installIntent),
1103
+ resumeRecovery: (installIntent) =>
1104
+ resumeDefinitionInstallRollback(installer, installIntent),
1105
+ resumeStoppedService: (installIntent, priorManager) =>
1106
+ resumeServiceForInstall(installer, installIntent, priorManager),
1107
+ beforeFinalize: (installIntent) =>
1108
+ completeDefinitionInstall(installer, installIntent),
1109
+ });
1110
+ console.log(
1111
+ [
1112
+ result.nonce,
1113
+ result.legacyShim ? "legacy" : "none",
1114
+ result.serviceOwnership,
1115
+ result.serviceQuiescence,
1116
+ result.serviceManager.loaded ? "loaded" : "unloaded",
1117
+ result.serviceManager.active ? "active" : "inactive",
1118
+ result.serviceManager.enabled ? "enabled" : "disabled",
1119
+ ].join(" "),
1120
+ );
1121
+ }
1122
+
1123
+ async function cmdStopRuntimeInstallService(rest: string[]): Promise<void> {
1124
+ const installIntent = exactFlagValue(rest, "--install-intent");
1125
+ if (!installIntent) {
1126
+ throw new Error(
1127
+ "usage: moondeck-station runtime stop-service --install-intent <nonce>",
1128
+ );
1129
+ }
1130
+ const installer = await loadInstaller();
1131
+ await stopManagedRuntimeServiceForInstall({
1132
+ installIntent,
1133
+ stopService: (intent, priorManager) =>
1134
+ stopServiceForInstall(installer, intent, priorManager),
1135
+ });
1136
+ }
1137
+
1138
+ async function cmdCompleteRuntimeInstall(rest: string[]): Promise<void> {
1139
+ const installIntent = exactFlagValue(rest, "--install-intent");
1140
+ if (!installIntent) {
1141
+ throw new Error(
1142
+ "usage: moondeck-station runtime complete-install --install-intent <nonce>",
1143
+ );
1144
+ }
1145
+ const installer = await loadInstaller();
1146
+ await completeManagedRuntimeInstall({
1147
+ installIntent,
1148
+ beforeFinalize: (intent) => completeDefinitionInstall(installer, intent),
1149
+ });
1150
+ }
1151
+
1152
+ async function cmdAbortRuntimeInstall(rest: string[]): Promise<void> {
1153
+ const installIntent = exactFlagValue(rest, "--install-intent");
1154
+ if (!installIntent) {
1155
+ throw new Error(
1156
+ "usage: moondeck-station runtime abort-install --install-intent <nonce>",
1157
+ );
1158
+ }
1159
+ const installer = await loadInstaller();
1160
+ await abortManagedRuntimeInstall({
1161
+ installIntent,
1162
+ prepareRestore: (intent) =>
1163
+ prepareDefinitionInstallRollback(installer, intent),
1164
+ resumeRestore: (intent) =>
1165
+ resumeDefinitionInstallRollback(installer, intent),
1166
+ resumeStoppedService: (intent, priorManager) =>
1167
+ resumeServiceForInstall(installer, intent, priorManager),
1168
+ beforeFinalize: (intent) => completeDefinitionInstall(installer, intent),
1169
+ });
1170
+ }
1171
+
1172
+ async function cmdInstallRuntimeArchive(rest: string[]): Promise<void> {
1173
+ const parsed = parseRuntimeInstallArgs(rest);
1174
+ if (!parsed) {
1175
+ console.error(
1176
+ red(
1177
+ "usage: moondeck-station runtime install-archive --archive <file> --version <x.y.z> --size <bytes> --sha256 <hex>",
1178
+ ),
1179
+ );
1180
+ process.exitCode = 1;
1181
+ return;
1182
+ }
1183
+ const platform = nativeReleasePlatform();
1184
+ const result = await installManagedRuntimeArchive({
1185
+ archivePath: parsed.archive,
1186
+ artifact: {
1187
+ // Bootstrap authenticated the tuple in the cloud-rendered script. The
1188
+ // local installer consumes only size/hash; update URLs come from the
1189
+ // separately verified signed manifest.
1190
+ url: "bootstrap-authenticated-artifact",
1191
+ size: parsed.size,
1192
+ sha256: parsed.sha256,
1193
+ },
1194
+ version: parsed.version,
1195
+ platform,
1196
+ allowDowngrade: parsed.allowDowngrade,
1197
+ // Legacy conversion takes over the old service's live tasks, so it needs
1198
+ // every task idle. A managed version update restarts only our own
1199
+ // service, which running tasks survive (durable sessions) — it drains
1200
+ // only in-flight lifecycle mutations.
1201
+ canActivate: (context) =>
1202
+ context.legacyConversion
1203
+ ? !hasActiveHostTasks()
1204
+ : !hasInFlightHostTaskLifecycle(),
1205
+ releaseRollbackUpdateHold: true,
1206
+ installIntent: parsed.installIntent,
1207
+ });
1208
+ if (result.deferred) {
1209
+ console.error(red("runtime activation deferred because host tasks are active"));
1210
+ process.exitCode = 1;
1211
+ return;
1212
+ }
1213
+ console.log(
1214
+ green(result.changed ? `installed host runtime ${result.version}` : `host runtime ${result.version} is already installed`),
1215
+ );
1216
+ }
1217
+
1218
+ interface RuntimeInstallArgs {
1219
+ archive: string;
1220
+ version: string;
1221
+ size: number;
1222
+ sha256: string;
1223
+ allowDowngrade: boolean;
1224
+ installIntent?: string;
1225
+ }
1226
+
1227
+ function parseRuntimeInstallArgs(rest: string[]): RuntimeInstallArgs | null {
1228
+ const values = new Map<string, string>();
1229
+ let allowDowngrade = false;
1230
+ for (let index = 0; index < rest.length; index += 1) {
1231
+ const arg = rest[index]!;
1232
+ if (arg === "--allow-downgrade") {
1233
+ if (allowDowngrade) return null;
1234
+ allowDowngrade = true;
1235
+ continue;
1236
+ }
1237
+ if (
1238
+ ![
1239
+ "--archive",
1240
+ "--version",
1241
+ "--size",
1242
+ "--sha256",
1243
+ "--install-intent",
1244
+ ].includes(arg)
1245
+ ) {
1246
+ return null;
1247
+ }
1248
+ if (values.has(arg)) return null;
1249
+ const value = rest[index + 1];
1250
+ if (!value || value.startsWith("--")) return null;
1251
+ values.set(arg, value);
1252
+ index += 1;
1253
+ }
1254
+ const archive = values.get("--archive");
1255
+ const version = values.get("--version");
1256
+ const sizeValue = values.get("--size");
1257
+ const sha256 = values.get("--sha256");
1258
+ const installIntent = values.get("--install-intent");
1259
+ if (!archive || !version || !sizeValue || !sha256) return null;
1260
+ if (!/^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)$/.test(version)) {
1261
+ return null;
1262
+ }
1263
+ if (!/^[1-9]\d*$/.test(sizeValue) || !/^[a-f0-9]{64}$/.test(sha256)) {
1264
+ return null;
1265
+ }
1266
+ const size = Number(sizeValue);
1267
+ if (!Number.isSafeInteger(size)) return null;
1268
+ if (installIntent && !/^[a-f0-9]{64}$/.test(installIntent)) return null;
1269
+ return { archive, version, size, sha256, allowDowngrade, installIntent };
1270
+ }
1271
+
1272
+ async function cmdUpdate(rest: string[]): Promise<void> {
1273
+ if (rest.length !== 0) {
1274
+ console.error(red("usage: moondeck-station update"));
1275
+ process.exitCode = 1;
1276
+ return;
1277
+ }
1278
+ // Running tasks survive the service restart (durable sessions); only a
1279
+ // task mid-lifecycle blocks, and only until it drains.
1280
+ if (hasInFlightHostTaskLifecycle()) {
1281
+ console.error(red("refusing to update while a task is still starting; retry in a moment"));
1282
+ process.exitCode = 1;
1283
+ return;
1284
+ }
1285
+ const result = await updateManagedRuntime({
1286
+ canActivate: () => !hasInFlightHostTaskLifecycle(),
1287
+ releaseRollbackUpdateHold: true,
1288
+ });
1289
+ if (result.status === "not-managed") {
1290
+ console.error(red("this host is not installed as a managed runtime"));
1291
+ process.exitCode = 1;
1292
+ return;
1293
+ }
1294
+ if (result.status === "current") {
1295
+ console.log(green(`host runtime ${result.version} is current`));
1296
+ return;
1297
+ }
1298
+ if (result.status === "deferred") {
1299
+ console.error(red("host runtime update was deferred because the host became busy"));
1300
+ process.exitCode = 1;
1301
+ return;
1302
+ }
1303
+ console.log(
1304
+ green(`updated host runtime ${result.fromVersion} → ${result.version}`) +
1305
+ dim(" — activation staged; the service will restart after in-flight work drains"),
1306
+ );
1307
+ }
1308
+
1309
+ async function cmdRollback(rest: string[]): Promise<void> {
1310
+ if (rest.length !== 0) {
1311
+ console.error(red("usage: moondeck-station rollback"));
1312
+ process.exitCode = 1;
1313
+ return;
1314
+ }
1315
+ if (hasActiveHostTasks()) {
1316
+ console.error(red("refusing to roll back while host tasks are active"));
1317
+ process.exitCode = 1;
1318
+ return;
1319
+ }
1320
+ const idle = () => !hasActiveHostTasks();
1321
+ const result = await rollbackManagedRuntime(undefined, undefined, idle);
1322
+ console.log(
1323
+ green(`rolled back host runtime ${result.fromVersion} → ${result.version}`) +
1324
+ dim(" — activation staged; the service will restart after in-flight work drains"),
1325
+ );
1326
+ console.log(
1327
+ yellow("automatic runtime updates are paused; run `moondeck-station update` to resume them"),
1328
+ );
1329
+ }
1330
+
1331
+ function cmdEnrollmentStatus(rest: string[]): void {
1332
+ if (rest.length !== 1 || rest[0] !== "status") {
1333
+ console.error(red("usage: moondeck-station enrollment status"));
1334
+ process.exitCode = 2;
1335
+ return;
1336
+ }
1337
+ const identity = hostIdentity();
1338
+ if (identity.kind === "complete") {
1339
+ console.log("enrolled");
1340
+ return;
1341
+ }
1342
+ if (identity.kind === "partial") {
1343
+ console.error(red("incomplete host identity"));
1344
+ process.exitCode = 2;
1345
+ return;
1346
+ }
1347
+ console.log("unenrolled");
1348
+ process.exitCode = 1;
1349
+ }
1350
+
1351
+ function managedAutoupdateEnabled(): boolean {
1352
+ return (
1353
+ process.env.UAI_HOST_AUTOUPDATE !== "0" &&
1354
+ PINNED_RELEASE_MANIFEST_KEYS.size > 0 &&
1355
+ existsSync(managedRuntimePaths().current)
1356
+ );
1357
+ }
1358
+
1359
+ // --- open -------------------------------------------------------------------
1360
+
1361
+ async function cmdOpen(): Promise<void> {
1362
+ const port = readPort();
1363
+ if (port == null) {
1364
+ console.error(red("no host.port — is the service running?"));
1365
+ process.exitCode = 1;
1366
+ return;
1367
+ }
1368
+ const url = `http://127.0.0.1:${port}`;
1369
+ if (process.platform === "darwin") detach("open", [url]);
1370
+ else if (process.platform === "win32") detach("cmd", ["/c", "start", "", url]);
1371
+ else detach("xdg-open", [url]);
1372
+ console.log(`opening ${url}`);
1373
+ }
1374
+
1375
+ function detach(cmd: string, args: string[]): void {
1376
+ const child = spawn(cmd, args, { stdio: "ignore", detached: true });
1377
+ child.on("error", (err) => console.error(red(`could not run ${cmd}: ${err.message}`)));
1378
+ child.unref();
1379
+ }
1380
+
1381
+ // --- logs -------------------------------------------------------------------
1382
+
1383
+ async function cmdLogs(follow: boolean): Promise<void> {
1384
+ const log = serviceLogPath();
1385
+ if (log.startsWith("journald")) {
1386
+ const args = ["--user", "-u", STATION.command];
1387
+ if (follow) args.push("-f");
1388
+ inherit("journalctl", args);
1389
+ return;
1390
+ }
1391
+ if (!existsSync(log)) {
1392
+ console.error(red(`log not found: ${log}`));
1393
+ console.error(dim("(install + start the service first, or run in the foreground)"));
1394
+ process.exitCode = 1;
1395
+ return;
1396
+ }
1397
+ inherit("tail", follow ? ["-f", log] : ["-n", "200", log]);
1398
+ }
1399
+
1400
+ function inherit(cmd: string, args: string[]): void {
1401
+ const child = spawn(cmd, args, { stdio: "inherit" });
1402
+ child.on("error", (err) => {
1403
+ console.error(red(`could not run ${cmd}: ${err.message}`));
1404
+ process.exitCode = 1;
1405
+ });
1406
+ child.on("exit", (code) => process.exit(code ?? 0));
1407
+ }
1408
+
1409
+ // --- setup (enrollment, ADR-031) --------------------------------------------
1410
+
1411
+ /** Read a `--name value` or `--name=value` flag from argv. */
1412
+ function flagValue(rest: string[], name: string): string | undefined {
1413
+ const i = rest.indexOf(name);
1414
+ if (i >= 0 && rest[i + 1]) return rest[i + 1];
1415
+ const eq = rest.find((a) => a.startsWith(`${name}=`));
1416
+ return eq ? eq.slice(name.length + 1) : undefined;
1417
+ }
1418
+
1419
+ function exactFlagValue(rest: string[], name: string): string | undefined {
1420
+ return rest.length === 2 && rest[0] === name && rest[1]
1421
+ ? rest[1]
1422
+ : undefined;
1423
+ }
1424
+
1425
+ type EnrollArgs =
1426
+ | { ok: true; token: string; cloud?: string }
1427
+ | { ok: false };
1428
+
1429
+ /** `enroll` spends a one-time token, so reject malformed/ambiguous argv
1430
+ * instead of silently falling back to persisted configuration. */
1431
+ function parseEnrollArgs(rest: string[]): EnrollArgs {
1432
+ let token: string | undefined;
1433
+ let cloud: string | undefined;
1434
+ let sawCloud = false;
1435
+
1436
+ for (let index = 0; index < rest.length; index += 1) {
1437
+ const arg = rest[index]!;
1438
+ if (arg === "--cloud") {
1439
+ if (sawCloud) return { ok: false };
1440
+ sawCloud = true;
1441
+ const value = rest[index + 1];
1442
+ if (!value || value.startsWith("-")) return { ok: false };
1443
+ cloud = value;
1444
+ index += 1;
1445
+ continue;
1446
+ }
1447
+ if (arg.startsWith("--cloud=")) {
1448
+ if (sawCloud) return { ok: false };
1449
+ sawCloud = true;
1450
+ cloud = arg.slice("--cloud=".length);
1451
+ if (!cloud) return { ok: false };
1452
+ continue;
1453
+ }
1454
+ if (arg.startsWith("-") || token) return { ok: false };
1455
+ token = arg;
1456
+ }
1457
+
1458
+ return token ? { ok: true, token, ...(cloud ? { cloud } : {}) } : { ok: false };
1459
+ }
1460
+
1461
+ type HostIdentity =
1462
+ | { kind: "none" }
1463
+ | { kind: "partial" }
1464
+ | { kind: "complete"; hostId: string; hostToken: string };
1465
+
1466
+ /** Resolve both modern env identities and legacy `pair` identities whose id
1467
+ * lives in `$UAI_DATA_DIR/host-id`. Exactly one unresolved half fails closed. */
1468
+ function hostIdentity(): HostIdentity {
1469
+ const hostToken = process.env.UAI_HOST_TOKEN;
1470
+ const hostId = process.env.UAI_HOST_ID ?? (hostToken ? readPersistedHostId() : null);
1471
+ if (!hostId && !hostToken) return { kind: "none" };
1472
+ if (!hostId || !hostToken) return { kind: "partial" };
1473
+ return { kind: "complete", hostId, hostToken };
1474
+ }
1475
+
1476
+ function rejectPartialIdentity(): void {
1477
+ console.error(
1478
+ red("incomplete host identity — refusing to replace an existing enrollment"),
1479
+ );
1480
+ console.error(
1481
+ dim("restore UAI_HOST_ID/UAI_HOST_TOKEN, or use `moondeck-station setup --force` for a deliberate reset."),
1482
+ );
1483
+ process.exitCode = 1;
1484
+ }
1485
+
1486
+ /** The bridge credential is durable, so its destination trust anchor must be
1487
+ * durable too. Process env is intentionally NOT consulted here: a pasted
1488
+ * command can override it before load-env reads the host's real config. */
1489
+ function persistedCloudUrl(): string | undefined {
1490
+ for (const path of [envLocalPath(), join(uaiHome(), ".env")]) {
1491
+ if (!existsSync(path)) continue;
1492
+ try {
1493
+ const value = parseDotenv(readFileSync(path)).UAI_CLOUD_URL;
1494
+ if (value) return value;
1495
+ } catch {
1496
+ // The normal env loader already reports malformed/unreadable files. A
1497
+ // missing trust anchor is handled by the caller without leaking secrets.
1498
+ }
1499
+ }
1500
+ return undefined;
1501
+ }
1502
+
1503
+ /**
1504
+ * Claim this machine as a host: redeem a one-time enrollment token (minted in
1505
+ * the web app) for a permanent bridge credential, then write the config. The
1506
+ * bridge secret is generated HERE and never leaves — only its hash is sent
1507
+ * (ADR-015/ADR-031).
1508
+ *
1509
+ * uai-host setup --cloud wss://app.runuai.com/host --enroll uaienroll_…
1510
+ */
1511
+ async function cmdSetup(rest: string[]): Promise<void> {
1512
+ const enroll = flagValue(rest, "--enroll");
1513
+ const cloud = flagValue(rest, "--cloud");
1514
+ if (!enroll || !cloud) {
1515
+ console.error(
1516
+ red("usage: moondeck-station setup --cloud <wss-url> --enroll <token>"),
1517
+ );
1518
+ process.exitCode = 1;
1519
+ return;
1520
+ }
1521
+
1522
+ let cloudEndpoint: ReturnType<typeof parseHostCloudEndpoint>;
1523
+ try {
1524
+ cloudEndpoint = parseHostCloudEndpoint(cloud);
1525
+ } catch (error) {
1526
+ console.error(
1527
+ red(
1528
+ `invalid --cloud URL: ${error instanceof Error ? error.message : String(error)}`,
1529
+ ),
1530
+ );
1531
+ process.exitCode = 1;
1532
+ return;
1533
+ }
1534
+ const normalizedCloud = cloudEndpoint.bridgeUrl;
1535
+
1536
+ // An already-claimed machine ATTACHES the token to its existing identity
1537
+ // (ADR-098) — pasting a second org's enroll command Just Works, no new host
1538
+ // row, no --force. --force still re-enrolls from scratch (new identity,
1539
+ // orphans the old row) for the rare deliberate reset.
1540
+ const identity = hostIdentity();
1541
+ if (identity.kind === "partial" && !rest.includes("--force")) {
1542
+ rejectPartialIdentity();
1543
+ return;
1544
+ }
1545
+ if (identity.kind === "complete" && !rest.includes("--force")) {
1546
+ try {
1547
+ removeCommittedPendingEnrollment(pendingEnrollmentPath(uaiHome()), {
1548
+ hostId: identity.hostId,
1549
+ hostSecret: identity.hostToken,
1550
+ });
1551
+ } catch (err) {
1552
+ console.error(
1553
+ red(
1554
+ `cannot verify pending enrollment state: ${err instanceof Error ? err.message : String(err)}`,
1555
+ ),
1556
+ );
1557
+ process.exitCode = 1;
1558
+ return;
1559
+ }
1560
+ console.log(
1561
+ dim("already enrolled — attaching this machine to the token's workspace instead."),
1562
+ );
1563
+ return attachToOrg(enroll, normalizedCloud, identity);
1564
+ }
1565
+
1566
+ const pendingFile = pendingEnrollmentPath(uaiHome());
1567
+ if (rest.includes("--force")) discardPendingEnrollment(pendingFile);
1568
+
1569
+ const redeemUrl = `${cloudEndpoint.apiOrigin}/api/hosts/enroll/redeem`;
1570
+
1571
+ // Persist this host's proposed identity BEFORE spending the one-time token.
1572
+ // A retry after a lost response reuses the exact id/secret, and the cloud's
1573
+ // same-host replay returns the already-completed result without new access.
1574
+ const candidate: PendingEnrollment = {
1575
+ schemaVersion: 1,
1576
+ enrollmentTokenSha256: enrollmentTokenSha256(enroll),
1577
+ hostId: ulid().toLowerCase(),
1578
+ hostSecret: randomBytes(32).toString("hex"),
1579
+ cloudUrl: normalizedCloud,
1580
+ hostName: hostname(),
1581
+ createdAt: Date.now(),
1582
+ };
1583
+ let pending: PendingEnrollment;
1584
+ try {
1585
+ pending = ensurePendingEnrollment(pendingFile, candidate).pending;
1586
+ } catch (err) {
1587
+ console.error(
1588
+ red(
1589
+ `could not persist pending enrollment: ${err instanceof Error ? err.message : String(err)}`,
1590
+ ),
1591
+ );
1592
+ process.exitCode = 1;
1593
+ return;
1594
+ }
1595
+ if (!pendingEnrollmentMatches(pending, enroll, normalizedCloud)) {
1596
+ console.error(red("another enrollment is already pending on this machine"));
1597
+ console.error(
1598
+ dim(
1599
+ "retry the original enrollment command, or use `moondeck-station setup --force` for a deliberate reset.",
1600
+ ),
1601
+ );
1602
+ process.exitCode = 1;
1603
+ return;
1604
+ }
1605
+ const hostId = pending.hostId;
1606
+ const secret = pending.hostSecret;
1607
+ const tokenHash = createHash("sha256").update(secret).digest("hex");
1608
+ const name = pending.hostName;
1609
+
1610
+ console.log(
1611
+ dim(`enrolling ${name} (${hostId}) with ${cloudEndpoint.displayHost}…`),
1612
+ );
1613
+ let res: Response;
1614
+ try {
1615
+ res = await fetch(redeemUrl, {
1616
+ method: "POST",
1617
+ redirect: "error",
1618
+ headers: { "content-type": "application/json" },
1619
+ body: JSON.stringify({ enrollToken: enroll, hostId, tokenHash, name }),
1620
+ signal: AbortSignal.timeout(15_000),
1621
+ });
1622
+ } catch (err) {
1623
+ console.error(
1624
+ red(`could not reach the cloud: ${err instanceof Error ? err.message : String(err)}`),
1625
+ );
1626
+ console.error(dim("the proposed identity is saved locally; retry this exact command."));
1627
+ process.exitCode = 1;
1628
+ return;
1629
+ }
1630
+ if (!res.ok) {
1631
+ let detail = `HTTP ${res.status}`;
1632
+ try {
1633
+ const j = (await boundedResponseJson(res)) as {
1634
+ error?: { message?: string };
1635
+ };
1636
+ if (j.error?.message) detail = j.error.message;
1637
+ } catch {
1638
+ /* keep the HTTP status */
1639
+ }
1640
+ console.error(red(`enrollment failed: ${detail}`));
1641
+ const definitiveUnclaimed =
1642
+ (res.status === 404 || res.status === 410) &&
1643
+ res.headers.get(ENROLLMENT_REPLAY_PROTOCOL_HEADER) ===
1644
+ ENROLLMENT_REPLAY_PROTOCOL_VERSION;
1645
+ if (definitiveUnclaimed) {
1646
+ // A same-host-v1 cloud checks `redeemedAt` before expiry, so these are
1647
+ // proven unclaimed. Old/rolled-back handlers carry no header and may
1648
+ // return 410 after committing a response that was lost in transit.
1649
+ discardPendingEnrollment(pendingFile);
1650
+ console.error(dim("mint a fresh enrollment command in the web app and retry."));
1651
+ } else {
1652
+ // USED is intentionally retained for rolling compatibility: an older
1653
+ // cloud may have completed the first request but lack same-host replay.
1654
+ console.error(dim("the proposed identity is saved locally; retry this exact command."));
1655
+ }
1656
+ process.exitCode = 1;
1657
+ return;
1658
+ }
1659
+
1660
+ let success: unknown;
1661
+ try {
1662
+ success = await boundedResponseJson(res);
1663
+ } catch (error) {
1664
+ console.error(
1665
+ red(
1666
+ `enrollment outcome could not be verified: ${error instanceof Error ? error.message : String(error)}`,
1667
+ ),
1668
+ );
1669
+ console.error(dim("the proposed identity is saved locally; retry this exact command."));
1670
+ process.exitCode = 1;
1671
+ return;
1672
+ }
1673
+ // ADR-136 step 4: a replace-machine enrollment answers with the EXISTING
1674
+ // row's id — this machine adopts it instead of its own proposal, so the
1675
+ // runner keeps its identity (and every task created against it).
1676
+ const outcome =
1677
+ success && typeof success === "object" && !Array.isArray(success)
1678
+ ? (success as Record<string, unknown>)
1679
+ : null;
1680
+ const replacedHostId =
1681
+ outcome?.replaced === true && typeof outcome.hostId === "string" && outcome.hostId.length > 0
1682
+ ? outcome.hostId
1683
+ : null;
1684
+ if (
1685
+ !outcome ||
1686
+ outcome.ok !== true ||
1687
+ (replacedHostId === null && outcome.hostId !== pending.hostId)
1688
+ ) {
1689
+ console.error(red("enrollment outcome did not match the proposed host identity"));
1690
+ console.error(dim("the proposed identity is saved locally; retry this exact command."));
1691
+ process.exitCode = 1;
1692
+ return;
1693
+ }
1694
+
1695
+ // Atomically commit all three values. The pending file is removed only after
1696
+ // the replacement is durable; a crash can leave both files, never neither.
1697
+ try {
1698
+ commitPendingEnrollment(pendingFile, envLocalPath(), pending);
1699
+ } catch (err) {
1700
+ console.error(
1701
+ red(
1702
+ `enrolled, but could not write ${envLocalPath()}: ${err instanceof Error ? err.message : String(err)}`,
1703
+ ),
1704
+ );
1705
+ console.error(
1706
+ dim("the identity remains in protected pending state — retry this exact enrollment command."),
1707
+ );
1708
+ process.exitCode = 1;
1709
+ return;
1710
+ }
1711
+ if (replacedHostId !== null) {
1712
+ // The pending commit wrote the proposed id; the row this machine now
1713
+ // answers as is the replaced one. Same secret, so the bridge admits it.
1714
+ try {
1715
+ writeEnvValuesAtomic(envLocalPath(), { UAI_HOST_ID: replacedHostId });
1716
+ } catch (err) {
1717
+ console.error(
1718
+ red(
1719
+ `replaced, but could not record the station id in ${envLocalPath()}: ${err instanceof Error ? err.message : String(err)}`,
1720
+ ),
1721
+ );
1722
+ console.error(dim(`set UAI_HOST_ID=${replacedHostId} there by hand before starting.`));
1723
+ process.exitCode = 1;
1724
+ return;
1725
+ }
1726
+ console.log(
1727
+ green("✓ replaced") + dim(` — this machine now answers as station ${replacedHostId}; config written to ${envLocalPath()}`),
1728
+ );
1729
+ } else {
1730
+ console.log(green("✓ enrolled") + dim(` — config written to ${envLocalPath()}`));
1731
+ }
1732
+ printTelemetryNotice();
1733
+ const steps = enrollmentNextSteps();
1734
+ if (steps.length > 0) console.log("");
1735
+ for (const step of steps) {
1736
+ console.log(step.command ? cyan(step.command) + dim(step.note ?? "") : step.text);
1737
+ }
1738
+ }
1739
+
1740
+ // --- enroll (attach an org, ADR-098) -----------------------------------------
1741
+
1742
+ /**
1743
+ * Enroll this machine. Without an identity this delegates to fresh setup; with
1744
+ * one, it spends the token against the existing host rather than minting a new
1745
+ * identity. The bridge secret authenticates attach (same credential the WSS
1746
+ * bridge presents) and may only be sent over TLS or to a loopback dev cloud.
1747
+ *
1748
+ * uai-host enroll <token> [--cloud <wss-url>]
1749
+ */
1750
+ async function cmdEnroll(rest: string[]): Promise<void> {
1751
+ const parsed = parseEnrollArgs(rest);
1752
+ if (!parsed.ok) {
1753
+ console.error(red("usage: moondeck-station enroll <token> [--cloud <wss-url>]"));
1754
+ process.exitCode = 1;
1755
+ return;
1756
+ }
1757
+ const identity = hostIdentity();
1758
+ if (identity.kind === "partial") {
1759
+ rejectPartialIdentity();
1760
+ return;
1761
+ }
1762
+ // A fresh station enrolls with moondeck.io unless told otherwise; an
1763
+ // enrolled one stays on the cloud it already belongs to.
1764
+ const cloud =
1765
+ parsed.cloud ??
1766
+ (identity.kind === "complete"
1767
+ ? persistedCloudUrl()
1768
+ : process.env.UAI_CLOUD_URL || STATION_DEFAULT_CLOUD_URL);
1769
+ if (!cloud) {
1770
+ console.error(
1771
+ red(
1772
+ identity.kind === "complete"
1773
+ ? "no persisted cloud URL — restore UAI_CLOUD_URL in the host config"
1774
+ : "no cloud URL — pass --cloud <wss-url>",
1775
+ ),
1776
+ );
1777
+ process.exitCode = 1;
1778
+ return;
1779
+ }
1780
+ if (identity.kind === "none") {
1781
+ return cmdSetup(["--cloud", cloud, "--enroll", parsed.token]);
1782
+ }
1783
+ return attachToOrg(parsed.token, cloud, identity);
1784
+ }
1785
+
1786
+ /** Shared by `enroll` and `setup`-on-an-enrolled-machine. */
1787
+ async function attachToOrg(
1788
+ enrollToken: string,
1789
+ cloud: string,
1790
+ identity: Extract<HostIdentity, { kind: "complete" }>,
1791
+ ): Promise<void> {
1792
+ const { hostId, hostToken } = identity;
1793
+
1794
+ let cloudEndpoint: ReturnType<typeof parseHostCloudEndpoint>;
1795
+ try {
1796
+ cloudEndpoint = parseHostCloudEndpoint(cloud);
1797
+ } catch (error) {
1798
+ console.error(
1799
+ red(
1800
+ `invalid cloud URL: ${error instanceof Error ? error.message : String(error)}`,
1801
+ ),
1802
+ );
1803
+ process.exitCode = 1;
1804
+ return;
1805
+ }
1806
+
1807
+ const configuredCloud = persistedCloudUrl();
1808
+ if (!configuredCloud) {
1809
+ console.error(
1810
+ red("cannot verify the enrollment destination — UAI_CLOUD_URL is not persisted"),
1811
+ );
1812
+ console.error(dim("restore the original cloud URL before attaching this host."));
1813
+ process.exitCode = 1;
1814
+ return;
1815
+ }
1816
+ let configuredEndpoint: ReturnType<typeof parseHostCloudEndpoint>;
1817
+ try {
1818
+ configuredEndpoint = parseHostCloudEndpoint(configuredCloud);
1819
+ } catch (error) {
1820
+ console.error(
1821
+ red(
1822
+ `invalid configured UAI_CLOUD_URL: ${error instanceof Error ? error.message : String(error)}`,
1823
+ ),
1824
+ );
1825
+ process.exitCode = 1;
1826
+ return;
1827
+ }
1828
+ if (cloudEndpoint.apiOrigin !== configuredEndpoint.apiOrigin) {
1829
+ console.error(
1830
+ red("refusing to send the host credential to a different cloud origin"),
1831
+ );
1832
+ console.error(dim(`configured cloud: ${configuredEndpoint.displayHost}`));
1833
+ process.exitCode = 1;
1834
+ return;
1835
+ }
1836
+ const attachUrl = `${cloudEndpoint.apiOrigin}/api/hosts/enroll/attach`;
1837
+
1838
+ console.log(
1839
+ dim(`attaching ${hostname()} (${hostId}) via ${cloudEndpoint.displayHost}…`),
1840
+ );
1841
+ let res: Response;
1842
+ try {
1843
+ res = await fetch(attachUrl, {
1844
+ method: "POST",
1845
+ redirect: "error",
1846
+ headers: { "content-type": "application/json" },
1847
+ body: JSON.stringify({ enrollToken, hostId, token: hostToken }),
1848
+ signal: AbortSignal.timeout(15_000),
1849
+ });
1850
+ } catch (err) {
1851
+ console.error(
1852
+ red(`could not reach the cloud: ${err instanceof Error ? err.message : String(err)}`),
1853
+ );
1854
+ process.exitCode = 1;
1855
+ return;
1856
+ }
1857
+ if (!res.ok) {
1858
+ let detail = `HTTP ${res.status}`;
1859
+ try {
1860
+ const j = (await boundedResponseJson(res)) as {
1861
+ error?: { message?: string };
1862
+ };
1863
+ if (j.error?.message) detail = j.error.message;
1864
+ } catch {
1865
+ /* keep the HTTP status */
1866
+ }
1867
+ console.error(red(`attach failed: ${detail}`));
1868
+ console.error(
1869
+ dim("the token may be expired or already used — mint a fresh one in the web app."),
1870
+ );
1871
+ process.exitCode = 1;
1872
+ return;
1873
+ }
1874
+ let body: unknown;
1875
+ try {
1876
+ body = await boundedResponseJson(res);
1877
+ } catch (error) {
1878
+ console.error(
1879
+ red(
1880
+ `attach outcome could not be verified: ${error instanceof Error ? error.message : String(error)}`,
1881
+ ),
1882
+ );
1883
+ process.exitCode = 1;
1884
+ return;
1885
+ }
1886
+ const result = body as Record<string, unknown> | null;
1887
+ if (
1888
+ !result ||
1889
+ Array.isArray(result) ||
1890
+ result.ok !== true ||
1891
+ result.hostId !== hostId ||
1892
+ typeof result.orgId !== "string" ||
1893
+ result.orgId.length === 0 ||
1894
+ result.orgId.length > 128 ||
1895
+ (result.orgName !== null &&
1896
+ result.orgName !== undefined &&
1897
+ typeof result.orgName !== "string")
1898
+ ) {
1899
+ console.error(red("attach outcome did not match this host identity"));
1900
+ process.exitCode = 1;
1901
+ return;
1902
+ }
1903
+ const where = result.orgName ? ` to ${result.orgName}` : "";
1904
+ console.log(green(`✓ enrolled${where}`) + dim(" — no restart needed."));
1905
+ }
1906
+
1907
+ // --- pair -------------------------------------------------------------------
1908
+
1909
+ async function cmdPair(token: string | undefined): Promise<void> {
1910
+ if (!token) {
1911
+ console.error(red("usage: moondeck-station pair <token>"));
1912
+ process.exitCode = 1;
1913
+ return;
1914
+ }
1915
+ // v1 "pairing" = persist the host token to .env.local (load-env.ts reads it).
1916
+ // The cloud-issued-pairing-token -> host-token handshake is a future flow
1917
+ // (hosted-architecture.md, "what we don't do now"); this stores what you paste.
1918
+ upsertEnvLocal("UAI_HOST_TOKEN", token);
1919
+ console.log(green("paired") + dim(` — wrote UAI_HOST_TOKEN to ${envLocalPath()}`));
1920
+ console.log(dim("apply it: moondeck-station restart (or start the service)"));
1921
+ printTelemetryNotice();
1922
+ }
1923
+
1924
+ function upsertEnvLocal(key: string, value: string): void {
1925
+ writeEnvValuesAtomic(envLocalPath(), { [key]: value });
1926
+ }
1927
+
1928
+ // --- install dispatch -------------------------------------------------------
1929
+
1930
+ async function cmdInstall(rest: string[]): Promise<void> {
1931
+ const parsed = parseInstallArgs(rest);
1932
+ if (!parsed) {
1933
+ console.error(
1934
+ red(
1935
+ "usage: moondeck-station install [--no-start] [--dry-run] [--install-intent <nonce>]",
1936
+ ),
1937
+ );
1938
+ process.exitCode = 1;
1939
+ return;
1940
+ }
1941
+ const { dryRun, startOnInstall, installIntent } = parsed;
1942
+ // Disclosure BEFORE activation (review): install starts the service, and
1943
+ // the notice must precede the first telemetry-armed run.
1944
+ if (!dryRun) printTelemetryNotice();
1945
+ if (!dryRun) mkdirSync(uaiHome(), { recursive: true, mode: 0o700 });
1946
+ const installer = await loadInstaller();
1947
+ if (dryRun) {
1948
+ await installer.install(installContext(true, startOnInstall));
1949
+ } else if (installIntent) {
1950
+ await runManagedRuntimeInstallIntentMutation({
1951
+ installIntent,
1952
+ mutation: (identity) =>
1953
+ installer.install(installContext(false, startOnInstall, identity)),
1954
+ });
1955
+ } else {
1956
+ await runInstallLifecycleMutation(installer, () =>
1957
+ installer.install(installContext(false, startOnInstall)),
1958
+ );
1959
+ }
1960
+ if (!dryRun) {
1961
+ console.log(
1962
+ green("installed") +
1963
+ dim(
1964
+ startOnInstall
1965
+ ? " — service registered and started"
1966
+ : " — service registered but not started; start it: moondeck-station start",
1967
+ ),
1968
+ );
1969
+ }
1970
+ }
1971
+
1972
+ function parseInstallArgs(rest: string[]): {
1973
+ dryRun: boolean;
1974
+ startOnInstall: boolean;
1975
+ installIntent?: string;
1976
+ } | null {
1977
+ let dryRun = false;
1978
+ let noStart = false;
1979
+ let installIntent: string | undefined;
1980
+ for (let index = 0; index < rest.length; index += 1) {
1981
+ const arg = rest[index]!;
1982
+ if (arg === "--dry-run") {
1983
+ if (dryRun) return null;
1984
+ dryRun = true;
1985
+ continue;
1986
+ }
1987
+ if (arg === "--no-start") {
1988
+ if (noStart) return null;
1989
+ noStart = true;
1990
+ continue;
1991
+ }
1992
+ if (arg === "--install-intent") {
1993
+ if (installIntent) return null;
1994
+ const value = rest[index + 1];
1995
+ if (!value || !/^[a-f0-9]{64}$/.test(value)) return null;
1996
+ installIntent = value;
1997
+ index += 1;
1998
+ continue;
1999
+ }
2000
+ return null;
2001
+ }
2002
+ if (dryRun && installIntent) return null;
2003
+ return { dryRun, startOnInstall: !noStart, installIntent };
2004
+ }
2005
+
2006
+ /**
2007
+ * ADR-071 default-on disclosure at the surfaces an operator actually SEES:
2008
+ * the service's own start line only reaches host.out.log/journald, so the
2009
+ * interactive commands (install/pair/setup/start/restart) print it — and
2010
+ * PERSIST the shown-once marker that gates the baked default (a silently
2011
+ * upgraded service stays off until one of these runs).
2012
+ */
2013
+ function printTelemetryNotice(): void {
2014
+ const notice = telemetryNoticeIfActive();
2015
+ if (notice) {
2016
+ console.log(yellow("telemetry: ") + notice);
2017
+ persistTelemetryDisclosure();
2018
+ }
2019
+ }
2020
+
2021
+ async function cmdInstaller(
2022
+ action: "start" | "stop" | "restart",
2023
+ dryRun: boolean,
2024
+ ): Promise<void> {
2025
+ // Upgrade path (review finding): operators of EXISTING installs meet
2026
+ // default-on telemetry through start/restart — disclose BEFORE the
2027
+ // service action activates anything, and persist the gate marker.
2028
+ if (!dryRun && (action === "start" || action === "restart")) {
2029
+ printTelemetryNotice();
2030
+ }
2031
+ const installer = await loadInstaller();
2032
+ if (dryRun) {
2033
+ await installer[action](true);
2034
+ } else {
2035
+ await runInstallLifecycleMutation(installer, () => installer[action](false));
2036
+ }
2037
+ }
2038
+
2039
+ async function cmdUninstall(rest: string[]): Promise<void> {
2040
+ const dryRun = rest.includes("--dry-run");
2041
+ const purge = rest.includes("--purge");
2042
+ if (
2043
+ rest.some((arg) => arg !== "--dry-run" && arg !== "--purge") ||
2044
+ rest.filter((arg) => arg === "--dry-run").length > 1 ||
2045
+ rest.filter((arg) => arg === "--purge").length > 1
2046
+ ) {
2047
+ console.error(red("usage: moondeck-station uninstall [--purge] [--dry-run]"));
2048
+ process.exitCode = 1;
2049
+ return;
2050
+ }
2051
+ const installer = await loadInstaller();
2052
+ const paths = managedRuntimePaths();
2053
+ if (dryRun) {
2054
+ await installer.uninstall(true);
2055
+ console.log(`$ rm -rf ${paths.root}`);
2056
+ if (purge) console.log(`$ rm -rf ${paths.data}`);
2057
+ return;
2058
+ }
2059
+ await reconcileInstallLifecycle(installer);
2060
+ await removeManagedRuntime({
2061
+ purgeData: purge,
2062
+ canRemove: () => !hasNonTerminalHostTasks(),
2063
+ beforeRemove: () => installer.uninstall(false),
2064
+ afterServiceTeardown: () => teardownBundledRuntimeForUninstall(purge),
2065
+ });
2066
+ console.log(
2067
+ green("uninstalled") +
2068
+ (purge
2069
+ ? dim(" — host runtime and ~/.moondeck data removed")
2070
+ : dim(" — ~/.moondeck data preserved (use --purge to remove it)")),
2071
+ );
2072
+ }
2073
+
2074
+ /**
2075
+ * The service is already down, but the active signed generation and purge
2076
+ * target are still attached here — the last moment the bundled CLI exists to
2077
+ * clean up what Uai provisioned through it. Ownership-proven and fail-closed
2078
+ * (see lib/apple-uninstall-teardown.ts): an incomplete teardown THROWS, which
2079
+ * deliberately leaves the managed-removal transaction's teardown marker and
2080
+ * the signed runtime in place for a retry — never remove the escape route
2081
+ * while Uai resources may remain.
2082
+ */
2083
+ async function teardownBundledRuntimeForUninstall(purge: boolean): Promise<void> {
2084
+ if (process.platform !== "darwin") return;
2085
+ // The durable provider claim marker is the authority for whether Apple
2086
+ // state can exist at all: it is written BEFORE any apple task provisioning
2087
+ // (runtime-provider-state.ts). Absent marker = nothing was ever
2088
+ // provisioned = nothing to tear down. A malformed marker throws from the
2089
+ // reader — uncertainty keeps the uninstall retryable. With the marker
2090
+ // present, a corrupt bundle must THROW rather than skip (review
2091
+ // 2026-08-18 round 3: the old `.catch(() => null)` silently removed the
2092
+ // signed runtime while Apple state remained — the escape-route failure).
2093
+ const providerState = readRuntimeProviderState(
2094
+ runtimeProviderStatePath(env.dataDir),
2095
+ );
2096
+ if (providerState?.statefulProvider !== "apple-container") return;
2097
+ let bundle: Awaited<ReturnType<typeof resolveBundledRuntimeFromEnvironment>>;
2098
+ try {
2099
+ bundle = await resolveBundledRuntimeFromEnvironment();
2100
+ } catch (error) {
2101
+ throw new Error(
2102
+ "Apple container state exists but the bundled runtime failed " +
2103
+ `verification (${error instanceof Error ? error.message : String(error)}); ` +
2104
+ "uninstall was left retryable — repair or reinstall the host runtime, then retry",
2105
+ );
2106
+ }
2107
+ const cliPath = bundle?.containerCliPath;
2108
+ if (!cliPath) {
2109
+ throw new Error(
2110
+ "Apple container state exists but no bundled CLI is available to tear it down; uninstall was left retryable",
2111
+ );
2112
+ }
2113
+ const cli = (args: string[], timeoutMs: number) =>
2114
+ new Promise<{ code: number | null; stdout: string; stderr: string }>(
2115
+ (resolveCli) => {
2116
+ let stdout = "";
2117
+ let stderr = "";
2118
+ let settled = false;
2119
+ const child = spawn(cliPath, args, {
2120
+ stdio: ["ignore", "pipe", "pipe"],
2121
+ });
2122
+ const settle = (code: number | null): void => {
2123
+ if (settled) return;
2124
+ settled = true;
2125
+ clearTimeout(timer);
2126
+ resolveCli({ code, stdout, stderr });
2127
+ };
2128
+ const timer = setTimeout(() => {
2129
+ child.kill("SIGKILL");
2130
+ const backstop = setTimeout(() => settle(null), 1_000);
2131
+ backstop.unref();
2132
+ }, timeoutMs);
2133
+ timer.unref();
2134
+ child.stdout?.setEncoding("utf8");
2135
+ child.stderr?.setEncoding("utf8");
2136
+ child.stdout?.on("data", (chunk: string) => {
2137
+ stdout += chunk;
2138
+ });
2139
+ child.stderr?.on("data", (chunk: string) => {
2140
+ stderr += chunk;
2141
+ });
2142
+ child.on("error", () => settle(null));
2143
+ child.on("close", (code) => settle(code));
2144
+ },
2145
+ );
2146
+ const result = await teardownAppleRuntimeStateForUninstall({ cli, purge });
2147
+ for (const removed of result.removedContainers) {
2148
+ console.log(dim(`removed container ${removed}`));
2149
+ }
2150
+ for (const removed of result.removedImages) {
2151
+ console.log(dim(`removed image ${removed}`));
2152
+ }
2153
+ if (result.problems.length > 0) {
2154
+ throw new Error(
2155
+ `Apple container teardown is incomplete — uninstall was left retryable: ${result.problems.join("; ")}`,
2156
+ );
2157
+ }
2158
+ }
2159
+
2160
+ async function loadInstaller(): Promise<Installer> {
2161
+ const name =
2162
+ process.platform === "darwin"
2163
+ ? "darwin"
2164
+ : process.platform === "win32"
2165
+ ? "win"
2166
+ : "linux";
2167
+ try {
2168
+ const mod = (await import(`../scripts/install/${name}`)) as {
2169
+ installer: Installer;
2170
+ };
2171
+ return mod.installer;
2172
+ } catch (err) {
2173
+ throw new Error(
2174
+ `no installer available for ${process.platform}: ${err instanceof Error ? err.message : String(err)}`,
2175
+ );
2176
+ }
2177
+ }
2178
+
2179
+ async function reconcileDefinitionInstall(
2180
+ installer: Installer,
2181
+ installIntent?: string,
2182
+ ): Promise<void> {
2183
+ const reconcile = (
2184
+ installer as Installer & {
2185
+ reconcileDefinitionInstall?: (installIntent?: string) => Promise<void>;
2186
+ }
2187
+ ).reconcileDefinitionInstall;
2188
+ if (!reconcile) {
2189
+ throw new Error(
2190
+ "service installer cannot reconcile an interrupted definition update",
2191
+ );
2192
+ }
2193
+ await reconcile.call(installer, installIntent);
2194
+ }
2195
+
2196
+ async function completeDefinitionInstall(
2197
+ installer: Installer,
2198
+ installIntent?: string,
2199
+ ): Promise<void> {
2200
+ const complete = (
2201
+ installer as Installer & {
2202
+ completeDefinitionInstall?: (installIntent?: string) => Promise<void>;
2203
+ }
2204
+ ).completeDefinitionInstall;
2205
+ if (!complete) {
2206
+ throw new Error(
2207
+ "service installer cannot complete its durable definition update",
2208
+ );
2209
+ }
2210
+ await complete.call(installer, installIntent);
2211
+ }
2212
+
2213
+ async function prepareDefinitionInstallRollback(
2214
+ installer: Installer,
2215
+ installIntent: string,
2216
+ ): Promise<void> {
2217
+ const prepare = (
2218
+ installer as Installer & {
2219
+ prepareDefinitionInstallRollback?: (installIntent: string) => Promise<void>;
2220
+ }
2221
+ ).prepareDefinitionInstallRollback;
2222
+ if (!prepare) {
2223
+ throw new Error(
2224
+ "service installer cannot prepare durable definition rollback",
2225
+ );
2226
+ }
2227
+ await prepare.call(installer, installIntent);
2228
+ }
2229
+
2230
+ async function resumeDefinitionInstallRollback(
2231
+ installer: Installer,
2232
+ installIntent: string,
2233
+ ): Promise<void> {
2234
+ const resume = (
2235
+ installer as Installer & {
2236
+ resumeDefinitionInstallRollback?: (installIntent: string) => Promise<void>;
2237
+ }
2238
+ ).resumeDefinitionInstallRollback;
2239
+ if (!resume) {
2240
+ throw new Error(
2241
+ "service installer cannot resume durable definition rollback",
2242
+ );
2243
+ }
2244
+ await resume.call(installer, installIntent);
2245
+ }
2246
+
2247
+ async function serviceDefinitionState(
2248
+ installer: Installer,
2249
+ managedCommand: string,
2250
+ ): Promise<ManagedServiceDefinitionState> {
2251
+ const inspect = (
2252
+ installer as Installer & {
2253
+ definitionState?: (
2254
+ managedCommand: string,
2255
+ ) => Promise<ManagedServiceDefinitionState>;
2256
+ }
2257
+ ).definitionState;
2258
+ if (!inspect) {
2259
+ throw new Error("service installer cannot inspect definition lifecycle state");
2260
+ }
2261
+ return inspect.call(installer, managedCommand);
2262
+ }
2263
+
2264
+ async function stopServiceForInstall(
2265
+ installer: Installer,
2266
+ installIntent: string,
2267
+ priorManager: ManagedServiceManagerState,
2268
+ ): Promise<void> {
2269
+ const stop = (
2270
+ installer as Installer & {
2271
+ stopForInstall?: (
2272
+ installIntent: string,
2273
+ priorManager: ManagedServiceManagerState,
2274
+ ) => Promise<void>;
2275
+ }
2276
+ ).stopForInstall;
2277
+ if (!stop) {
2278
+ throw new Error("service installer cannot stop an install-owned service");
2279
+ }
2280
+ await stop.call(installer, installIntent, priorManager);
2281
+ }
2282
+
2283
+ async function resumeServiceForInstall(
2284
+ installer: Installer,
2285
+ installIntent: string,
2286
+ priorManager: ManagedServiceManagerState,
2287
+ ): Promise<void> {
2288
+ const resume = (
2289
+ installer as Installer & {
2290
+ resumeServiceForInstall?: (
2291
+ installIntent: string,
2292
+ priorManager: ManagedServiceManagerState,
2293
+ ) => Promise<void>;
2294
+ }
2295
+ ).resumeServiceForInstall;
2296
+ if (!resume) {
2297
+ throw new Error("service installer cannot resume an install-owned service");
2298
+ }
2299
+ await resume.call(installer, installIntent, priorManager);
2300
+ }
2301
+
2302
+ async function reconcileInstallLifecycle(installer: Installer): Promise<void> {
2303
+ await reconcileManagedRuntimeInstallLifecycle(
2304
+ installLifecycleCallbacks(installer),
2305
+ );
2306
+ }
2307
+
2308
+ async function runInstallLifecycleMutation<T>(
2309
+ installer: Installer,
2310
+ mutation: () => Promise<T>,
2311
+ ): Promise<T> {
2312
+ return runManagedRuntimeInstallLifecycleMutation({
2313
+ ...installLifecycleCallbacks(installer),
2314
+ mutation,
2315
+ });
2316
+ }
2317
+
2318
+ function installLifecycleCallbacks(
2319
+ installer: Installer,
2320
+ ): ReconcileManagedRuntimeInstallLifecycleOptions {
2321
+ return {
2322
+ reconcileDefinition: (installIntent) =>
2323
+ reconcileDefinitionInstall(installer, installIntent),
2324
+ completeDefinition: (installIntent) =>
2325
+ completeDefinitionInstall(installer, installIntent),
2326
+ prepareDefinitionRollback: (installIntent) =>
2327
+ prepareDefinitionInstallRollback(installer, installIntent),
2328
+ resumeDefinitionRollback: (installIntent) =>
2329
+ resumeDefinitionInstallRollback(installer, installIntent),
2330
+ resumeStoppedService: (installIntent, priorManager) =>
2331
+ resumeServiceForInstall(installer, installIntent, priorManager),
2332
+ };
2333
+ }
2334
+
2335
+ function installContext(
2336
+ dryRun: boolean,
2337
+ startOnInstall = true,
2338
+ definitionInstallIntent?: { nonce: string; ownerPid: number },
2339
+ ): InstallContext {
2340
+ const run = resolveRunCommand();
2341
+ const currentPath = process.env.PATH ?? "";
2342
+ const managedBin = pathForManagedCommand(run.execPath);
2343
+ const envPath = managedBin
2344
+ ? [managedBin, ...currentPath.split(":").filter((entry) => entry !== managedBin)].join(":")
2345
+ : currentPath;
2346
+ const context: InstallContext & {
2347
+ definitionInstallIntent?: { nonce: string; ownerPid: number };
2348
+ } = {
2349
+ execPath: run.execPath,
2350
+ args: run.args,
2351
+ cwd: uaiHome(),
2352
+ dryRun,
2353
+ envPath,
2354
+ startOnInstall,
2355
+ definitionInstallIntent,
2356
+ };
2357
+ return context;
2358
+ }
2359
+
2360
+ /** The command that runs `uai-host run`: the installed binary if on PATH,
2361
+ * else `pnpm host-agent` from the repo (dev). */
2362
+ function resolveRunCommand(): { execPath: string; args: string[] } {
2363
+ const managed = managedRuntimePaths();
2364
+ if (existsSync(managed.current) && existsSync(managed.shim)) {
2365
+ return { execPath: managed.shim, args: ["run"] };
2366
+ }
2367
+ const uaiHost = which(STATION.command);
2368
+ if (uaiHost) return { execPath: uaiHost, args: ["run"] };
2369
+ return { execPath: which("pnpm") ?? "pnpm", args: ["host-agent"] };
2370
+ }
2371
+
2372
+ function pathForManagedCommand(execPath: string): string | null {
2373
+ const managed = managedRuntimePaths();
2374
+ return resolve(execPath) === managed.shim ? dirname(managed.shim) : null;
2375
+ }
2376
+
2377
+ function which(cmd: string): string | null {
2378
+ const finder = process.platform === "win32" ? "where" : "which";
2379
+ const res = spawnSync(finder, [cmd], { encoding: "utf8" });
2380
+ if (res.status !== 0 || !res.stdout) return null;
2381
+ return res.stdout.split("\n")[0]?.trim() || null;
2382
+ }
2383
+
2384
+ // --- helpers ----------------------------------------------------------------
2385
+
2386
+ function readPort(): number | null {
2387
+ const p = uiPortFilePath();
2388
+ if (!existsSync(p)) return null;
2389
+ const n = Number(readFileSync(p, "utf8").trim());
2390
+ return Number.isInteger(n) && n > 0 ? n : null;
2391
+ }
2392
+
2393
+ function fmtDuration(ms: number): string {
2394
+ const s = Math.floor(ms / 1000);
2395
+ if (s < 60) return `${s}s`;
2396
+ const m = Math.floor(s / 60);
2397
+ if (m < 60) return `${m}m`;
2398
+ const h = Math.floor(m / 60);
2399
+ if (h < 24) return `${h}h ${m % 60}m`;
2400
+ return `${Math.floor(h / 24)}d ${h % 24}h`;
2401
+ }
2402
+
2403
+ function fmtBytes(n: number): string {
2404
+ if (n < 1024) return `${n} B`;
2405
+ const units = ["KB", "MB", "GB", "TB"];
2406
+ let v = n / 1024;
2407
+ let i = 0;
2408
+ while (v >= 1024 && i < units.length - 1) {
2409
+ v /= 1024;
2410
+ i += 1;
2411
+ }
2412
+ return `${v.toFixed(1)} ${units[i]}`;
2413
+ }
2414
+
2415
+ function printHelp(): void {
2416
+ console.log(`${bold("moondeck-station")} — Moondeck Station: runs Moondeck tasks on this machine (ADR-028)
2417
+
2418
+ run run the service in the foreground (debug)
2419
+ install [--dry-run] install as a per-user service for this OS
2420
+ [--no-start] register the service without launching it
2421
+ uninstall [--purge] remove service/runtime; preserve ~/.moondeck unless --purge
2422
+ uninstall-legacy [--purge] [--force] [--dry-run]
2423
+ remove the old Uai host from this machine: its service,
2424
+ runtime, uai-host commands and task containers; keeps
2425
+ ~/.uai unless --purge; refuses while it runs tasks
2426
+ unless --force (their work is lost)
2427
+ start | stop control the installed service
2428
+ restart stop + start
2429
+ update accept signed updates (also resumes after rollback)
2430
+ rollback reactivate previous runtime; pause automatic updates
2431
+ moondeck-station-recover rollback
2432
+ roll back via previous runtime if current cannot start
2433
+ enrollment status report whether this machine has a complete identity
2434
+ status connection, service info, active tasks (same as the UI)
2435
+ logs [--follow] tail the service log
2436
+ runtime recheck probe Docker again and refresh cloud capabilities
2437
+ tune (macOS, sudo) raise kernel vnode/file-table ceilings for
2438
+ apple-container task VMs and persist them across boots
2439
+ aws setup [--region <r>] [--rotate]
2440
+ enable machine-backed tasks in YOUR AWS account: uses
2441
+ the admin credentials already in this shell to mint a
2442
+ scoped uai-host IAM key and writes the host config
2443
+ setup --cloud <wss-url> --enroll <token>
2444
+ claim this machine via an enrollment token from the web
2445
+ app (mints + writes the host credential); on an
2446
+ already-enrolled machine, attaches the token's workspace
2447
+ enroll <token> [--cloud <wss-url>]
2448
+ first-time setup, or attach this machine to another
2449
+ workspace (token minted on that workspace's Hosts page)
2450
+ pair <token> store a host token directly (manual fallback)
2451
+ open open the local UI in your browser
2452
+ `);
2453
+ }
2454
+
2455
+ main().catch((err) => {
2456
+ console.error(red(err instanceof Error ? err.message : String(err)));
2457
+ process.exitCode = 1;
2458
+ });
2459
+
2460
+ // --- the old @runuai/host on this machine (Moondeck migration plan §4) ------
2461
+
2462
+ function legacyHostSeams(): LegacyHostSeams {
2463
+ return {
2464
+ home: homedir(),
2465
+ platform: process.platform,
2466
+ uid: process.getuid?.() ?? 0,
2467
+ inheritedEnv: process.env,
2468
+ userBus: systemUserBusProbe(),
2469
+ exec: (command, args, commandEnv) => {
2470
+ const result = spawnSync(command, [...args], {
2471
+ encoding: "utf8",
2472
+ timeout: 120_000,
2473
+ ...(commandEnv ? { env: { ...commandEnv } } : {}),
2474
+ });
2475
+ return {
2476
+ status: result.error ? null : result.status,
2477
+ stdout: result.stdout ?? "",
2478
+ stderr: result.stderr ?? (result.error ? String(result.error) : ""),
2479
+ };
2480
+ },
2481
+ fetchJson: async (url) => {
2482
+ const response = await fetch(url, { signal: AbortSignal.timeout(2_000) });
2483
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
2484
+ return response.json();
2485
+ },
2486
+ exists: (target) => existsSync(target),
2487
+ readText: (target) => {
2488
+ try {
2489
+ return readFileSync(target, "utf8");
2490
+ } catch {
2491
+ return null;
2492
+ }
2493
+ },
2494
+ remove: (target) => rmSync(target, { recursive: true, force: true }),
2495
+ which,
2496
+ readLegacyTaskIds: () => {
2497
+ // The old host's default store (its env is pinned to its defaults the
2498
+ // same way: legacyCommandEnv). Read only; anything wrong reads as null.
2499
+ const file = join(homedir(), LEGACY_HOST.homeDirName, "data", "host.sqlite");
2500
+ if (!existsSync(file)) return null;
2501
+ try {
2502
+ const db = new Database(file, { readonly: true, fileMustExist: true });
2503
+ try {
2504
+ const rows = db.prepare("SELECT task_id FROM uai_host_tasks").all() as Array<{ task_id: unknown }>;
2505
+ return new Set(rows.flatMap((row) => (typeof row.task_id === "string" ? [row.task_id] : [])));
2506
+ } finally {
2507
+ db.close();
2508
+ }
2509
+ } catch {
2510
+ return null;
2511
+ }
2512
+ },
2513
+ processCommandOf: (pid) => {
2514
+ if (!Number.isInteger(pid) || pid <= 0) return null;
2515
+ const result = spawnSync("ps", ["-o", "command=", "-p", String(pid)], { encoding: "utf8", timeout: 5_000 });
2516
+ const command = result.status === 0 ? (result.stdout ?? "").trim() : "";
2517
+ return command || null;
2518
+ },
2519
+ packageNameOf: (command) =>
2520
+ packageNameOfCommand(command, {
2521
+ realpath: (target) => realpathSync(target),
2522
+ readText: (target) => {
2523
+ try {
2524
+ return readFileSync(target, "utf8");
2525
+ } catch {
2526
+ return null;
2527
+ }
2528
+ },
2529
+ }),
2530
+ log: (line) => console.log(line),
2531
+ };
2532
+ }
2533
+
2534
+ /** True (and explains why) when the old host runs here, so the caller must stop. */
2535
+ async function refuseWhileLegacyHostRuns(): Promise<boolean> {
2536
+ const reason = legacyHostRunningReason(await inspectLegacyHost(legacyHostSeams()));
2537
+ if (!reason) return false;
2538
+ console.error(red(reason));
2539
+ process.exitCode = LEGACY_HOST_RUNNING_EXIT_CODE;
2540
+ return true;
2541
+ }
2542
+
2543
+ /** `moondeck-station uninstall-legacy [--purge] [--force] [--dry-run] [--check]` */
2544
+ async function cmdUninstallLegacy(rest: string[]): Promise<void> {
2545
+ const known = new Set(["--purge", "--force", "--dry-run", "--check"]);
2546
+ if (rest.some((arg) => !known.has(arg)) || new Set(rest).size !== rest.length) {
2547
+ console.error(red("usage: moondeck-station uninstall-legacy [--purge] [--force] [--dry-run]"));
2548
+ process.exitCode = 1;
2549
+ return;
2550
+ }
2551
+ const seams = legacyHostSeams();
2552
+ if (rest.includes("--check")) {
2553
+ // For the installer: exit LEGACY_HOST_RUNNING_EXIT_CODE with the reason
2554
+ // when the old host runs, 0 otherwise. Changes nothing.
2555
+ await refuseWhileLegacyHostRuns();
2556
+ return;
2557
+ }
2558
+ // Run by the installer, the retry is the installer's own pipeline:
2559
+ // moondeck-station is not on PATH yet (scripts/host-runtime/install.sh).
2560
+ const installerRetry = process.env.UAI_UNINSTALL_LEGACY_FORCE_COMMAND?.trim();
2561
+ const result = await uninstallLegacyHost(seams, {
2562
+ purge: rest.includes("--purge"),
2563
+ force: rest.includes("--force"),
2564
+ dryRun: rest.includes("--dry-run"),
2565
+ ...(installerRetry ? { forceCommand: installerRetry } : {}),
2566
+ });
2567
+ if (result.outcome === "absent") {
2568
+ console.log(dim("no old Uai host found on this machine"));
2569
+ return;
2570
+ }
2571
+ if (result.outcome === "refused") {
2572
+ console.error(red(result.reason));
2573
+ process.exitCode = 1;
2574
+ return;
2575
+ }
2576
+ for (const step of result.steps) console.log(dim(`$ ${step}`));
2577
+ for (const note of result.notes) console.log(yellow(note));
2578
+ console.log(
2579
+ rest.includes("--dry-run")
2580
+ ? dim("dry run: nothing was changed")
2581
+ : green("old Uai host removed") +
2582
+ dim(` — next: ${STATION.command} install && ${STATION.command} enroll <token from app.moondeck.io>`),
2583
+ );
2584
+ }
2585
+