@crvouga/dev-fleet 0.1.0 → 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 (110) hide show
  1. package/README.md +237 -37
  2. package/agents/setup.md +42 -12
  3. package/ansible/macos/manager.yml +7 -7
  4. package/ansible/macos/policy.json +2 -10
  5. package/ansible/monitoring/README.md +86 -25
  6. package/ansible/monitoring/agent.yml +40 -2
  7. package/ansible/monitoring/hub-route-darwin.yml +51 -0
  8. package/ansible/monitoring/lock.json +5 -0
  9. package/ansible/pyproject.toml +12 -0
  10. package/ansible/ubuntu/baseline.yml +1 -1
  11. package/ansible/ubuntu/disk-gc.py +336 -0
  12. package/ansible/ubuntu/policy.json +12 -3
  13. package/ansible/ubuntu/tailscale.yml +8 -5
  14. package/ansible/ubuntu/tasks/resources.yml +61 -1
  15. package/ansible/uv.lock +190 -0
  16. package/bootstrap/bootstrap.sh +25 -18
  17. package/bootstrap/doctor.ts +46 -1
  18. package/bootstrap/fleet-manager-run.sh +42 -15
  19. package/bootstrap/fleet-remote-bootstrap.sh +85 -12
  20. package/bootstrap/global-config.ts +64 -6
  21. package/bootstrap/install-launcher.sh +3 -0
  22. package/bootstrap/npm.ts +10 -2
  23. package/bootstrap/release-version.ts +157 -0
  24. package/docs/consumer-setup.md +408 -0
  25. package/docs/declared.md +89 -0
  26. package/docs/machine-setup.md +399 -0
  27. package/docs/operations.md +276 -0
  28. package/docs/releasing.md +103 -0
  29. package/docs/state.md +254 -0
  30. package/docs/topology.md +67 -0
  31. package/docs/ubuntu-sync.md +351 -0
  32. package/docs/worker-contract.md +67 -0
  33. package/examples/fleet.json +5 -0
  34. package/llms.txt +35 -0
  35. package/mise.lock +29 -0
  36. package/mise.toml +12 -1
  37. package/package.json +11 -2
  38. package/schema/fleet.schema.json +71 -15
  39. package/src/agents/fleet-verify.ts +9 -0
  40. package/src/agents/native-verify.ts +167 -22
  41. package/src/agents/provider-errors.ts +9 -2
  42. package/src/agents/registry.ts +7 -1
  43. package/src/cli/agent-guide.ts +39 -5
  44. package/src/cli/app.ts +11 -4
  45. package/src/cli/cli.ts +7 -0
  46. package/src/cli/command-index.ts +7 -0
  47. package/src/cli/fleet-state.ts +499 -0
  48. package/src/cli/fleet-sync-checkout.ts +56 -17
  49. package/src/cli/fleet-sync-enrollment.ts +19 -23
  50. package/src/cli/fleet-sync-forward.ts +1 -1
  51. package/src/cli/fleet-sync-manager.ts +64 -8
  52. package/src/cli/fleet-sync-monitoring.ts +5 -5
  53. package/src/cli/fleet-sync-output.ts +4 -1
  54. package/src/cli/fleet-sync-remote.ts +11 -5
  55. package/src/cli/fleet-sync-state.ts +86 -0
  56. package/src/cli/fleet-sync.ts +123 -57
  57. package/src/cli/fleet.ts +4 -2
  58. package/src/cli/managed-health.ts +4 -4
  59. package/src/cli/node-sync.ts +7 -0
  60. package/src/cli/program.ts +20 -4
  61. package/src/cli/project.ts +51 -0
  62. package/src/cli/register-fleet.ts +51 -1
  63. package/src/cli/registry.ts +28 -1
  64. package/src/cli/roster.ts +46 -30
  65. package/src/cli/settings.ts +15 -12
  66. package/src/cli/source-operations.ts +27 -3
  67. package/src/cli/tailnet.ts +2 -2
  68. package/src/definition.ts +31 -1
  69. package/src/diagnostics.ts +1 -1
  70. package/src/fleet-config.ts +105 -19
  71. package/src/machine/source.ts +3 -3
  72. package/src/machine/state.ts +15 -27
  73. package/src/manager-sync.ts +8 -3
  74. package/src/monitoring/agent-evidence.ts +146 -0
  75. package/src/monitoring/apply.ts +15 -3
  76. package/src/monitoring/beszel.ts +11 -4
  77. package/src/monitoring/enroll.ts +2 -1
  78. package/src/monitoring/hub-access.ts +4 -3
  79. package/src/monitoring/hub-backup.ts +121 -0
  80. package/src/monitoring/hub-viewer.ts +8 -8
  81. package/src/monitoring/hub.ts +3 -3
  82. package/src/monitoring/manager-hub.ts +30 -14
  83. package/src/node-converge-linux.ts +2 -0
  84. package/src/package-info.ts +45 -0
  85. package/src/platform/ansible.ts +19 -15
  86. package/src/platform/resource-verify.ts +8 -4
  87. package/src/platform/ubuntu-audit.ts +5 -2
  88. package/src/remote-bootstrap.ts +17 -19
  89. package/src/repair-catalog.ts +1 -1
  90. package/src/runner.ts +1 -1
  91. package/src/state/bucket.ts +208 -0
  92. package/src/state/check.ts +74 -0
  93. package/src/state/connection.ts +92 -0
  94. package/src/state/enrollment.ts +45 -0
  95. package/src/state/legacy.ts +89 -0
  96. package/src/state/litestream.ts +143 -0
  97. package/src/state/operator.ts +20 -0
  98. package/src/state/paths.ts +40 -0
  99. package/src/state/roster.ts +62 -0
  100. package/src/state/store.ts +169 -0
  101. package/src/state/sync.ts +352 -0
  102. package/src/t3-endpoint.ts +64 -8
  103. package/src/t3-linked-check.ts +23 -1
  104. package/src/toolchain-checks.ts +25 -0
  105. package/src/types.ts +4 -0
  106. package/src/worker-access-checks.ts +56 -3
  107. package/src/worker-host-checks.ts +5 -0
  108. package/src/workstation-checks.ts +1 -1
  109. package/src/index.ts +0 -6
  110. package/src/test-fixtures.ts +0 -36
