@crvouga/dev-fleet 0.2.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 (72) hide show
  1. package/README.md +28 -0
  2. package/ansible/macos/manager.yml +3 -0
  3. package/ansible/macos/t3-policy.yml +49 -0
  4. package/ansible/ubuntu/policy.json +1 -0
  5. package/ansible/ubuntu/t3-desktop.yml +19 -0
  6. package/ansible/ubuntu/t3-health.sh +93 -22
  7. package/ansible/ubuntu/t3-idle.py +42 -0
  8. package/ansible/ubuntu/t3-policy.yml +4 -11
  9. package/ansible/ubuntu/tasks/recovery.yml +60 -1
  10. package/ansible/ubuntu/tasks/t3-restart.yml +37 -0
  11. package/ansible/ubuntu/wip-guard.py +121 -0
  12. package/bootstrap/native-t3.ts +22 -11
  13. package/cliproxy/README.md +3 -3
  14. package/cliproxy/scripts/accounts.py +216 -0
  15. package/cliproxy/scripts/checkpoint.py +29 -1
  16. package/cliproxy/scripts/cli.py +1 -1
  17. package/cliproxy/scripts/clientkeys.py +99 -0
  18. package/cliproxy/scripts/lease.py +80 -0
  19. package/cliproxy/scripts/ops +137 -25
  20. package/cliproxy/scripts/restore-verify +15 -0
  21. package/docs/accounts.md +154 -0
  22. package/docs/declared.md +3 -1
  23. package/docs/durability.md +26 -0
  24. package/docs/machine-setup.md +4 -3
  25. package/docs/topology.md +2 -0
  26. package/docs/ubuntu-sync.md +28 -22
  27. package/llms.txt +1 -0
  28. package/mise.toml +3 -1
  29. package/package.json +2 -2
  30. package/schema/fleet.schema.json +66 -0
  31. package/src/accounts/checks.ts +220 -0
  32. package/src/accounts/context.ts +76 -0
  33. package/src/accounts/propagate.ts +62 -0
  34. package/src/accounts/roster.ts +85 -0
  35. package/src/accounts/selection.ts +67 -0
  36. package/src/accounts/sessions.ts +120 -0
  37. package/src/agents/account-relay.ts +176 -26
  38. package/src/agents/launch.ts +84 -5
  39. package/src/agents/native-verify.ts +128 -2
  40. package/src/cli/fleet-accounts.ts +253 -0
  41. package/src/cli/fleet-sync-accounts.ts +58 -0
  42. package/src/cli/fleet-sync-manager.ts +20 -4
  43. package/src/cli/fleet-sync-output.ts +3 -2
  44. package/src/cli/fleet-sync-remote.ts +20 -13
  45. package/src/cli/fleet-sync.ts +36 -0
  46. package/src/cli/register-fleet.ts +35 -0
  47. package/src/cli/registry.ts +13 -0
  48. package/src/definition.ts +85 -3
  49. package/src/ide/definition.ts +86 -0
  50. package/src/ide/ide.ts +43 -0
  51. package/src/ide/index.ts +5 -0
  52. package/src/ide/orchestrate.ts +50 -0
  53. package/src/ide/t3.ts +111 -0
  54. package/src/manager-sync.ts +1 -15
  55. package/src/node-converge-linux.ts +28 -7
  56. package/src/platform/darwin.ts +0 -13
  57. package/src/platform/fallback.ts +0 -8
  58. package/src/platform/linux.ts +0 -10
  59. package/src/platform/t3-policy.ts +33 -6
  60. package/src/platform/t3-service.ts +104 -0
  61. package/src/platform/types.ts +0 -4
  62. package/src/platform/windows.ts +0 -14
  63. package/src/remote-entry.ts +15 -1
  64. package/src/service-auth.ts +15 -5
  65. package/src/t3/accounts.ts +7 -1
  66. package/src/t3/models.ts +133 -0
  67. package/src/t3/pairing.ts +31 -0
  68. package/src/t3/profile.ts +203 -0
  69. package/src/t3/version.ts +119 -0
  70. package/src/t3-linked-check.ts +1 -1
  71. package/src/transport.ts +3 -2
  72. package/src/worker-host-checks.ts +36 -13
