@crvouga/dev-fleet 0.0.0-stage → 0.1.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 (333) 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 +157 -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 +128 -0
  30. package/agents/t3.json +40 -0
  31. package/ansible/macos/manager.yml +30 -0
  32. package/ansible/macos/policy.json +17 -0
  33. package/ansible/monitoring/README.md +154 -0
  34. package/ansible/monitoring/agent-darwin.yml +198 -0
  35. package/ansible/monitoring/agent.yml +134 -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/lock.json +11 -0
  40. package/ansible/net/remote-probe.sh +13 -0
  41. package/ansible/ubuntu/authorize-sudo.sh +51 -0
  42. package/ansible/ubuntu/baseline.yml +89 -0
  43. package/ansible/ubuntu/disk-guard.py +104 -0
  44. package/ansible/ubuntu/policy.json +43 -0
  45. package/ansible/ubuntu/recovery.yml +15 -0
  46. package/ansible/ubuntu/t3/policy.json +7 -0
  47. package/ansible/ubuntu/t3-desktop.yml +220 -0
  48. package/ansible/ubuntu/t3-health.sh +33 -0
  49. package/ansible/ubuntu/t3-policy.yml +76 -0
  50. package/ansible/ubuntu/t3-route.yml +28 -0
  51. package/ansible/ubuntu/tailscale.yml +75 -0
  52. package/ansible/ubuntu/tasks/credentials.yml +47 -0
  53. package/ansible/ubuntu/tasks/docker.yml +31 -0
  54. package/ansible/ubuntu/tasks/packages.yml +56 -0
  55. package/ansible/ubuntu/tasks/power.yml +51 -0
  56. package/ansible/ubuntu/tasks/recovery.yml +39 -0
  57. package/ansible/ubuntu/tasks/resources.yml +252 -0
  58. package/ansible/ubuntu/tasks/services.yml +31 -0
  59. package/ansible/ubuntu/tasks/ssh.yml +71 -0
  60. package/ansible/ubuntu/tasks/user.yml +120 -0
  61. package/bin/devfleet.mjs +36 -0
  62. package/bootstrap/bootstrap.ps1 +50 -0
  63. package/bootstrap/bootstrap.sh +73 -0
  64. package/bootstrap/devfleet +7 -0
  65. package/bootstrap/docker-desktop.sh +33 -0
  66. package/bootstrap/doctor.ts +189 -0
  67. package/bootstrap/fleet-manager-run.sh +76 -0
  68. package/bootstrap/fleet-remote-bootstrap.sh +185 -0
  69. package/bootstrap/global-config.ts +113 -0
  70. package/bootstrap/install-launcher.sh +7 -0
  71. package/bootstrap/native-aws.ts +86 -0
  72. package/bootstrap/native-t3.ts +154 -0
  73. package/bootstrap/npm.ts +350 -0
  74. package/bootstrap/setup-status.ts +51 -0
  75. package/bootstrap/setup.ts +88 -0
  76. package/bootstrap/shell-path.ts +105 -0
  77. package/bootstrap/verify-t3-archive.py +49 -0
  78. package/bootstrap/worker-auth.ts +65 -0
  79. package/bootstrap/worker-github.ts +47 -0
  80. package/bootstrap/worker-tools.ts +24 -0
  81. package/cliproxy/AGENTS.md +11 -0
  82. package/cliproxy/README.md +269 -0
  83. package/cliproxy/clients/bootstrap.py +143 -0
  84. package/cliproxy/clients/launcher.py +57 -0
  85. package/cliproxy/compose.yaml +90 -0
  86. package/cliproxy/config.template.yaml +70 -0
  87. package/cliproxy/docs/cloud-adoption.md +16 -0
  88. package/cliproxy/docs/deployment.png +0 -0
  89. package/cliproxy/docs/deployment.svg +109 -0
  90. package/cliproxy/docs/health-workflow.example.yml +185 -0
  91. package/cliproxy/docs/migration.md +131 -0
  92. package/cliproxy/docs/model-health-report.schema.json +364 -0
  93. package/cliproxy/docs/model-health.md +393 -0
  94. package/cliproxy/image.lock.json +5 -0
  95. package/cliproxy/launchd/dev.devfleet.cliproxy-backup-check.plist +38 -0
  96. package/cliproxy/launchd/dev.devfleet.cliproxy-backup.plist +48 -0
  97. package/cliproxy/launchd/dev.devfleet.cliproxy-fleet.plist +32 -0
  98. package/cliproxy/launchd/dev.devfleet.cliproxy-gc.plist +38 -0
  99. package/cliproxy/launchd/dev.devfleet.cliproxy-monitor.plist +29 -0
  100. package/cliproxy/launchd/dev.devfleet.cliproxy-vault-renew.plist +31 -0
  101. package/cliproxy/models.json +1618 -0
  102. package/cliproxy/requirements.lock +78 -0
  103. package/cliproxy/scripts/accounts.py +38 -0
  104. package/cliproxy/scripts/audit.py +123 -0
  105. package/cliproxy/scripts/backup +234 -0
  106. package/cliproxy/scripts/check.py +34 -0
  107. package/cliproxy/scripts/checkpoint.py +226 -0
  108. package/cliproxy/scripts/cli.py +64 -0
  109. package/cliproxy/scripts/cliproxy_host.py +257 -0
  110. package/cliproxy/scripts/cloud_state.py +150 -0
  111. package/cliproxy/scripts/container-health +6 -0
  112. package/cliproxy/scripts/docker +24 -0
  113. package/cliproxy/scripts/external-health.py +79 -0
  114. package/cliproxy/scripts/fleet_config.py +43 -0
  115. package/cliproxy/scripts/fleet_sync.py +274 -0
  116. package/cliproxy/scripts/initialize +84 -0
  117. package/cliproxy/scripts/install-dependencies +48 -0
  118. package/cliproxy/scripts/install.py +406 -0
  119. package/cliproxy/scripts/model_health.py +16 -0
  120. package/cliproxy/scripts/modelhealth/__init__.py +5 -0
  121. package/cliproxy/scripts/modelhealth/checks.py +429 -0
  122. package/cliproxy/scripts/modelhealth/cli.py +117 -0
  123. package/cliproxy/scripts/modelhealth/config.py +186 -0
  124. package/cliproxy/scripts/modelhealth/emulator.py +536 -0
  125. package/cliproxy/scripts/modelhealth/expect.py +88 -0
  126. package/cliproxy/scripts/modelhealth/findings.py +183 -0
  127. package/cliproxy/scripts/modelhealth/protocol.py +330 -0
  128. package/cliproxy/scripts/modelhealth/redact.py +28 -0
  129. package/cliproxy/scripts/modelhealth/report.py +150 -0
  130. package/cliproxy/scripts/modelhealth/runner.py +390 -0
  131. package/cliproxy/scripts/modelhealth/scenario.py +131 -0
  132. package/cliproxy/scripts/modelhealth/schema.py +63 -0
  133. package/cliproxy/scripts/modelhealth/simulate.py +111 -0
  134. package/cliproxy/scripts/modelhealth/transport.py +213 -0
  135. package/cliproxy/scripts/monitor +68 -0
  136. package/cliproxy/scripts/ops +527 -0
  137. package/cliproxy/scripts/public-endpoint +127 -0
  138. package/cliproxy/scripts/recover_node.py +177 -0
  139. package/cliproxy/scripts/restore-verify +60 -0
  140. package/cliproxy/scripts/retention.py +144 -0
  141. package/cliproxy/scripts/service-start +7 -0
  142. package/cliproxy/scripts/validate-config +56 -0
  143. package/cliproxy/scripts/vault-access.py +57 -0
  144. package/cliproxy/scripts/vault-backup-login +55 -0
  145. package/cliproxy/scripts/vault-recovery +56 -0
  146. package/cliproxy/scripts/vault-renew +30 -0
  147. package/cliproxy/systemd/cliproxy-backup-check.service +10 -0
  148. package/cliproxy/systemd/cliproxy-backup-check.timer +8 -0
  149. package/cliproxy/systemd/cliproxy-backup.service +13 -0
  150. package/cliproxy/systemd/cliproxy-backup.timer +8 -0
  151. package/cliproxy/systemd/cliproxy-fleet.service +17 -0
  152. package/cliproxy/systemd/cliproxy-gc.service +13 -0
  153. package/cliproxy/systemd/cliproxy-gc.timer +8 -0
  154. package/cliproxy/systemd/cliproxy-monitor.service +8 -0
  155. package/cliproxy/systemd/cliproxy-monitor.timer +7 -0
  156. package/cliproxy/systemd/cliproxy-vault-renew.service +8 -0
  157. package/cliproxy/systemd/cliproxy-vault-renew.timer +7 -0
  158. package/cliproxy/vendor/LICENSE +22 -0
  159. package/cliproxy/vendor/config.upstream.yaml +1349 -0
  160. package/examples/fleet.json +95 -0
  161. package/examples/roster.json +13 -0
  162. package/mise.lock +1492 -0
  163. package/mise.toml +99 -0
  164. package/package.json +65 -4
  165. package/schema/fleet.schema.json +364 -0
  166. package/schema/roster.schema.json +77 -0
  167. package/src/agents/account-relay.ts +111 -0
  168. package/src/agents/accounts.ts +85 -0
  169. package/src/agents/audit.ts +77 -0
  170. package/src/agents/cli.ts +13 -0
  171. package/src/agents/command.ts +204 -0
  172. package/src/agents/converge.ts +123 -0
  173. package/src/agents/doctor.ts +107 -0
  174. package/src/agents/errors.ts +2 -0
  175. package/src/agents/files.ts +73 -0
  176. package/src/agents/fleet-verify.ts +156 -0
  177. package/src/agents/launch.ts +290 -0
  178. package/src/agents/native-verify.ts +226 -0
  179. package/src/agents/provider-errors.ts +50 -0
  180. package/src/agents/registry.ts +55 -0
  181. package/src/agents/render.ts +110 -0
  182. package/src/agents/round-trip.ts +192 -0
  183. package/src/agents/secrets.ts +13 -0
  184. package/src/agents/toolchain.ts +34 -0
  185. package/src/agents/verify-cache.ts +103 -0
  186. package/src/agents/verify.ts +127 -0
  187. package/src/auth-checks.ts +163 -0
  188. package/src/cli/agent-guide.ts +95 -0
  189. package/src/cli/app.ts +115 -0
  190. package/src/cli/cli.ts +21 -0
  191. package/src/cli/command-index.ts +50 -0
  192. package/src/cli/contracts.ts +25 -0
  193. package/src/cli/fleet-reference.ts +17 -0
  194. package/src/cli/fleet-sync-checkout.ts +91 -0
  195. package/src/cli/fleet-sync-enrollment.ts +144 -0
  196. package/src/cli/fleet-sync-forward.ts +134 -0
  197. package/src/cli/fleet-sync-manager.ts +193 -0
  198. package/src/cli/fleet-sync-monitoring.ts +185 -0
  199. package/src/cli/fleet-sync-output.ts +314 -0
  200. package/src/cli/fleet-sync-privilege.ts +27 -0
  201. package/src/cli/fleet-sync-progress.ts +109 -0
  202. package/src/cli/fleet-sync-remote.ts +236 -0
  203. package/src/cli/fleet-sync-report.ts +26 -0
  204. package/src/cli/fleet-sync-schedule.ts +138 -0
  205. package/src/cli/fleet-sync-vault.ts +166 -0
  206. package/src/cli/fleet-sync.ts +309 -0
  207. package/src/cli/fleet.ts +163 -0
  208. package/src/cli/managed-health.ts +219 -0
  209. package/src/cli/node-deployment.ts +34 -0
  210. package/src/cli/node-sync.ts +114 -0
  211. package/src/cli/node-t3.ts +31 -0
  212. package/src/cli/options.ts +141 -0
  213. package/src/cli/probe-source.ts +7 -0
  214. package/src/cli/program.ts +169 -0
  215. package/src/cli/register-fleet.ts +246 -0
  216. package/src/cli/registry.ts +158 -0
  217. package/src/cli/repair-apply.ts +67 -0
  218. package/src/cli/repair-guard.ts +11 -0
  219. package/src/cli/repair-inspect.ts +150 -0
  220. package/src/cli/repair-script.ts +76 -0
  221. package/src/cli/repair.ts +209 -0
  222. package/src/cli/roster.ts +89 -0
  223. package/src/cli/search.ts +35 -0
  224. package/src/cli/settings.ts +64 -0
  225. package/src/cli/source-operations.ts +78 -0
  226. package/src/cli/t3/command.ts +90 -0
  227. package/src/cli/t3/register.ts +55 -0
  228. package/src/cli/tailnet.ts +127 -0
  229. package/src/cli/ui-actions.ts +133 -0
  230. package/src/cli/ui-controller.ts +113 -0
  231. package/src/cli/ui-data.ts +106 -0
  232. package/src/cli/ui-execute.ts +163 -0
  233. package/src/cli/ui-repairs.ts +34 -0
  234. package/src/cli/ui-reports.ts +211 -0
  235. package/src/cli/ui-targets.ts +100 -0
  236. package/src/cli/ui-terminal.ts +109 -0
  237. package/src/cli/ui-types.ts +49 -0
  238. package/src/cli/ui-wizard.ts +101 -0
  239. package/src/cli/ui.ts +9 -0
  240. package/src/cli/worker-health-output.ts +37 -0
  241. package/src/config.ts +186 -0
  242. package/src/configure.ts +255 -0
  243. package/src/definition.ts +260 -0
  244. package/src/diagnostics.ts +92 -0
  245. package/src/doctor-check.ts +10 -0
  246. package/src/errors.ts +1 -0
  247. package/src/fingerprint.ts +120 -0
  248. package/src/fleet-config.ts +88 -0
  249. package/src/index.ts +6 -0
  250. package/src/input-hashes.ts +73 -0
  251. package/src/machine/local-source.ts +52 -0
  252. package/src/machine/lock-error.ts +2 -0
  253. package/src/machine/platform.ts +44 -0
  254. package/src/machine/source.ts +68 -0
  255. package/src/machine/state.ts +185 -0
  256. package/src/machine-health.ts +95 -0
  257. package/src/managed-source.ts +47 -0
  258. package/src/manager-failure.ts +48 -0
  259. package/src/manager-sync.ts +400 -0
  260. package/src/manager.ts +62 -0
  261. package/src/monitoring/apply.ts +171 -0
  262. package/src/monitoring/beszel.ts +191 -0
  263. package/src/monitoring/enroll.ts +110 -0
  264. package/src/monitoring/hub-access.ts +113 -0
  265. package/src/monitoring/hub-viewer.ts +142 -0
  266. package/src/monitoring/hub.ts +93 -0
  267. package/src/monitoring/manager-agent.ts +73 -0
  268. package/src/monitoring/manager-hub.ts +232 -0
  269. package/src/monitoring/remote.ts +65 -0
  270. package/src/network-definition.ts +83 -0
  271. package/src/node-converge-linux.ts +372 -0
  272. package/src/node-converge-shared.ts +83 -0
  273. package/src/node-converge.ts +40 -0
  274. package/src/onboard.ts +35 -0
  275. package/src/platform/ansible-receipt.ts +128 -0
  276. package/src/platform/ansible.ts +62 -0
  277. package/src/platform/darwin.ts +203 -0
  278. package/src/platform/detect.ts +11 -0
  279. package/src/platform/fallback.ts +110 -0
  280. package/src/platform/index.ts +38 -0
  281. package/src/platform/launchd.ts +98 -0
  282. package/src/platform/linux.ts +139 -0
  283. package/src/platform/ps.ts +45 -0
  284. package/src/platform/resource-verify.ts +128 -0
  285. package/src/platform/systemd-user.ts +95 -0
  286. package/src/platform/t3-policy.ts +137 -0
  287. package/src/platform/t3-route.ts +68 -0
  288. package/src/platform/t3-service.ts +75 -0
  289. package/src/platform/types.ts +123 -0
  290. package/src/platform/ubuntu-audit.ts +63 -0
  291. package/src/platform/ubuntu-verify.ts +105 -0
  292. package/src/platform/windows.ts +148 -0
  293. package/src/power-recovery.ts +55 -0
  294. package/src/probe.ts +51 -0
  295. package/src/protocol.ts +119 -0
  296. package/src/proxy-converge.ts +85 -0
  297. package/src/remote-bootstrap.ts +212 -0
  298. package/src/remote-entry.ts +89 -0
  299. package/src/remote-lock.ts +12 -0
  300. package/src/repair-catalog.ts +206 -0
  301. package/src/repair-plan.ts +78 -0
  302. package/src/repair-runner.ts +84 -0
  303. package/src/report.ts +71 -0
  304. package/src/runner.ts +104 -0
  305. package/src/secrets.ts +169 -0
  306. package/src/service-auth.ts +115 -0
  307. package/src/ssh.ts +135 -0
  308. package/src/sync.ts +104 -0
  309. package/src/t3/accounts.ts +81 -0
  310. package/src/t3/desired.ts +165 -0
  311. package/src/t3/files.ts +81 -0
  312. package/src/t3/names-check.ts +53 -0
  313. package/src/t3/names.ts +155 -0
  314. package/src/t3/plan.ts +82 -0
  315. package/src/t3/providers.ts +74 -0
  316. package/src/t3/reconcile.ts +123 -0
  317. package/src/t3-endpoint.ts +48 -0
  318. package/src/t3-linked-check.ts +101 -0
  319. package/src/tailnet-health.ts +231 -0
  320. package/src/tailnet-matching.ts +37 -0
  321. package/src/tailscale.ts +141 -0
  322. package/src/test-fixtures.ts +36 -0
  323. package/src/toolchain-checks.ts +83 -0
  324. package/src/topology.ts +24 -0
  325. package/src/transport.ts +122 -0
  326. package/src/types.ts +95 -0
  327. package/src/vault-config.ts +38 -0
  328. package/src/worker-access-checks.ts +115 -0
  329. package/src/worker-capacity.ts +34 -0
  330. package/src/worker-host-checks.ts +176 -0
  331. package/src/worker-policy.ts +64 -0
  332. package/src/worker-readiness.ts +64 -0
  333. package/src/workstation-checks.ts +29 -0
