@yadurajfleetos/cli 0.1.7 → 0.1.8

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,13 @@ 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 { applyCommand, deployCommand, deploymentsCommand, initCommand, logsCommand, removeServiceCommand, restartCommand, rollbackCommand, rescheduleCommand, servicesCommand, validateCommand, whereCommand, } from './services.js';
11
12
  export const commands = {
12
13
  up: upCommand,
13
14
  open: openCommand,
14
15
  down: downCommand,
16
+ rm: removeServiceCommand,
15
17
  auth: authCommand,
16
18
  config: configCommand,
17
19
  use: useCommand,
@@ -31,4 +33,6 @@ export const commands = {
31
33
  rollback: rollbackCommand,
32
34
  events: eventsCommand,
33
35
  alerts: alertsCommand,
36
+ unpair: unpairCommand,
37
+ agent: agentCommand,
34
38
  };
@@ -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);
@@ -53,7 +53,13 @@ export const applyCommand = {
53
53
  },
54
54
  };
55
55
  export const servicesCommand = {
56
- async run(_args, flags) {
56
+ async run(args, flags) {
57
+ // `fleet services rm <name>` is the same action as `fleet rm <name>`;
58
+ // both spellings exist because one reads as a subcommand of the noun and
59
+ // the other as the short form an operator reaches for under pressure.
60
+ if (args[0] === 'rm' || args[0] === 'remove' || args[0] === 'delete') {
61
+ return removeServiceCommand.run(args.slice(1), flags);
62
+ }
57
63
  const fleetId = await requireFleet(typeof flags.fleet === 'string' ? flags.fleet : undefined);
58
64
  const { body } = await request('GET', `/fleets/${fleetId}/services`);
59
65
  if (flags.json)
@@ -363,3 +369,52 @@ export const initCommand = {
363
369
  console.log(c.dim(`\n …or just run: fleet up`));
364
370
  },
365
371
  };
372
+ /**
373
+ * fleet rm <service> / fleet services rm <service> — permanently undeploy.
374
+ *
375
+ * Distinct from `fleet down`, which stops the workload but keeps the service
376
+ * definition so it can be redeployed. This removes the definition too, which
377
+ * is not recoverable from the control plane, so the confirmation is required
378
+ * rather than best-effort: a non-interactive caller must pass --yes explicitly
379
+ * instead of having silence taken as consent.
380
+ */
381
+ export const removeServiceCommand = {
382
+ async run(args, flags) {
383
+ // Before requireFleet, which reaches the control plane when no fleet is
384
+ // saved: a missing argument is a usage error and should not depend on the
385
+ // network being up to say so.
386
+ const [name] = args;
387
+ if (!name)
388
+ throw new CliError('usage: fleet rm <service> [--yes]', EXIT.usage);
389
+ const fleetId = await requireFleet(typeof flags.fleet === 'string' ? flags.fleet : undefined);
390
+ const service = await findService(fleetId, name);
391
+ const confirmed = flags.yes === true || flags.y === true;
392
+ if (!confirmed) {
393
+ if (!process.stdin.isTTY) {
394
+ throw new CliError(`Deleting "${service.name}" is permanent. Re-run with --yes to confirm.`, EXIT.usage);
395
+ }
396
+ const { createInterface } = await import('node:readline/promises');
397
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
398
+ try {
399
+ console.log(`\n This permanently deletes ${c.bold(service.name)} from the fleet:` +
400
+ `\n ${c.dim('·')} its containers are removed from the node it runs on` +
401
+ `\n ${c.dim('·')} its deployment history and URL are released` +
402
+ `\n ${c.dim('To stop it without deleting it, use `fleet down` instead.')}\n`);
403
+ const ans = await rl.question(` Type the service name to confirm [${c.dim(service.name)}]: `);
404
+ if (ans.trim() !== service.name) {
405
+ console.log(c.dim('Delete cancelled.'));
406
+ return;
407
+ }
408
+ }
409
+ finally {
410
+ rl.close();
411
+ }
412
+ }
413
+ const { body } = await request('DELETE', `/services/${service.id}`);
414
+ if (flags.json)
415
+ return console.log(JSON.stringify(body, null, 2));
416
+ console.log(`${glyph.ok} ${c.red('deleted')} ${c.bold(body.service)}`);
417
+ if (body.note)
418
+ console.log(c.dim(` ${body.note}`));
419
+ },
420
+ };
@@ -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
+ };
package/dist/index.js CHANGED
@@ -43,6 +43,7 @@ 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'],
@@ -50,6 +51,7 @@ const GROUPS = [
50
51
  ['nodes cordon <name>', 'Stop scheduling new work onto a node'],
51
52
  ['nodes uncordon <name>', 'Allow scheduling again'],
52
53
  ['nodes rm <name>', 'Revoke and remove a node'],
54
+ ['unpair', 'Remove this machine from its fleet, run on the machine'],
53
55
  ['alerts', 'List, add, and test alert rules'],
54
56
  ['auth login|logout|whoami', 'Sign in to a control plane'],
55
57
  ],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yadurajfleetos/cli",
3
- "version": "0.1.7",
3
+ "version": "0.1.8",
4
4
  "description": "Fleet OS command-line interface for deploying and orchestrating services on user-owned hardware",
5
5
  "type": "module",
6
6
  "license": "MIT",