@@ -0,0 +1,253 @@
1
+ import { rm } from 'node:fs/promises';
2
+ import { homedir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { FleetUsageError } from '../errors';
5
+ import { fleet } from '../fleet-config';
6
+ import { runCommand } from '../runner';
7
+ import { blocking, type Check, type Runner } from '../types';
8
+ import {
9
+ nativeLoginCheck,
10
+ nativeLogins,
11
+ providerAccountChecks,
12
+ rosterCurrentCheck,
13
+ } from '../accounts/checks';
14
+ import { exposedVersion, t3Settings } from '../accounts/context';
15
+ import { parseRoster, type ProxyRoster } from '../accounts/roster';
16
+ import { exposedInstances } from '../accounts/sessions';
17
+ import { forwardSourceOperation, ownerCheckout } from './source-operations';
18
+ import type { CommandOutcome } from './contracts';
19
+ import type { Settings } from './settings';
20
+ import { fleetSync } from './fleet-sync';
21
+
22
+ export interface FleetAccountsOptions {
23
+ diff?: boolean;
24
+ add?: boolean;
25
+ relogin?: boolean;
26
+ remove?: boolean;
27
+ removeNativeLogins?: boolean;
28
+ yes?: boolean;
29
+ }
30
+
31
+ export interface AccountsDependencies {
32
+ run?: Runner;
33
+ /** Runs a proxy command attached to the terminal (a sign-in). */
34
+ operate?: (args: string[]) => Promise<number>;
35
+ /** `fleet-sync --only accounts`. */
36
+ propagate?: () => Promise<CommandOutcome>;
37
+ home?: string;
38
+ settingsFile?: string;
39
+ }
40
+
41
+ const PROVIDER = /^[a-z][a-z0-9-]{0,31}$/;
42
+
43
+ /** `devfleet fleet-accounts`: the provider account roster the proxy holds,
44
+ * each account's state, and the account checks. `--add`, `--relogin` and
45
+ * `--remove` change one account on the proxy and propagate the result to
46
+ * every node at once.
47
+ */
48
+ export async function fleetAccounts(
49
+ options: FleetAccountsOptions,
50
+ args: string[],
51
+ settings: Settings,
52
+ dependencies: AccountsDependencies = {}
53
+ ): Promise<CommandOutcome> {
54
+ const home = dependencies.home ?? homedir();
55
+ if (options.removeNativeLogins)
56
+ return removeNativeLogins(Boolean(options.yes), home);
57
+ const change = lifecycle(options, args);
58
+ const changed = change
59
+ ? await applyChange(change, settings, dependencies)
60
+ : undefined;
61
+ if (changed && !changed.data) return failedChange();
62
+ return report(options, settings, dependencies, { change, changed });
63
+ }
64
+
65
+ async function report(
66
+ options: FleetAccountsOptions,
67
+ settings: Settings,
68
+ dependencies: AccountsDependencies,
69
+ {
70
+ change,
71
+ changed,
72
+ }: { change: string[] | null; changed: CommandOutcome | undefined }
73
+ ): Promise<CommandOutcome> {
74
+ const home = dependencies.home ?? homedir();
75
+ const roster = await readRoster(
76
+ dependencies.run ?? runCommand,
77
+ settings.workspace
78
+ );
79
+ const checks: Check[] = providerAccountChecks(
80
+ roster,
81
+ fleet.services.proxy.accounts?.minimumUsable
82
+ );
83
+ if (options.diff)
84
+ checks.push(...(await thisMachine(roster, home, dependencies)));
85
+ const ok = !checks.some(blocking) && !changed?.exitCode;
86
+ return {
87
+ exitCode: ok ? 0 : 1,
88
+ data: {
89
+ version: 1,
90
+ command: 'fleet-accounts',
91
+ ok,
92
+ ...listing(roster),
93
+ checks,
94
+ ...(change && {
95
+ changed: change.slice(0, 2),
96
+ propagation: changed?.data,
97
+ }),
98
+ },
99
+ };
100
+ }
101
+
102
+ /** The roster as listed: no routing prefix, no file. */
103
+ function listing(roster: ProxyRoster) {
104
+ return {
105
+ rosterVersion: roster.rosterVersion,
106
+ managementApi: roster.managementApi,
107
+ accounts: roster.accounts.map(
108
+ ({ provider, index, label, state, until }) => ({
109
+ provider,
110
+ index,
111
+ label,
112
+ state,
113
+ until,
114
+ })
115
+ ),
116
+ };
117
+ }
118
+
119
+ /** Runs the change on the proxy, then propagates it to every node. A change
120
+ * the proxy did not complete answers with no data: nothing was propagated.
121
+ */
122
+ async function applyChange(
123
+ change: string[],
124
+ settings: Settings,
125
+ dependencies: AccountsDependencies
126
+ ): Promise<CommandOutcome> {
127
+ const operate =
128
+ dependencies.operate ??
129
+ ((forwarded: string[]) =>
130
+ forwardSourceOperation(['cliproxy', ...forwarded], settings));
131
+ if ((await operate(change)) !== 0) return { exitCode: 1, data: null };
132
+ return (
133
+ dependencies.propagate ??
134
+ (() => fleetSync({ only: 'accounts', json: true }, settings))
135
+ )();
136
+ }
137
+
138
+ function failedChange(): CommandOutcome {
139
+ return {
140
+ exitCode: 1,
141
+ data: {
142
+ version: 1,
143
+ command: 'fleet-accounts',
144
+ ok: false,
145
+ error: 'ACCOUNT_CHANGE_FAILED',
146
+ summary:
147
+ 'The proxy did not complete the change; an interrupted sign-in leaves no credential behind. Nothing was propagated.',
148
+ },
149
+ };
150
+ }
151
+
152
+ /** `--diff`: what this machine offers against the roster. */
153
+ async function thisMachine(
154
+ roster: ProxyRoster,
155
+ home: string,
156
+ dependencies: AccountsDependencies
157
+ ): Promise<Check[]> {
158
+ const exposed = await exposedInstances(
159
+ dependencies.settingsFile ?? t3Settings()
160
+ );
161
+ return [
162
+ rosterCurrentCheck(roster.rosterVersion, exposedVersion(exposed)),
163
+ nativeLoginCheck(nativeLogins(home)),
164
+ ];
165
+ }
166
+
167
+ /** The proxy command a lifecycle flag stands for, or null for a listing. */
168
+ function lifecycle(
169
+ options: FleetAccountsOptions,
170
+ [provider, index]: string[]
171
+ ): string[] | null {
172
+ const chosen = (['add', 'relogin', 'remove'] as const).filter(
173
+ (flag) => options[flag]
174
+ );
175
+ if (chosen.length === 0) return null;
176
+ if (chosen.length > 1)
177
+ throw new FleetUsageError('Choose one of --add, --relogin and --remove.');
178
+ const flag = chosen[0]!;
179
+ if (!provider || !PROVIDER.test(provider))
180
+ throw new FleetUsageError(
181
+ `--${flag} needs a provider, such as claude or codex.`
182
+ );
183
+ if (flag === 'add') return ['add-account', provider];
184
+ if (!index || !/^[1-9][0-9]{0,3}$/.test(index))
185
+ throw new FleetUsageError(
186
+ `--${flag} needs the account's index, as devfleet fleet-accounts lists it.`
187
+ );
188
+ return [flag === 'relogin' ? 'relogin' : 'remove-account', provider, index];
189
+ }
190
+
191
+ /** The roster as the proxy on this manager reports it. */
192
+ async function readRoster(
193
+ run: Runner,
194
+ workspace: string
195
+ ): Promise<ProxyRoster> {
196
+ const checkout = ownerCheckout(workspace);
197
+ if (!checkout)
198
+ throw new FleetUsageError(
199
+ 'The account roster is kept by the proxy on the manager. Run devfleet fleet-accounts there, after a first fleet-sync.'
200
+ );
201
+ const result = await run(
202
+ [
203
+ 'python3',
204
+ '-B',
205
+ join(checkout, 'cliproxy/scripts/cli.py'),
206
+ 'accounts',
207
+ '--json',
208
+ ],
209
+ { cwd: checkout, timeoutMs: 60_000 }
210
+ );
211
+ try {
212
+ if (!result.ok) throw new Error();
213
+ return parseRoster(result.stdout);
214
+ } catch {
215
+ throw new FleetUsageError(
216
+ 'The proxy did not report its accounts. Check it with devfleet cliproxy status.'
217
+ );
218
+ }
219
+ }
220
+
221
+ /** Opt-in: deletes the provider logins agent CLIs keep outside the proxy. */
222
+ async function removeNativeLogins(
223
+ yes: boolean,
224
+ home: string
225
+ ): Promise<CommandOutcome> {
226
+ const found = nativeLogins(home);
227
+ if (!yes)
228
+ return {
229
+ exitCode: found.length ? 1 : 0,
230
+ data: {
231
+ version: 1,
232
+ command: 'fleet-accounts',
233
+ ok: found.length === 0,
234
+ applied: false,
235
+ checks: [nativeLoginCheck(found)],
236
+ next: found.length
237
+ ? 'Add --yes to remove these logins from this machine.'
238
+ : undefined,
239
+ },
240
+ };
241
+ for (const login of found) await rm(login.path, { force: true });
242
+ return {
243
+ exitCode: 0,
244
+ data: {
245
+ version: 1,
246
+ command: 'fleet-accounts',
247
+ ok: true,
248
+ applied: true,
249
+ removed: [...new Set(found.map((login) => login.cli))].sort(),
250
+ checks: [nativeLoginCheck(nativeLogins(home))],
251
+ },
252
+ };
253
+ }
@@ -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
+ }
@@ -6,11 +6,13 @@ import { convergeProxy } from '../proxy-converge';
6
6
  import { attempt } from '../node-converge-shared';