@@ -0,0 +1,276 @@
1
+ > This document was written for the fleet dev-fleet was extracted from. Paths and
2
+ > commands were updated; some statements still describe that fleet and are being
3
+ > revised. The README is authoritative.
4
+
5
+ # Developer fleet
6
+
7
+ `@pkgs/fleet` diagnoses development computers: SSH, VS Code Remote-SSH,
8
+ per-machine authentication, T3 Connect and shared toolchain/dependency drift.
9
+ It is a local CLI/library, not a daemon or cloud manager. It reuses the
10
+ existing mise/workstation owners rather than introducing another installer.
11
+
12
+ The fleet is one singleton **manager** (the human's macOS machine, which runs
13
+ `fleet-sync` and every singleton service such as the CLI proxy and Beszel hub)
14
+ and stateless Linux **workers**. See [topology](topology.md).
15
+
16
+ See [Ubuntu fleet-sync implementation and operator status](ubuntu-sync.md) for
17
+ the manager-driven public command and its outstanding full-machine gates.
18
+
19
+ ## Worker contract
20
+
21
+ Use `devfleet fleet-sync` to reconcile all declared workers and
22
+ `devfleet fleet-status --workers` to inspect remaining gaps. See
23
+ [worker-contract.md](worker-contract.md) for bootstrap, ownership and the exact
24
+ scope of readiness. `fleet-status` without flags diagnoses the local roster.
25
+ Workers are Ubuntu 26.04; the manager is macOS and never sleeps on AC power
26
+ (`ansible/macos/manager.yml`). Machine policy (never sleep, no automatic
27
+ logout, always-on Tailscale/SSH/Docker/Beszel, memory and disk safety, per-node
28
+ credential renewal) is
29
+ declared in Ansible and measured by `fleet-status --workers`, which also verifies
30
+ the centralized Beszel dashboard. See the
31
+ [platform matrix](ubuntu-sync.md#machine-policy-by-platform).
32
+
33
+ ## Quick start
34
+
35
+ Run from the repository root after `bun install --frozen-lockfile`:
36
+
37
+ ```sh
38
+ devfleet fleet-node-add --id dev-linux --ssh dev-linux --path /home/dev/workspace --json
39
+ devfleet fleet-node-add --id dev-linux --ssh dev-linux --path /home/dev/workspace --apply --yes --json
40
+ devfleet fleet-nodes --json # validate the roster; no network
41
+ devfleet fleet-nodes --onboard --node dev-linux --json
42
+ devfleet fleet-status --json # manager + all roster nodes
43
+ devfleet fleet-status --node dev-linux --json
44
+ devfleet fleet-status --local --json # no roster/SSH needed
45
+ devfleet fleet-repair --node dev-linux --repair workspace --json # plan only
46
+ devfleet fleet-repair --node dev-linux --repair workspace --apply --yes --json
47
+ ```
48
+
49
+ This private package exposes a TypeScript API from `@pkgs/fleet`; the public
50
+ executable is `personal` (`bun run personal` from the checkout). Schema, roster
51
+ listing, onboarding and repair previews do not contact nodes or auth services.
52
+ Status never opens a login flow.
53
+
54
+ ## Roster
55
+
56
+ The roster is kept in the [fleet state](state.md) and replicated to its bucket.
57
+ A `.fleet.local.json` found from the current working directory (gitignored), or
58
+ `--config PATH`, names a roster file instead (keep it out of Git yourself). This describes
59
+ **existing developer computers**, not desired cloud infrastructure. OpenTofu
60
+ remains the sole cloud resource owner; fleet creates no networks, hosts or secrets.
61
+
62
+ ```json
63
+ {
64
+ "$schema": "./packages/fleet/schema.json",
65
+ "version": 1,
66
+ "nodes": [
67
+ {
68
+ "id": "dev-linux",
69
+ "ssh": "dev-linux",
70
+ "workspace": "/home/developer/workspace",
71
+ "runtime": "/home/developer/.local/share/mise/shims/node",
72
+ "required": ["github", "vault", "t3", "toolchain"]
73
+ }
74
+ ]
75
+ }
76
+ ```
77
+
78
+ - `id`: unique lowercase name, up to 63 characters; 1–64 nodes per roster.
79
+ - `ssh`: existing alias/hostname. Put User, Port, IdentityFile, ProxyJump and
80
+ keychain configuration in normal SSH config. user@host/IPv6 should use aliases.
81
+ - `workspace`: absolute POSIX checkout path. Spaces/apostrophes work; relative
82
+ paths, tilde expansion and control characters are rejected.
83
+ - `runtime`: optional absolute Node/Bun executable or mise shim. Defaults to `node` on
84
+ the **non-login SSH PATH**. Configure explicitly when GUI terminals work but SSH
85
+ cannot find Node. A shim also needs mise's environment/PATH working.
86
+ - `required`: `github`, `vault`, `t3`, `vscode`, `toolchain`. Defaults to all except
87
+ vscode. Set vscode on editor/manager machines, not just on headless targets.
88
+ `[]` still checks checkout/dependencies/SSH. Unknown fields fail closed.
89
+
90
+ The manager reference defaults to where the CLI runs from: the published
91
+ package, or the dev-fleet checkout a source run is started in. `--workspace PATH`
92
+ changes that reference/local sync target. Nodes are compared to **that checkout**,
93
+ including uncommitted lockfile edits, not an implicitly fetched origin/main.
94
+ Manager capabilities default to all five; headless managers can pass
95
+ `--require github,vault,t3,toolchain` or `--require none`. This does not override
96
+ remote nodes' required lists. Run `fleet-nodes --schema` for editor/agent validation.
97
+
98
+ ## Evidence and diagnosis
99
+
100
+ | Check | Evidence / next action |
101
+ | ---------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
102
+ | `workspace`, `git:clean` | HEAD, input availability and dirty-tree status; no paths/diffs/file contents logged. |
103
+ | `revision`, `sync:*` | Compare exact HEAD and SHA-256 of package.json, bun.lock, mise.toml, mise.lock against manager. Missing inputs never pass. |
104
+ | `dependencies` | Last successful fleet frozen-sync receipt matches input hashes. Missing receipt is unverified, not in-sync. |
105
+ | `auth:toolchain` | Existing bootstrap/doctor.ts runs in the node checkout with its Bun, bounded to 90s. Use personal workstation tools check --json for individual pins. |
106
+ | `ssh:agent` | Manager agent exposes an identity; missing agent warns because IdentityFile keys may still work. |
107
+ | `ssh:session` | Fresh batch SSH session actually runs the Node/Bun probe. No reused multiplexed connection or agent forwarding. |
108
+ | `ssh:host-key`, `ssh:dns`, `ssh:auth`, `ssh:network`, `ssh:runtime`, `ssh:timeout` | Classified failure and targeted remedy, without raw logs. |
109
+ | `auth:github` | gh auth status --active succeeds on this machine; not proof of Git SSH or every repository permission. |
110
+ | `auth:vault` | Server-side vault token lookup succeeds in this context; not proof of access to every secret path. |
111
+ | `auth:vscode` | code CLI finds the local Remote-SSH extension. Other profiles/Cursor/GUI PATH/agents can differ. |
112
+ | `t3:settings` | Connect explicitly enabled and not revoked in t3 status --json; settings are not connectivity. |
113
+ | `t3:api` | Read-only t3 environment list --json succeeds against the local API; no pairing/token minting. |
114
+ | `t3:end-to-end`, `vscode:forwarding` | SKIP: actual relay/peer authorization and VS Code tunnel/server installation require operator testing. |
115
+
116
+ An SSH shell is not proof that VS Code or T3 Connect works. Diagnostics check
117
+ **directed manager → node** connectivity, not all pairs. Run from each intended
118
+ manager to check reverse directions. No host-key acceptance, known_hosts edits,
119
+ sshd changes, agent forwarding, password retries or credential copying happen.
120
+ SSH config/executables/hosts are trusted operator inputs; configured ProxyCommand
121
+ or Match commands can execute the operator's own code.
122
+
123
+ Fingerprints also cover all flat workspace package manifests and the files under
124
+ `.mise/locks`, so uncommitted dependency/sidecar edits cannot hide behind an
125
+ unchanged root lockfile. Missing/unreadable inputs cannot prove synchronization.
126
+
127
+ The receipt (`node_modules/.fleet-sync.json`) is historical evidence of completed
128
+ frozen setup, **not a full integrity audit**. Manual node_modules edits/deletions
129
+ can invalidate that assumption; resync when in doubt. Native package builds still
130
+ need normal repository validation. A green install is not a healthy fleet.
131
+
132
+ ## Add a computer
133
+
134
+ 1. Review and run the shared [machine bootstrap](../bootstrap/bootstrap.sh) on
135
+ the new machine. Reopen terminal/editor. Do not invent a second installer.
136
+ 2. As operator, enable OS SSH access on a trusted private network. Generate a
137
+ distinct machine key, authorize only its public key and verify the server
138
+ fingerprint out of band. Never expose public SSH or copy private keys/auth
139
+ folders. Cloud provisioning remains an OpenTofu task.
140
+ 3. Configure the manager's SSH alias, append the node to the local roster,
141
+ then run `fleet-nodes` and `fleet-nodes --onboard --node ID --json`. Onboarding
142
+ emits a ready-to-edit SSH stanza, VS Code settings and ordered instructions;
143
+ it does not overwrite user config. Test `ssh ALIAS` manually first.
144
+ 4. Ensure noninteractive shell startup is quiet and Node (or explicit Bun runtime) is reachable. Fleet
145
+ bundles/streams its own dependency-free probe over stdin: the remote computer
146
+ needs Node or Bun, **not a matching installation of this package** or its dependencies.
147
+ 5. Authenticate separately: GitHub, Git SSH, Vault, coding providers and T3 pairing
148
+ are distinct sessions. Use gh auth login/setup-git, approved vault login and
149
+ operator-driven t3 up --connect as needed. Never put secrets in reports.
150
+ Workstation/provider configuration remains an explicit `personal workstation sync` operation.
151
+ 6. In VS Code's local profile install Remote-SSH, select the same SSH binary,
152
+ alias/config, enable its login terminal temporarily and inspect Output →
153
+ Remote - SSH. Restart after PATH/agent changes. Check disk space, remote
154
+ server prerequisites and TCP forwarding policy. Onboarding includes a
155
+ loopback-only forwarding test against a known service, not a public listener.
156
+ 7. Run doctor, review remedies and explicitly sync if needed. Actually open the
157
+ remote workspace in VS Code and test the linked peer in T3 Settings →
158
+ Connections. These are the end-to-end acceptance gates.
159
+
160
+ Remote nodes require macOS/Linux/WSL and a **POSIX SSH shell**. Native Windows
161
+ supports local commands/diagnostics; use a WSL SSH endpoint for remote checks.
162
+ Fleet does not guess cmd.exe/PowerShell quoting. Use the existing native Windows
163
+ bootstrap and run local reports when a WSL endpoint is unavailable.
164
+
165
+ ## Explicit sync
166
+
167
+ `sync` is a plan unless **both --apply --yes** are present. Selected nodes apply
168
+ serially; diagnostics run at most four remote probes concurrently.
169
+
170
+ - `--pull` requires a clean attached branch, then runs git pull --ff-only against
171
+ its upstream. No stash/reset/force/rebase/discard. Without it, the branch stays.
172
+ - Invalidate the old receipt, then delegate to
173
+ `mise exec --locked bun -- bun bootstrap/setup.ts --non-interactive`.
174
+ This existing owner installs locked tools, publishes user mise config, sets
175
+ shell PATH and installs frozen Bun dependencies. Review OS-specific effects
176
+ and existing config conflict guards in the shared bootstrap runbook.
177
+ - Record input hashes only after success and stable checkout inputs. Failed
178
+ setup can leave partial installs; no rollback is claimed. Trusted checkout
179
+ package lifecycle scripts can execute during installation.
180
+ - No other application repos, credentials, provider settings or cloud resources
181
+ are synchronized. `personal workstation update`/`personal workstation sync` remain explicit broader convergence.
182
+ - Run doctor again afterward; successful sync does not prove connectivity/auth.
183
+
184
+ Subprocesses have deadlines and a combined 1 MiB output cap. SSH probes default
185
+ to 120s per node; --timeout MS allows 1s–30m. Applied sync defaults to a 25m SSH
186
+ envelope and 20m setup deadline. A killed/disconnected SSH session does **not**
187
+ guarantee remote installers stop: inspect the node before retrying. POSIX local
188
+ subprocess groups are killed at their deadline.
189
+
190
+ ## Agent contract
191
+
192
+ `--json` emits one versioned document on stdout, with no progress/auth/child
193
+ output. Bun's script runner may print its command to stderr. Reports have
194
+ `version: 1`, command, ok, generatedAt, nodes and pass/warn/fail/skip counts.
195
+ Node checks have stable id, status, safe summary and optional remedy.action;
196
+ snapshots include platform and revision/hash fingerprints. Preserve skips and
197
+ warnings: neither is a pass. A plan is not completed work.
198
+
199
+ Exit 0: no failing checks (warnings/skips remain visible). Exit 1: health or
200
+ operation failure. Exit 2: usage/config error. `doctor --strict` also fails on
201
+ warnings/skips, including intentional end-to-end skips: it is a **coverage gate**,
202
+ not a way to certify the whole fleet from one shell. Usage errors use
203
+ `code: invalid_usage`; unexpected errors use `operation_failed` without exposing
204
+ exception strings. Fleet never prompts; --non-interactive is accepted.
205
+
206
+ No telemetry or report file is written automatically. Reports still contain
207
+ aliases/hashes: review before sharing publicly. For monitoring, schedule read-only
208
+ `devfleet fleet-status --json` in your existing task runner and alert on exit 1/2;
209
+ do not schedule automatic sync/repairs. `bun run health` remains the separate
210
+ single read-only **cloud platform** report.
211
+
212
+ ## Development
213
+
214
+ ```sh
215
+ bun run --cwd packages/fleet tc
216
+ bun run --cwd packages/fleet lint
217
+ bun run --cwd packages/fleet test
218
+ ```
219
+
220
+ Tests inject results/use temporary fixtures; they do not contact nodes, login or
221
+ change global config. The transport smoke test runs the bundled probe locally
222
+ against a temporary checkout, not an SSH server.
223
+
224
+ ## Tailscale fleet-status --tailnet in the public CLI
225
+
226
+ `devfleet fleet-discover --json` lists computers visible to the local signed-in
227
+ Tailscale daemon, including offline devices, while explicitly excluding iOS,
228
+ Android and tvOS. `devfleet fleet-status --tailnet --json` checks them all and reports
229
+ unregistered/ambiguous computers and registered nodes missing from the inventory.
230
+ Register actual SSH aliases and checkout paths; the checker does not guess them.
231
+ Existing verified-host-key SSH policy and bounded probes apply. Tailscale daemon
232
+ health errors invalidate complete discovery. ACL-hidden devices cannot be certified.
233
+
234
+ Health probes each registered remote node's declared `required` capabilities; it
235
+ no longer adds cloud-service logins or direct provider authentication implicitly.
236
+ The manager default covers T3, toolchain and workstation configuration.
237
+ Explicit `services` requirements retain Railway, Vercel, Cloudflare, AWS and npm
238
+ checks. No raw account/auth output is retained. Version checks keep
239
+ per-tool results, including the expected pinned version when available, and require
240
+ Docker Engine/Compose/Buildx readiness. `workstation` delegates to read-only
241
+ `personal workstation doctor --json --non-interactive`; provider configuration presence is not live
242
+ authorization. Warnings/skips fail the strict whole-tailnet health coverage gate.
243
+
244
+ ## Repair planning and shared access
245
+
246
+ The public CLI exposes `devfleet fleet-repair --list` (static catalog) and
247
+ `devfleet fleet-repair` (read-only inspection/plan by default). The repair library
248
+ in this package only executes allowlisted existing owner commands. Native
249
+ account logins and unknown/manual checks always remain operator steps.
250
+
251
+ Use `--apply --yes` only after review; `--plan-hash` binds application to the
252
+ previewed steps, targets and checkout/shared-input fingerprints. The remote
253
+ protocol independently requires explicit apply, a supported repair action and
254
+ expected input fingerprints. It preserves verified SSH and disables forwarding.
255
+ After owner commands, the manager obtains fresh health checks. Successful
256
+ installation never substitutes for authentication or resource authorization.
257
+
258
+ The workstation owner reads permitted shared provider keys from each machine's
259
+ existing Vault session. For process-scoped shared secret access, use the existing
260
+ `personal infra-vault run --config dev -- COMMAND` workflow on that machine with its intended
261
+ project/config. Infrastructure, access policy and secret seeding remain owned by
262
+ OpenTofu. No OAuth/session/keychain/SSH credential transfer is implemented.
263
+
264
+ All commands are in the `devfleet` entry point.
265
+
266
+ ## Reviewed workstation configuration rollout
267
+
268
+ `devfleet fleet-node-sync --revision FULL_SHA --dry-run` previews this node's
269
+ reviewed configuration; without `--dry-run` it applies. `devfleet fleet-sync
270
+ --revision FULL_SHA` rolls the same reviewed revision out to the enrolled fleet
271
+ after approval. The [setup runbook](../agents/setup.md)
272
+ covers canaries, offline/partial failures, per-node receipts and rollback. Both
273
+ delegate rendering to personal workstation and installation to existing mise/bootstrap.
274
+ They never reset the developer checkout or transfer credentials. Status delegates
275
+ managed-profile drift evidence to the workstation owner; historical rollout receipts
276
+ alone are not a live readiness assertion.
@@ -0,0 +1,103 @@
1
+ # Releasing
2
+
3
+ dev-fleet is published to npm as `@crvouga/dev-fleet`. The package is how
4
+ dev-fleet is used: a fleet is a project that depends on it and runs `devfleet`
5
+ from there (see the [README](../README.md#how-you-use-it)). Nobody clones this
6
+ repository to run a fleet.
7
+
8
+ ```sh
9
+ npm install --save-dev --save-exact @crvouga/dev-fleet
10
+ npx devfleet fleet-sync
11
+ ```
12
+
13
+ The package ships the TypeScript sources, the Ansible policy, the bootstrap
14
+ scripts and the proxy scripts, because the CLI reads them at run time. Node
15
+ will not run TypeScript from `node_modules`, so the `devfleet` entry is a small
16
+ JavaScript launcher (`bin/devfleet.mjs`) that runs the CLI on the Bun this
17
+ release pins (a dependency of the package), falling back to a Bun on `PATH`.
18
+
19
+ Machines do not run the package. `fleet-sync` rebuilds every machine from one
20
+ git commit of this repository, and the package says which: the Release
21
+ workflow writes the commit it publishes from into the package's `package.json`
22
+ as `gitHead` ([`src/package-info.ts`](../src/package-info.ts) reads it). Run
23
+ from the package, `fleet-sync` fetches that commit from the repository
24
+ `fleet.json` names under `source` into a cache the manager owns, and converges
25
+ the manager and every worker from checkouts of it. So a version of the package
26
+ is a revision of the fleet, and the commit a release names must stay reachable
27
+ in the source repository.
28
+
29
+ ## What a release is
30
+
31
+ Every merge to `main` is a release. When CI passes on `main`, the
32
+ [Release workflow](../.github/workflows/release.yml) picks the next version from
33
+ the commits since the newest `vX.Y.Z` tag (or, before there is one, the newest
34
+ version on npm)
35
+ ([`bootstrap/release-version.ts`](../bootstrap/release-version.ts)), publishes it
36
+ to `@crvouga/dev-fleet` with provenance, pushes the tag and creates the GitHub
37
+ Release. The commit messages choose the bump:
38
+
39
+ | Commits since the last tag | Bump |
40
+ | ---------------------------------------------- | ------------------------------------ |
41
+ | any `feat!:`, `fix(x)!:` or `BREAKING CHANGE:` | major (minor while the major is `0`) |
42
+ | otherwise any `feat:` | minor |
43
+ | otherwise | patch |
44
+
45
+ `package.json` stays at `0.0.0-development` and carries no `gitHead`; the
46
+ workflow stamps the version and the commit into the package it publishes, so
47
+ nobody edits a version or runs `npm publish` by hand after the one-time setup
48
+ below. Merges that land while a release runs
49
+ wait, and the newest one releases everything since the last tag. A run that
50
+ cannot release because publishing was never set up (no tag and nothing on
51
+ npm) fails, and the run summary says why; a merge with nothing new to release
52
+ passes and says so. Re-running a
53
+ release whose version is already on npm skips the publish and finishes the tag
54
+ and GitHub Release.
55
+
56
+ A hand-pushed tag still works and publishes exactly that version:
57
+
58
+ ```sh
59
+ git tag vX.Y.Z && git push origin vX.Y.Z
60
+ ```
61
+
62
+ CI never receives an npm token: it publishes through npm Trusted Publishing
63
+ (OIDC).
64
+
65
+ ## One-time setup
66
+
67
+ npm can publish new versions through OIDC but cannot create a package that does
68
+ not exist yet. One command does the one-time work with your own npm login, from
69
+ `main` with a clean tree:
70
+
71
+ ```sh
72
+ bun run bootstrap
73
+ ```
74
+
75
+ It signs in to npm (browser approval), runs `bun run check`, publishes `0.1.0`
76
+ from a throwaway checkout of `HEAD` (so your working tree is never edited),
77
+ makes `release.yml` the package's trusted publisher, and tags `v0.1.0` on the
78
+ commit npm recorded. When npm recorded none (as for the `0.1.0` that is on
79
+ npm), there is nothing to tag: until the first tag exists, the Release
80
+ workflow counts from the newest version on npm and every commit up to `HEAD`
81
+ picks the bump. Every step measures first and skips what is already done,
82
+ so run it again after any interruption. It needs npm 11.10 or newer (for
83
+ `npm trust`) and an npm account that owns the `@crvouga` scope. Flags: `--yes`
84
+ skips the confirmation, `--skip-check` skips `bun run check`.
85
+
86
+ Verify from a machine that has never seen the package:
87
+
88
+ ```sh
89
+ npx --yes @crvouga/dev-fleet --version
90
+ ```
91
+
92
+ If `npm trust` is unavailable, set the publisher in the browser instead:
93
+ <https://www.npmjs.com/package/@crvouga/dev-fleet/access> → Trusted Publisher →
94
+ GitHub Actions → organization `crvouga`, repository `dev-fleet`, workflow
95
+ filename `release.yml`, environment empty.
96
+
97
+ ## Why the package looks like this
98
+
99
+ - `files` is an allow-list; tests and Python caches are excluded. `features/rules/package.feature`
100
+ fails if a file the CLI reads is missing from the tarball.
101
+ - `bun` is a dependency pinned to the same version as `packageManager` and the
102
+ `mise.toml` pin; bump the three together.
103
+ - The provenance statement links each version to the workflow run that built it.
package/docs/state.md ADDED
@@ -0,0 +1,254 @@
1
+ # Fleet state
2
+
3
+ A fleet is described in two places you own. Your project in Git holds
4
+ `fleet.json` and pins the dev-fleet version. Everything else the manager
5
+ cannot rebuild from Git is the **fleet state**:
6
+
7
+ | Document | What it is |
8
+ | ------------ | -------------------------------------------------------------------- |
9
+ | `roster` | The enrolled connection to each worker (SSH alias, Tailscale device) |
10
+ | `enrollment` | The revision `fleet-sync` approved and the one it last applied |
11
+ | `definition` | The fleet configuration `fleet-sync` last applied: your `fleet.json` |
12
+
13
+ They live in one SQLite database, and [Litestream](https://litestream.io)
14
+ replicates that database to a storage bucket you own. The bucket is the source
15
+ of truth. The copy on the manager is disposable: delete it, or replace the
16
+ whole machine, and the next command brings the fleet back.
17
+
18
+ ```
19
+ your project (Git)
20
+ fleet.json the configuration; says where the bucket is
21
+ package.json the dev-fleet version
22
+ ~/.local/share/dev-fleet/state/ the local copy of the state; safe to delete
23
+ fleet.db the database Litestream replicates
24
+ definition.json the stored configuration as a file, rewritten from fleet.db
25
+ replicated.json, litestream.yml bookkeeping
26
+ ~/.config/dev-fleet/bucket.json optional: credentials saved for unattended runs
27
+ ```
28
+
29
+ ## Set it up
30
+
31
+ Any S3-compatible bucket works: AWS S3, Cloudflare R2, Backblaze B2, MinIO.
32
+ Create an empty private bucket and an access key that can read and write it.
33
+ Name the bucket in `fleet.json`:
34
+
35
+ ```json
36
+ "state": {
37
+ "bucket": "my-fleet-state",
38
+ "endpoint": "https://ACCOUNT.r2.cloudflarestorage.com",
39
+ "region": "auto",
40
+ "prefix": "dev-fleet"
41
+ }
42
+ ```
43
+
44
+ Omit `endpoint` for AWS S3. `prefix` defaults to `dev-fleet`; one bucket can
45
+ hold several fleets under different prefixes. The block never holds
46
+ credentials, and a `fleet.json` that does is refused.
47
+
48
+ Every devfleet command reads the credentials from its environment:
49
+
50
+ ```sh
51
+ export DEVFLEET_BUCKET_ACCESS_KEY_ID=…
52
+ export DEVFLEET_BUCKET_SECRET_ACCESS_KEY=…
53
+ npx devfleet fleet-sync
54
+ ```
55
+
56
+ Set both or neither. With neither, and nothing saved on the machine, the
57
+ standard AWS environment or profile is used. Credentials are never taken from
58
+ arguments and never written to a report or a log.
59
+
60
+ **A new fleet.** Nothing more: the first `fleet-sync` seeds the bucket.
61
+
62
+ **A replacement manager, or a second machine.** Clone your project, `npm ci`,
63
+ and run `fleet-sync` with the credentials. The bucket already holds the fleet,
64
+ so its state is downloaded before anything is changed.
65
+
66
+ **Runs the manager starts by itself.** A worker that hands `fleet-sync` to the
67
+ manager starts a command there that gets no environment and no project. Save
68
+ the credentials on the manager once:
69
+
70
+ ```sh
71
+ npx devfleet fleet-state --init
72
+ ```
73
+
74
+ It takes the bucket from `fleet.json` and the credentials from the environment,
75
+ or asks for them on the terminal, and writes both to
76
+ `~/.config/dev-fleet/bucket.json` (readable by you alone). Such a run then
77
+ uses the configuration last applied and the saved credentials. Without them it
78
+ still converges the fleet from the local copy and reports that the bucket was
79
+ not reached; the next run that has credentials uploads what is pending.
80
+
81
+ **A fleet that kept its configuration in the bucket.** Before projects, the
82
+ configuration was stored with `fleet-state --import` and the bucket was named
83
+ with `fleet-state --init --bucket …`. To move such a fleet into a project, on
84
+ the manager:
85
+
86
+ ```sh
87
+ mkdir my-fleet && cd my-fleet && git init && npm init -y
88
+ npm install --save-dev --save-exact @crvouga/dev-fleet
89
+ npx devfleet fleet-state --export > fleet.json
90
+ ```
91
+
92
+ Add the `state` block to `fleet.json` with the same bucket, endpoint, region
93
+ and prefix as `~/.config/dev-fleet/bucket.json`, then run
94
+ `npx devfleet fleet-sync`. The credentials already saved for that bucket keep
95
+ working.
96
+
97
+ The clone of dev-fleet the fleet used to run from is then no longer read by
98
+ `fleet-sync`, with one exception: the CLI proxy's installed code is a Git
99
+ worktree of the repository it was installed from. Install the proxy again
100
+ from the project (`npx devfleet cliproxy install`, which takes the revision
101
+ `fleet-sync` applied from the manager's own copy of the source) before you
102
+ delete the clone.
103
+
104
+ Without a `fleet.json`, `fleet-state --init --bucket NAME --endpoint https://HOST`
105
+ still connects a bucket and restores the configuration stored in it.
106
+
107
+ On a manager older than the fleet state itself, `--init` (or the first
108
+ `fleet-sync`) also moves the configuration, the roster and the approved
109
+ revision from the files that held them into the state. Those files are left in
110
+ place and no longer read.
111
+
112
+ `"state": { "directory": "/Volumes/backup/fleet" }` replicates to a directory
113
+ instead of a bucket (an attached disk, a mounted share). It behaves the same
114
+ way, needs no credentials and is the quickest way to try this out.
115
+
116
+ ## Day to day
117
+
118
+ ```sh
119
+ devfleet fleet-state # read-only: where the state is, and how it stands with the bucket
120
+ devfleet fleet-state --example # print a fleet.json to start a project from
121
+ devfleet fleet-state --export # print the configuration fleet-sync last applied
122
+ devfleet fleet-state --push # upload changes made while the bucket was unreachable
123
+ devfleet fleet-state --restore # replace the local copy with the bucket's
124
+ ```
125
+
126
+ To change the fleet, for example to add a worker, edit `fleet.json`, commit
127
+ and run `devfleet fleet-sync` from the project. `fleet-sync` validates the
128
+ file, installs it on every machine and records it in the state.
129
+
130
+ Which configuration a command uses, in this order:
131
+
132
+ 1. The file `DEVFLEET_CONFIG` names.
133
+ 2. The closest `fleet.json` in the working directory or above it.
134
+ 3. The configuration `fleet-sync` last applied, from the fleet state (the
135
+ devfleet CLI on the manager).
136
+ 4. The copy `fleet-sync` installed at `~/.config/dev-fleet/fleet.json`
137
+ (launchers and scheduled jobs on every machine read only this one).
138
+
139
+ `fleet-state --import PATH` stores a configuration in the state without
140
+ running `fleet-sync`.
141
+
142
+ ## How it stays correct
143
+
144
+ Litestream does not run as a daemon. A devfleet command that changes the state
145
+ runs it once, so the command has not finished until the bucket has the change
146
+ and nothing has to be kept running on the manager. Every change follows the
147
+ same three steps:
148
+
149
+ 1. **Download** the bucket's current version and compare it with the local copy.
150
+ 2. **Change** the local copy, on top of the bucket's version.
151
+ 3. **Upload**, then download again. The change counts only once a restore from
152
+ the bucket returns it.
153
+
154
+ `fleet-sync` does step 1 before it reads the configuration and step 3 after
155
+ it records what it applied. Its report carries the result as the
156
+ `state:replica` check.
157
+
158
+ `fleet-state` reports one of these:
159
+
160
+ | Standing | Meaning | What happens |
161
+ | -------------- | ------------------------------------------------------ | ---------------------------------------------------------- |
162
+ | `in-sync` | The bucket and this machine hold the same version | Nothing to do |
163
+ | `local-only` | No bucket is configured | A warning on every `fleet-sync`; name one in `fleet.json` |
164
+ | `empty` | Neither side holds any state | The first `fleet-sync` records it |
165
+ | `unpublished` | The bucket holds nothing yet | The next change, or `--push`, seeds it |
166
+ | `local-ahead` | This machine has changes the bucket has not received | The next change, or `--push`, uploads them |
167
+ | `bucket-ahead` | Another manager changed the bucket; this copy is stale | The next change or `fleet-sync` takes the bucket's version |
168
+ | `unreachable` | The bucket did not answer, or did not take an upload | Changes still apply locally and are uploaded later |
169
+ | `diverged` | Both sides changed | Refused. `--restore --yes` takes the bucket's version |
170
+
171
+ A stale copy never overwrites the bucket. A machine that was offline while
172
+ another one changed the fleet is brought up to date before it can change
173
+ anything, and if it also changed something itself it stops and says so. The
174
+ bucket wins: export what you still need, then `--restore --yes`.
175
+
176
+ An unreachable bucket does not block `fleet-sync`. The fleet converges, the
177
+ report warns, and the next command uploads what is pending.
178
+
179
+ ## What is not in the bucket yet
180
+
181
+ The fleet state covers what `fleet-sync` itself needs. The manager also holds
182
+ state that is replicated differently or not at all:
183
+
184
+ | State | Where | If the manager is lost |
185
+ | ---------------------------------------------------- | -------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ |
186
+ | Secrets | Your `secrets` provider (`vault`, `sops`, `file`, `env`) | Unaffected for `vault`; `file` and `env` secrets must be supplied again |
187
+ | CLI proxy: provider logins, keys, Tailscale identity | `~/Library/Application Support/cliproxy-fleet/state` | Restored from its own encrypted restic snapshots ([cliproxy](../cliproxy/README.md)); needs `vault` |
188
+ | Monitoring hub (Beszel) database | `~/Library/Application Support/personal-beszel` | `fleet-sync` recreates accounts and systems; metric history, alerts and settings made by hand are lost |
189
+ | T3 ownership record | `~/.t3/userdata/personal-managed.json` | `t3-apply --import ID` adopts instances again |
190
+
191
+ Everything else under `~/.local/share/personal/fleet` and
192
+ `~/.local/state/personal/fleet` (the copy of the source repository, run
193
+ checkouts, reports, receipts, logs) is a cache that the next `fleet-sync`
194
+ rebuilds. The CLI proxy's installed code is a Git worktree of that copy of the
195
+ source repository: after deleting it, run `devfleet cliproxy install` again
196
+ once `fleet-sync` has fetched it back.
197
+
198
+ ## Reference
199
+
200
+ | Setting | Meaning |
201
+ | -------------------------------------------------------------------- | ------------------------------------------------------------ |
202
+ | `state` in `fleet.json` | Where the bucket is |
203
+ | `DEVFLEET_BUCKET_ACCESS_KEY_ID`, `DEVFLEET_BUCKET_SECRET_ACCESS_KEY` | The bucket's credentials, for the command they are passed to |
204
+ | `DEVFLEET_CONFIG` | A configuration file, instead of the project's `fleet.json` |
205
+ | `DEVFLEET_STATE` | Another state directory. Earlier files are not read for it |
206
+ | `DEVFLEET_BUCKET` | Another `bucket.json` |
207
+
208
+ How a command reaches the bucket:
209
+
210
+ - **Where it is**: the `state` block of the configuration; without one, the
211
+ bucket saved in `bucket.json`.
212
+ - **Credentials**: the two variables; otherwise the ones saved in
213
+ `bucket.json`, only when they were saved for that same bucket; otherwise the
214
+ standard AWS environment or profile.
215
+
216
+ `fleet-state --json` reports both as `bucket.location` (`fleet.json` or
217
+ `bucket.json`) and `bucket.credentials` (`environment`, `bucket.json` or
218
+ `aws`), never a credential.
219
+
220
+ `bucket.json`, written by `fleet-state --init`:
221
+
222
+ ```json
223
+ {
224
+ "version": 1,
225
+ "bucket": "my-fleet-state",
226
+ "endpoint": "https://ACCOUNT.r2.cloudflarestorage.com",
227
+ "region": "auto",
228
+ "prefix": "dev-fleet",
229
+ "accessKeyId": "…",
230
+ "secretAccessKey": "…"
231
+ }
232
+ ```
233
+
234
+ It must be readable by you alone (`chmod 600`); devfleet refuses to use it
235
+ otherwise. The keys reach Litestream through its environment and are never
236
+ written to its configuration, a report or a log. To rotate them, pass the new
237
+ ones to `fleet-state --init`.
238
+
239
+ The database is stored under `PREFIX/fleet.db/`. Each change adds a few
240
+ kilobytes and Litestream keeps thirty days of snapshots, so a restore to an
241
+ earlier point is possible with Litestream directly (with the bucket's keys in
242
+ `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY`):
243
+
244
+ ```sh
245
+ mise exec litestream -- litestream restore -no-expand-env \
246
+ -config ~/.local/share/dev-fleet/state/litestream.yml \
247
+ -timestamp 2026-11-01T00:00:00Z -o /tmp/fleet-then.db \
248
+ ~/.local/share/dev-fleet/state/fleet.db
249
+ ```
250
+
251
+ Litestream is pinned in `mise.toml` and installed the first time it is used.
252
+ The manager is the only machine that runs it. There is one manager, so there is
253
+ one writer; two machines changing the state at the same moment are not
254
+ supported, only detected afterwards.