@yadurajfleetos/cli 0.1.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.
package/README.md ADDED
@@ -0,0 +1,86 @@
1
+ # Fleet OS CLI (`fleet`)
2
+
3
+ The command-line interface for **Fleet OS** — git-push deploys onto hardware you already own.
4
+
5
+ ## Installation
6
+
7
+ ### Via npm (Global)
8
+ ```bash
9
+ npm install -g @yadurajfleetos/cli
10
+ ```
11
+
12
+ ### Run Directly via npx
13
+ ```bash
14
+ npx @yadurajfleetos/cli --help
15
+ ```
16
+
17
+ ### Build and Install from Source
18
+ ```bash
19
+ git clone https://github.com/YadurajManu/BhaiMerra.git fleet-os
20
+ cd fleet-os/cli
21
+ npm install
22
+ npm run build
23
+ npm link
24
+ ```
25
+
26
+ ---
27
+
28
+ ## Quick Start
29
+
30
+ ### 1. Sign In
31
+ ```bash
32
+ # Hosted control plane (default: https://fleetapi.plastikworld.xyz)
33
+ fleet auth login
34
+
35
+ # Self-hosted control plane
36
+ fleet auth login --api https://fleetapi.example.com
37
+ ```
38
+
39
+ ### 2. Pair a Machine
40
+ ```bash
41
+ fleet nodes pair
42
+ ```
43
+ Run the generated `curl -fsSL ... | sh` command on the machine you want to add to your fleet.
44
+
45
+ ### 3. Check Fleet Status & Health
46
+ ```bash
47
+ fleet status
48
+ fleet doctor
49
+ ```
50
+
51
+ ### 4. Deploy a Service
52
+ ```bash
53
+ # Validate manifest
54
+ fleet validate
55
+
56
+ # Apply services to fleet
57
+ fleet apply
58
+
59
+ # Plan and deploy a service
60
+ fleet deploy web
61
+ ```
62
+
63
+ ---
64
+
65
+ ## Command Reference
66
+
67
+ | Command | Description |
68
+ | :--- | :--- |
69
+ | `fleet auth login` | Sign in and save secure local session |
70
+ | `fleet config show` | Show active control plane and selected fleet |
71
+ | `fleet use <fleet>` | Choose default fleet |
72
+ | `fleet nodes pair` | Generate single-use pairing token for a new node |
73
+ | `fleet nodes` | List nodes, status, and resource usage |
74
+ | `fleet doctor` | Diagnostic health check across cluster |
75
+ | `fleet apply [file]` | Apply `fleet.yaml` manifest |
76
+ | `fleet deploy <svc>` | Plan, build, schedule, and roll out a service |
77
+ | `fleet logs <svc> -f` | Follow live container logs |
78
+ | `fleet restart <svc>` | Restart a service |
79
+ | `fleet rollback <svc>` | Roll back to previous deployment |
80
+ | `fleet where <svc>` | Explain scheduler placement and candidate scores |
81
+
82
+ ---
83
+
84
+ ## License
85
+
86
+ MIT License.
package/dist/api.js ADDED
@@ -0,0 +1,97 @@
1
+ import { loadProfile, saveProfile } from './config.js';
2
+ export class CliError extends Error {
3
+ exitCode;
4
+ detail;
5
+ constructor(message, exitCode = 1, detail) {
6
+ super(message);
7
+ this.exitCode = exitCode;
8
+ this.detail = detail;
9
+ }
10
+ }
11
+ /** Exit codes are a contract; scripts branch on them (docs/cli reference). */
12
+ export const EXIT = {
13
+ ok: 0,
14
+ failure: 1,
15
+ usage: 2,
16
+ noEligibleNode: 3,
17
+ healthCheckFailed: 4,
18
+ };
19
+ export async function request(method, path, opts = {}) {
20
+ const profile = opts.profile ?? (await loadProfile());
21
+ if (!profile.api) {
22
+ throw new CliError('No control plane URL is configured. Run `fleet auth login --api https://your-api-host` or set FLEET_API.', EXIT.usage);
23
+ }
24
+ if (opts.auth !== false && !profile.accessToken) {
25
+ throw new CliError('Not signed in. Run `fleet auth login` first.', EXIT.usage);
26
+ }
27
+ const send = async (token) => fetch(profile.api.replace(/\/+$/, '') + path, {
28
+ method,
29
+ headers: {
30
+ ...(token ? { authorization: `Bearer ${token}` } : {}),
31
+ ...(opts.body ? { 'content-type': 'application/json' } : {}),
32
+ },
33
+ body: opts.body ? JSON.stringify(opts.body) : undefined,
34
+ signal: AbortSignal.timeout(20 * 60_000),
35
+ });
36
+ let res;
37
+ try {
38
+ res = await send(profile.accessToken);
39
+ }
40
+ catch (err) {
41
+ throw new CliError(`Could not reach ${profile.api}. Is the control plane running?\n ${String(err)}`, EXIT.failure);
42
+ }
43
+ // Access tokens are short-lived; refresh once and retry rather than making
44
+ // the user log in again mid-command.
45
+ if (res.status === 401) {
46
+ if (profile.refreshToken) {
47
+ try {
48
+ const refreshed = await fetch(profile.api.replace(/\/+$/, '') + '/auth/refresh', {
49
+ method: 'POST',
50
+ headers: { 'content-type': 'application/json' },
51
+ body: JSON.stringify({ refreshToken: profile.refreshToken }),
52
+ signal: AbortSignal.timeout(15_000),
53
+ });
54
+ if (refreshed.ok) {
55
+ const tokens = (await refreshed.json());
56
+ await saveProfile({ ...profile, ...tokens });
57
+ res = await send(tokens.accessToken);
58
+ }
59
+ }
60
+ catch {
61
+ // Fall through to a deliberate, actionable session-expired message.
62
+ // A network error while refreshing must not turn into a misleading
63
+ // "invalid token" response from the original request.
64
+ }
65
+ }
66
+ if (res.status === 401) {
67
+ throw new CliError('Your Fleet session has expired. Run `fleet auth login` to sign in again.', EXIT.usage);
68
+ }
69
+ }
70
+ const text = await res.text();
71
+ let body;
72
+ try {
73
+ body = JSON.parse(text);
74
+ }
75
+ catch {
76
+ body = text;
77
+ }
78
+ if (res.status >= 400) {
79
+ const err = body.error;
80
+ throw new CliError(err?.message ?? `Request failed (${res.status})`, err?.code === 'no_eligible_node' ? EXIT.noEligibleNode : EXIT.failure, err?.detail);
81
+ }
82
+ return { status: res.status, body: body };
83
+ }
84
+ export async function requireFleet(explicit) {
85
+ if (explicit)
86
+ return explicit;
87
+ const profile = await loadProfile();
88
+ if (profile.fleetId)
89
+ return profile.fleetId;
90
+ const { body } = await request('GET', '/fleets');
91
+ if (body.fleets.length === 1)
92
+ return body.fleets[0].id;
93
+ if (!body.fleets.length)
94
+ throw new CliError('You have no fleets yet.', EXIT.usage);
95
+ throw new CliError(`You are in several fleets. Pass --fleet <id> or run \`fleet use <name>\`:\n` +
96
+ body.fleets.map((f) => ` ${f.name} ${f.id}`).join('\n'), EXIT.usage);
97
+ }
package/dist/args.js ADDED
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Minimal argument parser: no dependency, and the flag set is small and
3
+ * stable. Lives apart from the entrypoint so it can be tested without the
4
+ * entrypoint running.
5
+ */
6
+ export function parseArgs(argv) {
7
+ const positional = [];
8
+ const flags = {};
9
+ for (let i = 0; i < argv.length; i++) {
10
+ const arg = argv[i];
11
+ if (!arg.startsWith('-')) {
12
+ positional.push(arg);
13
+ continue;
14
+ }
15
+ const name = arg.replace(/^--?/, '');
16
+ const next = argv[i + 1];
17
+ // A flag followed by a non-flag takes it as a value; otherwise boolean.
18
+ if (next !== undefined && !next.startsWith('-')) {
19
+ flags[name] = next;
20
+ i++;
21
+ }
22
+ else {
23
+ flags[name] = true;
24
+ }
25
+ }
26
+ return { positional, flags };
27
+ }
@@ -0,0 +1,53 @@
1
+ import { request, requireFleet, CliError, EXIT } from '../api.js';
2
+ import { c, table } from '../render.js';
3
+ export const alertsCommand = {
4
+ async run(args, flags) {
5
+ const fleetId = await requireFleet(typeof flags.fleet === 'string' ? flags.fleet : undefined);
6
+ const [sub] = args;
7
+ if (!sub || sub === 'ls' || sub === 'list') {
8
+ const { body } = await request('GET', `/fleets/${fleetId}/alert-rules`);
9
+ if (flags.json)
10
+ return console.log(JSON.stringify(body.rules, null, 2));
11
+ if (!body.rules.length) {
12
+ console.log('No alert rules. Failover will happen silently — add one with `fleet alerts add`.');
13
+ return;
14
+ }
15
+ console.log(table(['channel', 'target', 'events', 'enabled'], body.rules.map((r) => [
16
+ r.channelType,
17
+ r.target,
18
+ r.eventTypes.length ? r.eventTypes.join(', ') : c.dim('everything'),
19
+ r.enabled ? c.green('yes') : c.dim('no'),
20
+ ])));
21
+ return;
22
+ }
23
+ if (sub === 'add') {
24
+ const channelType = typeof flags.channel === 'string' ? flags.channel : 'webhook';
25
+ const url = typeof flags.url === 'string' ? flags.url : undefined;
26
+ const to = typeof flags.to === 'string' ? flags.to : undefined;
27
+ const secret = typeof flags.secret === 'string' ? flags.secret : undefined;
28
+ const eventTypes = typeof flags.events === 'string' ? flags.events.split(',').map((s) => s.trim()) : [];
29
+ if (!url && !to) {
30
+ throw new CliError('usage: fleet alerts add --channel webhook|discord|slack --url <url> [--secret <s>]\n' +
31
+ ' fleet alerts add --channel email --to you@example.com', EXIT.usage);
32
+ }
33
+ await request('POST', `/fleets/${fleetId}/alert-rules`, {
34
+ body: { channelType, url, to, secret, eventTypes },
35
+ });
36
+ console.log(`${c.green('added')} ${channelType} alert rule`);
37
+ console.log(c.dim(' verify it with `fleet alerts test` before you need it'));
38
+ return;
39
+ }
40
+ if (sub === 'test') {
41
+ const { body } = await request('POST', `/fleets/${fleetId}/alert-rules/test`);
42
+ if (!body.results.length)
43
+ return console.log('no alert rules to test');
44
+ for (const r of body.results) {
45
+ console.log(r.ok ? `${c.green('ok')} ${r.channel}` : `${c.red('fail')} ${r.channel} ${r.error}`);
46
+ }
47
+ if (body.delivered !== body.results.length)
48
+ process.exit(EXIT.failure);
49
+ return;
50
+ }
51
+ throw new CliError('usage: fleet alerts [ls|add|test]', EXIT.usage);
52
+ },
53
+ };
@@ -0,0 +1,101 @@
1
+ import { createInterface } from 'node:readline/promises';
2
+ import { request, CliError, EXIT } from '../api.js';
3
+ import { loadProfile, saveProfile, configLocation } from '../config.js';
4
+ import { c, keyValues } from '../render.js';
5
+ import { banner } from '../mark.js';
6
+ import { glyph, rule, task } from '../ui.js';
7
+ async function prompt(question, silent = false) {
8
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
9
+ if (!silent) {
10
+ const answer = await rl.question(question);
11
+ rl.close();
12
+ return answer.trim();
13
+ }
14
+ // Passwords must not land in the terminal scrollback or a screen recording.
15
+ const stdout = process.stdout;
16
+ process.stdout.write(question);
17
+ const rlAny = rl;
18
+ rlAny._writeToOutput = () => { };
19
+ const answer = await rl.question('');
20
+ rl.close();
21
+ process.stdout.write('\n');
22
+ void stdout;
23
+ return answer.trim();
24
+ }
25
+ async function requiredPrompt(label, opts = {}) {
26
+ if (opts.hint)
27
+ console.log(c.dim(` ${opts.hint}`));
28
+ const value = await prompt(` ${c.dim(label.padEnd(18))}`, opts.silent);
29
+ if (!value)
30
+ throw new CliError(`${label.trim()} is required.`, EXIT.usage);
31
+ return value;
32
+ }
33
+ function validApi(value) {
34
+ try {
35
+ const url = new URL(value);
36
+ if (!['http:', 'https:'].includes(url.protocol))
37
+ throw new Error('scheme');
38
+ return url.toString().replace(/\/+$/, '');
39
+ }
40
+ catch {
41
+ throw new CliError('Control plane URL must begin with http:// or https://', EXIT.usage);
42
+ }
43
+ }
44
+ export const authCommand = {
45
+ async run(args, flags) {
46
+ const [sub] = args;
47
+ const profile = await loadProfile();
48
+ if (typeof flags.api === 'string')
49
+ profile.api = flags.api;
50
+ switch (sub) {
51
+ case 'login': {
52
+ const interactive = !flags.email && !flags.password;
53
+ if (interactive) {
54
+ console.log(banner('secure control-plane sign in'));
55
+ console.log(`\n${rule('sign in')}`);
56
+ }
57
+ if (!profile.api) {
58
+ profile.api = validApi(await requiredPrompt('control plane URL', {
59
+ hint: 'Example: https://fleetapi.yourdomain.com',
60
+ }));
61
+ }
62
+ if (interactive) {
63
+ console.log(`${c.dim(' control plane ')}${c.cyan(profile.api)}`);
64
+ console.log();
65
+ }
66
+ const email = (typeof flags.email === 'string' ? flags.email : '') ||
67
+ (await requiredPrompt('email'));
68
+ const password = (typeof flags.password === 'string' ? flags.password : '') ||
69
+ (await requiredPrompt('password', { silent: true, hint: 'Password is hidden while you type.' }));
70
+ const body = await task('verifying credentials', async () => (await request('POST', '/auth/login', { body: { email, password }, auth: false, profile })).body, { hints: ['the control plane never stores your password in this CLI'] });
71
+ await saveProfile({
72
+ ...profile,
73
+ accessToken: body.accessToken,
74
+ refreshToken: body.refreshToken,
75
+ });
76
+ console.log(`\n${glyph.ok} ${c.signal('signed in')} ${body.user.email}`);
77
+ console.log(c.dim(` profile saved to ${configLocation()}`));
78
+ console.log(c.dim(' next: fleet status'));
79
+ return;
80
+ }
81
+ case 'logout': {
82
+ await saveProfile({ api: profile.api });
83
+ console.log('signed out');
84
+ return;
85
+ }
86
+ case 'whoami': {
87
+ const { body } = await request('GET', '/auth/me', { profile });
88
+ if (flags.json)
89
+ return console.log(JSON.stringify(body, null, 2));
90
+ console.log(keyValues([
91
+ ['email', body.user.email],
92
+ ['control plane', profile.api],
93
+ ...body.orgs.map((o) => [o.orgName, `${o.role} · ${o.plan}`]),
94
+ ]));
95
+ return;
96
+ }
97
+ default:
98
+ throw new CliError('usage: fleet auth login|logout|whoami', EXIT.usage);
99
+ }
100
+ },
101
+ };
@@ -0,0 +1,51 @@
1
+ import { CliError, EXIT, request } from '../api.js';
2
+ import { loadProfile, saveProfile } from '../config.js';
3
+ import { c, keyValues } from '../render.js';
4
+ import { glyph, rule, task } from '../ui.js';
5
+ /** Keep credentials out of normal command output and screen recordings. */
6
+ function profileRows(profile) {
7
+ return [
8
+ ['control plane', profile.api || c.yellow('not configured')],
9
+ ['fleet', profile.fleetName ?? profile.fleetId ?? c.yellow('not selected')],
10
+ ['signed in', profile.accessToken ? c.green('yes') : c.yellow('no')],
11
+ ];
12
+ }
13
+ export const configCommand = {
14
+ async run(args, flags) {
15
+ const [sub = 'show'] = args;
16
+ if (sub !== 'show')
17
+ throw new CliError('usage: fleet config show', EXIT.usage);
18
+ const profile = await loadProfile();
19
+ if (flags.json) {
20
+ return console.log(JSON.stringify({
21
+ api: profile.api || null,
22
+ fleetId: profile.fleetId ?? null,
23
+ fleetName: profile.fleetName ?? null,
24
+ signedIn: Boolean(profile.accessToken),
25
+ }, null, 2));
26
+ }
27
+ console.log(keyValues(profileRows(profile)));
28
+ },
29
+ };
30
+ export const useCommand = {
31
+ async run(args, flags) {
32
+ const [target] = args;
33
+ if (!target)
34
+ throw new CliError('usage: fleet use <fleet-name-or-id>', EXIT.usage);
35
+ const profile = await loadProfile();
36
+ const fleets = await task('finding fleets you can access', async () => (await request('GET', '/fleets')).body.fleets);
37
+ const matches = fleets.filter((fleet) => fleet.id === target || fleet.id.startsWith(target) || fleet.name === target);
38
+ if (!matches.length) {
39
+ throw new CliError(`No fleet called "${target}". Available: ${fleets.map((fleet) => fleet.name).join(', ') || 'none'}`, EXIT.usage);
40
+ }
41
+ if (matches.length > 1) {
42
+ throw new CliError(`"${target}" is ambiguous. Use one of:\n${matches.map((f) => ` ${f.name} ${f.id}`).join('\n')}`, EXIT.usage);
43
+ }
44
+ const fleet = matches[0];
45
+ await saveProfile({ ...profile, fleetId: fleet.id, fleetName: fleet.name });
46
+ if (flags.json)
47
+ return console.log(JSON.stringify({ fleet }, null, 2));
48
+ console.log(`${glyph.ok} using ${c.signal(fleet.name)} ${c.dim(`${fleet.role} · ${fleet.id}`)}`);
49
+ },
50
+ };
51
+ export { profileRows };
@@ -0,0 +1,141 @@
1
+ import { CliError, EXIT, request, requireFleet } from '../api.js';
2
+ import { loadProfile } from '../config.js';
3
+ import { c, relativeTime } from '../render.js';
4
+ import { glyph, rule, task } from '../ui.js';
5
+ const icon = (state) => state === 'ok' ? glyph.ok : state === 'warn' ? glyph.warn : glyph.fail;
6
+ async function reach(url) {
7
+ try {
8
+ const response = await fetch(url, { redirect: 'manual', signal: AbortSignal.timeout(8_000) });
9
+ return response.status >= 200 && response.status < 400
10
+ ? { ok: true, detail: `HTTPS answered ${response.status}` }
11
+ : { ok: false, detail: `HTTPS answered ${response.status}` };
12
+ }
13
+ catch (error) {
14
+ return { ok: false, detail: error instanceof Error ? error.message : String(error) };
15
+ }
16
+ }
17
+ /**
18
+ * A candid, read-only diagnosis. A check is never marked healthy merely
19
+ * because Fleet lacks enough telemetry to prove it — that is a warning with
20
+ * the next concrete product capability stated plainly.
21
+ */
22
+ export const doctorCommand = {
23
+ async run(_args, flags) {
24
+ const profile = await loadProfile();
25
+ const fleetId = await requireFleet(typeof flags.fleet === 'string' ? flags.fleet : undefined);
26
+ const result = await task('checking Fleet health', async () => {
27
+ const [identity, fleet, nodes, services, github, health] = await Promise.all([
28
+ request('GET', '/auth/me'),
29
+ request('GET', `/fleets/${fleetId}`),
30
+ request('GET', `/fleets/${fleetId}/nodes`),
31
+ request('GET', `/fleets/${fleetId}/services`),
32
+ request('GET', `/fleets/${fleetId}/github/status`),
33
+ request('GET', '/healthz'),
34
+ ]);
35
+ const deploymentHistory = await Promise.all(services.body.services.map(async (service) => ({
36
+ service,
37
+ deployments: (await request('GET', `/services/${service.id}/deployments`)).body.deployments,
38
+ })));
39
+ const urls = services.body.services
40
+ .map((service) => ({ name: service.name, hostname: service.domain ?? service.hostname }))
41
+ .filter((service) => Boolean(service.hostname));
42
+ const ingress = await Promise.all(urls.map(async (service) => ({ ...service, ...(await reach(`https://${service.hostname}`)) })));
43
+ return { identity: identity.body, fleet: fleet.body, nodes: nodes.body.nodes, services: services.body.services, github: github.body, health: health.body, deploymentHistory, ingress };
44
+ });
45
+ const checks = [
46
+ { state: 'ok', label: 'control plane', detail: profile.api },
47
+ { state: 'ok', label: 'signed in', detail: result.identity.user.email },
48
+ { state: 'ok', label: 'fleet access', detail: `${result.fleet.fleet.name} · ${result.fleet.role}` },
49
+ ];
50
+ if (!result.nodes.length) {
51
+ checks.push({ state: 'fail', label: 'nodes', detail: 'No nodes are paired.', remedy: 'Run `fleet nodes pair`, then run the printed command on a machine you own.' });
52
+ }
53
+ else {
54
+ const offline = result.nodes.filter((node) => node.status === 'offline' || !node.live);
55
+ const cordoned = result.nodes.filter((node) => node.status === 'cordoned');
56
+ const versions = new Set(result.nodes.map((node) => node.agentVersion).filter(Boolean));
57
+ checks.push({
58
+ state: offline.length ? 'fail' : cordoned.length ? 'warn' : 'ok',
59
+ label: 'nodes',
60
+ detail: offline.length
61
+ ? `${offline.map((node) => `${node.name} (${relativeTime(node.lastHeartbeatAt)})`).join(', ')} not reporting`
62
+ : cordoned.length
63
+ ? `${result.nodes.length} paired; ${cordoned.map((node) => node.name).join(', ')} cordoned`
64
+ : `${result.nodes.length} paired and reporting`,
65
+ remedy: offline.length ? 'Check the agent service and its outbound connection, then run `fleet doctor` again.' : undefined,
66
+ });
67
+ checks.push({
68
+ state: versions.size > 1 ? 'warn' : 'ok',
69
+ label: 'agent versions',
70
+ detail: versions.size ? [...versions].join(', ') : 'agent version not reported',
71
+ remedy: versions.size > 1 ? 'Update nodes so all agents run the same compatible release.' : undefined,
72
+ });
73
+ for (const node of result.nodes) {
74
+ const runtime = node.telemetry?.runtime;
75
+ const diskPercent = node.diskMb ? Math.round(((node.telemetry?.diskUsedMb ?? 0) / node.diskMb) * 100) : 0;
76
+ // Redis intentionally retains the last heartbeat briefly, but a node
77
+ // that has stopped reporting must not have old host facts rendered as
78
+ // current failures. The heartbeat check above is the only actionable
79
+ // check until the agent resumes.
80
+ if (!node.live || !node.telemetry) {
81
+ checks.push({
82
+ state: 'warn',
83
+ label: `runtime ${node.name}`,
84
+ detail: 'Unavailable because this node is not reporting a current heartbeat.',
85
+ remedy: 'Restart the agent, then run `fleet doctor` again for live Docker, registry, and disk checks.',
86
+ });
87
+ continue;
88
+ }
89
+ checks.push({ state: runtime?.dockerAvailable ? 'ok' : 'fail', label: `Docker ${node.name}`, detail: runtime?.dockerAvailable ? `available${runtime.dockerVersion ? ` · ${runtime.dockerVersion}` : ''}` : runtime?.dockerError ?? 'No Docker runtime reported', remedy: runtime?.dockerAvailable ? undefined : 'Start Docker, then inspect the local fleet-agent log.' });
90
+ checks.push({ state: runtime?.registryStatus === 'ok' ? 'ok' : runtime?.registryStatus === 'failed' ? 'fail' : 'warn', label: `registry ${node.name}`, detail: runtime?.registryStatus === 'ok' ? 'latest real image pull succeeded' : runtime?.registryError ?? 'not tested by a real image pull yet', remedy: runtime?.registryStatus === 'ok' ? undefined : 'Use a LAN-reachable REGISTRY_URL, then restart a service to run an authenticated pull.' });
91
+ checks.push({ state: diskPercent >= 90 ? 'fail' : diskPercent >= 80 ? 'warn' : 'ok', label: `disk ${node.name}`, detail: `${diskPercent}% used`, remedy: diskPercent >= 80 ? 'Free space from Docker images/volumes before the node becomes unschedulable.' : undefined });
92
+ if (runtime?.lastReconcileError)
93
+ checks.push({ state: 'fail', label: `reconcile ${node.name}`, detail: runtime.lastReconcileError, remedy: 'Run `fleet logs <service> --follow` and inspect the deployment history.' });
94
+ }
95
+ }
96
+ const failed = result.deploymentHistory.flatMap(({ service, deployments }) => deployments.filter((deployment) => deployment.status === 'failed' || deployment.failureReason).slice(0, 1).map((deployment) => ({ service: service.name, deployment })));
97
+ checks.push(failed.length
98
+ ? {
99
+ state: 'fail',
100
+ label: 'deployments',
101
+ detail: failed.map(({ service, deployment }) => `${service}: ${deployment.failureReason ?? deployment.status}`).join('; '),
102
+ remedy: 'Run `fleet deployments <service>` for history and `fleet logs <service> --follow` for the current container tail.',
103
+ }
104
+ : { state: 'ok', label: 'deployments', detail: result.services.length ? 'No recorded deployment failures.' : 'No services declared yet.' });
105
+ if (!result.ingress.length) {
106
+ checks.push({ state: 'warn', label: 'ingress', detail: 'No public service hostname is configured yet.' });
107
+ }
108
+ else {
109
+ for (const service of result.ingress) {
110
+ checks.push({
111
+ state: service.ok ? 'ok' : 'fail',
112
+ label: `HTTPS ${service.name}`,
113
+ detail: service.detail,
114
+ remedy: service.ok ? undefined : 'Check the node is online, its advertised address is reachable, and the ingress domain resolves to this control plane.',
115
+ });
116
+ }
117
+ }
118
+ checks.push(result.github.configured && !result.github.error
119
+ ? { state: 'ok', label: 'GitHub App', detail: `${result.github.installations?.length ?? 0} installation(s) available` }
120
+ : {
121
+ state: 'warn',
122
+ label: 'GitHub App',
123
+ detail: result.github.error ?? 'Not configured; public repositories can still deploy.',
124
+ remedy: 'Set GITHUB_APP_ID and GITHUB_APP_PRIVATE_KEY, restart the control plane, then connect repositories in Dashboard → Settings.',
125
+ });
126
+ checks.push({ state: 'ok', label: 'control-plane version', detail: result.health.version ?? 'version not reported' });
127
+ if (flags.json)
128
+ return console.log(JSON.stringify({ fleetId, checks }, null, 2));
129
+ console.log(`\n${rule(`doctor · ${result.fleet.fleet.name}`)}`);
130
+ for (const check of checks) {
131
+ console.log(`${icon(check.state)} ${c.bold(check.label.padEnd(18))} ${check.detail}`);
132
+ if (check.remedy)
133
+ console.log(` ${c.dim(check.remedy)}`);
134
+ }
135
+ const failing = checks.filter((check) => check.state === 'fail').length;
136
+ const warnings = checks.filter((check) => check.state === 'warn').length;
137
+ console.log(`\n${failing ? c.red(`${failing} failed`) : c.green('no blocking failures')}${warnings ? c.dim(` · ${warnings} needs attention`) : ''}`);
138
+ if (failing)
139
+ process.exitCode = EXIT.failure;
140
+ },
141
+ };
@@ -0,0 +1,50 @@
1
+ /**
2
+ * fleet down <service> — cleanly stops and tears down a running service deployment.
3
+ *
4
+ * Marks active deployments as stopped and instructs the assigned node agent
5
+ * to unassign and remove the container on its next reconciliation cycle.
6
+ */
7
+ import { request, requireFleet, CliError, EXIT } from '../api.js';
8
+ import { c } from '../render.js';
9
+ import { glyph } from '../ui.js';
10
+ async function confirmTeardown(name) {
11
+ if (!process.stdin.isTTY)
12
+ return true;
13
+ const { createInterface } = await import('node:readline/promises');
14
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
15
+ try {
16
+ const ans = await rl.question(` Stop and tear down ${c.bold(name)}? [y/N] `);
17
+ return ans.trim().toLowerCase() === 'y';
18
+ }
19
+ finally {
20
+ rl.close();
21
+ }
22
+ }
23
+ export const downCommand = {
24
+ async run(args, flags) {
25
+ const fleetId = await requireFleet(typeof flags.fleet === 'string' ? flags.fleet : undefined);
26
+ const [name] = args;
27
+ if (!name)
28
+ throw new CliError('usage: fleet down <service> [--yes]', EXIT.usage);
29
+ const { body: listBody } = await request('GET', `/fleets/${fleetId}/services`);
30
+ const service = listBody.services.find((s) => s.name === name || s.id === name);
31
+ if (!service) {
32
+ throw new CliError(`No service called "${name}". Known: ${listBody.services.map((s) => s.name).join(', ') || 'none'}`, EXIT.usage);
33
+ }
34
+ if (!flags.yes && !flags.y && !(await confirmTeardown(service.name))) {
35
+ console.log(c.dim('Teardown cancelled.'));
36
+ return;
37
+ }
38
+ const { body } = await request('POST', `/services/${service.id}/stop`, { body: {} });
39
+ if (flags.json)
40
+ return console.log(JSON.stringify(body, null, 2));
41
+ if (body.stopped === 0) {
42
+ console.log(`${glyph.info} ${body.message ?? `"${service.name}" is not currently running.`}`);
43
+ return;
44
+ }
45
+ console.log(`${glyph.ok} ${c.yellow('stopped')} ${c.bold(service.name)}`);
46
+ if (body.note) {
47
+ console.log(c.dim(` ${body.note}`));
48
+ }
49
+ },
50
+ };
@@ -0,0 +1,34 @@
1
+ import { authCommand } from './auth.js';
2
+ import { nodesCommand } from './nodes.js';
3
+ import { statusCommand, eventsCommand } from './status.js';
4
+ import { alertsCommand } from './alerts.js';
5
+ import { configCommand, useCommand } from './config.js';
6
+ import { doctorCommand } from './doctor.js';
7
+ import { upCommand } from './up.js';
8
+ import { openCommand } from './open.js';
9
+ import { downCommand } from './down.js';
10
+ import { applyCommand, deployCommand, deploymentsCommand, initCommand, logsCommand, restartCommand, rollbackCommand, rescheduleCommand, servicesCommand, validateCommand, whereCommand, } from './services.js';
11
+ export const commands = {
12
+ up: upCommand,
13
+ open: openCommand,
14
+ down: downCommand,
15
+ auth: authCommand,
16
+ config: configCommand,
17
+ use: useCommand,
18
+ doctor: doctorCommand,
19
+ init: initCommand,
20
+ validate: validateCommand,
21
+ apply: applyCommand,
22
+ status: statusCommand,
23
+ nodes: nodesCommand,
24
+ services: servicesCommand,
25
+ deploy: deployCommand,
26
+ where: whereCommand,
27
+ reschedule: rescheduleCommand,
28
+ deployments: deploymentsCommand,
29
+ logs: logsCommand,
30
+ restart: restartCommand,
31
+ rollback: rollbackCommand,
32
+ events: eventsCommand,
33
+ alerts: alertsCommand,
34
+ };