@crvouga/dev-fleet 0.0.0-stage → 0.2.0

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 (363) hide show
  1. package/.mise/locks/aws-cdk/2.1146.1/aube-lock.yaml +24 -0
  2. package/.mise/locks/aws-cdk/2.1146.1/package.json +7 -0
  3. package/.mise/locks/azure-cli/2.91.0~a837e014/pyproject.toml +5 -0
  4. package/.mise/locks/azure-cli/2.91.0~a837e014/uv.lock +2794 -0
  5. package/.mise/locks/gemini/0.66.0~84aac8e6/aube-lock.yaml +123 -0
  6. package/.mise/locks/gemini/0.66.0~84aac8e6/package.json +12 -0
  7. package/.mise/locks/npm-devcontainers-cli/0.90.0/aube-lock.yaml +24 -0
  8. package/.mise/locks/npm-devcontainers-cli/0.90.0/package.json +7 -0
  9. package/.mise/locks/npm-modelcontextprotocol-inspector/2.13.0~4c109bea/aube-lock.yaml +1432 -0
  10. package/.mise/locks/npm-modelcontextprotocol-inspector/2.13.0~4c109bea/package.json +7 -0
  11. package/.mise/locks/npm-vercel/65.8.0/aube-lock.yaml +3595 -0
  12. package/.mise/locks/npm-vercel/65.8.0/package.json +7 -0
  13. package/.mise/locks/npm-wrangler/4.156.0/aube-lock.yaml +895 -0
  14. package/.mise/locks/npm-wrangler/4.156.0/package.json +7 -0
  15. package/.mise/locks/npm-yarnpkg-cli-dist/4.18.1/aube-lock.yaml +24 -0
  16. package/.mise/locks/npm-yarnpkg-cli-dist/4.18.1/package.json +7 -0
  17. package/LICENSE +21 -0
  18. package/README.md +357 -2
  19. package/agents/antigravity-proxy.ts +3 -0
  20. package/agents/audit.md +89 -0
  21. package/agents/claude-proxy +5 -0
  22. package/agents/claude-proxy.ts +3 -0
  23. package/agents/codex-proxy.ts +3 -0
  24. package/agents/grok-proxy.ts +3 -0
  25. package/agents/managed-proxy.ts +55 -0
  26. package/agents/opencode-proxy.ts +3 -0
  27. package/agents/registry.json +209 -0
  28. package/agents/registry.schema.json +149 -0
  29. package/agents/setup.md +158 -0
  30. package/agents/t3.json +40 -0
  31. package/ansible/macos/manager.yml +30 -0
  32. package/ansible/macos/policy.json +9 -0
  33. package/ansible/monitoring/README.md +215 -0
  34. package/ansible/monitoring/agent-darwin.yml +198 -0
  35. package/ansible/monitoring/agent.yml +172 -0
  36. package/ansible/monitoring/beszel-agent.service +42 -0
  37. package/ansible/monitoring/compose.yml +74 -0
  38. package/ansible/monitoring/hub-darwin.yml +102 -0
  39. package/ansible/monitoring/hub-route-darwin.yml +51 -0
  40. package/ansible/monitoring/lock.json +16 -0
  41. package/ansible/net/remote-probe.sh +13 -0
  42. package/ansible/pyproject.toml +12 -0
  43. package/ansible/ubuntu/authorize-sudo.sh +51 -0
  44. package/ansible/ubuntu/baseline.yml +89 -0
  45. package/ansible/ubuntu/disk-gc.py +336 -0
  46. package/ansible/ubuntu/disk-guard.py +104 -0
  47. package/ansible/ubuntu/policy.json +52 -0
  48. package/ansible/ubuntu/recovery.yml +15 -0
  49. package/ansible/ubuntu/t3/policy.json +7 -0
  50. package/ansible/ubuntu/t3-desktop.yml +220 -0
  51. package/ansible/ubuntu/t3-health.sh +33 -0
  52. package/ansible/ubuntu/t3-policy.yml +76 -0
  53. package/ansible/ubuntu/t3-route.yml +28 -0
  54. package/ansible/ubuntu/tailscale.yml +78 -0
  55. package/ansible/ubuntu/tasks/credentials.yml +47 -0
  56. package/ansible/ubuntu/tasks/docker.yml +31 -0
  57. package/ansible/ubuntu/tasks/packages.yml +56 -0
  58. package/ansible/ubuntu/tasks/power.yml +51 -0
  59. package/ansible/ubuntu/tasks/recovery.yml +39 -0
  60. package/ansible/ubuntu/tasks/resources.yml +312 -0
  61. package/ansible/ubuntu/tasks/services.yml +31 -0
  62. package/ansible/ubuntu/tasks/ssh.yml +71 -0
  63. package/ansible/ubuntu/tasks/user.yml +120 -0
  64. package/ansible/uv.lock +190 -0
  65. package/bin/devfleet.mjs +36 -0
  66. package/bootstrap/bootstrap.ps1 +50 -0
  67. package/bootstrap/bootstrap.sh +80 -0
  68. package/bootstrap/devfleet +7 -0
  69. package/bootstrap/docker-desktop.sh +33 -0
  70. package/bootstrap/doctor.ts +234 -0
  71. package/bootstrap/fleet-manager-run.sh +103 -0
  72. package/bootstrap/fleet-remote-bootstrap.sh +258 -0
  73. package/bootstrap/global-config.ts +171 -0
  74. package/bootstrap/install-launcher.sh +10 -0
  75. package/bootstrap/native-aws.ts +86 -0
  76. package/bootstrap/native-t3.ts +154 -0
  77. package/bootstrap/npm.ts +358 -0
  78. package/bootstrap/release-version.ts +157 -0
  79. package/bootstrap/setup-status.ts +51 -0
  80. package/bootstrap/setup.ts +88 -0
  81. package/bootstrap/shell-path.ts +105 -0
  82. package/bootstrap/verify-t3-archive.py +49 -0
  83. package/bootstrap/worker-auth.ts +65 -0
  84. package/bootstrap/worker-github.ts +47 -0
  85. package/bootstrap/worker-tools.ts +24 -0
  86. package/cliproxy/AGENTS.md +11 -0
  87. package/cliproxy/README.md +269 -0
  88. package/cliproxy/clients/bootstrap.py +143 -0
  89. package/cliproxy/clients/launcher.py +57 -0
  90. package/cliproxy/compose.yaml +90 -0
  91. package/cliproxy/config.template.yaml +70 -0
  92. package/cliproxy/docs/cloud-adoption.md +16 -0
  93. package/cliproxy/docs/deployment.png +0 -0
  94. package/cliproxy/docs/deployment.svg +109 -0
  95. package/cliproxy/docs/health-workflow.example.yml +185 -0
  96. package/cliproxy/docs/migration.md +131 -0
  97. package/cliproxy/docs/model-health-report.schema.json +364 -0
  98. package/cliproxy/docs/model-health.md +393 -0
  99. package/cliproxy/image.lock.json +5 -0
  100. package/cliproxy/launchd/dev.devfleet.cliproxy-backup-check.plist +38 -0
  101. package/cliproxy/launchd/dev.devfleet.cliproxy-backup.plist +48 -0
  102. package/cliproxy/launchd/dev.devfleet.cliproxy-fleet.plist +32 -0
  103. package/cliproxy/launchd/dev.devfleet.cliproxy-gc.plist +38 -0
  104. package/cliproxy/launchd/dev.devfleet.cliproxy-monitor.plist +29 -0
  105. package/cliproxy/launchd/dev.devfleet.cliproxy-vault-renew.plist +31 -0
  106. package/cliproxy/models.json +1618 -0
  107. package/cliproxy/requirements.lock +78 -0
  108. package/cliproxy/scripts/accounts.py +38 -0
  109. package/cliproxy/scripts/audit.py +123 -0
  110. package/cliproxy/scripts/backup +234 -0
  111. package/cliproxy/scripts/check.py +34 -0
  112. package/cliproxy/scripts/checkpoint.py +226 -0
  113. package/cliproxy/scripts/cli.py +64 -0
  114. package/cliproxy/scripts/cliproxy_host.py +257 -0
  115. package/cliproxy/scripts/cloud_state.py +150 -0
  116. package/cliproxy/scripts/container-health +6 -0
  117. package/cliproxy/scripts/docker +24 -0
  118. package/cliproxy/scripts/external-health.py +79 -0
  119. package/cliproxy/scripts/fleet_config.py +43 -0
  120. package/cliproxy/scripts/fleet_sync.py +274 -0
  121. package/cliproxy/scripts/initialize +84 -0
  122. package/cliproxy/scripts/install-dependencies +48 -0
  123. package/cliproxy/scripts/install.py +406 -0
  124. package/cliproxy/scripts/model_health.py +16 -0
  125. package/cliproxy/scripts/modelhealth/__init__.py +5 -0
  126. package/cliproxy/scripts/modelhealth/checks.py +429 -0
  127. package/cliproxy/scripts/modelhealth/cli.py +117 -0
  128. package/cliproxy/scripts/modelhealth/config.py +186 -0
  129. package/cliproxy/scripts/modelhealth/emulator.py +536 -0
  130. package/cliproxy/scripts/modelhealth/expect.py +88 -0
  131. package/cliproxy/scripts/modelhealth/findings.py +183 -0
  132. package/cliproxy/scripts/modelhealth/protocol.py +330 -0
  133. package/cliproxy/scripts/modelhealth/redact.py +28 -0
  134. package/cliproxy/scripts/modelhealth/report.py +150 -0
  135. package/cliproxy/scripts/modelhealth/runner.py +390 -0
  136. package/cliproxy/scripts/modelhealth/scenario.py +131 -0
  137. package/cliproxy/scripts/modelhealth/schema.py +63 -0
  138. package/cliproxy/scripts/modelhealth/simulate.py +111 -0
  139. package/cliproxy/scripts/modelhealth/transport.py +213 -0
  140. package/cliproxy/scripts/monitor +68 -0
  141. package/cliproxy/scripts/ops +527 -0
  142. package/cliproxy/scripts/public-endpoint +127 -0
  143. package/cliproxy/scripts/recover_node.py +177 -0
  144. package/cliproxy/scripts/restore-verify +60 -0
  145. package/cliproxy/scripts/retention.py +144 -0
  146. package/cliproxy/scripts/service-start +7 -0
  147. package/cliproxy/scripts/validate-config +56 -0
  148. package/cliproxy/scripts/vault-access.py +57 -0
  149. package/cliproxy/scripts/vault-backup-login +55 -0
  150. package/cliproxy/scripts/vault-recovery +56 -0
  151. package/cliproxy/scripts/vault-renew +30 -0
  152. package/cliproxy/systemd/cliproxy-backup-check.service +10 -0
  153. package/cliproxy/systemd/cliproxy-backup-check.timer +8 -0
  154. package/cliproxy/systemd/cliproxy-backup.service +13 -0
  155. package/cliproxy/systemd/cliproxy-backup.timer +8 -0
  156. package/cliproxy/systemd/cliproxy-fleet.service +17 -0
  157. package/cliproxy/systemd/cliproxy-gc.service +13 -0
  158. package/cliproxy/systemd/cliproxy-gc.timer +8 -0
  159. package/cliproxy/systemd/cliproxy-monitor.service +8 -0
  160. package/cliproxy/systemd/cliproxy-monitor.timer +7 -0
  161. package/cliproxy/systemd/cliproxy-vault-renew.service +8 -0
  162. package/cliproxy/systemd/cliproxy-vault-renew.timer +7 -0
  163. package/cliproxy/vendor/LICENSE +22 -0
  164. package/cliproxy/vendor/config.upstream.yaml +1349 -0
  165. package/docs/consumer-setup.md +408 -0
  166. package/docs/declared.md +89 -0
  167. package/docs/machine-setup.md +399 -0
  168. package/docs/operations.md +276 -0
  169. package/docs/releasing.md +103 -0
  170. package/docs/state.md +254 -0
  171. package/docs/topology.md +67 -0
  172. package/docs/ubuntu-sync.md +351 -0
  173. package/docs/worker-contract.md +67 -0
  174. package/examples/fleet.json +100 -0
  175. package/examples/roster.json +13 -0
  176. package/llms.txt +35 -0
  177. package/mise.lock +1521 -0
  178. package/mise.toml +110 -0
  179. package/package.json +74 -4
  180. package/schema/fleet.schema.json +420 -0
  181. package/schema/roster.schema.json +77 -0
  182. package/src/agents/account-relay.ts +111 -0
  183. package/src/agents/accounts.ts +85 -0
  184. package/src/agents/audit.ts +77 -0
  185. package/src/agents/cli.ts +13 -0
  186. package/src/agents/command.ts +204 -0
  187. package/src/agents/converge.ts +123 -0
  188. package/src/agents/doctor.ts +107 -0
  189. package/src/agents/errors.ts +2 -0
  190. package/src/agents/files.ts +73 -0
  191. package/src/agents/fleet-verify.ts +165 -0
  192. package/src/agents/launch.ts +290 -0
  193. package/src/agents/native-verify.ts +371 -0
  194. package/src/agents/provider-errors.ts +57 -0
  195. package/src/agents/registry.ts +61 -0
  196. package/src/agents/render.ts +110 -0
  197. package/src/agents/round-trip.ts +192 -0
  198. package/src/agents/secrets.ts +13 -0
  199. package/src/agents/toolchain.ts +34 -0
  200. package/src/agents/verify-cache.ts +103 -0
  201. package/src/agents/verify.ts +127 -0
  202. package/src/auth-checks.ts +163 -0
  203. package/src/cli/agent-guide.ts +129 -0
  204. package/src/cli/app.ts +122 -0
  205. package/src/cli/cli.ts +28 -0
  206. package/src/cli/command-index.ts +57 -0
  207. package/src/cli/contracts.ts +25 -0
  208. package/src/cli/fleet-reference.ts +17 -0
  209. package/src/cli/fleet-state.ts +499 -0
  210. package/src/cli/fleet-sync-checkout.ts +130 -0
  211. package/src/cli/fleet-sync-enrollment.ts +140 -0
  212. package/src/cli/fleet-sync-forward.ts +134 -0
  213. package/src/cli/fleet-sync-manager.ts +249 -0
  214. package/src/cli/fleet-sync-monitoring.ts +185 -0
  215. package/src/cli/fleet-sync-output.ts +317 -0
  216. package/src/cli/fleet-sync-privilege.ts +27 -0
  217. package/src/cli/fleet-sync-progress.ts +109 -0
  218. package/src/cli/fleet-sync-remote.ts +242 -0
  219. package/src/cli/fleet-sync-report.ts +26 -0
  220. package/src/cli/fleet-sync-schedule.ts +138 -0
  221. package/src/cli/fleet-sync-state.ts +86 -0
  222. package/src/cli/fleet-sync-vault.ts +166 -0
  223. package/src/cli/fleet-sync.ts +375 -0
  224. package/src/cli/fleet.ts +165 -0
  225. package/src/cli/managed-health.ts +219 -0
  226. package/src/cli/node-deployment.ts +34 -0
  227. package/src/cli/node-sync.ts +121 -0
  228. package/src/cli/node-t3.ts +31 -0
  229. package/src/cli/options.ts +141 -0
  230. package/src/cli/probe-source.ts +7 -0
  231. package/src/cli/program.ts +185 -0
  232. package/src/cli/project.ts +51 -0
  233. package/src/cli/register-fleet.ts +296 -0
  234. package/src/cli/registry.ts +185 -0
  235. package/src/cli/repair-apply.ts +67 -0
  236. package/src/cli/repair-guard.ts +11 -0
  237. package/src/cli/repair-inspect.ts +150 -0
  238. package/src/cli/repair-script.ts +76 -0
  239. package/src/cli/repair.ts +209 -0
  240. package/src/cli/roster.ts +105 -0
  241. package/src/cli/search.ts +35 -0
  242. package/src/cli/settings.ts +67 -0
  243. package/src/cli/source-operations.ts +102 -0
  244. package/src/cli/t3/command.ts +90 -0
  245. package/src/cli/t3/register.ts +55 -0
  246. package/src/cli/tailnet.ts +127 -0
  247. package/src/cli/ui-actions.ts +133 -0
  248. package/src/cli/ui-controller.ts +113 -0
  249. package/src/cli/ui-data.ts +106 -0
  250. package/src/cli/ui-execute.ts +163 -0
  251. package/src/cli/ui-repairs.ts +34 -0
  252. package/src/cli/ui-reports.ts +211 -0
  253. package/src/cli/ui-targets.ts +100 -0
  254. package/src/cli/ui-terminal.ts +109 -0
  255. package/src/cli/ui-types.ts +49 -0
  256. package/src/cli/ui-wizard.ts +101 -0
  257. package/src/cli/ui.ts +9 -0
  258. package/src/cli/worker-health-output.ts +37 -0
  259. package/src/config.ts +186 -0
  260. package/src/configure.ts +255 -0
  261. package/src/definition.ts +290 -0
  262. package/src/diagnostics.ts +92 -0
  263. package/src/doctor-check.ts +10 -0
  264. package/src/errors.ts +1 -0
  265. package/src/fingerprint.ts +120 -0
  266. package/src/fleet-config.ts +174 -0
  267. package/src/input-hashes.ts +73 -0
  268. package/src/machine/local-source.ts +52 -0
  269. package/src/machine/lock-error.ts +2 -0
  270. package/src/machine/platform.ts +44 -0
  271. package/src/machine/source.ts +68 -0
  272. package/src/machine/state.ts +173 -0
  273. package/src/machine-health.ts +95 -0
  274. package/src/managed-source.ts +47 -0
  275. package/src/manager-failure.ts +48 -0
  276. package/src/manager-sync.ts +405 -0
  277. package/src/manager.ts +62 -0
  278. package/src/monitoring/agent-evidence.ts +146 -0
  279. package/src/monitoring/apply.ts +183 -0
  280. package/src/monitoring/beszel.ts +198 -0
  281. package/src/monitoring/enroll.ts +111 -0
  282. package/src/monitoring/hub-access.ts +114 -0
  283. package/src/monitoring/hub-backup.ts +121 -0
  284. package/src/monitoring/hub-viewer.ts +142 -0
  285. package/src/monitoring/hub.ts +93 -0
  286. package/src/monitoring/manager-agent.ts +73 -0
  287. package/src/monitoring/manager-hub.ts +248 -0
  288. package/src/monitoring/remote.ts +65 -0
  289. package/src/network-definition.ts +83 -0
  290. package/src/node-converge-linux.ts +374 -0
  291. package/src/node-converge-shared.ts +83 -0
  292. package/src/node-converge.ts +40 -0
  293. package/src/onboard.ts +35 -0
  294. package/src/package-info.ts +45 -0
  295. package/src/platform/ansible-receipt.ts +128 -0
  296. package/src/platform/ansible.ts +66 -0
  297. package/src/platform/darwin.ts +203 -0
  298. package/src/platform/detect.ts +11 -0
  299. package/src/platform/fallback.ts +110 -0
  300. package/src/platform/index.ts +38 -0
  301. package/src/platform/launchd.ts +98 -0
  302. package/src/platform/linux.ts +139 -0
  303. package/src/platform/ps.ts +45 -0
  304. package/src/platform/resource-verify.ts +132 -0
  305. package/src/platform/systemd-user.ts +95 -0
  306. package/src/platform/t3-policy.ts +137 -0
  307. package/src/platform/t3-route.ts +68 -0
  308. package/src/platform/t3-service.ts +75 -0
  309. package/src/platform/types.ts +123 -0
  310. package/src/platform/ubuntu-audit.ts +66 -0
  311. package/src/platform/ubuntu-verify.ts +105 -0
  312. package/src/platform/windows.ts +148 -0
  313. package/src/power-recovery.ts +55 -0
  314. package/src/probe.ts +51 -0
  315. package/src/protocol.ts +119 -0
  316. package/src/proxy-converge.ts +85 -0
  317. package/src/remote-bootstrap.ts +210 -0
  318. package/src/remote-entry.ts +89 -0
  319. package/src/remote-lock.ts +12 -0
  320. package/src/repair-catalog.ts +206 -0
  321. package/src/repair-plan.ts +78 -0
  322. package/src/repair-runner.ts +84 -0
  323. package/src/report.ts +71 -0
  324. package/src/runner.ts +104 -0
  325. package/src/secrets.ts +169 -0
  326. package/src/service-auth.ts +115 -0
  327. package/src/ssh.ts +135 -0
  328. package/src/state/bucket.ts +208 -0
  329. package/src/state/check.ts +74 -0
  330. package/src/state/connection.ts +92 -0
  331. package/src/state/enrollment.ts +45 -0
  332. package/src/state/legacy.ts +89 -0
  333. package/src/state/litestream.ts +143 -0
  334. package/src/state/operator.ts +20 -0
  335. package/src/state/paths.ts +40 -0
  336. package/src/state/roster.ts +62 -0
  337. package/src/state/store.ts +169 -0
  338. package/src/state/sync.ts +352 -0
  339. package/src/sync.ts +104 -0
  340. package/src/t3/accounts.ts +81 -0
  341. package/src/t3/desired.ts +165 -0
  342. package/src/t3/files.ts +81 -0
  343. package/src/t3/names-check.ts +53 -0
  344. package/src/t3/names.ts +155 -0
  345. package/src/t3/plan.ts +82 -0
  346. package/src/t3/providers.ts +74 -0
  347. package/src/t3/reconcile.ts +123 -0
  348. package/src/t3-endpoint.ts +104 -0
  349. package/src/t3-linked-check.ts +123 -0
  350. package/src/tailnet-health.ts +231 -0
  351. package/src/tailnet-matching.ts +37 -0
  352. package/src/tailscale.ts +141 -0
  353. package/src/toolchain-checks.ts +108 -0
  354. package/src/topology.ts +24 -0
  355. package/src/transport.ts +122 -0
  356. package/src/types.ts +99 -0
  357. package/src/vault-config.ts +38 -0
  358. package/src/worker-access-checks.ts +168 -0
  359. package/src/worker-capacity.ts +34 -0
  360. package/src/worker-host-checks.ts +181 -0
  361. package/src/worker-policy.ts +64 -0
  362. package/src/worker-readiness.ts +64 -0
  363. package/src/workstation-checks.ts +29 -0
