@crvouga/dev-fleet 0.1.0 → 0.3.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 (159) hide show
  1. package/README.md +265 -37
  2. package/agents/setup.md +42 -12
  3. package/ansible/macos/manager.yml +10 -7
  4. package/ansible/macos/policy.json +2 -10
  5. package/ansible/macos/t3-policy.yml +49 -0
  6. package/ansible/monitoring/README.md +86 -25
  7. package/ansible/monitoring/agent.yml +40 -2
  8. package/ansible/monitoring/hub-route-darwin.yml +51 -0
  9. package/ansible/monitoring/lock.json +5 -0
  10. package/ansible/pyproject.toml +12 -0
  11. package/ansible/ubuntu/baseline.yml +1 -1
  12. package/ansible/ubuntu/disk-gc.py +336 -0
  13. package/ansible/ubuntu/policy.json +13 -3
  14. package/ansible/ubuntu/t3-desktop.yml +19 -0
  15. package/ansible/ubuntu/t3-health.sh +93 -22
  16. package/ansible/ubuntu/t3-idle.py +42 -0
  17. package/ansible/ubuntu/t3-policy.yml +4 -11
  18. package/ansible/ubuntu/tailscale.yml +8 -5
  19. package/ansible/ubuntu/tasks/recovery.yml +60 -1
  20. package/ansible/ubuntu/tasks/resources.yml +61 -1
  21. package/ansible/ubuntu/tasks/t3-restart.yml +37 -0
  22. package/ansible/ubuntu/wip-guard.py +121 -0
  23. package/ansible/uv.lock +190 -0
  24. package/bootstrap/bootstrap.sh +25 -18
  25. package/bootstrap/doctor.ts +46 -1
  26. package/bootstrap/fleet-manager-run.sh +42 -15
  27. package/bootstrap/fleet-remote-bootstrap.sh +85 -12
  28. package/bootstrap/global-config.ts +64 -6
  29. package/bootstrap/install-launcher.sh +3 -0
  30. package/bootstrap/native-t3.ts +22 -11
  31. package/bootstrap/npm.ts +10 -2
  32. package/bootstrap/release-version.ts +157 -0
  33. package/cliproxy/README.md +3 -3
  34. package/cliproxy/scripts/accounts.py +216 -0
  35. package/cliproxy/scripts/checkpoint.py +29 -1
  36. package/cliproxy/scripts/cli.py +1 -1
  37. package/cliproxy/scripts/clientkeys.py +99 -0
  38. package/cliproxy/scripts/lease.py +80 -0
  39. package/cliproxy/scripts/ops +137 -25
  40. package/cliproxy/scripts/restore-verify +15 -0
  41. package/docs/accounts.md +154 -0
  42. package/docs/consumer-setup.md +408 -0
  43. package/docs/declared.md +91 -0
  44. package/docs/durability.md +26 -0
  45. package/docs/machine-setup.md +400 -0
  46. package/docs/operations.md +276 -0
  47. package/docs/releasing.md +103 -0
  48. package/docs/state.md +254 -0
  49. package/docs/topology.md +69 -0
  50. package/docs/ubuntu-sync.md +357 -0
  51. package/docs/worker-contract.md +67 -0
  52. package/examples/fleet.json +5 -0
  53. package/llms.txt +36 -0
  54. package/mise.lock +29 -0
  55. package/mise.toml +15 -2
  56. package/package.json +11 -2
  57. package/schema/fleet.schema.json +137 -15
  58. package/src/accounts/checks.ts +220 -0
  59. package/src/accounts/context.ts +76 -0
  60. package/src/accounts/propagate.ts +62 -0
  61. package/src/accounts/roster.ts +85 -0
  62. package/src/accounts/selection.ts +67 -0
  63. package/src/accounts/sessions.ts +120 -0
  64. package/src/agents/account-relay.ts +176 -26
  65. package/src/agents/fleet-verify.ts +9 -0
  66. package/src/agents/launch.ts +84 -5
  67. package/src/agents/native-verify.ts +293 -22
  68. package/src/agents/provider-errors.ts +9 -2
  69. package/src/agents/registry.ts +7 -1
  70. package/src/cli/agent-guide.ts +39 -5
  71. package/src/cli/app.ts +11 -4
  72. package/src/cli/cli.ts +7 -0
  73. package/src/cli/command-index.ts +7 -0
  74. package/src/cli/fleet-accounts.ts +253 -0
  75. package/src/cli/fleet-state.ts +499 -0
  76. package/src/cli/fleet-sync-accounts.ts +58 -0
  77. package/src/cli/fleet-sync-checkout.ts +56 -17
  78. package/src/cli/fleet-sync-enrollment.ts +19 -23
  79. package/src/cli/fleet-sync-forward.ts +1 -1
  80. package/src/cli/fleet-sync-manager.ts +84 -12
  81. package/src/cli/fleet-sync-monitoring.ts +5 -5
  82. package/src/cli/fleet-sync-output.ts +7 -3
  83. package/src/cli/fleet-sync-remote.ts +31 -18
  84. package/src/cli/fleet-sync-state.ts +86 -0
  85. package/src/cli/fleet-sync.ts +159 -57
  86. package/src/cli/fleet.ts +4 -2
  87. package/src/cli/managed-health.ts +4 -4
  88. package/src/cli/node-sync.ts +7 -0
  89. package/src/cli/program.ts +20 -4
  90. package/src/cli/project.ts +51 -0
  91. package/src/cli/register-fleet.ts +86 -1
  92. package/src/cli/registry.ts +41 -1
  93. package/src/cli/roster.ts +46 -30
  94. package/src/cli/settings.ts +15 -12
  95. package/src/cli/source-operations.ts +27 -3
  96. package/src/cli/tailnet.ts +2 -2
  97. package/src/definition.ts +116 -4
  98. package/src/diagnostics.ts +1 -1
  99. package/src/fleet-config.ts +105 -19
  100. package/src/ide/definition.ts +86 -0
  101. package/src/ide/ide.ts +43 -0
  102. package/src/ide/index.ts +5 -0
  103. package/src/ide/orchestrate.ts +50 -0
  104. package/src/ide/t3.ts +111 -0
  105. package/src/machine/source.ts +3 -3
  106. package/src/machine/state.ts +15 -27
  107. package/src/manager-sync.ts +9 -18
  108. package/src/monitoring/agent-evidence.ts +146 -0
  109. package/src/monitoring/apply.ts +15 -3
  110. package/src/monitoring/beszel.ts +11 -4
  111. package/src/monitoring/enroll.ts +2 -1
  112. package/src/monitoring/hub-access.ts +4 -3
  113. package/src/monitoring/hub-backup.ts +121 -0
  114. package/src/monitoring/hub-viewer.ts +8 -8
  115. package/src/monitoring/hub.ts +3 -3
  116. package/src/monitoring/manager-hub.ts +30 -14
  117. package/src/node-converge-linux.ts +30 -7
  118. package/src/package-info.ts +45 -0
  119. package/src/platform/ansible.ts +19 -15
  120. package/src/platform/darwin.ts +0 -13
  121. package/src/platform/fallback.ts +0 -8
  122. package/src/platform/linux.ts +0 -10
  123. package/src/platform/resource-verify.ts +8 -4
  124. package/src/platform/t3-policy.ts +33 -6
  125. package/src/platform/t3-service.ts +104 -0
  126. package/src/platform/types.ts +0 -4
  127. package/src/platform/ubuntu-audit.ts +5 -2
  128. package/src/platform/windows.ts +0 -14
  129. package/src/remote-bootstrap.ts +17 -19
  130. package/src/remote-entry.ts +15 -1
  131. package/src/repair-catalog.ts +1 -1
  132. package/src/runner.ts +1 -1
  133. package/src/service-auth.ts +15 -5
  134. package/src/state/bucket.ts +208 -0
  135. package/src/state/check.ts +74 -0
  136. package/src/state/connection.ts +92 -0
  137. package/src/state/enrollment.ts +45 -0
  138. package/src/state/legacy.ts +89 -0
  139. package/src/state/litestream.ts +143 -0
  140. package/src/state/operator.ts +20 -0
  141. package/src/state/paths.ts +40 -0
  142. package/src/state/roster.ts +62 -0
  143. package/src/state/store.ts +169 -0
  144. package/src/state/sync.ts +352 -0
  145. package/src/t3/accounts.ts +7 -1
  146. package/src/t3/models.ts +133 -0
  147. package/src/t3/pairing.ts +31 -0
  148. package/src/t3/profile.ts +203 -0
  149. package/src/t3/version.ts +119 -0
  150. package/src/t3-endpoint.ts +64 -8
  151. package/src/t3-linked-check.ts +24 -2
  152. package/src/toolchain-checks.ts +25 -0
  153. package/src/transport.ts +3 -2
  154. package/src/types.ts +4 -0
  155. package/src/worker-access-checks.ts +56 -3
  156. package/src/worker-host-checks.ts +41 -13
  157. package/src/workstation-checks.ts +1 -1
  158. package/src/index.ts +0 -6
  159. package/src/test-fixtures.ts +0 -36
