@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
@@ -0,0 +1,499 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { readFile } from 'node:fs/promises';
3
+ import { homedir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { parseDefinition } from '../definition';
6
+ import { FleetUsageError } from '../errors';
7
+ import { definitionSource } from '../fleet-config';
8
+ import { PACKAGE_ROOT, packageInfo } from '../package-info';
9
+ import { makeReport, renderReport, reportExitCode } from '../report';
10
+ import { runCommand } from '../runner';
11
+ import {
12
+ ACCESS_KEY_ID_ENV,
13
+ SECRET_ACCESS_KEY_ENV,
14
+ bucketIdentity,
15
+ describeBucket,
16
+ parseBucket,
17
+ withCredentials,
18
+ writeBucket,
19
+ type Bucket,
20
+ } from '../state/bucket';
21
+ import { replicaCheck } from '../state/check';
22
+ import {
23
+ connection,
24
+ environmentCredentials,
25
+ type Connection,
26
+ } from '../state/connection';
27
+ import { adoptEarlier, legacyPath, readLegacy } from '../state/legacy';
28
+ import { bucketPath, databasePath, stateRoot } from '../state/paths';
29
+ import {
30
+ DOCUMENTS,
31
+ listDocuments,
32
+ readDocument,
33
+ writeDocument,
34
+ type DocumentName,
35
+ } from '../state/store';
36
+ import {
37
+ mutateState,
38
+ pending,
39
+ pull,
40
+ push,
41
+ standing,
42
+ type Standing,
43
+ type StateContext,
44
+ } from '../state/sync';
45
+ import type { Check, Runner } from '../types';
46
+ import type { CommandOutcome } from './contracts';
47
+
48
+ export interface FleetStateOptions {
49
+ init?: boolean;
50
+ bucket?: string;
51
+ endpoint?: string;
52
+ region?: string;
53
+ prefix?: string;
54
+ directory?: string;
55
+ import?: string;
56
+ export?: boolean;
57
+ example?: boolean;
58
+ push?: boolean;
59
+ restore?: boolean;
60
+ yes?: boolean;
61
+ json?: boolean;
62
+ nonInteractive?: boolean;
63
+ }
64
+
65
+ export interface FleetStateServices {
66
+ run: Runner;
67
+ env: NodeJS.ProcessEnv;
68
+ home: string;
69
+ /** Asks for one hidden value on the terminal; null when there is none. */
70
+ ask: ((question: string) => Promise<string>) | null;
71
+ }
72
+
73
+ function terminalAsk(
74
+ options: FleetStateOptions
75
+ ): FleetStateServices['ask'] | null {
76
+ if (options.json || options.nonInteractive || process.env['CI']) return null;
77
+ if (!process.stdin.isTTY || !process.stderr.isTTY) return null;
78
+ return async (question) => {
79
+ const { password } = await import('@inquirer/prompts');
80
+ return password({ message: question }, { output: process.stderr });
81
+ };
82
+ }
83
+
84
+ const reach = (services: FleetStateServices): Connection =>
85
+ connection(services.env, services.home);
86
+
87
+ function context(services: FleetStateServices): StateContext {
88
+ return {
89
+ root: stateRoot(services.env, services.home),
90
+ bucket: reach(services).bucket,
91
+ run: services.run,
92
+ };
93
+ }
94
+
95
+ const ACTIONS = [
96
+ 'init',
97
+ 'import',
98
+ 'export',
99
+ 'example',
100
+ 'push',
101
+ 'restore',
102
+ ] as const;
103
+ type Action = (typeof ACTIONS)[number];
104
+
105
+ function action(options: FleetStateOptions): Action | 'status' {
106
+ const chosen = ACTIONS.filter((name) => options[name]);
107
+ if (chosen.length > 1)
108
+ throw new FleetUsageError(
109
+ 'Choose one of --init, --import, --export, --example, --push or --restore.'
110
+ );
111
+ const settings = ['bucket', 'endpoint', 'region', 'prefix', 'directory'];
112
+ if (
113
+ chosen[0] !== 'init' &&
114
+ settings.some((name) => options[name as keyof FleetStateOptions])
115
+ )
116
+ throw new FleetUsageError(
117
+ '--bucket, --endpoint, --region, --prefix and --directory belong to --init.'
118
+ );
119
+ return chosen[0] ?? 'status';
120
+ }
121
+
122
+ const TITLE: Record<DocumentName, string> = {
123
+ definition: 'The fleet configuration',
124
+ roster: 'The roster of enrolled workers',
125
+ enrollment: 'The approved revision',
126
+ };
127
+
128
+ function definitionCheck(
129
+ services: FleetStateServices,
130
+ missing: 'fail' | 'warn'
131
+ ): Check {
132
+ const id = 'state:definition';
133
+ const source = definitionSource(services.env, services.home);
134
+ if (source.kind === 'file' && existsSync(source.path))
135
+ return {
136
+ id,
137
+ status: 'pass',
138
+ summary: `The fleet configuration is ${source.path}. fleet-sync records the one it applies in the fleet state, for runs started anywhere else.`,
139
+ };
140
+ if (existsSync(source.path))
141
+ return {
142
+ id,
143
+ status: 'warn',
144
+ summary: `The fleet configuration is only the copy fleet-sync installed at ${source.path}; the fleet state does not hold it.`,
145
+ remedy: {
146
+ action: `Store it: devfleet fleet-state --import ${source.path}.`,
147
+ },
148
+ };
149
+ return {
150
+ id,
151
+ status: missing,
152
+ summary: 'This machine has no fleet configuration.',
153
+ remedy: {
154
+ action:
155
+ 'Run devfleet from the project that holds your fleet.json; devfleet fleet-state --example prints one to start from. A bucket that already holds a configuration restores it: devfleet fleet-state --init --bucket NAME --endpoint https://HOST.',
156
+ },
157
+ };
158
+ }
159
+
160
+ function documentCheck(
161
+ name: DocumentName,
162
+ services: FleetStateServices,
163
+ missing: 'fail' | 'warn'
164
+ ): Check {
165
+ const id = `state:${name}`;
166
+ const stored = listDocuments(
167
+ databasePath(stateRoot(services.env, services.home))
168
+ ).find((document) => document.name === name);
169
+ // The project's file is what runs, whatever is stored.
170
+ const named =
171
+ name === 'definition' &&
172
+ definitionSource(services.env, services.home).kind === 'file';
173
+ if (stored && !named)
174
+ return {
175
+ id,
176
+ status: 'pass',
177
+ summary: `${TITLE[name]} is in the fleet state (changed ${stored.updatedAt}).`,
178
+ };
179
+ if (name === 'definition') return definitionCheck(services, missing);
180
+ if (readLegacy(name, services.env, services.home) !== null)
181
+ return {
182
+ id,
183
+ status: 'warn',
184
+ summary: `${TITLE[name]} is still read from ${legacyPath(name, services.env, services.home)}, outside the fleet state.`,
185
+ remedy: {
186
+ action:
187
+ 'Move it into the fleet state: devfleet fleet-state --init. The next fleet-sync does the same. The file is left in place and no longer read afterwards.',
188
+ },
189
+ };
190
+ return {
191
+ id,
192
+ status: 'skip',
193
+ summary: `${TITLE[name]} is not recorded yet; fleet-sync records it.`,
194
+ };
195
+ }
196
+
197
+ const CREDENTIALS: Record<NonNullable<Connection['credentials']>, string> = {
198
+ environment: `the credentials in ${ACCESS_KEY_ID_ENV} and ${SECRET_ACCESS_KEY_ENV}`,
199
+ saved: 'the credentials saved in bucket.json on this machine',
200
+ ambient: 'the standard AWS environment or profile',
201
+ };
202
+
203
+ function bucketCheck({ bucket, location, credentials }: Connection): Check {
204
+ if (!bucket)
205
+ return {
206
+ id: 'state:bucket',
207
+ status: 'warn',
208
+ summary: 'No bucket is configured.',
209
+ remedy: {
210
+ action: `Name it under "state" in fleet.json and pass its credentials in ${ACCESS_KEY_ID_ENV} and ${SECRET_ACCESS_KEY_ENV}. Without a fleet.json, connect one on this machine: devfleet fleet-state --init --bucket NAME --endpoint https://HOST (docs/state.md lists the settings).`,
211
+ },
212
+ };
213
+ const named =
214
+ location === 'configuration'
215
+ ? 'named in the fleet configuration'
216
+ : 'saved in bucket.json on this machine';
217
+ return {
218
+ id: 'state:bucket',
219
+ status: 'pass',
220
+ summary:
221
+ bucket.kind === 'directory'
222
+ ? `The fleet state replicates to the directory ${bucket.path} (${named}).`
223
+ : `The fleet state replicates to the bucket ${bucket.bucket} under ${bucket.prefix}/ (${named}), with ${CREDENTIALS[credentials ?? 'ambient']}.`,
224
+ };
225
+ }
226
+
227
+ /** What a report may say about the connection: never a credential. */
228
+ function describeConnection(reached: Connection): Record<string, unknown> {
229
+ return {
230
+ ...describeBucket(reached.bucket),
231
+ ...(reached.location
232
+ ? {
233
+ location:
234
+ reached.location === 'configuration' ? 'fleet.json' : 'bucket.json',
235
+ }
236
+ : {}),
237
+ ...(reached.credentials
238
+ ? {
239
+ credentials: {
240
+ environment: 'environment',
241
+ saved: 'bucket.json',
242
+ ambient: 'aws',
243
+ }[reached.credentials],
244
+ }
245
+ : {}),
246
+ };
247
+ }
248
+
249
+ function report(
250
+ services: FleetStateServices,
251
+ input: { standing: Standing; done?: string[]; missing?: 'warn' }
252
+ ): CommandOutcome {
253
+ const reached = reach(services);
254
+ const checks = [
255
+ bucketCheck(reached),
256
+ replicaCheck(input.standing),
257
+ ...DOCUMENTS.map((name) =>
258
+ documentCheck(name, services, input.missing ?? 'fail')
259
+ ),
260
+ ];
261
+ const data = {
262
+ ...makeReport('fleet-state', [{ node: 'state', checks }]),
263
+ root: stateRoot(services.env, services.home),
264
+ bucket: describeConnection(reached),
265
+ standing: input.standing,
266
+ ...(input.done ? { done: input.done } : {}),
267
+ };
268
+ return {
269
+ exitCode: reportExitCode(data, false),
270
+ data,
271
+ text: [...(input.done ?? []), renderReport(data)].join('\n'),
272
+ };
273
+ }
274
+
275
+ /** The credentials for a bucket being saved: the ones passed to this
276
+ * command, or the answers to a prompt. Null to use the standard AWS
277
+ * environment or profile.
278
+ */
279
+ async function credentials(
280
+ services: FleetStateServices
281
+ ): Promise<{ accessKeyId: string; secretAccessKey: string } | null> {
282
+ const { env, ask } = services;
283
+ const given = environmentCredentials(env);
284
+ if (given) return given;
285
+ const accessKeyId = await ask?.(
286
+ 'Bucket access key ID (empty to use the AWS environment or profile)'
287
+ );
288
+ if (!accessKeyId) return null;
289
+ const secretAccessKey = await ask?.('Bucket secret access key');
290
+ if (!secretAccessKey)
291
+ throw new FleetUsageError(
292
+ `A bucket access key ID needs its secret: set ${SECRET_ACCESS_KEY_ENV} or answer the prompt. Credentials are never taken from arguments.`
293
+ );
294
+ return { accessKeyId, secretAccessKey };
295
+ }
296
+
297
+ /** The bucket named on the command line, or null when none is. */
298
+ async function namedBucket(
299
+ options: FleetStateOptions,
300
+ services: FleetStateServices
301
+ ): Promise<Bucket | null> {
302
+ const { bucket, endpoint, region, prefix, directory } = options;
303
+ const s3 = { bucket, endpoint, region, prefix };
304
+ const given = Object.values(s3).some((value) => value !== undefined);
305
+ if (directory !== undefined && given)
306
+ throw new FleetUsageError('Choose --directory or --bucket, not both.');
307
+ if (directory !== undefined) return parseBucket({ directory });
308
+ if (!given) return null;
309
+ if (bucket === undefined)
310
+ throw new FleetUsageError(
311
+ '--endpoint, --region and --prefix need --bucket.'
312
+ );
313
+ return parseBucket({
314
+ ...Object.fromEntries(
315
+ Object.entries(s3).filter(([, value]) => value !== undefined)
316
+ ),
317
+ ...(await credentials(services)),
318
+ });
319
+ }
320
+
321
+ /** The bucket the fleet configuration names, with credentials to save beside
322
+ * it so that runs this machine starts by itself (the schedule, a hand-off
323
+ * from a worker) reach it too. Null when there is nothing new to save: the
324
+ * configuration names no bucket, or names a directory, or this machine
325
+ * already holds credentials for it and none were passed.
326
+ */
327
+ async function declaredBucket(
328
+ services: FleetStateServices
329
+ ): Promise<Bucket | null> {
330
+ const reached = reach(services);
331
+ const { bucket } = reached;
332
+ if (reached.location !== 'configuration' || bucket?.kind !== 's3')
333
+ return null;
334
+ if (reached.credentials === 'saved') return null;
335
+ const given = await credentials(services);
336
+ return given ? withCredentials(bucket, given) : null;
337
+ }
338
+
339
+ /** What `--init` saves on this machine, or null to keep what is there. */
340
+ async function bucketToSave(
341
+ options: FleetStateOptions,
342
+ services: FleetStateServices
343
+ ): Promise<Bucket | null> {
344
+ const named = await namedBucket(options, services);
345
+ const reached = reach(services);
346
+ if (!named) return declaredBucket(services);
347
+ if (
348
+ reached.location === 'configuration' &&
349
+ reached.bucket &&
350
+ bucketIdentity(reached.bucket) !== bucketIdentity(named)
351
+ )
352
+ throw new FleetUsageError(
353
+ 'The fleet configuration names another bucket under "state". Change it there, or run --init without --bucket, --endpoint, --region, --prefix and --directory to connect that one.'
354
+ );
355
+ return named;
356
+ }
357
+
358
+ async function init(
359
+ options: FleetStateOptions,
360
+ services: FleetStateServices
361
+ ): Promise<CommandOutcome> {
362
+ const done: string[] = [];
363
+ const saved = await bucketToSave(options, services);
364
+ if (saved) {
365
+ writeBucket(bucketPath(services.env, services.home), saved);
366
+ done.push(
367
+ `Saved the bucket settings to ${bucketPath(services.env, services.home)}.`
368
+ );
369
+ }
370
+ const target = context(services);
371
+ const had = listDocuments(databasePath(target.root)).length > 0;
372
+ const { value, standing: after } = await mutateState(target, (before) => {
373
+ // A state the bucket already holds is the fleet's; this machine's
374
+ // earlier files only ever seed an empty one.
375
+ if (before === 'in-sync' || before === 'unreachable') return [];
376
+ return adoptEarlier(services.env, services.home);
377
+ });
378
+ if (value.length)
379
+ done.push(
380
+ `Moved into the fleet state from this machine's earlier files: ${value.join(', ')}.`
381
+ );
382
+ else if (!had && listDocuments(databasePath(target.root)).length)
383
+ done.push('Restored the fleet state from the bucket.');
384
+ // Connecting a bucket that is still empty succeeded; storing a
385
+ // configuration is the next step, not a failure of this one.
386
+ return report(services, { standing: after, done, missing: 'warn' });
387
+ }
388
+
389
+ async function importDefinition(
390
+ path: string,
391
+ services: FleetStateServices
392
+ ): Promise<CommandOutcome> {
393
+ let value: unknown;
394
+ try {
395
+ value = JSON.parse(await readFile(path, 'utf8'));
396
+ } catch {
397
+ throw new FleetUsageError(
398
+ '--import needs a readable JSON file: a fleet.json such as the one devfleet fleet-state --example prints.'
399
+ );
400
+ }
401
+ const body = JSON.stringify(parseDefinition(value), null, 2) + '\n';
402
+ const target = context(services);
403
+ const { standing: after } = await mutateState(target, () => {
404
+ adoptEarlier(services.env, services.home);
405
+ return writeDocument(databasePath(target.root), 'definition', body);
406
+ });
407
+ return report(services, {
408
+ standing: after,
409
+ done: ['Stored the fleet configuration in the fleet state.'],
410
+ });
411
+ }
412
+
413
+ function exportDefinition(services: FleetStateServices): CommandOutcome {
414
+ const body = readDocument(
415
+ databasePath(stateRoot(services.env, services.home)),
416
+ 'definition'
417
+ );
418
+ if (body === null)
419
+ throw new FleetUsageError(
420
+ 'The fleet state holds no fleet configuration. Store one with devfleet fleet-state --import PATH.'
421
+ );
422
+ return {
423
+ exitCode: 0,
424
+ data: {
425
+ version: 1,
426
+ command: 'fleet-state',
427
+ ok: true,
428
+ definition: JSON.parse(body) as unknown,
429
+ },
430
+ text: body.trimEnd(),
431
+ };
432
+ }
433
+
434
+ /** A configuration to start a project from: the packaged example, with its
435
+ * schema named where an installed dev-fleet keeps it.
436
+ */
437
+ async function exampleDefinition(): Promise<CommandOutcome> {
438
+ const example = JSON.parse(
439
+ await readFile(join(PACKAGE_ROOT, 'examples/fleet.json'), 'utf8')
440
+ ) as Record<string, unknown>;
441
+ const definition = {
442
+ ...example,
443
+ $schema: `./node_modules/${packageInfo().name}/schema/fleet.schema.json`,
444
+ };
445
+ return {
446
+ exitCode: 0,
447
+ data: { version: 1, command: 'fleet-state', ok: true, definition },
448
+ text: JSON.stringify(definition, null, 2),
449
+ };
450
+ }
451
+
452
+ async function restore(
453
+ options: FleetStateOptions,
454
+ services: FleetStateServices
455
+ ): Promise<CommandOutcome> {
456
+ const target = context(services);
457
+ if (!target.bucket)
458
+ throw new FleetUsageError(
459
+ 'No bucket is configured, so there is nothing to restore from. Name it under "state" in fleet.json, or run devfleet fleet-state --init --bucket NAME --endpoint https://HOST.'
460
+ );
461
+ if (pending(target) && !options.yes)
462
+ throw new FleetUsageError(
463
+ 'This machine has fleet state the bucket does not hold, and --restore discards it. Upload it with --push, or repeat with --yes to discard it.'
464
+ );
465
+ const after = await pull(target);
466
+ return report(services, {
467
+ standing: after,
468
+ done:
469
+ after === 'in-sync'
470
+ ? ['This machine now holds the fleet state from the bucket.']
471
+ : [],
472
+ });
473
+ }
474
+
475
+ const DEFAULTS = (options: FleetStateOptions): FleetStateServices => ({
476
+ run: runCommand,
477
+ env: process.env,
478
+ home: homedir(),
479
+ ask: terminalAsk(options),
480
+ });
481
+
482
+ /** `fleet-state` alone is read-only. Each flag is one explicit action on the
483
+ * manager's fleet state or its bucket.
484
+ */
485
+ export async function fleetState(
486
+ options: FleetStateOptions,
487
+ services: FleetStateServices = DEFAULTS(options)
488
+ ): Promise<CommandOutcome> {
489
+ const chosen = action(options);
490
+ if (chosen === 'init') return init(options, services);
491
+ if (chosen === 'import') return importDefinition(options.import!, services);
492
+ if (chosen === 'export') return exportDefinition(services);
493
+ if (chosen === 'example') return exampleDefinition();
494
+ if (chosen === 'restore') return restore(options, services);
495
+ const target = context(services);
496
+ if (chosen === 'push')
497
+ return report(services, { standing: await push(target) });
498
+ return report(services, { standing: await standing(target) });
499
+ }
@@ -0,0 +1,58 @@
1
+ import { enrolledRoster } from '../definition';
2
+ import { selectNodes } from '../config';
3
+ import { fleet as definition } from '../fleet-config';
4
+ import { bundleProbe } from '../transport';
5
+ import { machineProviders } from '../t3/providers';
6
+ import { accountContext, nodeAccountChecks } from '../accounts/context';
7
+ import { propagateAccounts } from '../accounts/propagate';
8
+ import { loadRoster } from './roster';
9
+ import { ownerCheckout } from './source-operations';
10
+ import type { Check, FleetNode, Runner } from '../types';
11
+ import type { FleetSyncOptions } from './fleet-sync';
12
+ import type { Settings } from './settings';
13
+ import type { CommandOutcome } from './contracts';
14
+
15
+ /** `fleet-sync --only accounts` on the manager. */
16
+ export async function syncAccounts(
17
+ options: FleetSyncOptions,
18
+ settings: Settings,
19
+ { revision, run }: { revision: string; run: Runner }
20
+ ): Promise<CommandOutcome> {
21
+ let nodes: FleetNode[] = [];
22
+ try {
23
+ nodes = selectNodes(
24
+ enrolledRoster(definition, await loadRoster(settings.config)),
25
+ options.node ?? []
26
+ );
27
+ } catch {
28
+ // No enrolled worker yet: the manager alone.
29
+ }
30
+ return propagateAccounts({
31
+ nodes,
32
+ revision,
33
+ source: await bundleProbe(),
34
+ manager: () => managerAccounts(settings.workspace),
35
+ run,
36
+ });
37
+ }
38
+
39
+ /** The manager's own account instances, from the checkout it runs. */
40
+ export async function managerAccounts(workspace: string): Promise<Check[]> {
41
+ const checkout = ownerCheckout(workspace);
42
+ if (!checkout)
43
+ return [
44
+ {
45
+ id: 't3:providers',
46
+ status: 'fail',
47
+ summary: 'No revision is applied on the manager yet.',
48
+ remedy: {
49
+ action: 'Run a full fleet-sync first.',
50
+ command: 'devfleet fleet-sync',
51
+ },
52
+ },
53
+ ];
54
+ return [
55
+ await machineProviders(definition.manager.id, checkout, true),
56
+ ...(await nodeAccountChecks(await accountContext())),
57
+ ];
58
+ }
@@ -1,4 +1,8 @@
1
1
  import { spawn } from 'node:child_process';
2
+ import { readFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { prepareSource } from '../machine/source';
5
+ import { PACKAGE_ROOT, packageInfo } from '../package-info';
2
6
  import { trackedScript } from '../remote-bootstrap';
3
7
  import type { Runner } from '../types';
4
8
  import type { CommandOutcome } from './contracts';
@@ -10,31 +14,64 @@ import { forwardedFlags, type ForwardSpawn } from './fleet-sync-forward';
10
14
  */
11
15
  export const RUN_CHECKOUT_ENV = 'PERSONAL_FLEET_RUN_CHECKOUT';
12
16
 
17
+ const RUN_SCRIPT = 'bootstrap/fleet-manager-run.sh';
18
+
13
19
  export const inRunCheckout = (revision: string): boolean =>
14
20
  process.env[RUN_CHECKOUT_ENV] === revision;
15
21
 
22
+ export interface RunSource {
23
+ revision: string;
24
+ /** Where this process runs from: the installed package, or a checkout. */
25
+ workspace: string;
26
+ /** The manager's own caches; the fleet-owned source repository lives here. */
27
+ root: string;
28
+ }
29
+
30
+ /** The revision's run script: tracked in a checkout, or shipped in the
31
+ * package when the package is that revision.
32
+ */
33
+ export async function managerRunScript(
34
+ workspace: string,
35
+ revision: string,
36
+ run: Runner,
37
+ shipped = { revision: packageInfo().revision, root: PACKAGE_ROOT }
38
+ ): Promise<string | null> {
39
+ const tracked = await trackedScript(workspace, revision, RUN_SCRIPT, run);
40
+ if (tracked || shipped.revision !== revision) return tracked;
41
+ try {
42
+ const script = readFileSync(join(shipped.root, RUN_SCRIPT), 'utf8');
43
+ return script.startsWith('#!/bin/sh\n') ? script : null;
44
+ } catch {
45
+ return null;
46
+ }
47
+ }
48
+
16
49
  /** The manager converges from a clean, fleet-owned checkout of the exact
17
- * revision, like every worker, never from the checkout fleet-sync was started
18
- * in: that one is a working tree whose uncommitted edits, branch switches or
19
- * removal would otherwise decide whether a sync (and every provider it
20
- * installs) works. Null when the revision has no tracked run script.
50
+ * revision, like every worker, never from where fleet-sync was started: that
51
+ * is an installed package a package manager may remove, or a working tree
52
+ * with uncommitted edits. The checkout is made from a repository this machine
53
+ * owns too: the fleet's source repository fetched into the manager's cache
54
+ * (a checkout that already holds the revision supplies it without a fetch).
55
+ * Null when the revision cannot be taken from either.
21
56
  */
22
57
  export async function runFromCheckout(
23
58
  options: FleetSyncOptions,
24
- source: { revision: string; workspace: string },
59
+ source: RunSource,
25
60
  run: Runner,
26
61
  spawnLocal: ForwardSpawn = spawnInherited
27
62
  ): Promise<CommandOutcome | null> {
28
63
  const { revision } = source;
29
- const script = await trackedScript(
30
- source.workspace,
64
+ const repository = await prepareSource(
65
+ source.root,
31
66
  revision,
32
- 'bootstrap/fleet-manager-run.sh',
33
- run
67
+ run,
68
+ source.workspace
34
69
  );
35
- if (!script) return null;
36
- // The run script takes the revision from this clone.
37
- process.env['DEVFLEET_REPO'] ||= source.workspace;
70
+ const script =
71
+ repository && (await trackedScript(repository, revision, RUN_SCRIPT, run));
72
+ if (!repository || !script) return null;
73
+ // The run script makes its checkout from this repository.
74
+ process.env['DEVFLEET_REPO'] = repository;
38
75
  const result = await spawnLocal([
39
76
  'sh',
40
77
  '-c',
@@ -58,17 +95,19 @@ export async function runFromCheckout(
58
95
  };
59
96
  }
60
97
 
61
- /** Null when this process is already the run checkout (or a test, which
98
+ /** `here` when this process is already the run checkout (or a test, which
62
99
  * never leaves its own process: the same rule as the worker hand-off).
100
+ * `unavailable` when the revision could not be checked out: nothing may
101
+ * converge from the place fleet-sync happened to be started in.
63
102
  */
64
103
  export async function rehome(
65
104
  options: FleetSyncOptions,
66
- source: { revision: string; workspace: string },
105
+ source: RunSource,
67
106
  run: Runner
68
- ): Promise<CommandOutcome | null> {
107
+ ): Promise<CommandOutcome | 'here' | 'unavailable'> {
69
108
  if (inRunCheckout(source.revision) || process.env['NODE_ENV'] === 'test')
70
- return null;
71
- return runFromCheckout(options, source, run);
109
+ return 'here';
110
+ return (await runFromCheckout(options, source, run)) ?? 'unavailable';
72
111
  }
73
112
 
74
113
  /** Prompts and progress stay on the terminal; the report is returned. */