@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
@@ -0,0 +1,769 @@
1
+ /**
2
+ * The frozen `@runuai/host` on the same machine as a Moondeck Station
3
+ * (Moondeck migration plan §4: hosts move by hand, with a fresh enrollment).
4
+ *
5
+ * Two things live here:
6
+ *
7
+ * - `legacyHostRunningReason`: a station refuses to start while the old host
8
+ * runs. Both drive the same container runtime, and the old host's cleanup
9
+ * reaps containers it does not know, so two daemons would remove each
10
+ * other's task containers.
11
+ * - `uninstallLegacyHost`: `moondeck-station uninstall-legacy`. It removes
12
+ * the old service, runtime and `uai-host` commands, and the old host's
13
+ * leftover Docker task stacks. It runs the old host's OWN `uninstall`
14
+ * first (its service and Apple-runtime teardown), and falls back to
15
+ * removing the known paths only with `--force`. It never touches the old
16
+ * data home (`~/.uai`) unless `--purge` is given, and refuses unless
17
+ * `--force` while the old host may still run tasks.
18
+ *
19
+ * What makes handing off to the old uninstall safe: a station names and
20
+ * labels every container, image and volume it creates differently from the
21
+ * old host (`io.moondeck.*`, `moondeck-*`), so the old host's teardown,
22
+ * which proves ownership by its own names and labels, can never select one
23
+ * of the station's (lib/frozen-apple-uninstall-teardown.test.ts runs 0.9.173's
24
+ * own teardown against a station's Apple objects). The old command runs only
25
+ * when it really is `@runuai/host` (symlinks resolved), and with an
26
+ * environment of its own, so it can't be pointed at the station's data.
27
+ *
28
+ * Everything that touches the machine goes through `LegacyHostSeams`, so the
29
+ * decisions are unit-tested without a second host.
30
+ */
31
+
32
+ import path from "node:path";
33
+
34
+ import { LEGACY_HOST, STATION } from "../src/station-identity";
35
+ import { STATION_FIRST_VERSION } from "./release-feed";
36
+ import { shellQuote } from "./machine-exec";
37
+ import { userBusEnv, withUserBusEnv, type UserBusProbe } from "./user-bus-env";
38
+
39
+ /**
40
+ * Docker labels the frozen host puts on what it creates outside a Compose
41
+ * task stack. Its Compose stacks carry no label of its own, only Compose's
42
+ * `com.docker.compose.project=task-<id>`.
43
+ */
44
+ export const LEGACY_DOCKER_LABELS: readonly string[] = Object.freeze([
45
+ "com.uai.task",
46
+ "com.uai.maintenance",
47
+ "com.uai.machine",
48
+ "com.runuai.preview-sidecar.task",
49
+ "com.runuai.engine-login",
50
+ ]);
51
+ /** Every label a station puts on what it creates starts with this. */
52
+ export const STATION_LABEL_PREFIX = "io.moondeck.";
53
+ const COMPOSE_PROJECT_LABEL = "com.docker.compose.project";
54
+ /**
55
+ * The Compose project the old host gives a task: `task-` and the cloud's
56
+ * task id, a lower-case ULID (lib/ulid.ts newId; compose_project_for_task in
57
+ * scripts/agent/_common.sh). The shape alone proves nothing (any project can
58
+ * be called that): a stack is the old host's only when its id is also in the
59
+ * old host's own task store, or, when that store can't be read, with --force.
60
+ */
61
+ const LEGACY_TASK_PROJECT = /^task-([0-7][0-9abcdefghjkmnpqrstvwxyz]{25})$/;
62
+
63
+ /** The environment variables an old-host command inherits; nothing else. */
64
+ const INHERITED_ENV = ["PATH", "USER", "LOGNAME", "LANG", "LC_ALL", "TMPDIR", "SHELL", "TERM"] as const;
65
+
66
+ export interface ExecResult {
67
+ readonly status: number | null;
68
+ readonly stdout: string;
69
+ readonly stderr: string;
70
+ }
71
+
72
+ export interface LegacyHostSeams {
73
+ readonly home: string;
74
+ readonly platform: NodeJS.Platform;
75
+ readonly uid: number;
76
+ /** This process's environment, filtered before an old-host command sees it. */
77
+ readonly inheritedEnv?: NodeJS.ProcessEnv;
78
+ /**
79
+ * Finds the user's service-manager bus when the environment lacks it, for
80
+ * every `systemctl --user` here and the old uninstall (lib/user-bus-env.ts).
81
+ */
82
+ readonly userBus?: UserBusProbe;
83
+ /** `env` given: the command gets exactly that environment. */
84
+ exec(command: string, args: readonly string[], env?: Readonly<Record<string, string>>): ExecResult;
85
+ /** GET a loopback JSON endpoint with a short timeout; throws on any failure. */
86
+ fetchJson(url: string): Promise<unknown>;
87
+ exists(target: string): boolean;
88
+ readText(target: string): string | null;
89
+ /** `rm -rf`. */
90
+ remove(target: string): void;
91
+ /** The first `command` on PATH, or null. */
92
+ which(command: string): string | null;
93
+ /**
94
+ * The task ids in the old host's own task store (`~/.uai/data/host.sqlite`,
95
+ * `uai_host_tasks`), read only. Null when it is absent or can't be read.
96
+ */
97
+ readLegacyTaskIds?(): ReadonlySet<string> | null;
98
+ /**
99
+ * The npm package `command` runs, symlinks resolved: for a managed runtime
100
+ * launcher (`<generation>/bin/<command>`) the generation's
101
+ * `agent/package.json`, otherwise the nearest package.json above it.
102
+ */
103
+ packageNameOf(command: string): string | null;
104
+ /**
105
+ * The command line of a running process (`ps -o command= -p <pid>`), or
106
+ * null when it can't be read. How an answering daemon is identified by the
107
+ * package it runs, not by the port it answers on.
108
+ */
109
+ processCommandOf?(pid: number): string | null;
110
+ log(line: string): void;
111
+ }
112
+
113
+ export interface LegacyHostState {
114
+ /** launchd plist or systemd unit file of the old service, when present. */
115
+ readonly serviceDefinition: string | null;
116
+ /**
117
+ * The service manager has the old job loaded (launchd) or active (systemd),
118
+ * or gave no clear answer about an installed one (see serviceError).
119
+ */
120
+ readonly serviceActive: boolean;
121
+ /** The service manager's error when it gave no clear answer; the service then counts as active. */
122
+ readonly serviceError: string | null;
123
+ /** How this OS's service manager names the old service. */
124
+ readonly serviceName: string;
125
+ /** The old daemon answered on its local port. */
126
+ readonly daemon: {
127
+ readonly port: number;
128
+ readonly pid: number;
129
+ readonly version: string | null;
130
+ /** null: the daemon answered /api/status but its task list could not be read. */
131
+ readonly activeTasks: number | null;
132
+ } | null;
133
+ /** `~/.uai-host`, when present. */
134
+ readonly runtimeRoot: string | null;
135
+ /** The old managed shims in ~/.local/bin that exist. */
136
+ readonly shims: readonly string[];
137
+ /** The command to run the old host's own uninstall with, when one exists. */
138
+ readonly command: string | null;
139
+ /** `~/.uai`, when present. */
140
+ readonly dataHome: string | null;
141
+ }
142
+
143
+ export function legacyHostPaths(home: string) {
144
+ const localBin = path.join(home, ".local", "bin");
145
+ const runtimeRoot = path.join(home, LEGACY_HOST.runtimeDirName);
146
+ return {
147
+ runtimeRoot,
148
+ managedCommand: path.join(runtimeRoot, "runtime", "current", "bin", LEGACY_HOST.command),
149
+ shims: [
150
+ path.join(localBin, LEGACY_HOST.command),
151
+ path.join(localBin, LEGACY_HOST.recoveryCommand),
152
+ ],
153
+ dataHome: path.join(home, LEGACY_HOST.homeDirName),
154
+ portFile: path.join(home, LEGACY_HOST.homeDirName, "data", "host.port"),
155
+ launchdPlist: path.join(home, "Library", "LaunchAgents", `${LEGACY_HOST.launchdLabel}.plist`),
156
+ systemdUnit: path.join(home, ".config", "systemd", "user", LEGACY_HOST.systemdUnit),
157
+ };
158
+ }
159
+
160
+ /**
161
+ * Whether the service manager runs the old service. Only a clear "not
162
+ * running" answer counts as stopped: launchd's "could not find service", or
163
+ * systemd's `inactive` / `failed` / `unknown` (an unknown unit). Any other
164
+ * failure (an unreachable bus, access denied, a crash) leaves the state
165
+ * unknown, and an installed old service then counts as running (fail closed),
166
+ * with the manager's error kept for the refusal. Without the old service's
167
+ * definition the old host never ran under that manager, so an error there is
168
+ * no evidence (a hand-started daemon is caught by daemonState).
169
+ */
170
+ function serviceState(seams: LegacyHostSeams): { definition: string | null; active: boolean; error: string | null } {
171
+ const paths = legacyHostPaths(seams.home);
172
+ const unknown = (definition: string | null, probe: ExecResult, call: string) => {
173
+ if (definition === null) return { definition, active: false, error: null };
174
+ const detail = probe.stderr.trim() || probe.stdout.trim() || (probe.status === null ? "no exit status" : `exit ${probe.status}`);
175
+ return { definition, active: true, error: `${call}: ${detail}` };
176
+ };
177
+ if (seams.platform === "darwin") {
178
+ const definition = seams.exists(paths.launchdPlist) ? paths.launchdPlist : null;
179
+ const target = `gui/${seams.uid}/${LEGACY_HOST.launchdLabel}`;
180
+ const probe = seams.exec("launchctl", ["print", target]);
181
+ // KeepAlive job: loaded means running, or about to be restarted.
182
+ if (probe.status === 0) return { definition, active: true, error: null };
183
+ if (probe.status === 113 || /Could not find (service|domain)/.test(probe.stderr)) {
184
+ return { definition, active: false, error: null };
185
+ }
186
+ return unknown(definition, probe, `launchctl print ${target}`);
187
+ }
188
+ if (seams.platform === "linux") {
189
+ const definition = seams.exists(paths.systemdUnit) ? paths.systemdUnit : null;
190
+ const probe = seams.exec("systemctl", ["--user", "is-active", LEGACY_HOST.systemdUnit], systemctlEnv(seams));
191
+ const state = probe.status === null ? "" : probe.stdout.trim();
192
+ if (["active", "activating", "deactivating", "reloading", "refreshing"].includes(state)) {
193
+ return { definition, active: true, error: null };
194
+ }
195
+ if (probe.status !== 0 && ["inactive", "failed", "unknown"].includes(state)) {
196
+ return { definition, active: false, error: null };
197
+ }
198
+ return unknown(definition, probe, `systemctl --user is-active ${LEGACY_HOST.systemdUnit}`);
199
+ }
200
+ return { definition: null, active: false, error: null };
201
+ }
202
+
203
+ /**
204
+ * The environment for `systemctl --user`: this process's own, with the
205
+ * user-bus variables filled in when it lacks them. Undefined (inherit) when
206
+ * the seams carry no probe.
207
+ */
208
+ function systemctlEnv(seams: LegacyHostSeams): Record<string, string> | undefined {
209
+ return seams.userBus ? withUserBusEnv(seams.inheritedEnv ?? {}, seams.userBus) : undefined;
210
+ }
211
+
212
+ async function daemonState(seams: LegacyHostSeams): Promise<LegacyHostState["daemon"]> {
213
+ const raw = seams.readText(legacyHostPaths(seams.home).portFile)?.trim();
214
+ if (!raw || !/^\d{1,5}$/.test(raw)) return null;
215
+ return probeDaemon(seams, Number(raw));
216
+ }
217
+
218
+ /** The old daemon answering on `port`, or null. */
219
+ /**
220
+ * Whether the host answering on the old host's port is the old host. In
221
+ * order:
222
+ * 1. It says it's a station: its status names `@moondeck/station` as its
223
+ * product. The frozen host never reports a product.
224
+ * 2. Its process says: the package the answering pid runs (its command line,
225
+ * resolved to the nearest package.json). `@moondeck/station` is a station,
226
+ * `@runuai/host` the old host.
227
+ * 3. Neither is known: the frozen host's own numbering decides. Every
228
+ * `@runuai/host` release is below 1.0.0 and no station is (the release
229
+ * floor, STATION_FIRST_VERSION), so a version below it, or none at all,
230
+ * is the old host, and anything else is not.
231
+ */
232
+ function isLegacyDaemon(seams: LegacyHostSeams, status: { pid: number; product?: unknown }, version: string | null): boolean {
233
+ if (status.product === STATION.packageName) return false;
234
+ const owner = daemonPackage(seams, status.pid);
235
+ if (owner === STATION.packageName) return false;
236
+ if (owner === LEGACY_HOST.packageName) return true;
237
+ return version === null || versionBelow(version, STATION_FIRST_VERSION);
238
+ }
239
+
240
+ /** The package an answering pid runs, read from its command line. */
241
+ function daemonPackage(seams: LegacyHostSeams, pid: number): string | null {
242
+ const command = seams.processCommandOf?.(pid);
243
+ if (!command) return null;
244
+ for (const token of command.split(/\s+/)) {
245
+ if (!path.isAbsolute(token)) continue;
246
+ const name = seams.packageNameOf(token);
247
+ if (name === STATION.packageName || name === LEGACY_HOST.packageName) return name;
248
+ }
249
+ return null;
250
+ }
251
+
252
+ /** a < b for dotted numeric versions (a pre-release or junk counts as below). */
253
+ function versionBelow(a: string, b: string): boolean {
254
+ const parse = (v: string) => (/^\d+\.\d+\.\d+$/.test(v) ? v.split(".").map(Number) : null);
255
+ const left = parse(a);
256
+ const right = parse(b)!;
257
+ if (left === null) return true;
258
+ for (let i = 0; i < 3; i++) if (left[i] !== right[i]) return left[i]! < right[i]!;
259
+ return false;
260
+ }
261
+
262
+ async function probeDaemon(seams: LegacyHostSeams, port: number): Promise<LegacyHostState["daemon"]> {
263
+ if (port < 1 || port > 65_535) return null;
264
+ let status: unknown;
265
+ try {
266
+ status = await seams.fetchJson(`http://127.0.0.1:${port}/api/status`);
267
+ } catch {
268
+ return null;
269
+ }
270
+ // A stale port file can name an unrelated process: only a host's status
271
+ // shape counts (the same test the installer's probe_local_host applies).
272
+ if (
273
+ !status ||
274
+ typeof status !== "object" ||
275
+ typeof (status as { pid?: unknown }).pid !== "number" ||
276
+ typeof (status as { hostId?: unknown }).hostId !== "string"
277
+ ) {
278
+ return null;
279
+ }
280
+ const version = (status as { version?: unknown }).version;
281
+ // Blocker #3 (2026-11-03): after a migration ~/.uai/data/host.port can still
282
+ // name 5876, where the STATION now answers with a host's status shape, so a
283
+ // station counted as the old host: --check exited 78 and every installer
284
+ // re-run refused. Only a positively identified old host counts.
285
+ if (!isLegacyDaemon(seams, status as { pid: number; product?: unknown }, typeof version === "string" ? version : null)) {
286
+ return null;
287
+ }
288
+ let activeTasks: number | null = null;
289
+ try {
290
+ const tasks = (await seams.fetchJson(`http://127.0.0.1:${port}/api/tasks`)) as {
291
+ tasks?: unknown;
292
+ };
293
+ if (Array.isArray(tasks?.tasks)) activeTasks = tasks.tasks.length;
294
+ } catch {
295
+ activeTasks = null;
296
+ }
297
+ return {
298
+ port,
299
+ pid: (status as { pid: number }).pid,
300
+ version: typeof version === "string" ? version : null,
301
+ activeTasks,
302
+ };
303
+ }
304
+
305
+ export async function inspectLegacyHost(seams: LegacyHostSeams): Promise<LegacyHostState> {
306
+ const paths = legacyHostPaths(seams.home);
307
+ const service = serviceState(seams);
308
+ return {
309
+ serviceDefinition: service.definition,
310
+ serviceActive: service.active,
311
+ serviceError: service.error,
312
+ serviceName: seams.platform === "linux" ? LEGACY_HOST.systemdUnit : LEGACY_HOST.launchdLabel,
313
+ daemon: await daemonState(seams),
314
+ runtimeRoot: seams.exists(paths.runtimeRoot) ? paths.runtimeRoot : null,
315
+ shims: paths.shims.filter((shim) => seams.exists(shim)),
316
+ command: verifiedLegacyCommand(seams, paths.managedCommand) ?? legacyCommandOnPath(seams),
317
+ dataHome: seams.exists(paths.dataHome) ? paths.dataHome : null,
318
+ };
319
+ }
320
+
321
+ /**
322
+ * `candidate`, only when it exists and really runs `@runuai/host` (symlinks
323
+ * resolved). A `uai-host` that is anything else, a link to the station's own
324
+ * launcher above all, is not ours to run `uninstall` on.
325
+ */
326
+ function verifiedLegacyCommand(seams: LegacyHostSeams, candidate: string | null): string | null {
327
+ if (!candidate || !seams.exists(candidate)) return null;
328
+ return seams.packageNameOf(candidate) === LEGACY_HOST.packageName ? candidate : null;
329
+ }
330
+
331
+ /** `uai-host` on PATH (an npm install), when it is `@runuai/host`'s. */
332
+ function legacyCommandOnPath(seams: LegacyHostSeams): string | null {
333
+ return verifiedLegacyCommand(seams, seams.which(LEGACY_HOST.command));
334
+ }
335
+
336
+ /**
337
+ * What an old-host command runs with: the old defaults (`HOME`, and
338
+ * `UAI_HOME=~/.uai`) and a few neutral variables, never this process's
339
+ * `UAI_*`, which name the station's own home and data.
340
+ */
341
+ export function legacyCommandEnv(
342
+ home: string,
343
+ inherited: NodeJS.ProcessEnv = {},
344
+ userBus?: UserBusProbe,
345
+ ): Record<string, string> {
346
+ const env: Record<string, string> = {};
347
+ for (const name of INHERITED_ENV) {
348
+ const value = inherited[name];
349
+ if (value) env[name] = value;
350
+ }
351
+ // The old uninstall stops its service with `systemctl --user`, which needs
352
+ // the user's bus (2026-11-01 rehearsal: "Failed to connect to bus").
353
+ Object.assign(env, userBusEnv(inherited, userBus ?? { uid: -1, stat: () => null }));
354
+ env.HOME = home;
355
+ env.UAI_HOME = path.join(home, LEGACY_HOST.homeDirName);
356
+ return env;
357
+ }
358
+
359
+ export function legacyHostPresent(state: LegacyHostState): boolean {
360
+ return Boolean(
361
+ state.serviceDefinition ||
362
+ state.serviceActive ||
363
+ state.daemon ||
364
+ state.runtimeRoot ||
365
+ state.shims.length > 0 ||
366
+ state.command,
367
+ );
368
+ }
369
+
370
+ /** Why a station must not start now, or null when the old host is not running. */
371
+ export function legacyHostRunningReason(state: LegacyHostState): string | null {
372
+ if (!state.serviceActive && !state.daemon) return null;
373
+ const what = state.daemon
374
+ ? `the old Uai host${state.daemon.version ? ` ${state.daemon.version}` : ""} is running on this machine (port ${state.daemon.port})`
375
+ : state.serviceError
376
+ ? `the old Uai host service (${state.serviceDefinition ?? state.serviceName}) is installed and the service manager ` +
377
+ `can't say it is stopped (${state.serviceError}), so it counts as running`
378
+ : `the old Uai host service (${state.serviceDefinition ?? state.serviceName}) is still loaded on this machine`;
379
+ return (
380
+ `${what}. Both would manage the same containers and remove each other's tasks. ` +
381
+ `Move this machine first: ${STATION.command} uninstall-legacy`
382
+ );
383
+ }
384
+
385
+ /** The old host's bundled Apple container CLI (ADR-106), in its runtime. */
386
+ export function legacyAppleContainerCli(home: string): string {
387
+ return path.join(
388
+ legacyHostPaths(home).runtimeRoot,
389
+ "runtime", "current", "agent", "bundled-runtime", "apple-container", "bin", "container",
390
+ );
391
+ }
392
+
393
+ /** What the old host's uninstall says when its Apple teardown can't prove a
394
+ * container gone (src/cli.ts there): it stops, keeping its runtime. */
395
+ const APPLE_TEARDOWN_INCOMPLETE = /Apple container teardown is incomplete/;
396
+
397
+ /**
398
+ * The refusal for an old-host uninstall stopped by its Apple teardown. The
399
+ * smoke test of 2026-11-02 hit it: a stopped maintenance container from
400
+ * August, under an Apple container system still running an older host's
401
+ * build, stayed listed after `delete` exited 0. The old uninstall stops
402
+ * before removing its runtime, so retrying is safe; `--force` would delete
403
+ * that runtime, the only CLI that tears those containers down, and leave them
404
+ * and their images behind for good. So the way out it names is a restart of
405
+ * the Apple container system and a retry, with --force last and its cost
406
+ * stated. Nothing here changes what the teardown does.
407
+ */
408
+ function appleTeardownRemedy(command: string, detail: string, cli: string, output: string, force: string): string {
409
+ const ids = [...new Set([...output.matchAll(/container (\S+) could not be proven removed/g)].map((match) => match[1]!))];
410
+ const quoted = `'${cli.replace(/'/g, "'\\''")}'`;
411
+ return [
412
+ `${command} uninstall failed: ${detail || "Apple container teardown is incomplete"}.`,
413
+ "The old host could not prove one of its Apple containers removed, so its uninstall stopped before removing its runtime. " +
414
+ "Its service is already stopped. Retrying is safe: run the same command again, without --force.",
415
+ "First restart the Apple container system with the old host's own CLI, and remove the container:",
416
+ ` C=${quoted}`,
417
+ ' "$C" list --all',
418
+ ' "$C" system stop && "$C" system start',
419
+ ...(ids.length > 0 ? ids.map((id) => ` "$C" delete --force ${id}`) : [' "$C" delete --force <container id>']),
420
+ `If it still won't go: ${force} removes the old host's files without that teardown. ` +
421
+ "Its Apple containers and images then stay behind, with no tool left that removes them.",
422
+ ].join("\n");
423
+ }
424
+
425
+ export interface UninstallLegacyOptions {
426
+ readonly purge: boolean;
427
+ readonly force: boolean;
428
+ readonly dryRun: boolean;
429
+ /**
430
+ * The command a refusal tells the operator to run to go ahead anyway. The
431
+ * installer passes its own pipeline (`moondeck-station` isn't on PATH
432
+ * yet); otherwise `moondeck-station uninstall-legacy --force [--purge]`.
433
+ */
434
+ readonly forceCommand?: string;
435
+ }
436
+
437
+ /** What a refusal tells the operator to run to remove the old host anyway. */
438
+ export function uninstallLegacyForceCommand(options: Pick<UninstallLegacyOptions, "purge" | "forceCommand">): string {
439
+ return options.forceCommand ?? `${STATION.command} uninstall-legacy --force${options.purge ? " --purge" : ""}`;
440
+ }
441
+
442
+ export type UninstallLegacyResult =
443
+ | { readonly outcome: "absent" }
444
+ | { readonly outcome: "refused"; readonly reason: string }
445
+ | { readonly outcome: "removed"; readonly steps: readonly string[]; readonly notes: readonly string[] };
446
+
447
+ const refused = (reason: string): UninstallLegacyResult => ({ outcome: "refused", reason });
448
+
449
+ export async function uninstallLegacyHost(
450
+ seams: LegacyHostSeams,
451
+ options: UninstallLegacyOptions,
452
+ ): Promise<UninstallLegacyResult> {
453
+ const force = uninstallLegacyForceCommand(options);
454
+ const state = await inspectLegacyHost(seams);
455
+ // Nothing is removed, Docker objects included, unless the old host is
456
+ // installed here: a task-shaped name or label alone is no install.
457
+ if (!legacyHostPresent(state) && !state.dataHome) {
458
+ return { outcome: "absent" };
459
+ }
460
+ // Read before anything runs: the old uninstall's --purge deletes the store.
461
+ const taskIds = seams.readLegacyTaskIds?.() ?? null;
462
+ const docker = legacyDockerObjects(seams, taskIds);
463
+ // Tasks must be ruled out before anything is removed, unless --force.
464
+ if (!options.force) {
465
+ if (state.daemon && state.daemon.activeTasks === null) {
466
+ return refused(
467
+ "the old Uai host is running but its task list could not be read; stop its tasks in the app, " +
468
+ `or remove it anyway (running tasks are lost): ${force}`,
469
+ );
470
+ }
471
+ if (state.daemon && state.daemon.activeTasks! > 0) {
472
+ return refused(
473
+ `the old Uai host has ${state.daemon.activeTasks} running task(s); finish or stop them in the app, ` +
474
+ `or remove it anyway (their work is lost): ${force}`,
475
+ );
476
+ }
477
+ // The service manager can't say whether the old service runs, and its
478
+ // daemon doesn't answer: the old uninstall would hit the same error, and
479
+ // its tasks can't be ruled out.
480
+ if (state.serviceError && !state.daemon) {
481
+ return refused(
482
+ `the service manager can't say whether the old Uai host service is running (${state.serviceError}), ` +
483
+ `so its tasks can't be ruled out; fix that and retry, or remove it anyway (running tasks are lost): ${force}`,
484
+ );
485
+ }
486
+ // An active service nobody can ask: neither its daemon answers nor is
487
+ // there an old uninstall (which refuses on its own running tasks).
488
+ if (state.serviceActive && !state.daemon && !state.command) {
489
+ return refused(
490
+ "the old Uai host service is running but neither answers nor can be asked to uninstall itself, " +
491
+ `so its tasks can't be ruled out; to remove it anyway (running tasks are lost): ${force}`,
492
+ );
493
+ }
494
+ // Unverified ones included: a running task-<id> stack may be a live task.
495
+ const running = docker.objects.filter((object) => object.kind === "container" && object.running);
496
+ if (running.length > 0) {
497
+ return refused(
498
+ `${running.length} of the old Uai host's task container(s) are still running (${running.map((o) => o.name).join(", ")}); finish or stop those tasks, ` +
499
+ `or remove them anyway (their work is lost): ${force}`,
500
+ );
501
+ }
502
+ }
503
+
504
+ // A task-shaped stack the store can't vouch for goes only with --force,
505
+ // and is decided before anything else: once the old install and its data
506
+ // are gone, nothing would recognise it as the old host's any more.
507
+ const unverified = docker.objects.filter((o) => o.evidence === "unverified");
508
+ if (unverified.length > 0 && !options.force) {
509
+ return refused(
510
+ `the old Uai host's task store could not be read, so ${unverified.length} Docker object(s) named like its tasks ` +
511
+ `can't be confirmed as its own: ${unverified.map((o) => o.name).join(", ")}. Nothing was changed. ` +
512
+ `If they are the old host's, run: ${force}`,
513
+ );
514
+ }
515
+
516
+ const steps: string[] = [];
517
+ const notes: string[] = [];
518
+ const run = (command: string, args: readonly string[], env?: Record<string, string>): ExecResult | null => {
519
+ steps.push([command, ...args].join(" "));
520
+ if (options.dryRun) return null;
521
+ return seams.exec(command, args, env);
522
+ };
523
+ const remove = (target: string) => {
524
+ steps.push(`rm -rf ${target}`);
525
+ if (!options.dryRun) seams.remove(target);
526
+ };
527
+
528
+ // A daemon that answers without a service was started by hand: no
529
+ // uninstall stops it, and its port file (in ~/.uai) is how a station sees
530
+ // it. So ~/.uai is purged only once no old daemon answers (step 5).
531
+ const handStarted = Boolean(state.daemon && !state.serviceActive);
532
+
533
+ // 1. The old host's own uninstall: its service teardown (and, on macOS,
534
+ // the Apple container runtime it provisioned) is code it already ships.
535
+ let delegated = false;
536
+ if (state.command) {
537
+ const purgeHere = options.purge && !handStarted;
538
+ const result = run(
539
+ state.command,
540
+ ["uninstall", ...(purgeHere ? ["--purge"] : [])],
541
+ legacyCommandEnv(seams.home, seams.inheritedEnv, seams.userBus),
542
+ );
543
+ delegated = result === null || result.status === 0;
544
+ if (!delegated) {
545
+ const detail = (result?.stderr || result?.stdout || "").trim().split("\n").at(-1) ?? "";
546
+ if (!options.force) {
547
+ const output = `${result?.stderr ?? ""}\n${result?.stdout ?? ""}`;
548
+ if (seams.platform === "darwin" && APPLE_TEARDOWN_INCOMPLETE.test(output)) {
549
+ return refused(appleTeardownRemedy(state.command, detail, legacyAppleContainerCli(seams.home), output, force));
550
+ }
551
+ return refused(
552
+ `${state.command} uninstall failed${detail ? `: ${detail}` : ""}. ` +
553
+ `Fix that and retry, or remove the old service and runtime directly: ${force}`,
554
+ );
555
+ }
556
+ notes.push(`the old host's own uninstall failed (${detail || "no output"}); removed its files directly`);
557
+ }
558
+ }
559
+
560
+ // 2. Whatever the old uninstall did not remove (or there was no command to
561
+ // run it with): the service definition, the runtime root, the shims.
562
+ // Each is checked again, because a successful delegated uninstall
563
+ // already removed them.
564
+ const after = delegated && !options.dryRun ? await inspectLegacyHost(seams) : state;
565
+ if (after.serviceActive || after.serviceDefinition) {
566
+ if (seams.platform === "darwin") {
567
+ run("launchctl", ["bootout", `gui/${seams.uid}/${LEGACY_HOST.launchdLabel}`]);
568
+ } else if (seams.platform === "linux") {
569
+ run("systemctl", ["--user", "disable", "--now", LEGACY_HOST.systemdUnit], systemctlEnv(seams));
570
+ }
571
+ if (after.serviceDefinition) remove(after.serviceDefinition);
572
+ if (seams.platform === "linux") run("systemctl", ["--user", "daemon-reload"], systemctlEnv(seams));
573
+ }
574
+ if (after.runtimeRoot) remove(after.runtimeRoot);
575
+ for (const shim of after.shims) remove(shim);
576
+
577
+ // 3. The old host's Docker objects, never anything carrying a station
578
+ // label. Containers first; a network goes once nothing is attached.
579
+ for (const object of docker.objects.filter((o) => o.kind === "container")) {
580
+ run("docker", ["rm", "--force", object.id]);
581
+ }
582
+ for (const object of docker.objects.filter((o) => o.kind === "network")) {
583
+ const result = run("docker", ["network", "rm", object.id]);
584
+ if (result && result.status !== 0) notes.push(`could not remove the old host's network ${object.name}`);
585
+ }
586
+ if (docker.unclaimed.length > 0) {
587
+ notes.push(
588
+ `left ${docker.unclaimed.length} task-shaped Docker object(s) the old host's task store does not name: ${docker.unclaimed.join(", ")}`,
589
+ );
590
+ }
591
+ if (docker.problem) notes.push(docker.problem);
592
+
593
+ // 4. Is an old daemon still answering? On its port file, or on the port it
594
+ // answered on before, in case its port file is gone.
595
+ let still: LegacyHostState["daemon"] = null;
596
+ if (!options.dryRun) {
597
+ still = (await inspectLegacyHost(seams)).daemon ?? (state.daemon ? await probeDaemon(seams, state.daemon.port) : null);
598
+ if (still) {
599
+ notes.push(
600
+ `the old host process (pid ${still.pid}) still answers on port ${still.port} outside any service; stop it (kill ${still.pid}) before starting the station`,
601
+ );
602
+ }
603
+ }
604
+
605
+ // 5. --purge last, and never under a running old daemon.
606
+ const dataHome = options.dryRun ? state.dataHome : seams.exists(legacyHostPaths(seams.home).dataHome) ? legacyHostPaths(seams.home).dataHome : null;
607
+ if (options.purge && dataHome) {
608
+ if (still) {
609
+ notes.push(`kept ${dataHome}: the old host is still running from it; stop it, then run uninstall-legacy --purge again`);
610
+ } else {
611
+ try {
612
+ remove(dataHome);
613
+ } catch (error) {
614
+ // Old task containers wrote their files as uid 1000; a host user that
615
+ // isn't 1000 can't delete them (Linux). Say what to run; don't crash.
616
+ const code = (error as NodeJS.ErrnoException).code;
617
+ if (code !== "EACCES" && code !== "EPERM") throw error;
618
+ notes.push(
619
+ `could not remove all of ${dataHome}: some files belong to another user (written by the old host's task containers); remove them with: ${purgeCommand(dataHome)}`,
620
+ );
621
+ }
622
+ }
623
+ } else if (!options.purge && dataHome) {
624
+ notes.push(`kept ${dataHome} (the old host's data); pass --purge to remove it`);
625
+ }
626
+
627
+ const npmCommand = options.dryRun ? null : legacyCommandOnPath(seams);
628
+ if (npmCommand && !after.shims.includes(npmCommand)) {
629
+ notes.push(
630
+ `${npmCommand} is still on PATH (an npm install): remove it with npm uninstall -g ${LEGACY_HOST.packageName}`,
631
+ );
632
+ }
633
+ return { outcome: "removed", steps, notes };
634
+ }
635
+
636
+ /**
637
+ * The command a person can paste to remove what --purge couldn't. The path is
638
+ * one shell word whatever it holds (a space, a quote, a leading dash), and
639
+ * `--` ends rm's options.
640
+ */
641
+ export function purgeCommand(target: string): string {
642
+ return `sudo rm -rf -- ${shellQuote(target)}`;
643
+ }
644
+
645
+ export interface LegacyDockerObject {
646
+ readonly kind: "container" | "network";
647
+ readonly id: string;
648
+ readonly name: string;
649
+ readonly running: boolean;
650
+ /**
651
+ * Why it is the old host's: one of its own labels (`label`), or a
652
+ * `task-<id>` stack whose id its task store names (`store`). `unverified`:
653
+ * a `task-<id>` stack while the store can't be read.
654
+ */
655
+ readonly evidence: "label" | "store" | "unverified";
656
+ }
657
+
658
+ /**
659
+ * The old host's Docker objects. Never anything with a station label (a
660
+ * station labels everything it creates `io.moondeck.*`). Positive evidence
661
+ * only: one of the old host's own labels, or a Compose project
662
+ * `task-<ULID>` whose id `taskIds` (the old host's task store) names. With no
663
+ * readable store (`taskIds` null) such a stack is listed `unverified`; with a
664
+ * store that doesn't name it, it is not listed (`unclaimed`, names only).
665
+ */
666
+ export function legacyDockerObjects(
667
+ seams: LegacyHostSeams,
668
+ taskIds: ReadonlySet<string> | null = null,
669
+ ): {
670
+ objects: LegacyDockerObject[];
671
+ unclaimed: string[];
672
+ problem?: string;
673
+ } {
674
+ if (!seams.which("docker")) return { objects: [], unclaimed: [] };
675
+ const objects: LegacyDockerObject[] = [];
676
+ const unclaimed: string[] = [];
677
+ const problems: string[] = [];
678
+ const classify = (name: string, labels: ReadonlyMap<string, string>): LegacyDockerObject["evidence"] | null => {
679
+ for (const key of labels.keys()) if (key.startsWith(STATION_LABEL_PREFIX)) return null;
680
+ if (LEGACY_DOCKER_LABELS.some((label) => labels.has(label))) return "label";
681
+ const project = LEGACY_TASK_PROJECT.exec(labels.get(COMPOSE_PROJECT_LABEL) ?? "");
682
+ if (!project) return null;
683
+ if (taskIds === null) return "unverified";
684
+ if (taskIds.has(project[1]!)) return "store";
685
+ unclaimed.push(name);
686
+ return null;
687
+ };
688
+ const containers = seams.exec("docker", [
689
+ "ps",
690
+ "--all",
691
+ "--no-trunc",
692
+ "--format",
693
+ "{{.ID}}\t{{.Names}}\t{{.State}}\t{{.Labels}}",
694
+ ]);
695
+ if (containers.status === 0) {
696
+ for (const line of containers.stdout.split("\n")) {
697
+ const [id, name, state, labels] = line.split("\t");
698
+ if (!id || !/^[a-f0-9]{12,64}$/.test(id)) continue;
699
+ const evidence = classify(name ?? id, parseDockerLabels(labels ?? ""));
700
+ if (!evidence) continue;
701
+ const running = state === "running" || state === "restarting" || state === "paused";
702
+ objects.push({ kind: "container", id, name: name ?? id, running, evidence });
703
+ }
704
+ } else {
705
+ problems.push("could not list Docker containers; the old host's task containers were not checked");
706
+ }
707
+ const networks = seams.exec("docker", ["network", "ls", "--no-trunc", "--format", "{{.ID}}\t{{.Name}}\t{{.Labels}}"]);
708
+ if (networks.status === 0) {
709
+ for (const line of networks.stdout.split("\n")) {
710
+ const [id, name, labels] = line.split("\t");
711
+ if (!id || !/^[a-f0-9]{12,64}$/.test(id)) continue;
712
+ const evidence = classify(name ?? id, parseDockerLabels(labels ?? ""));
713
+ if (!evidence) continue;
714
+ objects.push({ kind: "network", id, name: name ?? id, running: false, evidence });
715
+ }
716
+ } else {
717
+ problems.push("could not list Docker networks; the old host's task networks were not checked");
718
+ }
719
+ return problems.length > 0 ? { objects, unclaimed, problem: problems.join("; ") } : { objects, unclaimed };
720
+ }
721
+
722
+ /** Docker's `{{.Labels}}`: `key=value` pairs joined by commas. */
723
+ export function parseDockerLabels(text: string): Map<string, string> {
724
+ const labels = new Map<string, string>();
725
+ for (const pair of text.split(",")) {
726
+ const at = pair.indexOf("=");
727
+ if (at > 0) labels.set(pair.slice(0, at).trim(), pair.slice(at + 1));
728
+ }
729
+ return labels;
730
+ }
731
+
732
+ /**
733
+ * The npm package a command runs, symlinks resolved. A managed runtime's
734
+ * launcher is a shell script at `<generation>/bin/<command>` beside the
735
+ * package copy at `<generation>/agent/`; anything else is a package's bin,
736
+ * owned by the nearest package.json above it. Null when unknown.
737
+ */
738
+ export function packageNameOfCommand(
739
+ command: string,
740
+ fs: { realpath(target: string): string; readText(target: string): string | null },
741
+ ): string | null {
742
+ let real: string;
743
+ try {
744
+ real = fs.realpath(command);
745
+ } catch {
746
+ return null;
747
+ }
748
+ const nameIn = (manifest: string): string | null | undefined => {
749
+ const text = fs.readText(manifest);
750
+ if (text === null) return undefined;
751
+ try {
752
+ const name = (JSON.parse(text) as { name?: unknown }).name;
753
+ return typeof name === "string" ? name : null;
754
+ } catch {
755
+ return null;
756
+ }
757
+ };
758
+ const directory = path.dirname(real);
759
+ if (path.basename(directory) === "bin") {
760
+ const managed = nameIn(path.join(path.dirname(directory), "agent", "package.json"));
761
+ if (managed !== undefined) return managed;
762
+ }
763
+ for (let current = directory; ; current = path.dirname(current)) {
764
+ const name = nameIn(path.join(current, "package.json"));
765
+ if (name !== undefined) return name;
766
+ if (path.dirname(current) === current) return null;
767
+ }
768
+ }
769
+