@@ -0,0 +1,127 @@
1
+ import { workerHealthText } from './worker-health-output';
2
+ import { fleetReference } from './fleet-reference';
3
+ import {
4
+ managedHealthScope,
5
+ appendManagerLinks,
6
+ appendDashboardEvidence,
7
+ } from './managed-health';
8
+ import { discoverTailnet } from '../tailscale';
9
+ import { tailnetHealth } from '../tailnet-health';
10
+ import { makeReport, renderReport, reportExitCode } from '../report';
11
+ import { readRoster } from './roster';
12
+ import { PUBLIC_HUB_URL } from '../monitoring/beszel';
13
+ import { stableUrls } from './fleet-sync-output';
14
+ import { timeout } from './options';
15
+ import type { FleetServices } from './fleet';
16
+ import type { Settings } from './settings';
17
+ import type { CliOptions } from './options';
18
+ import type { CommandOutcome } from './contracts';
19
+
20
+ export async function executeTailnet(
21
+ command: string,
22
+ options: CliOptions,
23
+ settings: Settings,
24
+ services: FleetServices
25
+ ): Promise<CommandOutcome> {
26
+ const { tailnet, discovered, registered, coverage } = await healthContext(
27
+ command,
28
+ options,
29
+ settings,
30
+ services
31
+ );
32
+ if (command === 'discover') {
33
+ const ok = tailnet.checks.every((check) => check.status === 'pass');
34
+ return {
35
+ exitCode: ok ? 0 : 1,
36
+ data: {
37
+ version: 1,
38
+ command,
39
+ ok,
40
+ coverage,
41
+ computers: tailnet.computers,
42
+ checks: tailnet.checks,
43
+ },
44
+ };
45
+ }
46
+ const nodes = await tailnetHealth(
47
+ tailnet,
48
+ registered,
49
+ {
50
+ ...(options.workers ? { reference: fleetReference() } : {}),
51
+ includeManagerReport: options.workers !== true,
52
+ root: settings.workspace,
53
+ timeoutMs: timeout({ ...options, timeout: options.timeout ?? '300000' }),
54
+ source: services.source,
55
+ },
56
+ services.run
57
+ );
58
+ if (options.managed)
59
+ await appendManagerLinks(nodes, registered, services.run);
60
+ // The hub runs on the manager, which worker scope deliberately filters out of
61
+ // `tailnet`; its visibility comes from the unfiltered discovery.
62
+ const dashboard = options.workers
63
+ ? await appendDashboardEvidence(nodes, discovered)
64
+ : null;
65
+ const report = makeReport('health', nodes);
66
+ const exitCode = reportExitCode(report, true);
67
+ const verified = exitCode === 0;
68
+ return {
69
+ exitCode,
70
+ data: {
71
+ ...report,
72
+ ok: verified,
73
+ ...(options.workers ? { dashboardUrl: publicDashboard(dashboard) } : {}),
74
+ coverage: {
75
+ ...coverage,
76
+ verified,
77
+ inspected: nodes.filter((node) => node.snapshot).length,
78
+ },
79
+ },
80
+ text: options.workers
81
+ ? workerHealthText(
82
+ { ...report, ok: verified },
83
+ coverage,
84
+ publicDashboard(dashboard)
85
+ )
86
+ : `${stableUrls(renderReport({ ...report, ok: verified }))}\nCoverage: ${coverage.computers} visible computers; ${coverage.excludedMobile} mobile devices excluded.\n${coverage.limitation}`,
87
+ };
88
+ }
89
+
90
+ /** Shown to people: the stable public name, never the tailnet address. */
91
+ function publicDashboard(privateHub: string | null): string | null {
92
+ return privateHub ? PUBLIC_HUB_URL : null;
93
+ }
94
+
95
+ async function healthContext(
96
+ command: string,
97
+ options: CliOptions,
98
+ settings: Settings,
99
+ services: FleetServices
100
+ ) {
101
+ const discovered = await discoverTailnet(services.run);
102
+ const roster =
103
+ command === 'health' ? await readRoster(settings.config) : null;
104
+ const registered = roster === null ? [] : roster.nodes;
105
+ const managed = options.managed
106
+ ? managedHealthScope(discovered, registered, options.workers !== true)
107
+ : null;
108
+ const tailnet = managed === null ? discovered : managed.tailnet;
109
+ const coverage = {
110
+ scope: options.workers
111
+ ? 'canonical-workers'
112
+ : options.managed
113
+ ? 'canonical-fleet-and-manager'
114
+ : 'tailscale-visible-computers',
115
+ excludedUnmanaged: managed === null ? [] : managed.excluded,
116
+ computers: tailnet.computers.length,
117
+ excludedMobile: tailnet.excludedMobile,
118
+ limitation:
119
+ 'This manager can inspect only devices visible to its Tailscale account/ACLs. Other tailnets, hidden devices and all-pairs connectivity are not verified.',
120
+ };
121
+ return {
122
+ tailnet,
123
+ discovered,
124
+ registered: managed === null ? registered : managed.nodes,
125
+ coverage,
126
+ };
127
+ }
@@ -0,0 +1,133 @@
1
+ import type { CommandOutcome, CommandSpec } from './contracts';
2
+ import { execute, showOutcome, successful } from './ui-execute';
3
+ import { clean, reportChecks, reproduction, record } from './ui-data';
4
+ import { inspectChecks, inspectDetails } from './ui-reports';
5
+ import { chooseTargets } from './ui-targets';
6
+ import type { Choice, Session } from './ui-types';
7
+
8
+ export async function runCommand(
9
+ session: Session,
10
+ command: CommandSpec
11
+ ): Promise<void> {
12
+ session.display.panel(clean(command.title), clean(command.description));
13
+ const args = await actionArgs(session, command);
14
+ if (!args) return;
15
+ const preview = isRepair(args);
16
+ const mutation = !preview && (command.mutation || args.includes('--apply'));
17
+ if (mutation && !(await confirmMutation(session, args))) return;
18
+ await runWithResults(session, args, command.title, { preview, mutation });
19
+ }
20
+
21
+ function isRepair(args: string[]): boolean {
22
+ return args[0] === 'fleet-repair' && !args.includes('--list');
23
+ }
24
+
25
+ /** Palette entries with flags are fixed scopes; bare roster commands ask for targets. */
26
+ function targetAction(args: string[]): string | undefined {
27
+ if (isRepair(args)) return 'repair';
28
+ if (args[0] === 'fleet-status' && args.length === 1) return 'doctor';
29
+ if (args[0] === 'fleet-nodes' && args.includes('--onboard')) return 'onboard';
30
+ return undefined;
31
+ }
32
+
33
+ async function actionArgs(
34
+ session: Session,
35
+ command: CommandSpec
36
+ ): Promise<string[] | undefined> {
37
+ const args = [...command.args];
38
+ const action = targetAction(args);
39
+ if (!action) return args;
40
+ const targets = await chooseTargets(session, action);
41
+ return targets ? [...args, ...targets] : undefined;
42
+ }
43
+
44
+ export async function runWithResults(
45
+ session: Session,
46
+ initialArgs: string[],
47
+ title: string,
48
+ settings: { preview?: boolean; mutation?: boolean } = {}
49
+ ): Promise<void> {
50
+ let args = initialArgs;
51
+ let outcome = await execute(session, args, title);
52
+ let preview = settings.preview === true;
53
+ let mutation = settings.mutation === true;
54
+ while (true) {
55
+ showOutcome(session, outcome);
56
+ const selection = await session.prompts.select(
57
+ 'Next step',
58
+ resultChoices(outcome, preview)
59
+ );
60
+ if (selection === 'back') return;
61
+ await inspectSelection(session, outcome, selection);
62
+ if (selection === 'apply') {
63
+ const appliedArgs = applyArgs(args, outcome);
64
+ if (!(await confirmMutation(session, appliedArgs))) continue;
65
+ args = appliedArgs;
66
+ preview = false;
67
+ mutation = true;
68
+ outcome = await execute(session, args, title);
69
+ }
70
+ if (selection === 'retry') {
71
+ if (mutation && !(await confirmMutation(session, args))) continue;
72
+ outcome = await execute(session, args, title);
73
+ }
74
+ }
75
+ }
76
+
77
+ async function inspectSelection(
78
+ session: Session,
79
+ outcome: CommandOutcome,
80
+ selection: string
81
+ ): Promise<void> {
82
+ if (selection === 'checks') await inspectChecks(session, outcome.data);
83
+ if (selection === 'details') await inspectDetails(session, outcome.data);
84
+ }
85
+
86
+ function resultChoices(outcome: CommandOutcome, preview: boolean): Choice[] {
87
+ return [
88
+ { name: 'Back to commands', value: 'back' },
89
+ ...(reportChecks(outcome.data).length
90
+ ? [{ name: 'Explore checks and remedies…', value: 'checks' }]
91
+ : []),
92
+ { name: 'Inspect structured details / plan…', value: 'details' },
93
+ ...(preview && successful(outcome)
94
+ ? [{ name: 'Review and explicitly apply…', value: 'apply' }]
95
+ : []),
96
+ ...(!successful(outcome)
97
+ ? [{ name: 'Retry command…', value: 'retry' }]
98
+ : []),
99
+ ];
100
+ }
101
+
102
+ export async function confirmMutation(
103
+ session: Session,
104
+ args: string[]
105
+ ): Promise<boolean> {
106
+ const action = args[0];
107
+ const warning =
108
+ action === 'fleet-repair'
109
+ ? 'Repair runs the eligible owner actions in the displayed plan, including trusted setup/install scripts. Workstation actions may read this machine’s Vault session and write managed 0600 provider configuration. No login session is transferred between computers. Failures can leave partial changes; fresh diagnostics follow.'
110
+ : action === 'fleet-node-add'
111
+ ? 'This writes the local roster only. No cloud resources, SSH configuration, credentials or host keys are changed.'
112
+ : 'This command applies changes on invocation as documented in its --help. Failure can leave partial changes; inspect before retrying. No credentials are copied between computers.';
113
+ session.display.panel(
114
+ 'Explicit approval required',
115
+ [
116
+ warning,
117
+ `Workspace: ${clean(session.workspace ?? session.options.workspace)}`,
118
+ `Config: ${clean(session.config ?? session.options.config)}`,
119
+ `$ ${reproduction(session, args)}`,
120
+ ].join('\n')
121
+ );
122
+ return session.prompts.confirm('Apply this exact command? (default: no)');
123
+ }
124
+
125
+ function applyArgs(args: string[], outcome: CommandOutcome): string[] {
126
+ const guard =
127
+ isRepair(args) &&
128
+ record(outcome.data) &&
129
+ typeof outcome.data['planHash'] === 'string'
130
+ ? ['--plan-hash', outcome.data['planHash']]
131
+ : [];
132
+ return [...args, ...guard, '--apply', '--yes'];
133
+ }
@@ -0,0 +1,113 @@
1
+ import type { InteractiveOptions } from './contracts';
2
+ import { releaseLabel } from './program';
3
+ import { runCommand } from './ui-actions';
4
+ import { clean, commandChoices, isCancellation } from './ui-data';
5
+ import { addNode } from './ui-wizard';
6
+ import type { Choice, Display, Prompts, Session } from './ui-types';
7
+
8
+ export async function runInteractive(
9
+ options: InteractiveOptions,
10
+ prompts: Prompts,
11
+ display: Display
12
+ ): Promise<void> {
13
+ const session: Session = { options, prompts, display };
14
+ showHeader(session);
15
+ let running = true;
16
+ while (running) {
17
+ try {
18
+ const choice = await prompts.search(
19
+ 'Command palette · type to search · arrows + enter',
20
+ (term) => paletteChoices(session, term)
21
+ );
22
+ running = await dispatch(session, choice);
23
+ } catch (error) {
24
+ if (isCancellation(error)) {
25
+ display.write('\nGoodbye. No further actions will run.');
26
+ throw error;
27
+ }
28
+ display.write(
29
+ 'The interactive session could not continue safely. Use personal commands --json for noninteractive help.'
30
+ );
31
+ throw new Error('Interactive session failed safely.');
32
+ }
33
+ }
34
+ display.write('Goodbye. Session context was not saved.');
35
+ }
36
+
37
+ function showHeader(session: Session): void {
38
+ session.display.panel(
39
+ `personal · ${clean(releaseLabel(session.options))}`,
40
+ [
41
+ 'Your developer fleet, without the guesswork.',
42
+ `Workspace: ${clean(session.workspace ?? session.options.workspace)}`,
43
+ `Config: ${clean(session.config ?? session.options.config)}`,
44
+ 'Read-only first · explicit writes · no automatic repairs',
45
+ 'Search IDs, keywords or descriptions. Ctrl+C exits safely. Nothing is cleared.',
46
+ ].join('\n')
47
+ );
48
+ }
49
+
50
+ export function paletteChoices(session: Session, term: string): Choice[] {
51
+ const commands = commandChoices(session.options.commands, term);
52
+ const navigation = [
53
+ {
54
+ name: 'Session: Workspace / config…',
55
+ value: 'context',
56
+ description: 'Session-only overrides; no hidden configuration writes.',
57
+ },
58
+ { name: 'Exit', value: 'exit', description: 'Close the command palette.' },
59
+ ].filter((choice) =>
60
+ `${choice.name} ${choice.description}`
61
+ .toLowerCase()
62
+ .includes(term.toLowerCase().trim())
63
+ );
64
+ const choices = [...navigation, ...commands].sort((a, b) =>
65
+ a.name.localeCompare(b.name, 'en')
66
+ );
67
+ return choices.length
68
+ ? choices
69
+ : [{ name: 'No matching commands — change search', value: 'none' }];
70
+ }
71
+
72
+ async function dispatch(session: Session, choice: string): Promise<boolean> {
73
+ if (choice === 'exit') return false;
74
+ if (choice === 'context') await changeContext(session);
75
+ if (choice.startsWith('command:'))
76
+ await openCommand(session, choice.slice(8));
77
+ return true;
78
+ }
79
+
80
+ async function openCommand(session: Session, id: string): Promise<void> {
81
+ const command = session.options.commands.find((entry) => entry.id === id);
82
+ if (!command) return;
83
+ if (command.id === 'fleet-node-add') await addNode(session);
84
+ else await runCommand(session, command);
85
+ }
86
+
87
+ async function changeContext(session: Session): Promise<void> {
88
+ session.display.write(
89
+ 'Session only. Blank inputs reset to startup defaults; no file is written.'
90
+ );
91
+ const workspace = await session.prompts.input(
92
+ 'Workspace override',
93
+ session.workspace ?? '',
94
+ validContext
95
+ );
96
+ const config = await session.prompts.input(
97
+ 'Config override',
98
+ session.config ?? '',
99
+ validContext
100
+ );
101
+ if (workspace) session.workspace = workspace;
102
+ else delete session.workspace;
103
+ if (config) session.config = config;
104
+ else delete session.config;
105
+ showHeader(session);
106
+ }
107
+
108
+ function validContext(value: string): boolean | string {
109
+ return (
110
+ !/[\x00-\x1f\x7f]/.test(value) ||
111
+ 'Control characters are not allowed in paths.'
112
+ );
113
+ }
@@ -0,0 +1,106 @@
1
+ import type { CommandSpec } from './contracts';
2
+ import type { CheckRow, Choice, Session } from './ui-types';
3
+ import { searchCommands } from './search';
4
+
5
+ export function record(value: unknown): value is Record<string, unknown> {
6
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
7
+ }
8
+
9
+ export function clean(value: string, limit = 2000): string {
10
+ return value
11
+ .replace(/\x1b\][^\x07]*(?:\x07|\x1b\\)/g, '')
12
+ .replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, '')
13
+ .replace(/[\x00-\x1f\x7f-\x9f\u202a-\u202e\u2066-\u2069]/g, ' ')
14
+ .slice(0, limit);
15
+ }
16
+
17
+ export function text(value: unknown, fallback = ''): string {
18
+ return typeof value === 'string' ? clean(value) : fallback;
19
+ }
20
+
21
+ export function quote(value: string): string {
22
+ return /^[a-zA-Z0-9_./:=,@-]+$/.test(value)
23
+ ? value
24
+ : `'${value.replaceAll("'", "'\\''")}'`;
25
+ }
26
+
27
+ export function callArgs(session: Session, args: string[]): string[] {
28
+ return [
29
+ ...args,
30
+ ...(session.workspace ? ['--workspace', session.workspace] : []),
31
+ ...(session.config ? ['--config', session.config] : []),
32
+ ];
33
+ }
34
+
35
+ export function reproduction(session: Session, args: string[]): string {
36
+ return [
37
+ 'personal',
38
+ ...args,
39
+ '--workspace',
40
+ session.workspace ?? session.options.workspace,
41
+ '--config',
42
+ session.config ?? session.options.config,
43
+ '--json',
44
+ ]
45
+ .map(quote)
46
+ .join(' ');
47
+ }
48
+
49
+ export function commandChoices(
50
+ commands: CommandSpec[],
51
+ term: string
52
+ ): Choice[] {
53
+ return searchCommands(commands, term).map((command) => ({
54
+ name: `${clean(command.group)}: ${clean(command.title)}`,
55
+ value: `command:${command.id}`,
56
+ description: `${clean(command.description)}${command.mutation ? ' · confirmation required' : ''}`,
57
+ }));
58
+ }
59
+
60
+ export function reportChecks(data: unknown): CheckRow[] {
61
+ if (!record(data)) return [];
62
+ const nodes = Array.isArray(data['nodes']) ? data['nodes'] : [];
63
+ return [
64
+ ...nodes.flatMap(nodeChecks),
65
+ ...nodeChecks({ node: 'tailscale', checks: data['checks'] }),
66
+ ];
67
+ }
68
+
69
+ function nodeChecks(value: unknown): CheckRow[] {
70
+ if (!record(value) || !Array.isArray(value['checks'])) return [];
71
+ const node = text(value['node'], 'manager');
72
+ return value['checks'].flatMap((check: unknown) => {
73
+ if (!record(check) || !isStatus(check['status'])) return [];
74
+ const remedy = record(check['remedy']) ? check['remedy'] : {};
75
+ return [
76
+ {
77
+ node,
78
+ id: text(check['id'], 'check'),
79
+ status: check['status'],
80
+ summary: text(check['summary']),
81
+ remedy: text(remedy['action']),
82
+ command: text(remedy['command']),
83
+ },
84
+ ];
85
+ });
86
+ }
87
+
88
+ export function isStatus(value: unknown): value is string {
89
+ return ['pass', 'warn', 'fail', 'skip'].includes(String(value));
90
+ }
91
+
92
+ export function isCancellation(error: unknown): boolean {
93
+ if (!record(error)) return false;
94
+ return ['ExitPromptError', 'AbortPromptError', 'AbortError'].includes(
95
+ text(error['name'])
96
+ );
97
+ }
98
+
99
+ export function absolutePath(value: string): boolean | string {
100
+ return (
101
+ (value.startsWith('/') &&
102
+ value.length <= 4096 &&
103
+ !/[\x00-\x1f\x7f]/.test(value)) ||
104
+ 'Enter an absolute POSIX path (no ~ or control characters).'
105
+ );
106
+ }
@@ -0,0 +1,163 @@
1
+ import type { CommandOutcome } from './contracts';
2
+ import { showRepairs } from './ui-repairs';
3
+ import {
4
+ callArgs,
5
+ clean,
6
+ text,
7
+ isCancellation,
8
+ record,
9
+ reproduction,
10
+ reportChecks,
11
+ } from './ui-data';
12
+ import type { Session } from './ui-types';
13
+
14
+ export async function execute(
15
+ session: Session,
16
+ args: string[],
17
+ title: string
18
+ ): Promise<CommandOutcome> {
19
+ session.display.write(`\n$ ${reproduction(session, args)}`);
20
+ const progress = session.display.progress(title);
21
+ try {
22
+ return await session.options.execute(callArgs(session, args));
23
+ } catch (error) {
24
+ if (isCancellation(error)) throw error;
25
+ return { exitCode: 1, data: { ok: false, code: 'operation_failed' } };
26
+ } finally {
27
+ progress.stop();
28
+ }
29
+ }
30
+
31
+ export function successful(outcome: CommandOutcome): boolean {
32
+ return (
33
+ outcome.exitCode === 0 &&
34
+ !(record(outcome.data) && outcome.data['ok'] === false)
35
+ );
36
+ }
37
+
38
+ export function showOutcome(session: Session, outcome: CommandOutcome): void {
39
+ const checks = reportChecks(outcome.data);
40
+ const incomplete = checks.filter((check) =>
41
+ ['warn', 'skip'].includes(check.status)
42
+ ).length;
43
+ const state = outcomeState(outcome, incomplete);
44
+ const counts = ['pass', 'warn', 'fail', 'skip'].map(
45
+ (status) =>
46
+ `${checks.filter((check) => check.status === status).length} ${status}`
47
+ );
48
+ session.display.panel(
49
+ state,
50
+ checks.length ? counts.join(' · ') : outcomeMessage(outcome)
51
+ );
52
+ showAttention(session, checks);
53
+ showData(session, outcome);
54
+ if (!successful(outcome))
55
+ session.display.write(
56
+ record(outcome.data) && outcome.data['applied'] === true
57
+ ? 'Actions were attempted. Review execution results, partial changes and fresh checks before retrying.'
58
+ : 'No automatic repair was attempted. Review the command, context and setup.'
59
+ );
60
+ }
61
+
62
+ function outcomeMessage(outcome: CommandOutcome): string {
63
+ if (!successful(outcome)) return failureMessage(outcome.data);
64
+ if (record(outcome.data) && outcome.data['applied'] === false) {
65
+ return 'Preview only — no changes applied. Review before explicitly authorizing a write.';
66
+ }
67
+ return 'Command completed. Warnings and skipped checks are not passes.';
68
+ }
69
+
70
+ function failureMessage(data: unknown): string {
71
+ if (!record(data))
72
+ return 'Operation failed. Raw errors and subprocess output are intentionally hidden.';
73
+ if (data['code'] === 'invalid_usage' && typeof data['error'] === 'string')
74
+ return clean(data['error']);
75
+ const messages: Record<string, string> = {
76
+ invalid_usage:
77
+ 'Configuration or arguments need attention. Run fleet-nodes --schema to check the roster, or pass --config.',
78
+ config_missing:
79
+ 'No roster found. Run fleet-node-add, or run manager-only diagnostics with fleet-status --local.',
80
+ operation_failed:
81
+ 'Operation failed safely. Check connectivity and configuration before retrying.',
82
+ };
83
+ return (
84
+ messages[String(data['code'])] ??
85
+ 'Operation did not complete. Review setup and the noninteractive command.'
86
+ );
87
+ }
88
+
89
+ function showNodes(session: Session, data: Record<string, unknown>): void {
90
+ const nodes = Array.isArray(data['nodes'])
91
+ ? data['nodes'].filter(record)
92
+ : [];
93
+ if (!nodes.length) {
94
+ session.display.write(
95
+ 'No computers registered yet. Run fleet-node-add to add one, or fleet-discover to list Tailscale computers.'
96
+ );
97
+ return;
98
+ }
99
+ session.display.table(
100
+ ['Computer', 'SSH', 'Workspace'],
101
+ nodes.map((node) => [
102
+ text(node['id']),
103
+ text(node['ssh']),
104
+ text(node['workspace']),
105
+ ])
106
+ );
107
+ }
108
+
109
+ function showComputers(session: Session, data: Record<string, unknown>): void {
110
+ const computers = Array.isArray(data['computers'])
111
+ ? data['computers'].filter(record)
112
+ : [];
113
+ session.display.table(
114
+ ['Computer', 'OS', 'Online'],
115
+ computers.map((peer) => [
116
+ text(peer['dns']),
117
+ text(peer['os']),
118
+ peer['online'] === true ? 'yes' : 'no',
119
+ ])
120
+ );
121
+ session.display.write(
122
+ 'Visible Tailscale computers only; mobile devices excluded. Register each remote computer with its existing SSH alias and checkout path before full health checks.'
123
+ );
124
+ }
125
+
126
+ function showAttention(
127
+ session: Session,
128
+ checks: ReturnType<typeof reportChecks>
129
+ ): void {
130
+ const attention = checks.filter((check) => check.status !== 'pass');
131
+ if (!attention.length) return;
132
+ session.display.table(
133
+ ['Computer', 'Check', 'Status / next step'],
134
+ attention
135
+ .slice(0, 15)
136
+ .map((check) => [
137
+ check.node,
138
+ check.id,
139
+ `${check.status}: ${check.remedy ?? check.summary}`,
140
+ ])
141
+ );
142
+ if (attention.length > 15)
143
+ session.display.write(
144
+ `Showing 15 of ${attention.length} checks needing attention. Explore checks and remedies for the complete report.`
145
+ );
146
+ }
147
+
148
+ function showData(session: Session, outcome: CommandOutcome): void {
149
+ if (!record(outcome.data)) return;
150
+ if (['repair', 'repairs'].includes(String(outcome.data['command'])))
151
+ showRepairs(session, outcome.data);
152
+ if (successful(outcome) && outcome.data['command'] === 'nodes')
153
+ showNodes(session, outcome.data);
154
+ if (outcome.data['command'] === 'discover')
155
+ showComputers(session, outcome.data);
156
+ }
157
+
158
+ function outcomeState(outcome: CommandOutcome, incomplete: number): string {
159
+ if (record(outcome.data) && outcome.data['applied'] === false)
160
+ return 'PREVIEW';
161
+ if (!successful(outcome)) return 'FAILED';
162
+ return incomplete ? 'WARNING' : 'SUCCESS';
163
+ }
@@ -0,0 +1,34 @@
1
+ import { record, text } from './ui-data';
2
+ import type { Session } from './ui-types';
3
+
4
+ export function showRepairs(
5
+ session: Session,
6
+ data: Record<string, unknown>
7
+ ): void {
8
+ const catalog = data['command'] === 'repairs';
9
+ const entries = data[catalog ? 'recipes' : 'steps'];
10
+ if (!Array.isArray(entries)) return;
11
+ const steps = entries.filter(record);
12
+ session.display.table(
13
+ ['Computer / owner', 'Repair', 'Execution'],
14
+ steps
15
+ .slice(0, 15)
16
+ .map((step) => [
17
+ text(step[catalog ? 'owner' : 'node']),
18
+ text(step['title']),
19
+ step['automatic'] === true && (catalog || step['eligible'] === true)
20
+ ? 'Owner command after explicit apply'
21
+ : 'Operator action; inspect instructions',
22
+ ])
23
+ );
24
+ if (steps.length > 15)
25
+ session.display.write(
26
+ `Showing 15 of ${steps.length} repair entries. Inspect structured details for the complete plan and native login commands.`
27
+ );
28
+ if (!catalog)
29
+ session.display.write(
30
+ data['applied'] === true
31
+ ? `Owner commands completed: ${text(data['completed'])}. Fresh health verified: ${data['healthOk'] === true ? 'yes' : 'no; review remaining checks'}.`
32
+ : 'Preview only. Review the repair effects and native login instructions in structured details before applying.'
33
+ );
34
+ }