@@ -0,0 +1,140 @@
1
+ import { mkdir, rm } from 'node:fs/promises';
2
+ import { loadRoster } from './roster';
3
+ import { enrolledRoster } from '../definition';
4
+ import { FleetUsageError } from '../errors';
5
+ import { dirname } from 'node:path';
6
+ import { fleet as definition } from '../fleet-config';
7
+ import { discoverTailnet } from '../tailscale';
8
+ import { matchComputers } from '../tailnet-matching';
9
+ import { sshArgs, sshFailure } from '../ssh';
10
+ import { ensurePrivateRoot } from '../machine/state';
11
+ import type { FleetNode, Runner } from '../types';
12
+ import { STATE_ROSTER, readRosterText, writeRosterText } from '../state/roster';
13
+
14
+ /** Interactive enrollment/resume only. Existing paths and explicit identities
15
+ * are preserved. Tailnet candidates never enter the declared membership.
16
+ */
17
+ export async function enrollManager(path: string, run: Runner): Promise<void> {
18
+ const canonical = definition;
19
+ const original = await existingEnrollment(path);
20
+ const saved =
21
+ original === null
22
+ ? []
23
+ : enrolledRoster(canonical, await loadRoster(path)).nodes;
24
+ const nodes: FleetNode[] = canonical.workers.map((node) => ({
25
+ id: node.id,
26
+ ssh: node.ssh,
27
+ workspace: '/',
28
+ required: ['t3', 'toolchain', 'workstation'],
29
+ }));
30
+ const pending = nodes.filter(
31
+ (node) => !saved.find((entry) => entry.id === node.id)?.tailscaleId
32
+ );
33
+ if (!pending.length) return;
34
+ const tailnet = await discoverTailnet(run);
35
+ const matches = await matchComputers(nodes, tailnet.computers, run);
36
+ const enrolled: FleetNode[] = [];
37
+ for (const node of pending) {
38
+ const peers = tailnet.computers.filter((peer) =>
39
+ matches.get(peer.id)?.some((candidate) => candidate.id === node.id)
40
+ );
41
+ if (
42
+ ambiguous(
43
+ peers.map((peer) => peer.id),
44
+ matches
45
+ )
46
+ )
47
+ continue;
48
+ const connection = await inspectConnection(node, peers[0]?.id, run);
49
+ if (connection) enrolled.push(connection);
50
+ }
51
+ const merged = mergeEnrollment(saved, enrolled);
52
+ if (JSON.stringify(merged) === JSON.stringify(saved)) return;
53
+ // Only canonical nodes reached over host-key-verified SSH get here, so
54
+ // enrollment needs no extra confirmation; the list is still shown.
55
+ process.stderr.write(
56
+ `Enrolling verified SSH connections for canonical targets:\n${enrolled.map((node) => ` ${node.id} → ${node.ssh}; device ${enrollmentIdentity(node)}`).join('\n')}\nMissing targets remain visible and are never replaced by discovered peers.\n`
57
+ );
58
+ await persistEnrollment(path, original, merged);
59
+ }
60
+
61
+ async function inspectConnection(
62
+ node: FleetNode,
63
+ peerId: string | undefined,
64
+ run: Runner
65
+ ): Promise<FleetNode | null> {
66
+ const home = await run([...sshArgs(node), 'printf "%s" "$HOME"'], {
67
+ timeoutMs: 20_000,
68
+ });
69
+ if (!home.ok) {
70
+ const check = sshFailure(home);
71
+ process.stderr.write(
72
+ `[${node.id}] ${check.id}: ${check.summary} ${check.remedy?.action ?? ''}\n`
73
+ );
74
+ return null;
75
+ }
76
+ if (!/^\/[^\x00-\x1f\x7f]{1,4000}$/.test(home.stdout)) return null;
77
+ return {
78
+ ...node,
79
+ workspace: `${home.stdout}/.local/share/personal/fleet`,
80
+ ...(peerId ? { tailscaleId: peerId } : { bootstrapViaSsh: true }),
81
+ };
82
+ }
83
+
84
+ function enrollmentIdentity(node: FleetNode): string {
85
+ return (
86
+ node.tailscaleId ??
87
+ 'initial trusted SSH bootstrap; native Tailscale login required'
88
+ );
89
+ }
90
+
91
+ function ambiguous(ids: string[], matches: Map<string, FleetNode[]>): boolean {
92
+ return (
93
+ ids.length > 1 || (ids.length === 1 && matches.get(ids[0]!)?.length !== 1)
94
+ );
95
+ }
96
+ function existingEnrollment(path: string): Promise<string | null> {
97
+ return readRosterText(path);
98
+ }
99
+ export function mergeEnrollment(
100
+ saved: FleetNode[],
101
+ discovered: FleetNode[]
102
+ ): FleetNode[] {
103
+ const result = saved.map((node) => ({ ...node }));
104
+ for (const candidate of discovered) {
105
+ const existing = result.find((node) => node.id === candidate.id);
106
+ if (!existing) result.push(candidate);
107
+ else if (!existing.tailscaleId && candidate.tailscaleId) {
108
+ existing.tailscaleId = candidate.tailscaleId;
109
+ delete existing.bootstrapViaSsh;
110
+ }
111
+ }
112
+ return result;
113
+ }
114
+ async function persistEnrollment(
115
+ path: string,
116
+ original: string | null,
117
+ nodes: FleetNode[]
118
+ ): Promise<void> {
119
+ const write = async (): Promise<void> => {
120
+ if ((await existingEnrollment(path)) !== original)
121
+ throw new FleetUsageError(
122
+ 'Enrollment changed during approval. Inspect it and rerun; no connection was overwritten.'
123
+ );
124
+ await writeRosterText(
125
+ path,
126
+ JSON.stringify({ version: 1, nodes }, null, 2) + '\n'
127
+ );
128
+ };
129
+ // The fleet state serializes its own writers; a roster file needs a lock.
130
+ if (path === STATE_ROSTER) return write();
131
+ await ensurePrivateRoot(dirname(path));
132
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 });
133
+ const lock = `${path}.enroll.lock`;
134
+ await mkdir(lock, { mode: 0o700 });
135
+ try {
136
+ await write();
137
+ } finally {
138
+ await rm(lock, { recursive: true });
139
+ }
140
+ }
@@ -0,0 +1,134 @@
1
+ import { spawn } from 'node:child_process';
2
+ import type { FleetDefinition } from '../definition';
3
+ import { shellQuote, sshArgs } from '../ssh';
4
+ import type { FleetNode } from '../types';
5
+ import type { CommandOutcome } from './contracts';
6
+ import type { FleetSyncOptions } from './fleet-sync';
7
+
8
+ /** Set on the manager side of a hand-off (and in tests): never hand off again. */
9
+ export const NO_FORWARD_ENV = 'PERSONAL_FLEET_NO_FORWARD';
10
+
11
+ /** Streams the remote command: progress on stderr, the report on stdout. */
12
+ export type ForwardSpawn = (
13
+ command: string[]
14
+ ) => Promise<{ exitCode: number; stdout: string }>;
15
+
16
+ export function forwardingDisabled(): boolean {
17
+ return Boolean(process.env[NO_FORWARD_ENV]);
18
+ }
19
+
20
+ /** fleet-sync only ever runs on the manager. Started on a worker, it hands the
21
+ * same request to the manager over host-key-verified SSH (no agent forwarding,
22
+ * no credentials copied) and reports the manager's result unchanged.
23
+ */
24
+ export async function forwardToManager(
25
+ definition: FleetDefinition,
26
+ options: FleetSyncOptions,
27
+ revision: string | undefined,
28
+ remote: { spawn?: ForwardSpawn | undefined; script?: string | null } = {}
29
+ ): Promise<CommandOutcome> {
30
+ // A test must inject the spawn: it can never reach the real manager.
31
+ if (!remote.spawn && process.env['NODE_ENV'] === 'test')
32
+ throw new Error('FLEET_FORWARD_IN_TEST');
33
+ const spawnRemote = remote.spawn ?? spawnInherited;
34
+ const runScript = remote.script ?? null;
35
+ const { manager } = definition;
36
+ process.stderr.write(
37
+ `fleet-sync runs on the manager; handing off to ${manager.id} over SSH…\n`
38
+ );
39
+ const result = await spawnRemote(
40
+ managerCommand(definition, options, revision, runScript)
41
+ );
42
+ const stdout = result.stdout.trim();
43
+ if (options.json) {
44
+ try {
45
+ return { exitCode: result.exitCode, data: JSON.parse(stdout) as unknown };
46
+ } catch {
47
+ /* Fall through to the structured failure below. */
48
+ }
49
+ }
50
+ if (result.exitCode === 255 && !stdout)
51
+ return {
52
+ exitCode: 1,
53
+ data: {
54
+ version: 1,
55
+ ok: false,
56
+ code: 'FLEET_MANAGER_UNREACHABLE',
57
+ error: `Could not reach the manager (${manager.id}) over SSH. Wake it, check Tailscale and SSH (Remote Login), or run fleet-sync on the manager directly.`,
58
+ },
59
+ };
60
+ return {
61
+ exitCode: result.exitCode,
62
+ data: { version: 1, forwardedTo: manager.id, exitCode: result.exitCode },
63
+ text: stdout,
64
+ };
65
+ }
66
+
67
+ /** With the revision's tracked `fleet-manager-run.sh`, the manager runs
68
+ * fleet-sync from a clean checkout of that exact revision; otherwise (no
69
+ * revision, or an older one without the script) its installed launcher.
70
+ */
71
+ export function managerCommand(
72
+ definition: FleetDefinition,
73
+ options: FleetSyncOptions,
74
+ revision: string | undefined,
75
+ runScript: string | null = null
76
+ ): string[] {
77
+ const { manager } = definition;
78
+ const target: FleetNode = {
79
+ id: manager.id,
80
+ ssh: `${manager.network.user}@${manager.network.tailscaleName}`,
81
+ workspace: '~',
82
+ required: [],
83
+ };
84
+ const args =
85
+ runScript && revision
86
+ ? [
87
+ 'sh',
88
+ '-c',
89
+ runScript,
90
+ 'fleet-manager-run',
91
+ revision,
92
+ ...forwardedFlags(options, undefined),
93
+ ]
94
+ : ['devfleet', 'fleet-sync', ...forwardedFlags(options, revision)];
95
+ const color = process.stdout.isTTY && !process.env['NO_COLOR'];
96
+ // A login shell gives the manager's normal PATH (mise, Docker, Tailscale).
97
+ const script = `export ${NO_FORWARD_ENV}=1${color ? ' FORCE_COLOR=1' : ''}; exec ${args.map(shellQuote).join(' ')}`;
98
+ return [...sshArgs(target), `exec /bin/zsh -lc ${shellQuote(script)}`];
99
+ }
100
+
101
+ export function forwardedFlags(
102
+ options: FleetSyncOptions,
103
+ revision: string | undefined
104
+ ): string[] {
105
+ const flags = revision ? ['--revision', revision] : [];
106
+ for (const node of options.node ?? []) flags.push('--node', node);
107
+ if (options.schedule) flags.push('--schedule', options.schedule);
108
+ const switches: [boolean | undefined, string][] = [
109
+ [options.all, '--all'],
110
+ [options.dryRun, '--dry-run'],
111
+ [options.apply, '--apply'],
112
+ [options.yes, '--yes'],
113
+ [options.json, '--json'],
114
+ [options.nonInteractive, '--non-interactive'],
115
+ ];
116
+ for (const [on, flag] of switches) if (on) flags.push(flag);
117
+ return flags;
118
+ }
119
+
120
+ function spawnInherited(
121
+ command: string[]
122
+ ): Promise<{ exitCode: number; stdout: string }> {
123
+ return new Promise((resolve) => {
124
+ const child = spawn(command[0]!, command.slice(1), {
125
+ stdio: ['ignore', 'pipe', 'inherit'],
126
+ });
127
+ let stdout = '';
128
+ child.stdout.setEncoding('utf8').on('data', (chunk: string) => {
129
+ stdout += chunk;
130
+ });
131
+ child.on('error', () => resolve({ exitCode: 255, stdout: '' }));
132
+ child.on('close', (code) => resolve({ exitCode: code ?? 1, stdout }));
133
+ });
134
+ }
@@ -0,0 +1,249 @@
1
+ import { blocking, type Check, type Runner } from '../types';
2
+ import type { FleetDefinition } from '../definition';
3
+ import type { ManagerNode } from '../manager-sync';
4
+ import { hostId } from '../topology';
5
+ import { convergeProxy } from '../proxy-converge';
6
+ import { attempt } from '../node-converge-shared';
7
+ import { quotaLimited, verifyFleetProviders } from '../agents/fleet-verify';
8
+ import { verifyNativeAgents } from '../agents/native-verify';
9
+ import { convergeManagerHub } from '../monitoring/manager-hub';
10
+ import { enrollManagerMonitoring } from './fleet-sync-monitoring';
11
+ import { installConfig } from '../fleet-config';
12
+ import { machineProviders } from '../t3/providers';
13
+ import { providerNames } from '../t3/names-check';
14
+ import macos from '../../ansible/macos/policy.json';
15
+ import { ansibleCommand } from '../platform/ansible';
16
+ import { PACKAGE_ROOT } from '../package-info';
17
+ import { readFileSync } from 'node:fs';
18
+ import { join } from 'node:path';
19
+
20
+ /** The manager converges itself locally (never over SSH) before any worker:
21
+ * it must be the declared manager, stay awake on AC power, and host every
22
+ * singleton service (CLI proxy, Beszel hub). Workers only consume them.
23
+ */
24
+ export async function convergeManager(
25
+ definition: FleetDefinition,
26
+ input: {
27
+ revision: string;
28
+ apply: boolean;
29
+ workspace: string;
30
+ },
31
+ run: Runner
32
+ ): Promise<ManagerNode> {
33
+ const started = Date.now();
34
+ const id = definition.manager.id;
35
+ const identity = await managerIdentity(id, run);
36
+ const checks: Check[] = [identity];
37
+ if (identity.status === 'pass') {
38
+ // Launchers and scheduled jobs on the manager read the installed copy.
39
+ if (input.apply) installConfig();
40
+ checks.push(await managerPower(run, input.workspace));
41
+ checks.push(await managerDocker(run));
42
+ checks.push(await machineProviders(id, input.workspace, input.apply));
43
+ checks.push(await providerNames(id, input.workspace, input.apply));
44
+ if (input.apply) {
45
+ // Same provider profiles and the same Vault gateway credential as every
46
+ // worker: the instances above launch from these profiles, and the check
47
+ // proves the credential works end to end through the shared proxy.
48
+ checks.push(
49
+ ...(await attempt('configuration:apply', () =>
50
+ managerAgentProfiles(id, input.workspace, run)
51
+ ))
52
+ );
53
+ const providers = await attempt('provider:verification', () =>
54
+ verifyFleetProviders(id)
55
+ );
56
+ checks.push(...providers);
57
+ // Same agents, versions and shared accounts as every worker.
58
+ checks.push(
59
+ ...(await attempt('agent:verification', () =>
60
+ verifyNativeAgents(id, input.workspace, run, {
61
+ outOfQuota: quotaLimited(providers),
62
+ })
63
+ ))
64
+ );
65
+ }
66
+ checks.push(
67
+ ...(await convergeProxy(id, input.revision, run, {
68
+ apply: input.apply,
69
+ cwd: input.workspace,
70
+ }))
71
+ );
72
+ if (input.apply) {
73
+ const hub = await convergeManagerHub(input.workspace, definition, run);
74
+ checks.push(...hub);
75
+ // The manager is always a monitored system on its own hub.
76
+ if (!hub.some(blocking))
77
+ checks.push(
78
+ ...(await enrollManagerMonitoring(definition, input.workspace, run))
79
+ );
80
+ }
81
+ }
82
+ return {
83
+ node: id,
84
+ role: 'manager',
85
+ checks,
86
+ reachability: 'online',
87
+ desiredRevision: input.revision,
88
+ appliedRevision:
89
+ input.apply && !checks.some(blocking) ? input.revision : null,
90
+ durationMs: Date.now() - started,
91
+ };
92
+ }
93
+
94
+ const PROFILE_TOOLS = [
95
+ 'codex',
96
+ 'claude',
97
+ 'gemini',
98
+ 'opencode',
99
+ 'grok',
100
+ 'antigravity',
101
+ ] as const;
102
+
103
+ /** Applies the proxy agent profiles the managed T3 instances launch from
104
+ * (codex-proxy.ts refuses to start with a missing or stale profile). Workers
105
+ * apply the same profiles from a pinned deployment; the manager's checkout is
106
+ * the human's working tree, so only the workstation sources must be clean
107
+ * (the `agents apply` owner enforces that) and nothing else is touched.
108
+ */
109
+ export async function managerAgentProfiles(
110
+ id: string,
111
+ workspace: string,
112
+ run: Runner
113
+ ): Promise<Check[]> {
114
+ for (const tool of PROFILE_TOOLS) {
115
+ const result = await run(
116
+ [
117
+ 'mise',
118
+ 'exec',
119
+ '--locked',
120
+ 'bun',
121
+ '--',
122
+ 'bun',
123
+ 'src/agents/cli.ts',
124
+ '--json',
125
+ '--yes',
126
+ '--non-interactive',
127
+ 'agents',
128
+ 'apply',
129
+ '--node',
130
+ id,
131
+ '--tool',
132
+ tool,
133
+ '--profile',
134
+ 'proxy',
135
+ ],
136
+ { cwd: workspace, timeoutMs: 120_000 }
137
+ );
138
+ if (!result.ok)
139
+ return [
140
+ {
141
+ id: 'configuration:apply',
142
+ status: 'fail',
143
+ summary: 'MANAGER_AGENT_PROFILES_FAILED',
144
+ remedy: {
145
+ action: `Run bun src/agents/cli.ts --yes agents apply --node ${id} --tool ${tool} --profile proxy in the dev-fleet checkout on the manager and review the result.`,
146
+ },
147
+ },
148
+ ];
149
+ }
150
+ return [
151
+ {
152
+ id: 'configuration:apply',
153
+ status: 'pass',
154
+ summary: `The manager has the same proxy agent profiles as every worker (${PROFILE_TOOLS.join(', ')}).`,
155
+ },
156
+ ];
157
+ }
158
+
159
+ /** fleet-sync runs only on the declared manager. */
160
+ export async function managerIdentity(id: string, run: Runner): Promise<Check> {
161
+ const result = await run(['scutil', '--get', 'LocalHostName']);
162
+ const local = result.ok ? hostId(result.stdout.trim()) : '';
163
+ return local === id
164
+ ? {
165
+ id: 'manager:identity',
166
+ status: 'pass',
167
+ summary: `This machine is the declared fleet manager (${id}).`,
168
+ }
169
+ : {
170
+ id: 'manager:identity',
171
+ status: 'fail',
172
+ summary: 'FLEET_NOT_MANAGER',
173
+ remedy: {
174
+ action: `Run fleet-sync on the manager (${id}). Workers are never controlled from another worker; change the manager only by editing manager in fleet.json and moving its singleton services.`,
175
+ },
176
+ };
177
+ }
178
+
179
+ /** Workers depend on the manager's services, so it never sleeps on AC power.
180
+ * Measures every setting `ansible/macos/manager.yml` declares, from the same
181
+ * policy file the playbook applies.
182
+ */
183
+ export async function managerPower(
184
+ run: Runner,
185
+ workspace = '.'
186
+ ): Promise<Check> {
187
+ const result = await run(['pmset', '-g', 'custom']);
188
+ const ac = result.ok ? (result.stdout.split('AC Power:')[1] ?? '') : '';
189
+ const observed = (key: string) =>
190
+ new RegExp(`^\\s*${key}\\s+(\\S+)`, 'm').exec(ac)?.[1];
191
+ const drifted = Object.entries(macos.pmset)
192
+ .filter(([key, value]) => observed(key) !== value)
193
+ .map(([key]) => key);
194
+ return drifted.length === 0
195
+ ? {
196
+ id: 'manager:power',
197
+ status: 'pass',
198
+ summary: 'The manager never sleeps on AC power.',
199
+ }
200
+ : {
201
+ id: 'manager:power',
202
+ status: 'fail',
203
+ summary: drifted.includes('sleep')
204
+ ? 'MANAGER_SLEEPS_ON_POWER'
205
+ : `MANAGER_POWER_POLICY_DRIFT ${drifted.join(' ')}`,
206
+ remedy: {
207
+ action: `Workers lose the CLI proxy and Beszel hub while the manager sleeps. Apply the manager power policy once, as an administrator: cd ${workspace} && ${ansibleCommand().join(' ')} -K ansible/macos/manager.yml`,
208
+ },
209
+ };
210
+ }
211
+
212
+ /** The Docker Desktop release `mise.toml` pins, as the engine names itself. */
213
+ export function pinnedDockerDesktop(root = PACKAGE_ROOT): string {
214
+ const { vars } = Bun.TOML.parse(
215
+ readFileSync(join(root, 'mise.toml'), 'utf8')
216
+ ) as { vars: Record<string, string> };
217
+ return `Docker Desktop ${vars['docker_desktop_version']} (${vars['docker_desktop_build']})`;
218
+ }
219
+
220
+ /** The manager's services run in Docker Desktop, which no playbook installs
221
+ * and which updates itself: the pinned release is measured, and a different
222
+ * one warns instead of failing the run.
223
+ */
224
+ export async function managerDocker(
225
+ run: Runner,
226
+ pinned = pinnedDockerDesktop()
227
+ ): Promise<Check> {
228
+ const result = await run(
229
+ ['docker', 'version', '--format', '{{.Server.Platform.Name}}'],
230
+ { timeoutMs: 20_000 }
231
+ );
232
+ const observed = result.ok ? result.stdout.trim() : '';
233
+ if (observed === pinned)
234
+ return {
235
+ id: 'manager:docker',
236
+ status: 'pass',
237
+ summary: `The manager runs the pinned ${pinned}.`,
238
+ };
239
+ return {
240
+ id: 'manager:docker',
241
+ status: 'warn',
242
+ summary: observed
243
+ ? 'MANAGER_DOCKER_DESKTOP_DRIFT'
244
+ : 'MANAGER_DOCKER_DESKTOP_UNOBSERVED',
245
+ remedy: {
246
+ action: `The manager's services are tested on ${pinned}. Install that release and turn off its automatic updates, or expect differences; fleet-sync does not install or replace Docker Desktop.`,
247
+ },
248
+ };
249
+ }