ninedeploy 0.16.7 → 0.16.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.
@@ -0,0 +1,412 @@
1
+ import {
2
+ monitorCreate,
3
+ parseTcpTarget,
4
+ statusPageCreate,
5
+ statusPagePatch,
6
+ type MonitorCheckView,
7
+ type MonitorCreateInput,
8
+ type MonitorView,
9
+ type StatusPageView,
10
+ } from '@ninedeploy/schemas';
11
+ import { apiUrl, type NineDeployClient } from '../client.js';
12
+ import { prompt } from '../prompts.js';
13
+ import { c, error, fmtTime, header, info, kv, spinner, success, table } from '../lib/format.js';
14
+ import { plain } from './sources.js';
15
+
16
+ /**
17
+ * 0.16.8 uptime monitors and public status pages:
18
+ *
19
+ * ninedeploy monitors list [--service <id|slug>] [--json]
20
+ * ninedeploy monitors add <name> <target> [--tcp] [--service] [--interval] [--timeout] [--failures]
21
+ * [--expect] [--keyword] [--head] [--no-follow-redirects] [--insecure] [--disabled]
22
+ * ninedeploy monitors show|check|pause|resume|rm <id>
23
+ * ninedeploy status-pages list | add <slug> <title> [--monitor <id>...] [--publish]
24
+ * | publish|unpublish <id> | notice <id> [text] [--clear] | rm <id>
25
+ *
26
+ * Managing monitors and every status-page call is operator only on the
27
+ * server. Names, targets, errors and notices are user text and go through
28
+ * the F1011 sanitiser before they reach the terminal.
29
+ */
30
+
31
+ const message = (err: unknown): string => plain(err instanceof Error ? err.message : String(err));
32
+
33
+ function positiveInt(raw: string | undefined, what: string): number | null {
34
+ const n = Number(raw);
35
+ if (!raw || !Number.isInteger(n) || n <= 0) {
36
+ error(`${what} must be a positive integer`);
37
+ return null;
38
+ }
39
+ return n;
40
+ }
41
+
42
+ /** Skip the spinner for `--json` so stdout stays parseable. */
43
+ const load = <T>(json: boolean | undefined, msg: string, fn: () => Promise<T>): Promise<T> => (json ? fn() : spinner(msg, fn));
44
+
45
+ /** A service id, or the id of the service with that slug (or name); null after printing why not. */
46
+ export async function resolveServiceRef(client: NineDeployClient, ref: string): Promise<number | null> {
47
+ if (/^\d+$/.test(ref)) return positiveInt(ref, '--service');
48
+ const found = (await client.services.list()).find((s) => s.slug === ref || s.name === ref);
49
+ if (!found) {
50
+ error(`No service named "${plain(ref)}".`);
51
+ return null;
52
+ }
53
+ return found.id;
54
+ }
55
+
56
+ const pct = (v: number | null): string => (v === null ? c.gray('—') : `${v.toFixed(2)}%`);
57
+
58
+ function monitorStatus(s: MonitorView['status']): string {
59
+ switch (s) {
60
+ case 'up':
61
+ return c.green(s);
62
+ case 'down':
63
+ return c.red(s);
64
+ case 'pending':
65
+ return c.yellow(s);
66
+ default:
67
+ return c.gray(s);
68
+ }
69
+ }
70
+
71
+ /** Flag names for the schema fields, so a validation failure names what the user typed. */
72
+ const FLAG: Record<string, string> = {
73
+ name: '<name>',
74
+ target: '<target>',
75
+ serviceId: '--service',
76
+ intervalSeconds: '--interval',
77
+ timeoutSeconds: '--timeout',
78
+ failuresBeforeDown: '--failures',
79
+ expectedStatus: '--expect',
80
+ keyword: '--keyword',
81
+ slug: '<slug>',
82
+ title: '<title>',
83
+ notice: '<text>',
84
+ monitorIds: '--monitor',
85
+ };
86
+
87
+ /** A zod failure as one line, each issue led by the flag or argument it is about. */
88
+ export function validationMessage(err: { issues: Array<{ path: PropertyKey[]; message: string }> }): string {
89
+ return err.issues
90
+ .map((i) => {
91
+ const field = String(i.path[0] ?? '');
92
+ return `${FLAG[field] ?? (field || 'input')}: ${i.message}`;
93
+ })
94
+ .join('; ');
95
+ }
96
+
97
+ // ── monitors ──────────────────────────────────────────────────────────────
98
+
99
+ /** `ninedeploy monitors list [--service <id|slug>] [--json]` */
100
+ export async function monitorsList(client: NineDeployClient, opts: { service?: string; json?: boolean } = {}): Promise<void> {
101
+ try {
102
+ const serviceId = opts.service === undefined ? undefined : await resolveServiceRef(client, opts.service);
103
+ if (serviceId === null) return;
104
+ const rows = await load(opts.json, 'Loading monitors', () => client.monitors.list(serviceId === undefined ? undefined : { serviceId }));
105
+ if (opts.json) {
106
+ console.log(JSON.stringify(rows, null, 2));
107
+ return;
108
+ }
109
+ header('Uptime monitors');
110
+ if (rows.length === 0) {
111
+ info('No monitors yet. Add one with `ninedeploy monitors add <name> <url>`.');
112
+ return;
113
+ }
114
+ table(
115
+ rows.map((m) => ({
116
+ id: m.id,
117
+ status: monitorStatus(m.status),
118
+ name: plain(m.name),
119
+ target: `${m.kind === 'tcp' ? 'tcp ' : ''}${plain(m.target)}`,
120
+ '24h': pct(m.uptime.h24),
121
+ '30d': pct(m.uptime.d30),
122
+ 'last ms': m.lastResponseMs ?? c.gray('—'),
123
+ 'last checked': fmtTime(m.lastCheckedAt),
124
+ })),
125
+ ['id', 'status', 'name', 'target', '24h', '30d', 'last ms', 'last checked'],
126
+ );
127
+ } catch (err) {
128
+ error(message(err));
129
+ }
130
+ }
131
+
132
+ export interface MonitorAddOptions {
133
+ tcp?: boolean;
134
+ service?: string;
135
+ interval?: string;
136
+ timeout?: string;
137
+ failures?: string;
138
+ expect?: string;
139
+ keyword?: string;
140
+ head?: boolean;
141
+ /** Commander's `--no-follow-redirects` sets this to false. */
142
+ followRedirects?: boolean;
143
+ insecure?: boolean;
144
+ disabled?: boolean;
145
+ }
146
+
147
+ /** `--tcp`, or a target without a scheme that reads as host:port, is a TCP check; anything else HTTP. */
148
+ export function monitorKindFor(target: string, tcp?: boolean): 'http' | 'tcp' {
149
+ if (tcp) return 'tcp';
150
+ return !target.includes('://') && parseTcpTarget(target.trim()) ? 'tcp' : 'http';
151
+ }
152
+
153
+ function intFlag(raw: string | undefined, flag: string): number | undefined | null {
154
+ if (raw === undefined) return undefined;
155
+ const n = Number(raw);
156
+ if (raw.trim() === '' || !Number.isInteger(n)) {
157
+ error(`${flag} must be a whole number`);
158
+ return null;
159
+ }
160
+ return n;
161
+ }
162
+
163
+ /** `ninedeploy monitors add <name> <target> [...]` */
164
+ export async function monitorsAdd(client: NineDeployClient, name: string, target: string, opts: MonitorAddOptions = {}): Promise<void> {
165
+ const kind = monitorKindFor(target, opts.tcp);
166
+ const intervalSeconds = intFlag(opts.interval, '--interval');
167
+ const timeoutSeconds = intFlag(opts.timeout, '--timeout');
168
+ const failuresBeforeDown = intFlag(opts.failures, '--failures');
169
+ if (intervalSeconds === null || timeoutSeconds === null || failuresBeforeDown === null) return;
170
+ try {
171
+ const serviceId = opts.service === undefined ? undefined : await resolveServiceRef(client, opts.service);
172
+ if (serviceId === null) return;
173
+ const input: MonitorCreateInput = {
174
+ name,
175
+ kind,
176
+ target,
177
+ ...(serviceId !== undefined ? { serviceId } : {}),
178
+ ...(intervalSeconds !== undefined ? { intervalSeconds } : {}),
179
+ ...(timeoutSeconds !== undefined ? { timeoutSeconds } : {}),
180
+ ...(failuresBeforeDown !== undefined ? { failuresBeforeDown } : {}),
181
+ ...(opts.expect !== undefined ? { expectedStatus: opts.expect } : {}),
182
+ ...(opts.keyword !== undefined ? { keyword: opts.keyword } : {}),
183
+ ...(opts.head ? { method: 'HEAD' as const } : {}),
184
+ ...(opts.followRedirects === false ? { followRedirects: false } : {}),
185
+ ...(opts.insecure ? { verifyTls: false } : {}),
186
+ ...(opts.disabled ? { enabled: false } : {}),
187
+ };
188
+ const parsed = monitorCreate.safeParse(input);
189
+ if (!parsed.success) return error(validationMessage(parsed.error));
190
+ const m = await spinner('Creating the monitor', () => client.monitors.create(input));
191
+ success(`Monitor #${m.id} "${plain(m.name)}" created (${m.kind} ${plain(m.target)}, every ${m.intervalSeconds}s).`);
192
+ if (m.enabled) info(`Run a check now: ninedeploy monitors check ${m.id}`);
193
+ else info(`Paused. Start it with: ninedeploy monitors resume ${m.id}`);
194
+ } catch (err) {
195
+ error(message(err));
196
+ }
197
+ }
198
+
199
+ function printChecks(checks: MonitorCheckView[]): void {
200
+ if (checks.length === 0) {
201
+ info('No checks recorded yet.');
202
+ return;
203
+ }
204
+ table(
205
+ checks.map((k) => ({
206
+ time: fmtTime(k.ts),
207
+ result: k.ok ? c.green('ok') : c.red('fail'),
208
+ ms: k.responseMs ?? c.gray('—'),
209
+ code: k.statusCode ?? c.gray('—'),
210
+ error: k.error ? plain(k.error) : '',
211
+ })),
212
+ ['time', 'result', 'ms', 'code', 'error'],
213
+ );
214
+ }
215
+
216
+ /** Hours of history that hold the last ten checks of a monitor with this interval (1–168, at least a day). */
217
+ const historyHours = (intervalSeconds: number): number => Math.min(168, Math.max(24, Math.ceil((10 * intervalSeconds) / 3600) + 1));
218
+
219
+ /** `ninedeploy monitors show <id> [--json]` — details and the last 10 checks. */
220
+ export async function monitorsShow(client: NineDeployClient, idRaw: string, opts: { json?: boolean } = {}): Promise<void> {
221
+ const id = positiveInt(idRaw, 'Monitor id');
222
+ if (id === null) return;
223
+ try {
224
+ const m = await load(opts.json, 'Loading the monitor', () => client.monitors.get(id));
225
+ const checks = (await client.monitors.checks(id, { hours: historyHours(m.intervalSeconds) })).slice(0, 10);
226
+ if (opts.json) {
227
+ console.log(JSON.stringify({ monitor: m, checks }, null, 2));
228
+ return;
229
+ }
230
+ header(`Monitor #${m.id}: ${plain(m.name)}`);
231
+ kv('Status', `${monitorStatus(m.status)}${m.statusSince ? c.gray(` since ${m.statusSince}`) : ''}`);
232
+ kv('Kind', m.kind);
233
+ kv('Target', plain(m.target));
234
+ kv('Service', m.serviceId === null ? c.gray('— (operator only)') : `#${m.serviceId}`);
235
+ if (m.kind === 'http') {
236
+ kv('Method', m.method);
237
+ kv('Expect', m.expectedStatus);
238
+ kv('Keyword', m.keyword === null ? null : plain(m.keyword));
239
+ kv('Redirects', m.followRedirects ? 'followed' : 'not followed');
240
+ kv('TLS', m.verifyTls ? 'verified' : c.yellow('not verified'));
241
+ }
242
+ kv('Interval', `${m.intervalSeconds}s (timeout ${m.timeoutSeconds}s, down after ${m.failuresBeforeDown} failures)`);
243
+ kv('Uptime', `24h ${pct(m.uptime.h24)} 7d ${pct(m.uptime.d7)} 30d ${pct(m.uptime.d30)}`);
244
+ kv('Last check', m.lastCheckedAt);
245
+ kv('Last response', m.lastResponseMs === null ? null : `${m.lastResponseMs} ms${m.lastStatusCode !== null ? ` (HTTP ${m.lastStatusCode})` : ''}`);
246
+ kv('Last error', m.lastError === null ? null : plain(m.lastError));
247
+ if (m.certExpiresAt) kv('Cert expires', m.certExpiresAt);
248
+ kv('Created', m.createdAt);
249
+ console.log();
250
+ console.log(` ${c.bold('Last checks')}`);
251
+ printChecks(checks);
252
+ } catch (err) {
253
+ error(message(err));
254
+ }
255
+ }
256
+
257
+ /** `ninedeploy monitors check <id>` — run a check now. */
258
+ export async function monitorsCheck(client: NineDeployClient, idRaw: string): Promise<void> {
259
+ const id = positiveInt(idRaw, 'Monitor id');
260
+ if (id === null) return;
261
+ try {
262
+ const { check, monitor } = await spinner('Checking', () => client.monitors.checkNow(id));
263
+ const detail = [check.responseMs !== null ? `${check.responseMs} ms` : null, check.statusCode !== null ? `HTTP ${check.statusCode}` : null]
264
+ .filter(Boolean)
265
+ .join(', ');
266
+ if (check.ok) success(`Check passed${detail ? ` (${detail})` : ''}.`);
267
+ else error(`Check failed${detail ? ` (${detail})` : ''}: ${check.error ? plain(check.error) : 'no detail'}`);
268
+ info(`Monitor #${monitor.id} is ${monitor.status}.`);
269
+ } catch (err) {
270
+ error(message(err));
271
+ }
272
+ }
273
+
274
+ /** `ninedeploy monitors pause|resume <id>` */
275
+ export async function monitorsSetEnabled(client: NineDeployClient, idRaw: string, enabled: boolean): Promise<void> {
276
+ const id = positiveInt(idRaw, 'Monitor id');
277
+ if (id === null) return;
278
+ try {
279
+ const m = await spinner(enabled ? 'Resuming' : 'Pausing', () => client.monitors.update(id, { enabled }));
280
+ success(`Monitor #${m.id} ${enabled ? 'resumed' : 'paused'} (${m.status}).`);
281
+ } catch (err) {
282
+ error(message(err));
283
+ }
284
+ }
285
+
286
+ /** `ninedeploy monitors rm <id> [--yes]` */
287
+ export async function monitorsRemove(client: NineDeployClient, idRaw: string, opts: { yes?: boolean } = {}): Promise<void> {
288
+ const id = positiveInt(idRaw, 'Monitor id');
289
+ if (id === null) return;
290
+ if (!opts.yes) {
291
+ const answer = await prompt(`Delete monitor #${id} and its check history? Type "yes" to confirm`);
292
+ if (answer.trim().toLowerCase() !== 'yes') return info('Cancelled');
293
+ }
294
+ try {
295
+ await spinner('Deleting', () => client.monitors.delete(id));
296
+ success(`Monitor #${id} deleted.`);
297
+ } catch (err) {
298
+ error(message(err));
299
+ }
300
+ }
301
+
302
+ // ── status pages ──────────────────────────────────────────────────────────
303
+
304
+ /** The public address of a status page: the panel URL plus its path. */
305
+ export const statusPageUrl = (p: Pick<StatusPageView, 'path'>): string => apiUrl(p.path);
306
+
307
+ /** `ninedeploy status-pages list [--json]` */
308
+ export async function statusPagesList(client: NineDeployClient, opts: { json?: boolean } = {}): Promise<void> {
309
+ try {
310
+ const rows = await load(opts.json, 'Loading status pages', () => client.statusPages.list());
311
+ if (opts.json) {
312
+ console.log(JSON.stringify(rows, null, 2));
313
+ return;
314
+ }
315
+ header('Status pages');
316
+ if (rows.length === 0) {
317
+ info('No status pages yet. Add one with `ninedeploy status-pages add <slug> <title>`.');
318
+ return;
319
+ }
320
+ table(
321
+ rows.map((p) => ({
322
+ id: p.id,
323
+ slug: plain(p.slug),
324
+ title: plain(p.title),
325
+ published: p.published ? c.green('yes') : c.gray('no'),
326
+ monitors: p.monitorIds.length,
327
+ notice: p.notice ? 'yes' : '',
328
+ url: statusPageUrl(p),
329
+ })),
330
+ ['id', 'slug', 'title', 'published', 'monitors', 'notice', 'url'],
331
+ );
332
+ } catch (err) {
333
+ error(message(err));
334
+ }
335
+ }
336
+
337
+ function printPageUrl(p: StatusPageView): void {
338
+ if (p.published) info(`Public URL: ${statusPageUrl(p)}`);
339
+ else info(`Not published (answers 404). Publish it with: ninedeploy status-pages publish ${p.id}`);
340
+ }
341
+
342
+ /** `ninedeploy status-pages add <slug> <title> [--monitor <id>...] [--publish]` */
343
+ export async function statusPagesAdd(
344
+ client: NineDeployClient,
345
+ slug: string,
346
+ title: string,
347
+ opts: { monitor?: string[]; publish?: boolean } = {},
348
+ ): Promise<void> {
349
+ const monitorIds: number[] = [];
350
+ for (const raw of opts.monitor ?? []) {
351
+ const id = positiveInt(raw, '--monitor');
352
+ if (id === null) return;
353
+ monitorIds.push(id);
354
+ }
355
+ const input = { slug, title, monitorIds, published: opts.publish === true };
356
+ const parsed = statusPageCreate.safeParse(input);
357
+ if (!parsed.success) return error(validationMessage(parsed.error));
358
+ try {
359
+ const p = await spinner('Creating the status page', () => client.statusPages.create(input));
360
+ success(`Status page #${p.id} "${plain(p.title)}" created with ${p.monitorIds.length} monitor(s).`);
361
+ printPageUrl(p);
362
+ } catch (err) {
363
+ error(message(err));
364
+ }
365
+ }
366
+
367
+ /** `ninedeploy status-pages publish|unpublish <id>` */
368
+ export async function statusPagesSetPublished(client: NineDeployClient, idRaw: string, published: boolean): Promise<void> {
369
+ const id = positiveInt(idRaw, 'Status page id');
370
+ if (id === null) return;
371
+ try {
372
+ const p = await spinner(published ? 'Publishing' : 'Unpublishing', () => client.statusPages.update(id, { published }));
373
+ success(`Status page #${p.id} ${published ? 'published' : 'unpublished'}.`);
374
+ printPageUrl(p);
375
+ } catch (err) {
376
+ error(message(err));
377
+ }
378
+ }
379
+
380
+ /** `ninedeploy status-pages notice <id> [text] [--clear]` */
381
+ export async function statusPagesNotice(client: NineDeployClient, idRaw: string, text: string | undefined, opts: { clear?: boolean } = {}): Promise<void> {
382
+ const id = positiveInt(idRaw, 'Status page id');
383
+ if (id === null) return;
384
+ if (opts.clear && text !== undefined) return error('Pass the notice text or --clear, not both.');
385
+ if (!opts.clear && !text?.trim()) return error('Usage: ninedeploy status-pages notice <id> <text> | --clear');
386
+ const notice = opts.clear ? null : text!;
387
+ const parsed = statusPagePatch.safeParse({ notice });
388
+ if (!parsed.success) return error(validationMessage(parsed.error));
389
+ try {
390
+ const p = await spinner(notice === null ? 'Clearing the notice' : 'Setting the notice', () => client.statusPages.update(id, { notice }));
391
+ success(notice === null ? `Notice cleared on status page #${p.id}.` : `Notice set on status page #${p.id}.`);
392
+ printPageUrl(p);
393
+ } catch (err) {
394
+ error(message(err));
395
+ }
396
+ }
397
+
398
+ /** `ninedeploy status-pages rm <id> [--yes]` */
399
+ export async function statusPagesRemove(client: NineDeployClient, idRaw: string, opts: { yes?: boolean } = {}): Promise<void> {
400
+ const id = positiveInt(idRaw, 'Status page id');
401
+ if (id === null) return;
402
+ if (!opts.yes) {
403
+ const answer = await prompt(`Delete status page #${id}? Its public URL stops working. Type "yes" to confirm`);
404
+ if (answer.trim().toLowerCase() !== 'yes') return info('Cancelled');
405
+ }
406
+ try {
407
+ await spinner('Deleting', () => client.statusPages.delete(id));
408
+ success(`Status page #${id} deleted.`);
409
+ } catch (err) {
410
+ error(message(err));
411
+ }
412
+ }
package/src/index.ts CHANGED
@@ -138,6 +138,12 @@ import {
138
138
  type PlacementOptions,
139
139
  } from './commands/multiNode.js';