@@ -1,5 +1,9 @@
1
1
  import { localCheckoutRevision } from '../machine/local-source';
2
- import { fleet as canonicalDefinition } from '../fleet-config';
2
+ import {
3
+ definitionSource,
4
+ fleet as canonicalDefinition,
5
+ reloadFleet,
6
+ } from '../fleet-config';
3
7
  import { managerFailure } from '../manager-failure';
4
8
  import { managerText, stdoutColor } from './fleet-sync-output';
5
9
  import { managerSchedule, type ScheduleAction } from './fleet-sync-schedule';
@@ -9,12 +13,20 @@ import { homedir } from 'node:os';
9
13
  import { join } from 'node:path';
10
14
  import { existsSync, renameSync } from 'node:fs';
11
15
  import {
12
- readEnrollment,
13
16
  isRevision,
14
- saveEnrollment,
15
17
  withMachineLock,
16
18
  ensurePrivateRoot,
19
+ type Enrollment,
17
20
  } from '../machine/state';
21
+ import { loadEnrollment } from '../state/enrollment';
22
+ import { legacyHome } from '../state/paths';
23
+ import {
24
+ DIVERGED_REMEDY,
25
+ refresh,
26
+ stateContext,
27
+ type Standing,
28
+ } from '../state/sync';
29
+ import { runRecord } from './fleet-sync-state';
18
30
  import { runCommand } from '../runner';