7
7
  import { quotaLimited, verifyFleetProviders } from '../agents/fleet-verify';
8
8
  import { verifyNativeAgents } from '../agents/native-verify';
9
+ import { accountContext, nodeAccountChecks } from '../accounts/context';
9
10
  import { convergeManagerHub } from '../monitoring/manager-hub';
10
11
  import { enrollManagerMonitoring } from './fleet-sync-monitoring';
11
12
  import { installConfig } from '../fleet-config';
12
- import { machineProviders } from '../t3/providers';
13
- import { providerNames } from '../t3/names-check';
13
+ import { IDES } from '../ide';
14
+ import { enabled, managerChecks } from '../ide/orchestrate';
15
+ import { idesOf } from '../ide/definition';
14
16
  import macos from '../../ansible/macos/policy.json';
15
17
  import { ansibleCommand } from '../platform/ansible';
16
18
  import { PACKAGE_ROOT } from '../package-info';
@@ -39,8 +41,15 @@ export async function convergeManager(
39
41
  if (input.apply) installConfig();
40
42
  checks.push(await managerPower(run, input.workspace));
41
43
  checks.push(await managerDocker(run));
42
- checks.push(await machineProviders(id, input.workspace, input.apply));
43
- checks.push(await providerNames(id, input.workspace, input.apply));
44
+ // Each enabled IDE: its settings, pinned version and policy.
45
+ checks.push(
46
+ ...(await managerChecks(enabled(idesOf(definition), IDES), {
47
+ id,
48
+ workspace: input.workspace,
49
+ apply: input.apply,
50
+ run,
51
+ }))
52
+ );
44
53
  if (input.apply) {
45
54
  // Same provider profiles and the same Vault gateway credential as every
46
55
  // worker: the instances above launch from these profiles, and the check
@@ -55,10 +64,17 @@ export async function convergeManager(
55
64
  );
56
65
  checks.push(...providers);
57
66
  // Same agents, versions and shared accounts as every worker.
67
+ const accounts = await accountContext();
68
+ checks.push(
69
+ ...(await attempt('accounts:verification', () =>
70
+ nodeAccountChecks(accounts)
71
+ ))
72
+ );
58
73
  checks.push(
59
74
  ...(await attempt('agent:verification', () =>
60
75
  verifyNativeAgents(id, input.workspace, run, {
61
76
  outOfQuota: quotaLimited(providers),
77
+ ...(accounts ? { accounts } : {}),
62
78
  })
63
79
  ))
64
80
  );
@@ -19,9 +19,10 @@ const COLUMNS: { title: string; matches: (id: string) => boolean }[] = [
19
19
  matches: (id) =>
20
20
  [
21
21
  't3:service',
22
- 't3:policy',
22
+ 't3:policy-staged',
23
+ 't3:policy-loaded',
24
+ 't3:running-version',
23
25
  't3:providers',
24
- 't3:antigravity-key',
25
26
  't3:private-route',
26
27
  'worker:t3-recovery',
27
28
  'worker:t3-health',
@@ -1,7 +1,8 @@
1
- import { verifyT3Linked } from '../t3-linked-check';
2
- import { T3_RESTARTED_SUMMARY } from '../platform/t3-policy';
3
1
  import { createHash } from 'node:crypto';
4
- import { verifyT3Endpoint } from '../t3-endpoint';
2
+ import { IDES } from '../ide';
3
+ import { enabled, workerChecks } from '../ide/orchestrate';
4
+ import { idesOf } from '../ide/definition';
5
+ import { rememberEnvironment } from '../t3/pairing';
5
6
  import { authorizePrivilege } from './fleet-sync-privilege';
6
7
  import { authorizeVault } from './fleet-sync-vault';
7
8
  import { managerFailure } from '../manager-failure';
@@ -92,7 +93,11 @@ export async function selected(
92
93
  ),
93
94
  },
94
95
  run,
95
- deployWithMonitoring(payload, canonical, run, ui)
96
+ deployWithMonitoring(payload, canonical, run, {
97
+ ...ui,
98
+ workspace: settings.workspace,
99
+ roster: settings.config,
100
+ })
96
101
  ).finally(() => ui.progress.stop());
97
102
  if (settle) report.checks.push(await settle(report));
98
103
  return outcome(report, planFingerprint, started);
@@ -157,6 +162,9 @@ function deployWithMonitoring(
157
162
  ui: {
158
163
  progress: SyncProgress;
159
164
  interactiveAuthorization: boolean;
165
+ /** The manager's checkout, and the roster an identity is recorded in. */
166
+ workspace: string;
167
+ roster: string;
160
168
  }
161
169
  ): DeployNode {
162
170
  return async (node, revision) => {
@@ -194,16 +202,15 @@ function deployWithMonitoring(
194
202
  // Workers are Linux by definition.
195
203
  ui.progress.step(node.id, 'Verifying SSH, OS and T3');
196
204
  result.checks.push(...(await inspectBootstrap(node, run, 'linux')));
197
- result.checks.push(...(await verifyT3Endpoint(node, run)));
198
- // A worker T3 restarted by this sync (idle policy heal) needs a minute or
199
- // two before the manager's link reconnects.
200
- const restarted = result.checks.some(
201
- (check) => check.summary === T3_RESTARTED_SUMMARY
202
- );
203
205
  result.checks.push(
204
- await verifyT3Linked(node, run, {
205
- reconnectMs: restarted ? 180_000 : 0,
206
- })
206
+ ...(await workerChecks(enabled(idesOf(canonical), IDES), node, {
207
+ run,
208
+ workspace: ui.workspace,
209
+ earlier: result.checks,
210
+ remember: async (id, identity) => {
211
+ await rememberEnvironment(ui.roster, id, identity);
212
+ },
213
+ }))
207
214
  );
208
215
  if (
209
216
  result.checks.some(
@@ -36,6 +36,7 @@ import { FleetLockError } from '../machine/lock-error';
36
36
  import { selected } from './fleet-sync-remote';
37
37
  import { managerIdentity } from './fleet-sync-manager';
38
38
  import { managerRunScript, rehome } from './fleet-sync-checkout';
39
+ import { syncAccounts } from './fleet-sync-accounts';
39
40
  import { packageInfo } from '../package-info';
40
41
  import {
41
42
  forwardingDisabled,
@@ -53,6 +54,8 @@ export interface FleetSyncOptions {
53
54
  node?: string[];
54
55
  all?: boolean;
55
56
  schedule?: ScheduleAction;
57
+ /** `accounts`: only the account-derived configuration, on every node. */
58
+ only?: string;
56
59
  /** Internal: this revision already converged the whole fleet, so no canary
57
60
  * has to prove it first and every worker converges at once.
58
61
  */
@@ -80,6 +83,7 @@ export async function fleetSync(
80
83
  if (!(await onManager(run)))
81
84
  return offManager(options, settings, run, forward);
82
85
  if (options.schedule === 'status') return scheduleStatus(settings, run);
86
+ if (options.only === 'accounts') return onlyAccounts(options, settings, run);
83
87
  return managerSync(options, settings, {
84
88
  run,
85
89
  root: machineRoot(),
@@ -289,11 +293,43 @@ function validate(options: FleetSyncOptions): void {
289
293
  throw new FleetUsageError(
290
294
  '--revision requires an exact lowercase 40-character commit SHA.'
291
295
  );
296
+ validateOnly(options);
292
297
  if (options.dryRun && (options.apply || options.yes))
293
298
  throw new FleetUsageError(
294
299
  '--dry-run cannot be combined with apply confirmation.'
295
300
  );
296
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
+
297
333
  function interactive(options: FleetSyncOptions): boolean {
298
334
  return (
299
335
  ![
@@ -7,6 +7,7 @@ import { executeFleet } from './fleet';
7
7
  import { fleetSync, type FleetSyncOptions } from './fleet-sync';
8
8
  import { syncNode, type NodeSyncOptions } from './node-sync';
9
9
  import { fleetState, type FleetStateOptions } from './fleet-state';
10
+ import { fleetAccounts, type FleetAccountsOptions } from './fleet-accounts';
10
11
 
11
12
  type Complete = (outcome: CommandOutcome) => void;
12
13
 
@@ -198,6 +199,10 @@ function registerSync({ program, defaults, complete }: Registration) {
198
199
  '--schedule <action>',
199
200
  'Manager-only schedule: enable, pause, resume, disable, status'
200
201
  )
202
+ .option(
203
+ '--only <scope>',
204
+ 'accounts: only the account-derived configuration, on every node at once, without a full sync'
205
+ )
201
206
  .action(async (_options, command: Command) => {
202
207
  const options = command.optsWithGlobals<
203
208
  FleetSyncOptions & Partial<Settings>
@@ -293,4 +298,34 @@ export function registerFleet(
293
298
  registerState(registration);
294
299
  registerRepair(registration);
295
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
+ });
296
331
  }
@@ -135,6 +135,19 @@ export const COMMANDS: CommandSpec[] = [
135
135
  ['onboarding', 'register', 'new', 'setup', 'add'],
136
136
  true,
137
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
+ ],
138
151
  ]),
139
152
  ...group('T3', [
140
153
  [