@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,45 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+
5
+ /** Where this code's own files live: the published package, or a checkout. */
6
+ export const PACKAGE_ROOT = fileURLToPath(new URL('..', import.meta.url));
7
+
8
+ export interface PackageInfo {
9
+ name: string;
10
+ version: string;
11
+ /** The commit this package was published from; null in a checkout. */
12
+ revision: string | null;
13
+ /** Where a defect or a feature request is raised (`bugs` in the manifest). */
14
+ issues: string | null;
15
+ }
16
+
17
+ function issueTracker(bugs: unknown): string | null {
18
+ const url =
19
+ typeof bugs === 'object' && bugs !== null
20
+ ? (bugs as { url?: unknown }).url
21
+ : bugs;
22
+ return typeof url === 'string' && /^https:\/\/\S+$/.test(url) ? url : null;
23
+ }
24
+
25
+ /** What the running package says about itself. The release workflow stamps
26
+ * the version and the commit (`gitHead`) into the package it publishes, so an
27
+ * installed dev-fleet knows which revision every machine has to run. A
28
+ * checkout carries neither: its revision is its own HEAD. The issue tracker
29
+ * comes from the manifest too, so a fork sends its users to its own.
30
+ */
31
+ export function packageInfo(root = PACKAGE_ROOT): PackageInfo {
32
+ const manifest = JSON.parse(
33
+ readFileSync(join(root, 'package.json'), 'utf8')
34
+ ) as { name?: unknown; version?: unknown; gitHead?: unknown; bugs?: unknown };
35
+ const { gitHead } = manifest;
36
+ return {
37
+ name: String(manifest.name),
38
+ version: String(manifest.version),
39
+ revision:
40
+ typeof gitHead === 'string' && /^[a-f0-9]{40}$/.test(gitHead)
41
+ ? gitHead
42
+ : null,
43
+ issues: issueTracker(manifest.bugs),
44
+ };
45
+ }
@@ -0,0 +1,128 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises';
3
+ import { homedir } from 'node:os';
4
+ import { join, relative } from 'node:path';
5
+ import { runAnsible } from './ansible';
6
+ import type { CommandOptions, CommandResult, Runner } from '../types';
7
+
8
+ /** Full re-apply at least this often, so drift a cheap check misses still heals. */
9
+ export const RECEIPT_MAX_AGE_MS = 24 * 60 * 60 * 1000;
10
+
11
+ export interface Receipt {
12
+ /** Stable receipt name, e.g. `ubuntu-baseline`. */
13
+ name: string;
14
+ /** Files or directories whose content the playbook result depends on. */
15
+ sources: string[];
16
+ /** Cheap postcondition probe; a skip happens only while it passes. */
17
+ verify: () => Promise<boolean>;
18
+ /** Ignore any receipt and apply in full. */
19
+ force?: boolean;
20
+ /** Receipt directory; defaults to the user's fleet state. */
21
+ directory?: string;
22
+ now?: number;
23
+ }
24
+
25
+ export interface ReceiptResult extends CommandResult {
26
+ /** True when the playbook was skipped on an unchanged, verified receipt. */
27
+ cached: boolean;
28
+ }
29
+
30
+ export function receiptDirectory(home = homedir()): string {
31
+ return join(home, '.local/state/personal/fleet/receipts');
32
+ }
33
+
34
+ /** Ansible is idempotent but slow (fact gathering, package and service
35
+ * inspection). A successful apply records a digest of its exact inputs
36
+ * (playbook sources and variables, never the variables themselves). A later
37
+ * run with the same digest skips the playbook while the cheap postcondition
38
+ * still holds; any input change, failed probe, failed apply or a receipt
39
+ * older than a day applies in full again.
40
+ */
41
+ export async function runAnsibleWithReceipt(
42
+ run: Runner,
43
+ playbook: string,
44
+ options: CommandOptions & { input: string },
45
+ receipt: Receipt
46
+ ): Promise<ReceiptResult> {
47
+ const directory = receipt.directory ?? receiptDirectory();
48
+ const path = join(directory, `${receipt.name}.json`);
49
+ const now = receipt.now ?? Date.now();
50
+ const key = await inputKey(playbook, options.input, receipt.sources);
51
+ if (
52
+ !receipt.force &&
53
+ key !== null &&
54
+ (await fresh(path, key, now)) &&
55
+ (await receipt.verify().catch(() => false))
56
+ )
57
+ return { ok: true, reason: 'exit', stdout: '', stderr: '', cached: true };
58
+ await rm(path, { force: true }).catch(() => undefined);
59
+ const result = await runAnsible(run, playbook, options);
60
+ if (result.ok && key !== null)
61
+ await record(directory, path, { version: 1, key, appliedAt: now });
62
+ return { ...result, cached: false };
63
+ }
64
+
65
+ /** Best effort: a missing receipt only means the next run applies in full. */
66
+ async function record(directory: string, path: string, value: object) {
67
+ try {
68
+ await mkdir(directory, { recursive: true, mode: 0o700 });
69
+ await writeFile(path, JSON.stringify(value) + '\n', { mode: 0o600 });
70
+ } catch {
71
+ /* Never fail a successful apply over its receipt. */
72
+ }
73
+ }
74
+
75
+ async function fresh(path: string, key: string, now: number) {
76
+ try {
77
+ const value = JSON.parse(await readFile(path, 'utf8')) as {
78
+ version?: unknown;
79
+ key?: unknown;
80
+ appliedAt?: unknown;
81
+ };
82
+ return (
83
+ value.version === 1 &&
84
+ value.key === key &&
85
+ typeof value.appliedAt === 'number' &&
86
+ now - value.appliedAt >= 0 &&
87
+ now - value.appliedAt < RECEIPT_MAX_AGE_MS
88
+ );
89
+ } catch {
90
+ return false;
91
+ }
92
+ }
93
+
94
+ /** Null when any source is unreadable: then the playbook always runs. */
95
+ export async function inputKey(
96
+ playbook: string,
97
+ input: string,
98
+ sources: string[]
99
+ ): Promise<string | null> {
100
+ const hash = createHash('sha256');
101
+ hash.update(`playbook\0${playbook}\0input\0${input}\0`);
102
+ try {
103
+ for (const source of [...sources].sort())
104
+ for (const file of await files(source)) {
105
+ hash.update(`${relative(source, file)}\0`);
106
+ hash.update(await readFile(file));
107
+ hash.update('\0');
108
+ }
109
+ } catch {
110
+ return null;
111
+ }
112
+ return hash.digest('hex');
113
+ }
114
+
115
+ async function files(source: string): Promise<string[]> {
116
+ const entries = await readdir(source, {
117
+ recursive: true,
118
+ withFileTypes: true,
119
+ }).catch((error: unknown) => {
120
+ if ((error as NodeJS.ErrnoException).code === 'ENOTDIR') return null;
121
+ throw error;
122
+ });
123
+ if (entries === null) return [source];
124
+ return entries
125
+ .filter((entry) => entry.isFile())
126
+ .map((entry) => join(entry.parentPath, entry.name))
127
+ .sort();
128
+ }
@@ -0,0 +1,66 @@
1
+ import { mkdir, mkdtemp, writeFile, rm } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { homedir } from 'node:os';
4
+ import { PACKAGE_ROOT } from '../package-info';
5
+ import type { Runner, CommandOptions, CommandResult } from '../types';
6
+
7
+ /** The Ansible each machine runs playbooks with, pinned either way. A worker
8
+ * has the exact `ansible-core` package the Ubuntu policy names. The manager
9
+ * runs the project in `ansible/` (`pyproject.toml`, `uv.lock`): one release on
10
+ * one Python, every dependency checked against its locked hash, in a
11
+ * throwaway environment. No global Python environment is modified, nothing is
12
+ * written into the checkout, and an `ansible-playbook` that happens to be on
13
+ * PATH is never used.
14
+ */
15
+ export function ansibleCommand(
16
+ platform: NodeJS.Platform = process.platform,
17
+ root = PACKAGE_ROOT
18
+ ): string[] {
19
+ return platform === 'linux'
20
+ ? ['ansible-playbook']
21
+ : [
22
+ 'uv',
23
+ 'run',
24
+ '--project',
25
+ join(root, 'ansible'),
26
+ '--locked',
27
+ '--isolated',
28
+ '--quiet',
29
+ 'ansible-playbook',
30
+ ];
31
+ }
32
+
33
+ /** Private fleet state on disk, never /tmp: a worker's /tmp is RAM-backed with a
34
+ * per-user quota that agent caches can exhaust, and the baseline that bounds it
35
+ * must still be able to run then.
36
+ */
37
+ export function variablesDirectory(home = homedir()): string {
38
+ return join(home, '.local/state/personal/fleet/run');
39
+ }
40
+
41
+ /** Ansible resolves @file paths before opening them; @/dev/stdin is not portable
42
+ * across versions when stdin is a pipe. Keep runtime variables in a private,
43
+ * short-lived directory instead. Never put their values in argv or logs.
44
+ */
45
+ export async function runAnsible(
46
+ run: Runner,
47
+ playbook: string,
48
+ options: CommandOptions & { input: string },
49
+ scratch = variablesDirectory()
50
+ ): Promise<CommandResult> {
51
+ if (Buffer.byteLength(options.input) > 256 * 1024)
52
+ throw new Error('ANSIBLE_VARIABLE_LIMIT');
53
+ await mkdir(scratch, { recursive: true, mode: 0o700 });
54
+ const directory = await mkdtemp(join(scratch, 'personal-ansible-'));
55
+ const path = join(directory, 'variables.json');
56
+ try {
57
+ await writeFile(path, options.input, { mode: 0o600, flag: 'wx' });
58
+ const { input: _input, ...commandOptions } = options;
59
+ return await run(
60
+ [...ansibleCommand(), playbook, '--extra-vars', '@' + path],
61
+ commandOptions
62
+ );
63
+ } finally {
64
+ await rm(directory, { recursive: true, force: true });
65
+ }
66
+ }
@@ -0,0 +1,203 @@
1
+ import { execSync, spawnSync } from 'node:child_process';
2
+ import { existsSync, readdirSync } from 'node:fs';
3
+ import { homedir } from 'node:os';
4
+ import { join } from 'node:path';
5
+
6
+ import type {
7
+ NotificationKind,
8
+ NotifierCapability,
9
+ NotifierTestResult,
10
+ Platform,
11
+ ProcessInfo,
12
+ ScheduledJob,
13
+ ScheduledJobStatus,
14
+ } from './types';
15
+ import {
16
+ installLaunchdJob,
17
+ launchdJobStatus,
18
+ removeLaunchdJob,
19
+ } from './launchd';
20
+ import { psProcesses } from './ps';
21
+
22
+ function appleScriptString(value: string): string {
23
+ return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\r?\n/g, '\\n')}"`;
24
+ }
25
+
26
+ export class DarwinPlatform implements Platform {
27
+ readonly dockerSetupScript = 'bootstrap/docker-desktop.sh';
28
+ readonly name = 'darwin' as const;
29
+ readonly label = 'macOS';
30
+
31
+ opencodeDir(): string {
32
+ return join(homedir(), '.config/opencode');
33
+ }
34
+
35
+ cacheDir(): string {
36
+ return join(homedir(), '.cache');
37
+ }
38
+
39
+ globalBinDir(): string {
40
+ return join(homedir(), '.local/bin');
41
+ }
42
+
43
+ canBuildSwiftNotifier(): boolean {
44
+ return (
45
+ spawnSync('/usr/bin/which', ['swiftc'], { stdio: 'ignore' }).status === 0
46
+ );
47
+ }
48
+
49
+ shellRcFiles(): readonly string[] {
50
+ const home = homedir();
51
+ return [
52
+ join(home, '.zshrc'),
53
+ join(home, '.bashrc'),
54
+ join(home, '.bash_profile'),
55
+ join(home, '.config/fish/config.fish'),
56
+ ];
57
+ }
58
+
59
+ notifierCapabilities(): NotifierCapability {
60
+ return { native: true, sounds: true, clickToFocus: true };
61
+ }
62
+
63
+ async postNotification(input: {
64
+ title: string;
65
+ message: string;
66
+ kind: NotificationKind;
67
+ }): Promise<NotifierTestResult> {
68
+ const cli = join(this.opencodeDir(), 'bin/opencode-notifier');
69
+ try {
70
+ const { existsSync } = await import('node:fs');
71
+ if (existsSync(cli)) {
72
+ const body = JSON.stringify({
73
+ kind: input.kind,
74
+ title: input.title,
75
+ message: input.message,
76
+ subtitle: 'dev-fleet test',
77
+ sessionID: 'ses_ws_test',
78
+ directory: process.cwd(),
79
+ sessionTitle: 'dev-fleet test',
80
+ token: '',
81
+ });
82
+ const r = spawnSync(cli, ['--post', body], { encoding: 'utf8' });
83
+ if (r.status === 0) {
84
+ return { ok: true, detail: 'posted via OpenCodeNotifier daemon' };
85
+ }
86
+ }
87
+ } catch {
88
+ // Fall through to osascript.
89
+ }
90
+ try {
91
+ const script = `display notification ${appleScriptString(input.message)} with title ${appleScriptString(input.title)}`;
92
+ execSync(`osascript -e ${JSON.stringify(script)}`, { stdio: 'ignore' });
93
+ return { ok: true, detail: 'posted via osascript fallback' };
94
+ } catch (err) {
95
+ const msg = err instanceof Error ? err.message : String(err);
96
+ return { ok: false, detail: `osascript failed: ${msg}` };
97
+ }
98
+ }
99
+
100
+ isNotifierDaemonRunning(): boolean {
101
+ try {
102
+ const out = execSync('pgrep -fl "OpenCodeNotifier.*daemon"', {
103
+ encoding: 'utf8',
104
+ stdio: ['ignore', 'pipe', 'ignore'],
105
+ });
106
+ return out.trim().length > 0;
107
+ } catch {
108
+ return false;
109
+ }
110
+ }
111
+
112
+ restartNotifierDaemon(): void {
113
+ spawnSync('pkill', ['-f', 'OpenCodeNotifier.*--daemon'], {
114
+ stdio: 'ignore',
115
+ });
116
+ }
117
+
118
+ availableSystemSounds(): string[] {
119
+ const dirs = [
120
+ '/System/Library/Sounds',
121
+ '/Library/Sounds',
122
+ join(homedir(), 'Library/Sounds'),
123
+ ];
124
+ const names = new Set<string>();
125
+ for (const dir of dirs) {
126
+ let entries: string[] = [];
127
+ try {
128
+ entries = readdirSync(dir);
129
+ } catch {
130
+ continue;
131
+ }
132
+ for (const entry of entries) {
133
+ const m = entry.match(/^(.*)\.(aiff?|wav|mp3|m4a|caf)$/i);
134
+ if (m?.[1]) names.add(m[1]);
135
+ }
136
+ }
137
+ return [...names].sort((a, b) => a.localeCompare(b));
138
+ }
139
+
140
+ async playSystemSound(name: string): Promise<NotifierTestResult> {
141
+ const trimmed = name.trim();
142
+ if (trimmed.length === 0) {
143
+ return { ok: false, detail: 'empty sound name' };
144
+ }
145
+ const dirs = [
146
+ '/System/Library/Sounds',
147
+ '/Library/Sounds',
148
+ join(homedir(), 'Library/Sounds'),
149
+ ];
150
+ const exts = ['aiff', 'aif', 'wav', 'mp3', 'm4a', 'caf'];
151
+ for (const dir of dirs) {
152
+ for (const ext of exts) {
153
+ const candidate = join(dir, `${trimmed}.${ext}`);
154
+ if (existsSync(candidate)) {
155
+ const r = spawnSync('afplay', [candidate], { stdio: 'ignore' });
156
+ if (r.status === 0) {
157
+ return { ok: true, detail: `played ${trimmed} via afplay` };
158
+ }
159
+ return { ok: false, detail: `afplay failed for ${trimmed}` };
160
+ }
161
+ }
162
+ }
163
+ return {
164
+ ok: false,
165
+ detail: `unknown sound "${trimmed}" (see \`devfleet workstation opencode notifications sounds list\`)`,
166
+ };
167
+ }
168
+
169
+ listProcesses(): ProcessInfo[] | null {
170
+ return psProcesses();
171
+ }
172
+
173
+ tailscaleAppBinaries(): readonly string[] {
174
+ return ['/Applications/Tailscale.app/Contents/MacOS/Tailscale'].filter(
175
+ (p) => existsSync(p)
176
+ );
177
+ }
178
+
179
+ vscodeSettingsPath(): string | null {
180
+ return join(
181
+ homedir(),
182
+ 'Library/Application Support/Code/User/settings.json'
183
+ );
184
+ }
185
+
186
+ vscodeCliBinaries(): readonly string[] {
187
+ return [
188
+ '/Applications/Visual Studio Code.app/Contents/Resources/app/bin/code',
189
+ ].filter((p) => existsSync(p));
190
+ }
191
+
192
+ installScheduledJob(job: ScheduledJob): NotifierTestResult {
193
+ return installLaunchdJob(job);
194
+ }
195
+
196
+ removeScheduledJob(label: string): NotifierTestResult {
197
+ return removeLaunchdJob(label);
198
+ }
199
+
200
+ scheduledJobStatus(label: string): ScheduledJobStatus {
201
+ return launchdJobStatus(label);
202
+ }
203
+ }
@@ -0,0 +1,11 @@
1
+ import type { PlatformName } from './types';
2
+
3
+ /** Map `process.platform` onto the dev-fleet platform names. Pure — unit-tested. */
4
+ export function detectPlatformName(
5
+ platform: NodeJS.Platform = process.platform
6
+ ): PlatformName {
7
+ if (platform === 'darwin') return 'darwin';
8
+ if (platform === 'linux') return 'linux';
9
+ if (platform === 'win32') return 'windows';
10
+ return 'unknown';
11
+ }
@@ -0,0 +1,110 @@
1
+ import { homedir } from 'node:os';
2
+ import { join } from 'node:path';
3
+
4
+ import type {
5
+ NotificationKind,
6
+ NotifierCapability,
7
+ NotifierTestResult,
8
+ Platform,
9
+ ProcessInfo,
10
+ ScheduledJob,
11
+ ScheduledJobStatus,
12
+ } from './types';
13
+
14
+ /** Safe default for unrecognized platforms — filesystem converges, notify degrades. */
15
+ export class FallbackPlatform implements Platform {
16
+ readonly name = 'unknown' as const;
17
+ readonly label = 'Unknown';
18
+
19
+ opencodeDir(): string {
20
+ return join(homedir(), '.config/opencode');
21
+ }
22
+
23
+ cacheDir(): string {
24
+ return join(homedir(), '.cache');
25
+ }
26
+
27
+ globalBinDir(): string {
28
+ return join(homedir(), '.local/bin');
29
+ }
30
+
31
+ canBuildSwiftNotifier(): boolean {
32
+ return false;
33
+ }
34
+
35
+ shellRcFiles(): readonly string[] {
36
+ return [join(homedir(), '.profile')];
37
+ }
38
+
39
+ notifierCapabilities(): NotifierCapability {
40
+ return {
41
+ native: false,
42
+ sounds: false,
43
+ clickToFocus: false,
44
+ reason: 'unrecognized platform — notifications disabled',
45
+ };
46
+ }
47
+
48
+ async postNotification(_input: {
49
+ title: string;
50
+ message: string;
51
+ kind: NotificationKind;
52
+ }): Promise<NotifierTestResult> {
53
+ return { ok: false, detail: 'notifications unsupported on this platform' };
54
+ }
55
+
56
+ isNotifierDaemonRunning(): boolean {
57
+ return false;
58
+ }
59
+
60
+ restartNotifierDaemon(): void {
61
+ // No-op.
62
+ }
63
+
64
+ availableSystemSounds(): string[] {
65
+ return [];
66
+ }
67
+
68
+ async playSystemSound(name: string): Promise<NotifierTestResult> {
69
+ void name;
70
+ return { ok: false, detail: 'sounds unsupported on this platform' };
71
+ }
72
+
73
+ listProcesses(): ProcessInfo[] | null {
74
+ return null;
75
+ }
76
+
77
+ tailscaleAppBinaries(): readonly string[] {
78
+ return [];
79
+ }
80
+
81
+ vscodeSettingsPath(): string | null {
82
+ return null;
83
+ }
84
+
85
+ vscodeCliBinaries(): readonly string[] {
86
+ return [];
87
+ }
88
+
89
+ installScheduledJob(job: ScheduledJob): NotifierTestResult {
90
+ return {
91
+ ok: false,
92
+ detail: `scheduled jobs are not supported on ${this.label} (${job.label})`,
93
+ };
94
+ }
95
+
96
+ removeScheduledJob(label: string): NotifierTestResult {
97
+ return {
98
+ ok: true,
99
+ detail: `nothing to remove for ${label} on ${this.label}`,
100
+ };
101
+ }
102
+
103
+ scheduledJobStatus(label: string): ScheduledJobStatus {
104
+ return {
105
+ supported: false,
106
+ installed: false,
107
+ detail: `not supported on ${this.label} (${label})`,
108
+ };
109
+ }
110
+ }
@@ -0,0 +1,38 @@
1
+ import { DarwinPlatform } from './darwin';
2
+ import { detectPlatformName } from './detect';
3
+ import { FallbackPlatform } from './fallback';
4
+ import { LinuxPlatform } from './linux';
5
+ import { WindowsPlatform } from './windows';
6
+ import type { Platform, PlatformName } from './types';
7
+
8
+ export type { Platform, PlatformName } from './types';
9
+ export type {
10
+ NotificationKind,
11
+ NotifierCapability,
12
+ NotifierTestResult,
13
+ GlobalInstallResult,
14
+ } from './types';
15
+ export { detectPlatformName } from './detect';
16
+
17
+ /** Polymorphic factory — callers never branch on `process.platform` directly. */
18
+ export function getPlatform(name?: PlatformName): Platform {
19
+ const resolved = name ?? detectPlatformName();
20
+ switch (resolved) {
21
+ case 'darwin':
22
+ return new DarwinPlatform();
23
+ case 'linux':
24
+ return new LinuxPlatform();
25
+ case 'windows':
26
+ return new WindowsPlatform();
27
+ case 'unknown':
28
+ return new FallbackPlatform();
29
+ }
30
+ }
31
+
32
+ let cached: Platform | null = null;
33
+
34
+ /** Memoized current-platform adapter. */
35
+ export function currentPlatform(): Platform {
36
+ if (cached === null) cached = getPlatform();
37
+ return cached;
38
+ }
@@ -0,0 +1,98 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
3
+ import { homedir, userInfo } from 'node:os';
4
+ import { join } from 'node:path';
5
+
6
+ import type {
7
+ NotifierTestResult,
8
+ ScheduledJob,
9
+ ScheduledJobStatus,
10
+ } from './types';
11
+
12
+ function xml(value: string): string {
13
+ return value
14
+ .replace(/&/g, '&amp;')
15
+ .replace(/</g, '&lt;')
16
+ .replace(/>/g, '&gt;');
17
+ }
18
+
19
+ export function launchAgentPath(label: string, home = homedir()): string {
20
+ return join(home, 'Library/LaunchAgents', `${label}.plist`);
21
+ }
22
+
23
+ /** launchd agent plist for a periodic job. Pure — unit-tested. */
24
+ export function launchdPlist(job: ScheduledJob): string {
25
+ const args = job.command.map((a) => ` <string>${xml(a)}</string>`);
26
+ const env = Object.entries(job.env).map(
27
+ ([k, v]) => ` <key>${xml(k)}</key><string>${xml(v)}</string>`
28
+ );
29
+ return [
30
+ '<?xml version="1.0" encoding="UTF-8"?>',
31
+ '<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
32
+ '<plist version="1.0"><dict>',
33
+ ` <key>Label</key><string>${xml(job.label)}</string>`,
34
+ ' <key>ProgramArguments</key><array>',
35
+ ...args,
36
+ ' </array>',
37
+ ' <key>EnvironmentVariables</key><dict>',
38
+ ...env,
39
+ ' </dict>',
40
+ ` <key>StartInterval</key><integer>${job.intervalSeconds}</integer>`,
41
+ ' <key>RunAtLoad</key><true/>',
42
+ ' <key>LowPriorityIO</key><true/>',
43
+ ' <key>Nice</key><integer>10</integer>',
44
+ ` <key>StandardOutPath</key><string>${xml(join(job.logDir, `${job.label}.out.log`))}</string>`,
45
+ ` <key>StandardErrorPath</key><string>${xml(join(job.logDir, `${job.label}.err.log`))}</string>`,
46
+ '</dict></plist>',
47
+ '',
48
+ ].join('\n');
49
+ }
50
+
51
+ function domain(): string {
52
+ return `gui/${userInfo().uid}`;
53
+ }
54
+
55
+ export function installLaunchdJob(job: ScheduledJob): NotifierTestResult {
56
+ const plist = launchAgentPath(job.label);
57
+ mkdirSync(join(homedir(), 'Library/LaunchAgents'), { recursive: true });
58
+ mkdirSync(job.logDir, { recursive: true });
59
+ writeFileSync(plist, launchdPlist(job));
60
+ spawnSync('launchctl', ['bootout', `${domain()}/${job.label}`], {
61
+ stdio: 'ignore',
62
+ });
63
+ const r = spawnSync('launchctl', ['bootstrap', domain(), plist], {
64
+ encoding: 'utf8',
65
+ });
66
+ return r.status === 0
67
+ ? { ok: true, detail: `launchd agent ${plist}` }
68
+ : { ok: false, detail: `launchctl bootstrap failed: ${r.stderr.trim()}` };
69
+ }
70
+
71
+ export function removeLaunchdJob(label: string): NotifierTestResult {
72
+ spawnSync('launchctl', ['bootout', `${domain()}/${label}`], {
73
+ stdio: 'ignore',
74
+ });
75
+ rmSync(launchAgentPath(label), { force: true });
76
+ return { ok: true, detail: `removed launchd agent ${label}` };
77
+ }
78
+
79
+ export function launchdJobStatus(label: string): ScheduledJobStatus {
80
+ const r = spawnSync('launchctl', ['print', `${domain()}/${label}`], {
81
+ encoding: 'utf8',
82
+ });
83
+ if (r.status !== 0) {
84
+ const present = existsSync(launchAgentPath(label));
85
+ return {
86
+ supported: true,
87
+ installed: false,
88
+ detail: present ? 'plist present but not loaded' : 'not installed',
89
+ };
90
+ }
91
+ const lastExit =
92
+ /last exit code = (.+)/.exec(r.stdout)?.[1]?.trim() ?? 'unknown';
93
+ return {
94
+ supported: true,
95
+ installed: true,
96
+ detail: `loaded; last exit code ${lastExit}`,
97
+ };
98
+ }