140
140
  // ── end 0.16 T8 ──
141
+ import {
142
+ monitorsAdd, monitorsCheck, monitorsList, monitorsRemove, monitorsSetEnabled, monitorsShow,
143
+ statusPagesAdd, statusPagesList, statusPagesNotice, statusPagesRemove, statusPagesSetPublished,
144
+ type MonitorAddOptions,
145
+ } from './commands/uptime.js';
146
+ import { canaryOff, canaryPromote, canarySet, canaryShow, type CanarySetOptions } from './commands/canary.js';
141
147
 
142
148
  const program = new Command();
143
149
 
@@ -1145,6 +1151,88 @@ swarmCmd.command('leave <serverId>')
1145
1151
  .action((serverId: string, opts: { yes?: boolean }) => swarmLeave(getClient(), serverId, opts));
1146
1152
  /* v8 ignore stop */
1147
1153
 
1154
+ // ── 0.16.8: uptime monitors and public status pages ──────────────────────
1155
+ const monitorsCmd = program.command('monitors').alias('monitor').description('Uptime monitors: HTTP(S) and TCP checks with up/down alerts');
1156
+ /* v8 ignore start -- exercised by test/uptime.test.ts. */
1157
+ monitorsCmd.command('list')
1158
+ .description('List monitors with their status and uptime')
1159
+ .option('--service <idOrSlug>', 'Only the monitors of this service')
1160
+ .option('--json', 'Print raw JSON instead of the table')
1161
+ .action((opts: { service?: string; json?: boolean }) => monitorsList(getClient(), opts));
1162
+ monitorsCmd.command('add <name> <target>')
1163
+ .description('Add a monitor (operator): an http(s):// URL, or host:port for a TCP check')
1164
+ .option('--tcp', 'TCP connect check (the default for a host:port target)')
1165
+ .option('--service <idOrSlug>', 'Attach it to a service (its members can see it)')
1166
+ .option('--interval <seconds>', 'Seconds between checks (30-3600, default 60)')
1167
+ .option('--timeout <seconds>', 'Seconds before a check fails (1-60, default 10)')
1168
+ .option('--failures <n>', 'Failed checks in a row before it is down (1-10, default 2)')
1169
+ .option('--expect <codes>', 'Accepted HTTP status codes, e.g. 200-299,301 (default 200-399)')
1170
+ .option('--keyword <text>', 'Text the response body must contain')
1171
+ .option('--head', 'Send HEAD instead of GET')
1172
+ .option('--no-follow-redirects', 'Do not follow redirects')
1173
+ .option('--insecure', 'Accept a self-signed or expired certificate')
1174
+ .option('--disabled', 'Create it paused')
1175
+ .action((name: string, target: string, opts: MonitorAddOptions) => monitorsAdd(getClient(), name, target, opts));
1176
+ monitorsCmd.command('show <id>')
1177
+ .description('Monitor details and the last 10 checks')
1178
+ .option('--json', 'Print raw JSON')
1179
+ .action((id: string, opts: { json?: boolean }) => monitorsShow(getClient(), id, opts));
1180
+ monitorsCmd.command('check <id>').description('Run a check now (operator)').action((id: string) => monitorsCheck(getClient(), id));
1181
+ monitorsCmd.command('pause <id>').description('Stop checking (operator)').action((id: string) => monitorsSetEnabled(getClient(), id, false));
1182
+ monitorsCmd.command('resume <id>').description('Start checking again (operator)').action((id: string) => monitorsSetEnabled(getClient(), id, true));
1183
+ monitorsCmd.command('rm <id>')
1184
+ .alias('remove')
1185
+ .description('Delete a monitor and its history (operator)')
1186
+ .option('-y, --yes', 'Skip the confirmation prompt')
1187
+ .action((id: string, opts: { yes?: boolean }) => monitorsRemove(getClient(), id, opts));
1188
+ /* v8 ignore stop */
1189
+ const statusPagesCmd = program.command('status-pages').alias('status-page').description('Public status pages showing a set of monitors (operator)');
1190
+ /* v8 ignore start -- exercised by test/uptime.test.ts. */
1191
+ statusPagesCmd.command('list')
1192
+ .description('List status pages with their public URL')
1193
+ .option('--json', 'Print raw JSON instead of the table')
1194
+ .action((opts: { json?: boolean }) => statusPagesList(getClient(), opts));
1195
+ statusPagesCmd.command('add <slug> <title>')
1196
+ .description('Create a status page (unpublished unless --publish)')
1197
+ .option('--monitor <id...>', 'Monitors to show, in order')
1198
+ .option('--publish', 'Publish it right away')
1199
+ .action((slug: string, title: string, opts: { monitor?: string[]; publish?: boolean }) => statusPagesAdd(getClient(), slug, title, opts));
1200
+ statusPagesCmd.command('publish <id>').description('Make the page public').action((id: string) => statusPagesSetPublished(getClient(), id, true));
1201
+ statusPagesCmd.command('unpublish <id>').description('Take the page offline (404)').action((id: string) => statusPagesSetPublished(getClient(), id, false));
1202
+ statusPagesCmd.command('notice <id> [text]')
1203
+ .description('Set the notice shown at the top (maintenance, an incident)')
1204
+ .option('--clear', 'Remove the notice')
1205
+ .action((id: string, text: string | undefined, opts: { clear?: boolean }) => statusPagesNotice(getClient(), id, text, opts));
1206
+ statusPagesCmd.command('rm <id>')
1207
+ .alias('remove')
1208
+ .description('Delete a status page')
1209
+ .option('-y, --yes', 'Skip the confirmation prompt')
1210
+ .action((id: string, opts: { yes?: boolean }) => statusPagesRemove(getClient(), id, opts));
1211
+ /* v8 ignore stop */
1212
+
1213
+ // ── 0.16.9: canary releases ──────────────────────────────────────────────
1214
+ const canaryCmd = program.command('canary').description('Canary releases: shift traffic to a new release in steps');
1215
+ /* v8 ignore start -- exercised by test/canary.test.ts. */
1216
+ canaryCmd.command('show <service>')
1217
+ .description('The canary configuration and the canary running now')
1218
+ .option('--json', 'Print raw JSON')
1219
+ .action((service: string, opts: { json?: boolean }) => canaryShow(getClient(), service, opts));
1220
+ canaryCmd.command('set <service>')
1221
+ .description('Turn canary releases on or change them (docker services); options not given keep their value')
1222
+ .option('--steps <list>', 'Percent of requests per step, rising, 1-99, up to 5 (default 10,50)')
1223
+ .option('--step-seconds <n>', 'Seconds per step (30-3600, default 120)')
1224
+ .option('--max-error-rate <percent>', 'Highest share of 5xx responses the new release may have (default 5)')
1225
+ .option('--max-p95 <ms>', 'Highest p95 response time in ms, or off (default off)')
1226
+ .option('--min-requests <n>', 'Fewer requests in a step are not judged (default 20)')
1227
+ .option('--json', 'Print the stored configuration as JSON')
1228
+ .action((service: string, opts: CanarySetOptions) => canarySet(getClient(), service, opts));
1229
+ canaryCmd.command('off <service>').description('Turn canary releases off').action((service: string) => canaryOff(getClient(), service));
1230
+ canaryCmd.command('promote <service>')
1231
+ .description('Skip the remaining steps and send all traffic to the new release now')
1232
+ .option('--deployment <id>', 'The deployment to promote (default: the one running)')
1233
+ .action((service: string, opts: { deployment?: string }) => canaryPromote(getClient(), service, opts));
1234
+ /* v8 ignore stop */
1235
+
1148
1236
  deploys.command('watch <serviceId> <deployId>').description('Stream a deployment\'s build logs live').action((svcId: string, depId: string) => deploysWatch(svcId, depId));
1149
1237
 
1150
1238
  // ── Diagnostics ───────────────────────────────────────────────────────────