@yadurajfleetos/cli 0.1.7 → 0.1.9

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.
@@ -7,13 +7,30 @@
7
7
  import { request, requireFleet, CliError, EXIT } from '../api.js';
8
8
  import { c } from '../render.js';
9
9
  import { glyph } from '../ui.js';
10
- async function confirmTeardown(name) {
10
+ /**
11
+ * Say what teardown actually does before doing it.
12
+ *
13
+ * The distinction people get wrong is `down` versus `rm`, so the prompt names it:
14
+ * the container goes, the service definition stays and can be redeployed. Where
15
+ * it is running is included because that is the machine whose Docker is about to
16
+ * change, and it is usually not the one this command is typed on.
17
+ *
18
+ * Non-interactive callers are taken as consenting. That is deliberate and is not
19
+ * how `rm` behaves — stopping a service is undone by deploying it again, so a
20
+ * scripted `fleet down` in a CI teardown step should not need --yes.
21
+ */
22
+ async function confirmTeardown(service) {
11
23
  if (!process.stdin.isTTY)
12
24
  return true;
25
+ const where = service.current?.nodeName;
26
+ console.log(`\n This stops ${c.bold(service.name)}${where ? ` on ${c.bold(where)}` : ''}:` +
27
+ `\n ${c.dim('·')} its container is removed from that machine` +
28
+ `\n ${c.dim('·')} the service definition is kept, so ${c.cyan('fleet deploy')}${c.dim(' brings it back')}` +
29
+ `\n ${c.dim('To delete it outright, use `fleet rm` instead.')}\n`);
13
30
  const { createInterface } = await import('node:readline/promises');
14
31
  const rl = createInterface({ input: process.stdin, output: process.stdout });
15
32
  try {
16
- const ans = await rl.question(` Stop and tear down ${c.bold(name)}? [y/N] `);
33
+ const ans = await rl.question(` Stop and tear down ${c.bold(service.name)}? [y/N] `);
17
34
  return ans.trim().toLowerCase() === 'y';
18
35
  }
19
36
  finally {
@@ -22,16 +39,18 @@ async function confirmTeardown(name) {
22
39
  }
23
40
  export const downCommand = {
24
41
  async run(args, flags) {
25
- const fleetId = await requireFleet(typeof flags.fleet === 'string' ? flags.fleet : undefined);
42
+ // Before requireFleet, which reaches the control plane when no fleet is
43
+ // saved: a missing argument should not need the network to be reported.
26
44
  const [name] = args;
27
45
  if (!name)
28
46
  throw new CliError('usage: fleet down <service> [--yes]', EXIT.usage);
47
+ const fleetId = await requireFleet(typeof flags.fleet === 'string' ? flags.fleet : undefined);
29
48
  const { body: listBody } = await request('GET', `/fleets/${fleetId}/services`);
30
49
  const service = listBody.services.find((s) => s.name === name || s.id === name);
31
50
  if (!service) {
32
51
  throw new CliError(`No service called "${name}". Known: ${listBody.services.map((s) => s.name).join(', ') || 'none'}`, EXIT.usage);
33
52
  }
34
- if (!flags.yes && !flags.y && !(await confirmTeardown(service.name))) {
53
+ if (!flags.yes && !flags.y && !(await confirmTeardown(service))) {
35
54
  console.log(c.dim('Teardown cancelled.'));
36
55
  return;
37
56
  }
@@ -7,11 +7,14 @@ import { doctorCommand } from './doctor.js';
7
7
  import { upCommand } from './up.js';
8
8
  import { openCommand } from './open.js';
9
9
  import { downCommand } from './down.js';
10
- import { applyCommand, deployCommand, deploymentsCommand, initCommand, logsCommand, restartCommand, rollbackCommand, rescheduleCommand, servicesCommand, validateCommand, whereCommand, } from './services.js';
10
+ import { unpairCommand, agentCommand } from './unpair.js';
11
+ import { secretsCommand } from './secrets.js';
12
+ import { applyCommand, deployCommand, deploymentsCommand, initCommand, logsCommand, removeServiceCommand, restartCommand, rollbackCommand, rescheduleCommand, servicesCommand, validateCommand, whereCommand, } from './services.js';
11
13
  export const commands = {
12
14
  up: upCommand,
13
15
  open: openCommand,
14
16
  down: downCommand,
17
+ rm: removeServiceCommand,
15
18
  auth: authCommand,
16
19
  config: configCommand,
17
20
  use: useCommand,
@@ -31,4 +34,7 @@ export const commands = {
31
34
  rollback: rollbackCommand,
32
35
  events: eventsCommand,
33
36
  alerts: alertsCommand,
37
+ secrets: secretsCommand,
38
+ unpair: unpairCommand,
39
+ agent: agentCommand,
34
40
  };
@@ -56,8 +56,22 @@ export const nodesCommand = {
56
56
  throw new CliError(`This revokes ${node.name}'s credentials and removes it from the fleet.\n` +
57
57
  ` Anything pinned to it will have nowhere to run. Re-run with --force if that is intended.`, EXIT.usage);
58
58
  }
59
- await request('DELETE', `/fleets/${fleetId}/nodes/${node.id}`);
59
+ const { body } = await request('DELETE', `/fleets/${fleetId}/nodes/${node.id}`);
60
+ if (flags.json)
61
+ return console.log(JSON.stringify(body, null, 2));
60
62
  console.log(`${node.name} removed and its agent credentials revoked`);
63
+ for (const e of body.evicted ?? []) {
64
+ if (e.action === 'moved') {
65
+ console.log(c.dim(` ${e.service} → ${e.toNodeName ?? 'another node'}`));
66
+ }
67
+ else {
68
+ // Pinned and stranded services need saying out loud: the node is
69
+ // gone and these did not find a new home.
70
+ console.log(c.yellow(` ${e.service} could not move — ${e.reason ?? e.action}`));
71
+ }
72
+ }
73
+ console.log(c.dim(` To clean up that machine itself, run ` +
74
+ `${c.cyan('fleet unpair')}${c.dim(' on it.')}`));
61
75
  return;
62
76
  }
63
77
  throw new CliError('usage: fleet nodes [ls|pair|cordon|uncordon|rm]', EXIT.usage);
@@ -0,0 +1,117 @@
1
+ /**
2
+ * fleet secrets — the fleet credential store.
3
+ *
4
+ * The one rule this command exists to enforce: a secret value never appears in
5
+ * `argv`. Arguments land in shell history, in `ps` output for every user on the
6
+ * box, and in CI logs — so `fleet secrets set KEY hunter2` is deliberately not
7
+ * a supported spelling. The value comes from a pipe or from a prompt with the
8
+ * echo off, and nothing here ever prints one back.
9
+ */
10
+ import { request, requireFleet, CliError, EXIT } from '../api.js';
11
+ import { c, table, relativeTime } from '../render.js';
12
+ import { glyph } from '../ui.js';
13
+ import { askSecret, canPrompt } from '../prompt.js';
14
+ const KEY_PATTERN = /^[A-Z_][A-Z0-9_]{0,127}$/;
15
+ /**
16
+ * Read the value from a pipe when there is one, otherwise ask for it.
17
+ *
18
+ * The piped form is what a script or a password manager uses:
19
+ * pass show db/url | fleet secrets set DATABASE_URL
20
+ *
21
+ * Trailing newlines are stripped because every here-string and every `echo`
22
+ * adds one, and a credential with an invisible newline on the end fails
23
+ * authentication somewhere far away from here.
24
+ */
25
+ async function readValue(key) {
26
+ if (!process.stdin.isTTY) {
27
+ const chunks = [];
28
+ for await (const chunk of process.stdin)
29
+ chunks.push(Buffer.from(chunk));
30
+ const piped = Buffer.concat(chunks).toString('utf8').replace(/\r?\n$/, '');
31
+ if (piped)
32
+ return piped;
33
+ }
34
+ if (!canPrompt()) {
35
+ throw new CliError(`No value for ${key}. Pipe it in, or run this where there is a terminal to type into:\n` +
36
+ ` echo -n "value" | fleet secrets set ${key}`, EXIT.usage);
37
+ }
38
+ return askSecret(`${key}`, { hint: 'the value is not echoed and is not stored in shell history' });
39
+ }
40
+ async function resolveServiceId(fleetId, name) {
41
+ const { body } = await request('GET', `/fleets/${fleetId}/services`);
42
+ const match = body.services.find((s) => s.name === name || s.id === name);
43
+ if (!match) {
44
+ throw new CliError(`No service called "${name}". Known: ${body.services.map((s) => s.name).join(', ') || 'none'}`, EXIT.usage);
45
+ }
46
+ return match;
47
+ }
48
+ export const secretsCommand = {
49
+ async run(args, flags) {
50
+ const [sub, key] = args;
51
+ const service = typeof flags.service === 'string' ? flags.service : undefined;
52
+ /* ── list ──────────────────────────────────────────────────── */
53
+ if (!sub || sub === 'ls' || sub === 'list') {
54
+ const fleetId = await requireFleet(typeof flags.fleet === 'string' ? flags.fleet : undefined);
55
+ const { body } = await request('GET', `/fleets/${fleetId}/secrets`);
56
+ if (flags.json)
57
+ return console.log(JSON.stringify(body.secrets, null, 2));
58
+ if (!body.secrets.length) {
59
+ console.log('No secrets in this fleet.');
60
+ console.log(c.dim(' set one with `fleet secrets set DATABASE_URL`'));
61
+ return;
62
+ }
63
+ console.log(table(['key', 'scope', 'updated'], body.secrets.map((s) => [
64
+ s.key,
65
+ s.scope === 'service' ? `${c.dim('service:')}${s.service ?? '?'}` : c.dim('fleet'),
66
+ relativeTime(s.updatedAt),
67
+ ])));
68
+ console.log(c.dim(`\n ${body.secrets.length} stored. Values cannot be read back — only replaced.`));
69
+ return;
70
+ }
71
+ /* ── set ───────────────────────────────────────────────────── */
72
+ if (sub === 'set') {
73
+ if (!key)
74
+ throw new CliError('usage: fleet secrets set <KEY> [--service <name>]', EXIT.usage);
75
+ if (!KEY_PATTERN.test(key)) {
76
+ throw new CliError(`"${key}" is not a usable environment variable name.\n` +
77
+ ` Use upper snake case: A-Z, 0-9 and _, not starting with a digit.`, EXIT.usage);
78
+ }
79
+ // A third positional is almost always someone typing the value inline.
80
+ // Refuse it rather than accepting a credential into shell history.
81
+ if (args[2]) {
82
+ throw new CliError('Do not pass the value as an argument — it would be written to your shell history.\n' +
83
+ ` Pipe it: echo -n "value" | fleet secrets set ${key}\n` +
84
+ ` Or type it: fleet secrets set ${key}`, EXIT.usage);
85
+ }
86
+ const fleetId = await requireFleet(typeof flags.fleet === 'string' ? flags.fleet : undefined);
87
+ const target = service ? await resolveServiceId(fleetId, service) : null;
88
+ const value = await readValue(key);
89
+ const path = target
90
+ ? `/services/${target.id}/secrets/${encodeURIComponent(key)}`
91
+ : `/fleets/${fleetId}/secrets/${encodeURIComponent(key)}`;
92
+ const { body } = await request('PUT', path, { body: { value } });
93
+ const where = target ? ` for ${c.bold(target.name)}` : '';
94
+ console.log(`${glyph.ok} ${c.green(body.created ? 'stored' : 'replaced')} ${c.bold(key)}${where}`);
95
+ console.log(c.dim(' takes effect on the next deploy of any service that references it'));
96
+ return;
97
+ }
98
+ /* ── rm ────────────────────────────────────────────────────── */
99
+ if (sub === 'rm' || sub === 'remove' || sub === 'delete') {
100
+ if (!key)
101
+ throw new CliError('usage: fleet secrets rm <KEY> [--service <name>]', EXIT.usage);
102
+ const fleetId = await requireFleet(typeof flags.fleet === 'string' ? flags.fleet : undefined);
103
+ const target = service ? await resolveServiceId(fleetId, service) : null;
104
+ const path = target
105
+ ? `/services/${target.id}/secrets/${encodeURIComponent(key)}`
106
+ : `/fleets/${fleetId}/secrets/${encodeURIComponent(key)}`;
107
+ await request('DELETE', path);
108
+ const where = target ? ` override for ${c.bold(target.name)}` : '';
109
+ console.log(`${glyph.ok} ${c.yellow('removed')} ${c.bold(key)}${where}`);
110
+ console.log(c.dim(' services already running keep the value they started with until redeployed'));
111
+ return;
112
+ }
113
+ throw new CliError('usage: fleet secrets [ls]\n' +
114
+ ' fleet secrets set <KEY> [--service <name>]\n' +
115
+ ' fleet secrets rm <KEY> [--service <name>]', EXIT.usage);
116
+ },
117
+ };
@@ -2,7 +2,10 @@ import { readFile, writeFile, access } from 'node:fs/promises';
2
2
  import { join } from 'node:path';
3
3
  import { request, requireFleet, CliError, EXIT } from '../api.js';
4
4
  import { c, table, statusColour, keyValues, relativeTime, mb } from '../render.js';
5
- import { task, splash, glyph } from '../ui.js';
5
+ import { task, glyph } from '../ui.js';
6
+ import { withLadder } from '../ladder.js';
7
+ import { ask, canPrompt, confirm, selectOrThrow } from '../prompt.js';
8
+ import { DEPLOY_STEPS, follow, phaseWalker, } from '../progress.js';
6
9
  const manifestPath = (given) => given ?? 'fleet.yaml';
7
10
  async function readManifest(path) {
8
11
  try {
@@ -53,7 +56,13 @@ export const applyCommand = {
53
56
  },
54
57
  };
55
58
  export const servicesCommand = {
56
- async run(_args, flags) {
59
+ async run(args, flags) {
60
+ // `fleet services rm <name>` is the same action as `fleet rm <name>`;
61
+ // both spellings exist because one reads as a subcommand of the noun and
62
+ // the other as the short form an operator reaches for under pressure.
63
+ if (args[0] === 'rm' || args[0] === 'remove' || args[0] === 'delete') {
64
+ return removeServiceCommand.run(args.slice(1), flags);
65
+ }
57
66
  const fleetId = await requireFleet(typeof flags.fleet === 'string' ? flags.fleet : undefined);
58
67
  const { body } = await request('GET', `/fleets/${fleetId}/services`);
59
68
  if (flags.json)
@@ -173,16 +182,23 @@ export const deployCommand = {
173
182
  console.log(c.dim('Deployment cancelled. Re-run with --yes to skip confirmation.'));
174
183
  return;
175
184
  }
176
- const body = await splash(`deploying ${c.bold(service.name)}${gitSha ? c.dim(` at ${gitSha.slice(0, 7)}`) : ''}`, async () => (await request('POST', `/services/${service.id}/deploy`, { body: { gitSha } })).body, {
177
- // What the request involves, not a stage it has reached — the call is a
178
- // single synchronous POST and the CLI cannot see inside it.
179
- hints: [
180
- 'scoring every online node on headroom, reliability and load',
181
- 'building for every architecture an eligible node runs',
182
- 'the first multi-arch build is the slow one; layers cache after it',
183
- 'pushing the image to the fleet registry',
184
- ],
185
- done: (b) => `built and scheduled onto ${c.bold(b.placedOn.name)} ${c.dim(`score ${b.score?.toFixed(3)}`)}`,
185
+ const body = await withLadder(DEPLOY_STEPS, async (ladder) => {
186
+ const walker = phaseWalker(ladder);
187
+ const progress = follow(service.id, (p) => walker.apply(p), {
188
+ onUnavailable: () => ladder.note(c.dim('live progress unavailable; continuing with the deploy request')),
189
+ });
190
+ try {
191
+ const result = (await request('POST', `/services/${service.id}/deploy`, { body: { gitSha } })).body;
192
+ walker.finish(`scheduled onto ${result.placedOn.name}`);
193
+ return result;
194
+ }
195
+ finally {
196
+ await progress.stop();
197
+ }
198
+ }, {
199
+ mark: true,
200
+ title: `deploying ${service.name}${gitSha ? ` at ${gitSha.slice(0, 7)}` : ''}`,
201
+ onCancel: `deploy is still running on the control plane; inspect with fleet deployments ${service.name}`,
186
202
  });
187
203
  if (flags.json)
188
204
  return console.log(JSON.stringify(body, null, 2));
@@ -363,3 +379,52 @@ export const initCommand = {
363
379
  console.log(c.dim(`\n …or just run: fleet up`));
364
380
  },
365
381
  };
382
+ /**
383
+ * fleet rm <service> / fleet services rm <service> — permanently undeploy.
384
+ *
385
+ * Distinct from `fleet down`, which stops the workload but keeps the service
386
+ * definition so it can be redeployed. This removes the definition too, which
387
+ * is not recoverable from the control plane, so the confirmation is required
388
+ * rather than best-effort: a non-interactive caller must pass --yes explicitly
389
+ * instead of having silence taken as consent.
390
+ */
391
+ export const removeServiceCommand = {
392
+ async run(args, flags) {
393
+ // Before requireFleet, which reaches the control plane when no fleet is
394
+ // saved: a missing argument is a usage error and should not depend on the
395
+ // network being up to say so.
396
+ const [name] = args;
397
+ if (!name)
398
+ throw new CliError('usage: fleet rm <service> [--yes]', EXIT.usage);
399
+ const fleetId = await requireFleet(typeof flags.fleet === 'string' ? flags.fleet : undefined);
400
+ const service = await findService(fleetId, name);
401
+ const confirmed = flags.yes === true || flags.y === true;
402
+ if (!confirmed) {
403
+ if (!process.stdin.isTTY) {
404
+ throw new CliError(`Deleting "${service.name}" is permanent. Re-run with --yes to confirm.`, EXIT.usage);
405
+ }
406
+ const { createInterface } = await import('node:readline/promises');
407
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
408
+ try {
409
+ console.log(`\n This permanently deletes ${c.bold(service.name)} from the fleet:` +
410
+ `\n ${c.dim('·')} its containers are removed from the node it runs on` +
411
+ `\n ${c.dim('·')} its deployment history and URL are released` +
412
+ `\n ${c.dim('To stop it without deleting it, use `fleet down` instead.')}\n`);
413
+ const ans = await rl.question(` Type the service name to confirm [${c.dim(service.name)}]: `);
414
+ if (ans.trim() !== service.name) {
415
+ console.log(c.dim('Delete cancelled.'));
416
+ return;
417
+ }
418
+ }
419
+ finally {
420
+ rl.close();
421
+ }
422
+ }
423
+ const { body } = await request('DELETE', `/services/${service.id}`);
424
+ if (flags.json)
425
+ return console.log(JSON.stringify(body, null, 2));
426
+ console.log(`${glyph.ok} ${c.red('deleted')} ${c.bold(body.service)}`);
427
+ if (body.note)
428
+ console.log(c.dim(` ${body.note}`));
429
+ },
430
+ };
@@ -0,0 +1,189 @@
1
+ /**
2
+ * fleet unpair — take *this* machine out of a fleet, from the machine itself.
3
+ *
4
+ * The counterpart to `fleet nodes rm`, which is run by an operator elsewhere.
5
+ * This one has to be run on the host because only the host can stop its own
6
+ * daemon, remove its own containers, and delete its own credential file.
7
+ *
8
+ * Order matters and is the whole point of the command:
9
+ *
10
+ * 1. read local state — the node id is needed before the file is deleted
11
+ * 2. stop the agent, and disable it so a reboot does not resurrect it
12
+ * 3. tell the control plane, which reschedules the workloads elsewhere
13
+ * 4. remove the local containers
14
+ * 5. wipe the credential
15
+ *
16
+ * The agent is stopped first because it reconciles on a timer: with it still
17
+ * running, containers removed in step 4 are recreated seconds later, and its
18
+ * runtime check may relaunch Docker underneath you.
19
+ */
20
+ import { rm, access, readFile } from 'node:fs/promises';
21
+ import { join } from 'node:path';
22
+ import { homedir } from 'node:os';
23
+ import { exec } from 'node:child_process';
24
+ import { promisify } from 'node:util';
25
+ import { request, CliError, EXIT } from '../api.js';
26
+ import { c } from '../render.js';
27
+ import { glyph } from '../ui.js';
28
+ const execAsync = promisify(exec);
29
+ async function exists(path) {
30
+ try {
31
+ await access(path);
32
+ return true;
33
+ }
34
+ catch {
35
+ return false;
36
+ }
37
+ }
38
+ /**
39
+ * Where the agent keeps its state, mirroring state.DefaultPath() in the agent
40
+ * and the STATE_DIR logic in scripts/install.sh. These are per-platform and
41
+ * genuinely different — guessing /var/lib/fleet-os everywhere would report a
42
+ * successful wipe while leaving the credential on disk.
43
+ */
44
+ function stateDir() {
45
+ const override = process.env.FLEET_STATE_DIR;
46
+ if (override)
47
+ return override;
48
+ if (process.platform === 'win32')
49
+ return join(homedir(), '.fleet-os');
50
+ if (process.platform === 'darwin')
51
+ return join(homedir(), 'Library', 'Application Support', 'fleet-os');
52
+ return '/var/lib/fleet-os';
53
+ }
54
+ /** Best-effort shell step: report what happened, never abort the teardown. */
55
+ async function step(label, fn) {
56
+ process.stdout.write(` ${glyph.info} ${label}… `);
57
+ try {
58
+ const detail = await fn();
59
+ console.log(detail ? c.dim(detail) : c.green('done'));
60
+ }
61
+ catch (err) {
62
+ console.log(c.yellow(`skipped — ${err.message.split('\n')[0]}`));
63
+ }
64
+ }
65
+ async function confirm(state) {
66
+ const who = state?.name ? c.bold(state.name) : 'this machine';
67
+ console.log(` This removes ${who} from its fleet:\n` +
68
+ ` ${c.dim('·')} the background agent is stopped and disabled\n` +
69
+ ` ${c.dim('·')} Fleet containers on this host are removed\n` +
70
+ ` ${c.dim('·')} its credentials are deleted and revoked\n` +
71
+ ` ${c.dim('Services running here are rescheduled onto other nodes where possible.')}\n`);
72
+ if (!process.stdin.isTTY)
73
+ return false;
74
+ const { createInterface } = await import('node:readline/promises');
75
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
76
+ try {
77
+ const ans = await rl.question(' Unpair this machine? [y/N] ');
78
+ return ans.trim().toLowerCase() === 'y';
79
+ }
80
+ finally {
81
+ rl.close();
82
+ }
83
+ }
84
+ export const unpairCommand = {
85
+ async run(_args, flags) {
86
+ console.log(`\n ${c.bold('Unpair this machine')}\n`);
87
+ const dir = stateDir();
88
+ const statePath = join(dir, 'agent.json');
89
+ // 1. Read the identity before anything destroys it.
90
+ let state = null;
91
+ if (await exists(statePath)) {
92
+ try {
93
+ state = JSON.parse(await readFile(statePath, 'utf8'));
94
+ }
95
+ catch {
96
+ console.log(c.dim(` ${glyph.warn} ${statePath} is unreadable — continuing with local cleanup only`));
97
+ }
98
+ }
99
+ if (!state && !flags.force && !flags.f) {
100
+ throw new CliError(`No agent state at ${statePath} — this machine does not look paired.\n` +
101
+ ` Re-run with --force to clean up anyway.`, EXIT.usage);
102
+ }
103
+ const confirmed = flags.yes === true || flags.y === true;
104
+ if (!confirmed && !(await confirm(state))) {
105
+ console.log(c.dim('Unpair cancelled.'));
106
+ return;
107
+ }
108
+ const isWindows = process.platform === 'win32';
109
+ const isMac = process.platform === 'darwin';
110
+ // 2. Stop the agent first. Both the systemd unit (Restart=always) and the
111
+ // launchd job restart the process on their own, so stopping without
112
+ // disabling buys about five seconds.
113
+ await step('Stopping the Fleet agent', async () => {
114
+ if (isWindows) {
115
+ await execAsync('taskkill /IM fleet-agent.exe /F').catch(() => { });
116
+ return 'stopped';
117
+ }
118
+ if (isMac) {
119
+ const plist = join(homedir(), 'Library', 'LaunchAgents', 'dev.fleet-os.agent.plist');
120
+ if (await exists(plist)) {
121
+ await execAsync(`launchctl unload ${JSON.stringify(plist)}`).catch(() => { });
122
+ await rm(plist, { force: true }).catch(() => { });
123
+ }
124
+ await execAsync('pkill -f fleet-agent').catch(() => { });
125
+ return 'launchd job unloaded and removed';
126
+ }
127
+ // Linux: disable as well as stop, or the unit comes back on reboot. The
128
+ // unit is a system unit installed with sudo, so it takes sudo to remove.
129
+ await execAsync('systemctl disable --now fleet-agent').catch(() => execAsync('sudo -n systemctl disable --now fleet-agent')).catch(() => execAsync('pkill -f fleet-agent'));
130
+ return 'systemd unit stopped and disabled';
131
+ });
132
+ // 3. Tell the control plane, so services running here are rescheduled onto
133
+ // other nodes *before* the local containers go away. Uses the operator's
134
+ // CLI session: node removal is an owner action, and an agent token
135
+ // deliberately cannot delete its own node.
136
+ if (state?.node_id && state?.fleet_id) {
137
+ await step('Removing this node from the fleet', async () => {
138
+ const { body } = await request('DELETE', `/fleets/${state.fleet_id}/nodes/${state.node_id}`);
139
+ const moved = body.evicted?.filter((e) => e.action === 'moved').length ?? 0;
140
+ const held = body.evicted?.filter((e) => e.action !== 'moved') ?? [];
141
+ const parts = [`removed as ${body.removed?.name ?? state.name ?? 'node'}`];
142
+ if (moved)
143
+ parts.push(`${moved} service(s) rescheduled`);
144
+ if (held.length)
145
+ parts.push(`${held.length} could not move (${held.map((h) => h.service).join(', ')})`);
146
+ return parts.join(', ');
147
+ });
148
+ }
149
+ else {
150
+ console.log(c.dim(` ${glyph.warn} No node id in local state — skipping control plane removal.`) +
151
+ c.dim(`\n Remove it from another machine with: `) +
152
+ c.cyan('fleet nodes rm <name> --force'));
153
+ }
154
+ // 4. Remove the local containers. Only Fleet's own: the name filter matches
155
+ // what the agent creates, and anything else on this host is not ours.
156
+ await step('Removing Fleet containers', async () => {
157
+ const { stdout } = await execAsync('docker ps -aq --filter "name=fleet-"');
158
+ const ids = stdout.trim().split('\n').filter(Boolean);
159
+ if (!ids.length)
160
+ return 'none running';
161
+ await execAsync(`docker rm -f ${ids.join(' ')}`);
162
+ return `${ids.length} removed`;
163
+ });
164
+ // 5. Wipe the credential last, so a failure earlier on leaves the machine
165
+ // in a state this command can be run against again.
166
+ await step(`Wiping credentials in ${dir}`, async () => {
167
+ if (!(await exists(dir)))
168
+ return 'nothing to remove';
169
+ await rm(dir, { recursive: true, force: true });
170
+ return 'deleted';
171
+ });
172
+ console.log(`\n${glyph.ok} ${c.green(c.bold('This machine is unpaired'))}`);
173
+ console.log(c.dim(' Docker is left running and is yours to stop or start as you like.'));
174
+ console.log(c.dim(' To pair it again: ') + c.cyan('fleet nodes pair') + c.dim(' on your control plane\n'));
175
+ },
176
+ };
177
+ /**
178
+ * `fleet agent <sub>` — host-local operations, grouped under the thing they act
179
+ * on. Only unpair for now; `fleet unpair` is the shorter spelling of the same
180
+ * action, since it is the one people reach for.
181
+ */
182
+ export const agentCommand = {
183
+ async run(args, flags) {
184
+ const [sub, ...rest] = args;
185
+ if (sub === 'unpair')
186
+ return unpairCommand.run(rest, flags);
187
+ throw new CliError('usage: fleet agent unpair [--yes]', EXIT.usage);
188
+ },
189
+ };
@@ -11,7 +11,9 @@ import { readFile, writeFile, access } from 'node:fs/promises';
11
11
  import { join } from 'node:path';
12
12
  import { request, requireFleet, CliError, EXIT } from '../api.js';
13
13
  import { c } from '../render.js';
14
- import { task, splash, glyph } from '../ui.js';
14
+ import { task, glyph } from '../ui.js';
15
+ import { withLadder } from '../ladder.js';
16
+ import { DEPLOY_STEPS, follow, phaseWalker } from '../progress.js';
15
17
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
16
18
  export const upCommand = {
17
19
  async run(args, flags) {
@@ -63,14 +65,23 @@ export const upCommand = {
63
65
  }
64
66
  // ── Step 4: deploy ────────────────────────────────────────────────
65
67
  const gitSha = typeof flags.sha === 'string' ? flags.sha : undefined;
66
- const deployResult = await splash(`deploying ${c.bold(service.name)}`, async () => (await request('POST', `/services/${service.id}/deploy`, { body: { gitSha } })).body, {
67
- hints: [
68
- 'scoring every online node on headroom, reliability and load',
69
- 'building for every architecture an eligible node runs',
70
- 'the first multi-arch build is the slow one; layers cache after it',
71
- 'pushing the image to the fleet registry',
72
- ],
73
- done: (b) => `built and scheduled onto ${c.bold(b.placedOn.name)} ${c.dim(`score ${b.score?.toFixed(3)}`)}`,
68
+ const deployResult = await withLadder(DEPLOY_STEPS, async (ladder) => {
69
+ const walker = phaseWalker(ladder);
70
+ const progress = follow(service.id, (p) => walker.apply(p), {
71
+ onUnavailable: () => ladder.note(c.dim('live progress unavailable; continuing with the deploy request')),
72
+ });
73
+ try {
74
+ const result = (await request('POST', `/services/${service.id}/deploy`, { body: { gitSha } })).body;
75
+ walker.finish(`scheduled onto ${result.placedOn.name}`);
76
+ return result;
77
+ }
78
+ finally {
79
+ await progress.stop();
80
+ }
81
+ }, {
82
+ mark: true,
83
+ title: `deploying ${service.name}`,
84
+ onCancel: `deploy is still running on the control plane; inspect with fleet deployments ${service.name}`,
74
85
  });
75
86
  for (const w of deployResult.warnings ?? []) {
76
87
  console.log(`${glyph.warn} ${c.yellow('warning')} ${w}`);
package/dist/index.js CHANGED
@@ -43,13 +43,18 @@ const GROUPS = [
43
43
  'operating',
44
44
  [
45
45
  ['down <service>', 'Stop and tear down a service deployment'],
46
+ ['rm <service>', 'Permanently delete a service from the fleet'],
46
47
  ['validate [file]', 'Check a fleet.yaml without applying it'],
47
48
  ['reschedule <service>', 'Force a service to move'],
48
49
  ['restart <service>', 'Replace the current release on its node'],
49
50
  ['rollback <service> [release]', 'Restore the previous or selected release'],
51
+ ['secrets', 'List the fleet secret store'],
52
+ ['secrets set <KEY>', 'Store a credential; the value is never echoed or logged'],
53
+ ['secrets rm <KEY>', 'Remove a stored credential'],
50
54
  ['nodes cordon <name>', 'Stop scheduling new work onto a node'],
51
55
  ['nodes uncordon <name>', 'Allow scheduling again'],
52
56
  ['nodes rm <name>', 'Revoke and remove a node'],
57
+ ['unpair', 'Remove this machine from its fleet, run on the machine'],
53
58
  ['alerts', 'List, add, and test alert rules'],
54
59
  ['auth login|logout|whoami', 'Sign in to a control plane'],
55
60
  ],