19
31
  import type { Runner } from '../types';
20
32
  import type { Settings } from './settings';
@@ -23,15 +35,15 @@ import { FleetUsageError } from '../errors';
23
35
  import { FleetLockError } from '../machine/lock-error';
24
36
  import { selected } from './fleet-sync-remote';
25
37
  import { managerIdentity } from './fleet-sync-manager';
26
- import { trackedScript } from '../remote-bootstrap';
27
- import { rehome } from './fleet-sync-checkout';
38
+ import { managerRunScript, rehome } from './fleet-sync-checkout';
39
+ import { syncAccounts } from './fleet-sync-accounts';
40
+ import { packageInfo } from '../package-info';
28
41
  import {
29
42
  forwardingDisabled,
30
43
  forwardToManager,
31
44
  type ForwardSpawn,
32
45
  } from './fleet-sync-forward';
33
46
 
34
- declare const PERSONAL_SOURCE_REVISION: string | null;
35
47
  export interface FleetSyncOptions {
36
48
  revision?: string;
37
49
  dryRun?: boolean;
@@ -42,6 +54,8 @@ export interface FleetSyncOptions {
42
54
  node?: string[];
43
55
  all?: boolean;
44
56
  schedule?: ScheduleAction;
57
+ /** `accounts`: only the account-derived configuration, on every node. */
58
+ only?: string;
45
59
  /** Internal: this revision already converged the whole fleet, so no canary
46
60
  * has to prove it first and every worker converges at once.
47
61
  */
@@ -69,9 +83,30 @@ export async function fleetSync(
69
83
  if (!(await onManager(run)))
70
84
  return offManager(options, settings, run, forward);
71
85
  if (options.schedule === 'status') return scheduleStatus(settings, run);
72
- const root = machineRoot();
86
+ if (options.only === 'accounts') return onlyAccounts(options, settings, run);
87
+ return managerSync(options, settings, {
88
+ run,
89
+ root: machineRoot(),
90
+ restart: () => fleetSync(options, settings, run, forward),
91
+ });
92
+ }
93
+
94
+ export interface ManagerSync {
95
+ run: Runner;
96
+ /** This machine's run lock and caches. */
97
+ root: string;
98
+ /** Starts the request over once the fleet state has been replaced. */
99
+ restart: () => Promise<CommandOutcome>;
100
+ }
101
+
102
+ /** fleet-sync on the machine already known to be the manager. */
103
+ export async function managerSync(
104
+ options: FleetSyncOptions,
105
+ settings: Settings,
106
+ { run, root, restart }: ManagerSync
107
+ ): Promise<CommandOutcome> {
73
108
  await ensurePrivateRoot(root);
74
- const state = await readEnrollment(root);
109
+ const state = await loadEnrollment(legacyHome() ? root : null);
75
110
  const revision = await selectedRevision(
76
111
  options.revision,
77
112
  state?.approvedRevision,
@@ -81,61 +116,100 @@ export async function fleetSync(
81
116
  if (!isRevision(revision))
82
117
  return actionRequired(
83
118
  'FLEET_REVISION_REQUIRED',
84
- 'Select an approved full SHA with --revision, or use a published package containing clean source provenance.'
119
+ 'No revision to converge to. Run the published dev-fleet package (it names the commit it was built from), or select a full commit SHA with --revision.'
85
120
  );
86
121
  if (options.schedule)
87
122
  return scheduleRequest(options, revision, settings, run);
88
123
  const rehomed = await rehome(
89
124
  options,
90
- { revision, workspace: settings.workspace },
125
+ { revision, workspace: settings.workspace, root },
91
126
  run
92
127
  );
93
- if (rehomed) return rehomed;
94
- const request = { ...options, revision };
95
- if (request.dryRun) return selected(request, settings, run);
96
- // Applies without a confirmation prompt; --dry-run is the preview.
97
- request.apply = true;
98
- request.yes = true;
99
- if (interactive(options)) {
100
- await enrollManager(settings.config, run);
101
- // Needed to hand each node its own Vault token; nodes report any gap.
102
- await authorizeManager({ interactive: true }, run);
103
- request.interactiveAuthorization = true;
128
+ if (rehomed === 'unavailable')
129
+ return actionRequired(
130
+ 'FLEET_SOURCE_UNAVAILABLE',
131
+ `Revision ${revision} could not be taken from ${canonicalDefinition.source.repository}, so nothing was changed. Check the network and Git access to it, and that the revision is pushed there; fleet-sync never converges from the package or working tree it was started in.`,
132
+ revision
133
+ );
134
+ if (rehomed !== 'here') return rehomed;
135
+ if (options.dryRun) return selected({ ...options, revision }, settings, run);
136
+ // The bucket is the source of truth: a run starts from its version of the
137
+ // configuration, the roster and the approval, not from a stale local copy.
138
+ const fresh = await refresh(stateContext(run));
139
+ if (fresh.standing === 'diverged')
140
+ return actionRequired('FLEET_STATE_DIVERGED', DIVERGED_REMEDY, revision);
141
+ if (fresh.replaced) {
142
+ // Everything read so far came from the stale copy, the manager's own
143
+ // identity included.
144
+ reloadFleet();
145
+ return restart();
104
146
  }
105
- return applyLocked(root, state, request, { settings, run });
147
+ return authorizeAndApply(root, state, options, {
148
+ settings,
149
+ run,
150
+ revision,
151
+ standing: fresh.standing,
152
+ });
153
+ }
154
+
155
+ interface ApplyContext {
156
+ settings: Settings;
157
+ run: Runner;
158
+ revision: string;
159
+ /** How the fleet state stood with the bucket when this run started. */
160
+ standing: Standing;
161
+ }
162
+
163
+ /** Applies without a confirmation prompt; --dry-run is the preview. */
164
+ async function authorizeAndApply(
165
+ root: string,
166
+ state: Enrollment | null,
167
+ options: FleetSyncOptions,
168
+ context: ApplyContext
169
+ ): Promise<CommandOutcome> {
170
+ const { settings, run, revision } = context;
171
+ const request = { ...options, revision, apply: true, yes: true };
172
+ if (!interactive(options)) return applyLocked(root, state, request, context);
173
+ await enrollManager(settings.config, run);
174
+ // Needed to hand each node its own Vault token; nodes report any gap.
175
+ await authorizeManager({ interactive: true }, run);
176
+ return applyLocked(
177
+ root,
178
+ state,
179
+ { ...request, interactiveAuthorization: true },
180
+ context
181
+ );
106
182
  }
107
183
 
108
- /** Records the approved revision, converges, then records what was applied. */
184
+ /** Records the approved revision, converges, records what was applied, then
185
+ * uploads the fleet state: the report says whether the bucket holds it.
186
+ */
109
187
  function applyLocked(
110
188
  root: string,
111
- state: Awaited<ReturnType<typeof readEnrollment>>,
189
+ state: Enrollment | null,
112
190
  request: FleetSyncOptions & { revision: string },
113
- context: { settings: Settings; run: Runner }
191
+ context: ApplyContext
114
192
  ): Promise<CommandOutcome> {
115
- const { revision } = request;
116
- const { settings, run } = context;
193
+ const { settings, run, revision } = context;
194
+ const record = runRecord(state, revision, {
195
+ context: stateContext(run),
196
+ standing: context.standing,
197
+ ...(definitionSource().kind === 'file'
198
+ ? { definition: JSON.stringify(canonicalDefinition, null, 2) + '\n' }
199
+ : {}),
200
+ });
117
201
  return withMachineLock(root, async () => {
118
- const approval = {
119
- ...state,
120
- version: 1 as const,
121
- id: 'manager',
122
- approvedRevision: revision,
123
- };
124
- await saveEnrollment(root, approval);
125
- const result = await selected(
126
- { ...request, rolledOut: state?.appliedRevision === revision },
127
- settings,
128
- run
129
- );
130
- if (result.exitCode === 0)
131
- await saveEnrollment(root, {
132
- ...approval,
133
- appliedRevision: revision,
134
- ...(state?.appliedRevision && state.appliedRevision !== revision
135
- ? { previousRevision: state.appliedRevision }
136
- : {}),
137
- });
138
- return result;
202
+ record.approve();
203
+ try {
204
+ return await selected(
205
+ { ...request, rolledOut: state?.appliedRevision === revision },
206
+ settings,
207
+ run,
208
+ (report) => record.settle(report.ok)
209
+ );
210
+ } finally {
211
+ await record.abandon();
212
+ }
139
213
  }).catch((error: unknown) => {
140
214
  if (error instanceof FleetLockError)
141
215
  return actionRequired(
@@ -175,12 +249,7 @@ async function offManager(
175
249
  revision ?? null
176
250
  );
177
251
  const runScript = revision
178
- ? await trackedScript(
179
- settings.workspace,
180
- revision,
181
- 'bootstrap/fleet-manager-run.sh',
182
- run
183
- )
252
+ ? await managerRunScript(settings.workspace, revision, run)
184
253
  : null;
185
254
  return forwardToManager(definition, options, revision, {
186
255
  spawn: forward,
@@ -224,11 +293,43 @@ function validate(options: FleetSyncOptions): void {
224
293
  throw new FleetUsageError(
225
294
  '--revision requires an exact lowercase 40-character commit SHA.'
226
295
  );
296
+ validateOnly(options);
227
297
  if (options.dryRun && (options.apply || options.yes))
228
298
  throw new FleetUsageError(
229
299
  '--dry-run cannot be combined with apply confirmation.'
230
300
  );
231
301
  }
302
+ function validateOnly(options: FleetSyncOptions): void {
303
+ if (options.only !== undefined && options.only !== 'accounts')
304
+ throw new FleetUsageError('--only takes one scope: accounts.');
305
+ if (options.only && (options.dryRun || options.schedule))
306
+ throw new FleetUsageError(
307
+ '--only accounts applies at once; it takes neither --dry-run nor --schedule.'
308
+ );
309
+ }
310
+
311
+ /** `--only accounts`: workers keep the revision they run; only their
312
+ * accounts change.
313
+ */
314
+ async function onlyAccounts(
315
+ options: FleetSyncOptions,
316
+ settings: Settings,
317
+ run: Runner
318
+ ): Promise<CommandOutcome> {
319
+ const root = machineRoot();
320
+ await ensurePrivateRoot(root);
321
+ const state = await loadEnrollment(legacyHome() ? root : null);
322
+ const revision =
323
+ state?.approvedRevision ??
324
+ (await selectedRevision(options.revision, undefined, settings, run));
325
+ if (!isRevision(revision))
326
+ return actionRequired(
327
+ 'FLEET_REVISION_REQUIRED',
328
+ 'No revision is applied yet. Run a full fleet-sync first.'
329
+ );
330
+ return syncAccounts(options, settings, { revision, run });
331
+ }
332
+
232
333
  function interactive(options: FleetSyncOptions): boolean {
233
334
  return (
234
335
  ![
@@ -242,10 +343,11 @@ function interactive(options: FleetSyncOptions): boolean {
242
343
  );
243
344
  }
244
345
 
346
+ /** The commit an installed package was published from: the version of
347
+ * dev-fleet you run is the revision the fleet moves to.
348
+ */
245
349
  function packageRevision(): string | undefined {
246
- return typeof PERSONAL_SOURCE_REVISION === 'string'
247
- ? PERSONAL_SOURCE_REVISION
248
- : undefined;
350
+ return packageInfo().revision ?? undefined;
249
351
  }
250
352
 
251
353
  async function scheduleRequest(
package/src/cli/fleet.ts CHANGED
@@ -89,15 +89,17 @@ async function register(
89
89
  options: CliOptions
90
90
  ): Promise<CommandOutcome> {
91
91
  const node = nodeFromOptions(options);
92
- const config = await addNode(settings.config, node, options.apply === true);
92
+ const added = await addNode(settings.config, node, options.apply === true);
93
93
  return result({
94
94
  version: 1,
95
95
  command: 'add',
96
96
  ok: true,
97
97
  applied: options.apply === true,
98
98
  path: settings.config,
99
+ // How the fleet state stands with the bucket after this change.
100
+ ...(added.standing ? { state: added.standing } : {}),
99
101
  node,
100
- nodes: config.nodes,
102
+ nodes: added.roster.nodes,
101
103
  next: options.apply
102
104
  ? 'Run fleet-nodes --onboard, then fleet-status --json.'
103
105
  : 'Review this preview, then repeat with --apply --yes.',
@@ -6,7 +6,7 @@ import { enrolledRoster } from '../definition';
6
6
  import type { Tailnet } from '../tailscale';
7
7
  import type { FleetNode } from '../types';
8
8
  import {
9
- PUBLIC_HUB_URL,
9
+ publicHubUrl,
10
10
  verifyBeszel,
11
11
  type BeszelRequest,
12
12
  } from '../monitoring/beszel';
@@ -122,7 +122,7 @@ async function hubReachable(
122
122
  */
123
123
  async function publicEndpoint(fetcher: typeof fetch): Promise<Check> {
124
124
  try {
125
- const response = await fetcher(`${PUBLIC_HUB_URL}/api/health`, {
125
+ const response = await fetcher(`${publicHubUrl()}/api/health`, {
126
126
  redirect: 'error',
127
127
  signal: AbortSignal.timeout(10_000),
128
128
  });
@@ -130,7 +130,7 @@ async function publicEndpoint(fetcher: typeof fetch): Promise<Check> {
130
130
  return {
131
131
  id: 'beszel:public-endpoint',
132
132
  status: 'pass',
133
- summary: `Dashboard answers publicly at ${PUBLIC_HUB_URL}.`,
133
+ summary: `Dashboard answers publicly at ${publicHubUrl()}.`,
134
134
  };
135
135
  } catch {
136
136
  /* Connection details are not evidence; fall through. */
@@ -140,7 +140,7 @@ async function publicEndpoint(fetcher: typeof fetch): Promise<Check> {
140
140
  status: 'warn',
141
141
  summary: 'BESZEL_PUBLIC_ENDPOINT_OFFLINE',
142
142
  remedy: {
143
- action: `${PUBLIC_HUB_URL} did not answer. Create the Cloudflare tunnel and store its token in the fleet's secrets, then run fleet-sync on the manager so the hub starts its Cloudflare connector. The private dashboard is unaffected.`,
143
+ action: `${publicHubUrl()} did not answer. Create the Cloudflare tunnel and store its token in the fleet's secrets, then run fleet-sync on the manager so the hub starts its Cloudflare connector. The private dashboard is unaffected.`,
144
144
  },
145
145
  };
146
146
  }
@@ -2,6 +2,7 @@ import { join } from 'node:path';
2
2
  import { configureWorkspace } from '../configure';
3
3
  import { runCommand } from '../runner';
4
4
  import { FleetUsageError } from '../errors';
5
+ import { packageInfo } from '../package-info';
5
6
  import type { Runner } from '../types';
6
7
  import type { Settings } from './settings';
7
8
  import type { CommandOutcome } from './contracts';
@@ -24,6 +25,12 @@ export async function syncNode(
24
25
  'Codex proxy node sync requires macOS/Linux/WSL. Use dev-fleet commands for native Windows configuration.'
25
26
  );
26
27
  const node = nodeId(options.id);
28
+ // An installed package is not a checkout, and the Git repository around it
29
+ // is the operator's own project: its HEAD is not a dev-fleet revision.
30
+ if (packageInfo().revision)
31
+ throw new FleetUsageError(
32
+ 'fleet-node-sync works on a checkout of dev-fleet. Run fleet-sync instead: it converges this machine from a checkout it owns.'
33
+ );
27
34
  const head = await run(['git', 'rev-parse', '--verify', 'HEAD'], {
28
35
  cwd: settings.workspace,
29
36
  });
@@ -13,6 +13,14 @@ export interface Release {
13
13
  version: string;
14
14
  /** ISO timestamp recorded when this distribution was built; null for source runs. */
15
15
  releasedAt: string | null;
16
+ /** The commit a published package was built from: what fleet-sync run from
17
+ * it converges every machine to. Absent for source runs.
18
+ */
19
+ revision?: string | null;
20
+ /** The issue tracker of the package: where its users report a defect or
21
+ * ask for a feature.
22
+ */
23
+ issues?: string | null;
16
24
  }
17
25
 
18
26
  export const GLOBAL_OPTIONS = [
@@ -23,7 +31,8 @@ export const GLOBAL_OPTIONS = [
23
31
  { flag: '--non-interactive', description: 'Never open interactive prompts' },
24
32
  {
25
33
  flag: '--workspace <path>',
26
- description: 'Existing manager checkout; does not clone or change it',
34
+ description:
35
+ 'A dev-fleet checkout to run from instead of this package (development); does not clone or change it',
27
36
  },
28
37
  { flag: '--config <path>', description: 'Local, secret-free fleet roster' },
29
38
  ];
@@ -34,8 +43,10 @@ export const SOURCE_VERSION = '0.0.0-development';
34
43
  export function releaseLabel(release: Release): string {
35
44
  if (release.releasedAt)
36
45
  return `${release.version} (released ${release.releasedAt})`;
37
- return release.version === SOURCE_VERSION
38
- ? `${release.version} (unreleased source build)`
46
+ if (release.version === SOURCE_VERSION)
47
+ return `${release.version} (unreleased source build)`;
48
+ return release.revision
49
+ ? `${release.version} (commit ${release.revision})`
39
50
  : release.version;
40
51
  }
41
52
 
@@ -63,9 +74,14 @@ function createProgram(
63
74
  '',
64
75
  `Release: ${releaseLabel(release)}`,
65
76
  'Groups: fleet-* developer computers · t3-* T3 provider instances · cliproxy the CLI proxy service.',
66
- 'Apply on invocation (--dry-run previews): fleet-node-sync, t3-apply, t3-cleanup. fleet-sync confirms interactively or with --apply --yes.',
77
+ 'Apply on invocation (--dry-run previews): fleet-node-sync, t3-apply, t3-cleanup. fleet-sync applies on invocation too; --apply --yes is still accepted.',
67
78
  'Preview until --apply --yes: fleet-repair, fleet-node-add. --json does not make a command read-only.',
68
79
  'Agents: devfleet commands --json. Menu: devfleet --interactive.',
80
+ ...(release.issues
81
+ ? [
82
+ `Bugs and feature requests: ${release.issues}. Report them there; never patch the installed package.`,
83
+ ]
84
+ : []),
69
85
  ].join('\n')
70
86
  );
71
87
  return program;
@@ -0,0 +1,51 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { dirname, join, resolve } from 'node:path';
3
+ import { record } from '../config';
4
+
5
+ /** The file a project keeps its fleet configuration in. */
6
+ export const PROJECT_CONFIG = 'fleet.json';
7
+
8
+ /** Another tool's fleet.json is not this one: a fleet configuration is a JSON
9
+ * object that names a manager. Whether it is a valid one is said later, with
10
+ * the file's path, by whatever reads it.
11
+ */
12
+ function fleetConfiguration(path: string): boolean {
13
+ try {
14
+ const value: unknown = JSON.parse(readFileSync(path, 'utf8'));
15
+ return record(value) && record(value['manager']);
16
+ } catch {
17
+ return false;
18
+ }
19
+ }
20
+
21
+ /** The fleet.json of the project a command is run from: the closest one in
22
+ * the working directory or above it, or null.
23
+ */
24
+ export function projectConfig(cwd: string): string | null {
25
+ let current = resolve(cwd);
26
+ while (true) {
27
+ const candidate = join(current, PROJECT_CONFIG);
28
+ if (fleetConfiguration(candidate)) return candidate;
29
+ const parent = dirname(current);
30
+ if (parent === current) return null;
31
+ current = parent;
32
+ }
33
+ }
34
+
35
+ /** A fleet is a project of your own: a fleet.json in Git, and the devfleet
36
+ * CLI run from inside it. This names that file for the whole command, and
37
+ * for everything the command starts in another directory (fleet-sync re-runs
38
+ * itself from a checkout of the revision). `DEVFLEET_CONFIG` names a file
39
+ * elsewhere and wins. Only the CLI does this: launchers and scheduled jobs
40
+ * run in directories that belong to other projects and read the copy
41
+ * fleet-sync installed.
42
+ */
43
+ export function useProjectConfig(
44
+ env: NodeJS.ProcessEnv = process.env,
45
+ cwd = process.cwd()
46
+ ): string | null {
47
+ const named = env['DEVFLEET_CONFIG'];
48
+ const path = named ? resolve(cwd, named) : projectConfig(cwd);
49
+ if (path) env['DEVFLEET_CONFIG'] = path;
50
+ return path;
51
+ }
@@ -6,6 +6,8 @@ import { resolveSettings } from './settings';
6
6
  import { executeFleet } from './fleet';
7
7
  import { fleetSync, type FleetSyncOptions } from './fleet-sync';
8
8
  import { syncNode, type NodeSyncOptions } from './node-sync';
9
+ import { fleetState, type FleetStateOptions } from './fleet-state';
10
+ import { fleetAccounts, type FleetAccountsOptions } from './fleet-accounts';
9
11
 
10
12
  type Complete = (outcome: CommandOutcome) => void;
11
13
 
@@ -172,7 +174,7 @@ function registerSync({ program, defaults, complete }: Registration) {
172
174
  )
173
175
  .option(
174
176
  '--revision <sha>',
175
- 'Approve an exact source commit; defaults to published release or current canonical checkout'
177
+ 'Converge to an exact source commit; defaults to the commit the dev-fleet package you run was published from'
176
178
  )
177
179
  .option(
178
180
  '--node <id>',
@@ -197,6 +199,10 @@ function registerSync({ program, defaults, complete }: Registration) {
197
199
  '--schedule <action>',
198
200
  'Manager-only schedule: enable, pause, resume, disable, status'
199
201
  )
202
+ .option(
203
+ '--only <scope>',
204
+ 'accounts: only the account-derived configuration, on every node at once, without a full sync'
205
+ )
200
206
  .action(async (_options, command: Command) => {
201
207
  const options = command.optsWithGlobals<
202
208
  FleetSyncOptions & Partial<Settings>
@@ -228,6 +234,54 @@ function registerSync({ program, defaults, complete }: Registration) {
228
234
  });
229
235
  }
230
236
 
237
+ function registerState({ program, complete }: Registration) {
238
+ program
239
+ .command('fleet-state')
240
+ .description(
241
+ 'Show where the fleet state is kept and whether the bucket holds it; flags connect, upload and restore'
242
+ )
243
+ .option(
244
+ '--init',
245
+ 'Connect the bucket: restore the fleet state from it, or seed it from this machine, and save its credentials for runs this machine starts by itself'
246
+ )
247
+ .option(
248
+ '--bucket <name>',
249
+ 'With --init, when no fleet.json names the bucket: S3-compatible bucket name'
250
+ )
251
+ .option(
252
+ '--endpoint <url>',
253
+ 'With --init: https origin of the S3 service; omit for AWS S3'
254
+ )
255
+ .option('--region <name>', 'With --init: bucket region, such as auto')
256
+ .option('--prefix <prefix>', 'With --init: key prefix; default dev-fleet')
257
+ .option(
258
+ '--directory <path>',
259
+ 'With --init: replicate to a directory instead of a bucket'
260
+ )
261
+ .option(
262
+ '--import <path>',
263
+ 'Store a fleet configuration without running fleet-sync'
264
+ )
265
+ .option(
266
+ '--export',
267
+ 'Print the stored fleet configuration: the one fleet-sync last applied'
268
+ )
269
+ .option('--example', 'Print an example fleet.json to start a project from')
270
+ .option('--push', 'Upload this machine’s changes to the bucket')
271
+ .option(
272
+ '--restore',
273
+ 'Replace this machine’s copy with the bucket’s version'
274
+ )
275
+ .option('--yes', 'With --restore: discard changes the bucket does not hold')
276
+ .addHelpText(
277
+ 'after',
278
+ '\nRead-only without a flag. The bucket is the one named under "state" in fleet.json. Every command reads its credentials from DEVFLEET_BUCKET_ACCESS_KEY_ID and DEVFLEET_BUCKET_SECRET_ACCESS_KEY; --init also asks on the terminal. They are never taken from arguments.'
279
+ )
280
+ .action(async (_options, command: Command) => {
281
+ complete(await fleetState(command.optsWithGlobals<FleetStateOptions>()));
282
+ });
283
+ }
284
+
231
285
  export function registerFleet(
232
286
  program: Command,
233
287
  defaults: Settings,
@@ -241,6 +295,37 @@ export function registerFleet(
241
295
  'List computers visible through Tailscale, including offline devices'
242
296
  );
243
297
  registerSync(registration);
298
+ registerState(registration);
244
299
  registerRepair(registration);
245
300
  registerNodes(registration);
301
+ registerAccounts(registration);
302
+ }
303
+
304
+ function registerAccounts({ program, defaults, complete }: Registration) {
305
+ program
306
+ .command('fleet-accounts [args...]')
307
+ .description(
308
+ 'List the provider accounts signed in on the CLI proxy and their state; add, sign in again or remove one'
309
+ )
310
+ .option('--diff', 'Also compare what this machine offers with the roster')
311
+ .option('--add', 'Sign a new account in: --add PROVIDER')
312
+ .option('--relogin', 'Sign one account in again: --relogin PROVIDER INDEX')
313
+ .option('--remove', 'Remove one account: --remove PROVIDER INDEX')
314
+ .option(
315
+ '--remove-native-logins',
316
+ 'Remove provider logins agent CLIs keep outside the proxy on this machine; needs --yes'
317
+ )
318
+ .option('--yes', 'Confirm --remove-native-logins')
319
+ .action(async (args: string[], _options, command: Command) => {
320
+ const options = command.optsWithGlobals<
321
+ FleetAccountsOptions & CliOptions
322
+ >();
323
+ complete(
324
+ await fleetAccounts(
325
+ options,
326
+ args,
327
+ await withSettings(defaults, options)
328
+ )
329
+ );
330
+ });
246
331
  }
@@ -59,10 +59,37 @@ export const COMMANDS: CommandSpec[] = [
59
59
  [
60
60
  'fleet-sync',
61
61
  'Synchronize the enrolled Ubuntu fleet',
62
- 'Manager push to all enrolled Ubuntu nodes at one approved revision over verified SSH; interactive confirmation or --apply --yes. Readiness is verified separately.',
62
+ 'Manager push to all enrolled Ubuntu nodes at one approved revision over verified SSH: the commit the dev-fleet package was published from, with the fleet.json of the project it is run from. Interactive confirmation or --apply --yes. Readiness is verified separately.',
63
63
  ['ubuntu', 'enrollment', 'converge', 'sync'],
64
64
  true,
65
65
  ],
66
+ [
67
+ 'fleet-state',
68
+ 'Check the fleet state and its bucket',
69
+ 'Read-only: where the fleet configuration, the roster and the approved revision are kept on this manager, and whether the bucket holds the same version. --example prints a fleet.json to start a project from; --export prints the configuration fleet-sync last applied; --import PATH stores one.',
70
+ ['state', 'bucket', 's3', 'backup', 'litestream', 'configuration'],
71
+ ],
72
+ [
73
+ 'fleet-state --init',
74
+ 'Connect the bucket and restore or seed the fleet state',
75
+ 'Restore the fleet state from the bucket fleet.json names under "state" (or the one given with --bucket/--endpoint or --directory), or seed an empty bucket from this machine, and save the bucket and its credentials on this machine for runs it starts by itself. Credentials come from the environment or a terminal prompt.',
76
+ ['state', 'bucket', 's3', 'setup', 'restore', 'new manager'],
77
+ true,
78
+ ],
79
+ [
80
+ 'fleet-state --push',
81
+ 'Upload fleet state changes to the bucket',
82
+ 'Upload changes this manager made while the bucket was unreachable. Refuses when the bucket has changed since.',
83
+ ['state', 'bucket', 'upload', 'replicate'],
84
+ true,
85
+ ],
86
+ [
87
+ 'fleet-state --restore',
88
+ 'Replace the local fleet state with the bucket’s',
89
+ 'Discard this manager’s copy of the fleet state and download the bucket’s version. Needs --yes when this machine has changes the bucket does not hold.',
90
+ ['state', 'bucket', 'restore', 'recover', 'disposable'],
91
+ true,
92
+ ],
66
93
  [
67
94
  'fleet-node-sync',
68
95
  'Sync this node',
@@ -108,6 +135,19 @@ export const COMMANDS: CommandSpec[] = [
108
135
  ['onboarding', 'register', 'new', 'setup', 'add'],
109
136
  true,
110
137
  ],
138
+ [
139
+ 'fleet-accounts',
140
+ 'List provider accounts and their state',
141
+ 'Read-only: the provider accounts signed in on the CLI proxy (provider, index, label, state, when a cooldown clears), the roster version and the proxy:account:<provider> checks. --diff also compares what this machine offers with the roster.',
142
+ ['accounts', 'providers', 'login', 'quota', 'cooldown', 'roster'],
143
+ ],
144
+ [
145
+ 'fleet-accounts --add',
146
+ 'Add, sign in again or remove a provider account',
147
+ '--add PROVIDER, --relogin PROVIDER INDEX or --remove PROVIDER INDEX changes one account on the CLI proxy (signing in is the only manual step), then propagates the accounts to every node with fleet-sync --only accounts.',
148
+ ['accounts', 'login', 'relogin', 'remove', 'add', 'sign in'],
149
+ true,
150
+ ],
111
151
  ]),
112
152
  ...group('T3', [
113
153
  [