ninedeploy 0.10.5 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (149) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +1 -0
  3. package/dist/client.d.ts +34 -0
  4. package/dist/client.d.ts.map +1 -1
  5. package/dist/client.js +81 -22
  6. package/dist/client.js.map +1 -1
  7. package/dist/commands/backupPolicy.d.ts +29 -0
  8. package/dist/commands/backupPolicy.d.ts.map +1 -0
  9. package/dist/commands/backupPolicy.js +111 -0
  10. package/dist/commands/backupPolicy.js.map +1 -0
  11. package/dist/commands/certificates.d.ts +18 -7
  12. package/dist/commands/certificates.d.ts.map +1 -1
  13. package/dist/commands/certificates.js +121 -1
  14. package/dist/commands/certificates.js.map +1 -1
  15. package/dist/commands/databaseAccess.d.ts +37 -0
  16. package/dist/commands/databaseAccess.d.ts.map +1 -0
  17. package/dist/commands/databaseAccess.js +306 -0
  18. package/dist/commands/databaseAccess.js.map +1 -0
  19. package/dist/commands/deploy.d.ts.map +1 -1
  20. package/dist/commands/deploy.js +28 -7
  21. package/dist/commands/deploy.js.map +1 -1
  22. package/dist/commands/githubApps.d.ts +40 -0
  23. package/dist/commands/githubApps.d.ts.map +1 -0
  24. package/dist/commands/githubApps.js +278 -0
  25. package/dist/commands/githubApps.js.map +1 -0
  26. package/dist/commands/housekeeping.d.ts +7 -0
  27. package/dist/commands/housekeeping.d.ts.map +1 -1
  28. package/dist/commands/housekeeping.js +16 -0
  29. package/dist/commands/housekeeping.js.map +1 -1
  30. package/dist/commands/logs.d.ts.map +1 -1
  31. package/dist/commands/logs.js +17 -2
  32. package/dist/commands/logs.js.map +1 -1
  33. package/dist/commands/manage.d.ts +26 -10
  34. package/dist/commands/manage.d.ts.map +1 -1
  35. package/dist/commands/manage.js +219 -65
  36. package/dist/commands/manage.js.map +1 -1
  37. package/dist/commands/manifest.d.ts.map +1 -1
  38. package/dist/commands/manifest.js +9 -1
  39. package/dist/commands/manifest.js.map +1 -1
  40. package/dist/commands/misc.d.ts +4 -2
  41. package/dist/commands/misc.d.ts.map +1 -1
  42. package/dist/commands/misc.js +25 -11
  43. package/dist/commands/misc.js.map +1 -1
  44. package/dist/commands/multiNode.d.ts +55 -0
  45. package/dist/commands/multiNode.d.ts.map +1 -0
  46. package/dist/commands/multiNode.js +438 -0
  47. package/dist/commands/multiNode.js.map +1 -0
  48. package/dist/commands/notifications.d.ts +1 -1
  49. package/dist/commands/operations.d.ts +89 -0
  50. package/dist/commands/operations.d.ts.map +1 -0
  51. package/dist/commands/operations.js +468 -0
  52. package/dist/commands/operations.js.map +1 -0
  53. package/dist/commands/panelBackup.d.ts +28 -0
  54. package/dist/commands/panelBackup.d.ts.map +1 -0
  55. package/dist/commands/panelBackup.js +153 -0
  56. package/dist/commands/panelBackup.js.map +1 -0
  57. package/dist/commands/plugins.d.ts +1 -1
  58. package/dist/commands/plugins.d.ts.map +1 -1
  59. package/dist/commands/plugins.js +7 -3
  60. package/dist/commands/plugins.js.map +1 -1
  61. package/dist/commands/proxyConfig.d.ts +16 -0
  62. package/dist/commands/proxyConfig.d.ts.map +1 -0
  63. package/dist/commands/proxyConfig.js +122 -0
  64. package/dist/commands/proxyConfig.js.map +1 -0
  65. package/dist/commands/secretProviders.d.ts +59 -0
  66. package/dist/commands/secretProviders.d.ts.map +1 -0
  67. package/dist/commands/secretProviders.js +212 -0
  68. package/dist/commands/secretProviders.js.map +1 -0
  69. package/dist/commands/services.d.ts.map +1 -1
  70. package/dist/commands/services.js +3 -5
  71. package/dist/commands/services.js.map +1 -1
  72. package/dist/commands/setup.d.ts +3 -1
  73. package/dist/commands/setup.d.ts.map +1 -1
  74. package/dist/commands/setup.js +19 -3
  75. package/dist/commands/setup.js.map +1 -1
  76. package/dist/commands/sources.d.ts +10 -3
  77. package/dist/commands/sources.d.ts.map +1 -1
  78. package/dist/commands/sources.js +116 -18
  79. package/dist/commands/sources.js.map +1 -1
  80. package/dist/commands/templates.d.ts.map +1 -1
  81. package/dist/commands/templates.js +8 -1
  82. package/dist/commands/templates.js.map +1 -1
  83. package/dist/config.d.ts.map +1 -1
  84. package/dist/config.js +2 -1
  85. package/dist/config.js.map +1 -1
  86. package/dist/index.js +427 -23
  87. package/dist/index.js.map +1 -1
  88. package/dist/lib/format.d.ts.map +1 -1
  89. package/dist/lib/format.js +5 -4
  90. package/dist/lib/format.js.map +1 -1
  91. package/dist/lib/panelBackupFile.d.ts +3 -0
  92. package/dist/lib/panelBackupFile.d.ts.map +1 -0
  93. package/dist/lib/panelBackupFile.js +71 -0
  94. package/dist/lib/panelBackupFile.js.map +1 -0
  95. package/dist/lib/serverRunner.d.ts +6 -0
  96. package/dist/lib/serverRunner.d.ts.map +1 -1
  97. package/dist/lib/serverRunner.js +18 -3
  98. package/dist/lib/serverRunner.js.map +1 -1
  99. package/dist/prompts.d.ts.map +1 -1
  100. package/dist/prompts.js +9 -2
  101. package/dist/prompts.js.map +1 -1
  102. package/package.json +6 -5
  103. package/src/client.ts +137 -0
  104. package/src/commands/backupPolicy.ts +123 -0
  105. package/src/commands/branding.ts +83 -0
  106. package/src/commands/buildCache.ts +72 -0
  107. package/src/commands/certificates.ts +208 -0
  108. package/src/commands/communityTemplates.ts +97 -0
  109. package/src/commands/configCenter.ts +71 -0
  110. package/src/commands/configPresets.ts +215 -0
  111. package/src/commands/databaseAccess.ts +333 -0
  112. package/src/commands/demo.ts +22 -0
  113. package/src/commands/deploy.ts +284 -0
  114. package/src/commands/doctor.ts +170 -0
  115. package/src/commands/domains.ts +170 -0
  116. package/src/commands/egress.ts +135 -0
  117. package/src/commands/emailTemplates.ts +112 -0
  118. package/src/commands/firewall.ts +82 -0
  119. package/src/commands/githubApps.ts +292 -0
  120. package/src/commands/housekeeping.ts +106 -0
  121. package/src/commands/login.ts +58 -0
  122. package/src/commands/logs.ts +91 -0
  123. package/src/commands/manage.ts +714 -0
  124. package/src/commands/manifest.ts +314 -0
  125. package/src/commands/metrics.ts +69 -0
  126. package/src/commands/misc.ts +307 -0
  127. package/src/commands/multiNode.ts +442 -0
  128. package/src/commands/notifications.ts +174 -0
  129. package/src/commands/operations.ts +519 -0
  130. package/src/commands/panelBackup.ts +167 -0
  131. package/src/commands/pgbouncer.ts +65 -0
  132. package/src/commands/plugins.ts +123 -0
  133. package/src/commands/proxyConfig.ts +117 -0
  134. package/src/commands/secretProviders.ts +228 -0
  135. package/src/commands/server.ts +126 -0
  136. package/src/commands/services.ts +249 -0
  137. package/src/commands/setup.ts +94 -0
  138. package/src/commands/sources.ts +317 -0
  139. package/src/commands/sso.ts +106 -0
  140. package/src/commands/templates.ts +158 -0
  141. package/src/commands/token.ts +66 -0
  142. package/src/commands/webhooks.ts +93 -0
  143. package/src/commands/workspaces.ts +63 -0
  144. package/src/config.ts +53 -0
  145. package/src/index.ts +1273 -0
  146. package/src/lib/format.ts +182 -0
  147. package/src/lib/panelBackupFile.ts +67 -0
  148. package/src/lib/serverRunner.ts +223 -0
  149. package/src/prompts.ts +238 -0
@@ -0,0 +1,519 @@
1
+ import process from 'node:process';
2
+ import type {
3
+ AccessGrant,
4
+ AccessGrantCreate,
5
+ AccessGrantRole,
6
+ AccessGrantUpdate,
7
+ CreateTerminalSessionInput,
8
+ TerminalSession,
9
+ TerminalSessionStatus,
10
+ TerminalSocketFactory,
11
+ TerminalSocketLike,
12
+ TerminalTargetKind,
13
+ TrafficRange,
14
+ TrafficScopeTotal,
15
+ TrafficSettingsView,
16
+ } from '@ninedeploy/sdk';
17
+ import type { NineDeployClient } from '../client.js';
18
+ import { prompt, promptHidden } from '../prompts.js';
19
+ import { c, error, fmtBytes, fmtTime, header, info, kv, spinner, success, table } from '../lib/format.js';
20
+ import { plain } from './sources.js';
21
+
22
+ /**
23
+ * 0.15 operations surfaces:
24
+ *
25
+ * ninedeploy terminals list|show <id>|kill <id>
26
+ * ninedeploy terminal service <id> | db <id> [--client] | container <name> | host [serverId]
27
+ * ninedeploy traffic settings [--enable|--disable] [--retention <days>] | summary | service <id>
28
+ * ninedeploy access grants list|add|update <grantId> [--role|--suspend|--reinstate]|remove <grantId> --workspace <id>
29
+ * ninedeploy access me
30
+ *
31
+ * Terminals and the instance traffic routes are operator only on the
32
+ * server. The host-shell password is read with a hidden prompt, never from
33
+ * argv. Every server-provided string goes through the F1011 sanitiser.
34
+ */
35
+
36
+ const message = (err: unknown): string => plain(err instanceof Error ? err.message : String(err));
37
+
38
+ function positiveInt(raw: string | undefined, what: string): number | null {
39
+ const n = Number(raw);
40
+ if (!raw || !Number.isInteger(n) || n <= 0) {
41
+ error(`${what} must be a positive integer`);
42
+ return null;
43
+ }
44
+ return n;
45
+ }
46
+
47
+ const STATUSES: TerminalSessionStatus[] = ['pending', 'active', 'ended', 'failed', 'expired'];
48
+ const KINDS: TerminalTargetKind[] = ['service', 'database', 'container', 'host'];
49
+ const RANGES: TrafficRange[] = ['1h', '24h', '7d', '30d'];
50
+ const ROLES: AccessGrantRole[] = ['viewer', 'member', 'admin'];
51
+
52
+ function duration(ms: number | null): string {
53
+ if (ms === null) return '—';
54
+ const s = Math.round(ms / 1000);
55
+ return s < 60 ? `${s}s` : s < 3600 ? `${Math.floor(s / 60)}m ${s % 60}s` : `${Math.floor(s / 3600)}h ${Math.floor((s % 3600) / 60)}m`;
56
+ }
57
+
58
+ // ── terminals ─────────────────────────────────────────────────────────────
59
+
60
+ export interface TerminalsListOptions {
61
+ status?: string;
62
+ target?: string;
63
+ limit?: string;
64
+ before?: string;
65
+ }
66
+
67
+ /** `ninedeploy terminals list [--status] [--target] [--limit] [--before]` */
68
+ export async function terminalsList(client: NineDeployClient, opts: TerminalsListOptions = {}): Promise<void> {
69
+ if (opts.status && !STATUSES.includes(opts.status as TerminalSessionStatus)) return error(`--status must be one of ${STATUSES.join(', ')}`);
70
+ if (opts.target && !KINDS.includes(opts.target as TerminalTargetKind)) return error(`--target must be one of ${KINDS.join(', ')}`);
71
+ const limit = opts.limit === undefined ? undefined : positiveInt(opts.limit, '--limit');
72
+ const before = opts.before === undefined ? undefined : positiveInt(opts.before, '--before');
73
+ if (limit === null || before === null) return;
74
+ try {
75
+ const page = await spinner('Loading terminal sessions', () =>
76
+ client.terminals.list({
77
+ ...(opts.status ? { status: opts.status as TerminalSessionStatus } : {}),
78
+ ...(opts.target ? { targetKind: opts.target as TerminalTargetKind } : {}),
79
+ ...(limit !== undefined ? { limit } : {}),
80
+ ...(before !== undefined ? { before } : {}),
81
+ }),
82
+ );
83
+ header('Terminal sessions');
84
+ table(
85
+ page.items.map((s) => ({
86
+ id: s.id,
87
+ target: `${s.targetKind}: ${plain(s.targetLabel)}`,
88
+ user: plain(s.userEmail ?? '—'),
89
+ status: s.status,
90
+ started: fmtTime(s.startedAt ?? s.createdAt),
91
+ duration: duration(s.durationMs),
92
+ ended: s.endReason ? plain(s.endReason) : '',
93
+ })),
94
+ ['id', 'target', 'user', 'status', 'started', 'duration', 'ended'],
95
+ );
96
+ if (page.nextBefore !== null) info(`Older sessions: ninedeploy terminals list --before ${page.nextBefore}`);
97
+ } catch (err) {
98
+ error(message(err));
99
+ }
100
+ }
101
+
102
+ function printSession(s: TerminalSession): void {
103
+ kv('Session', `#${s.id}`);
104
+ kv('Target', `${s.targetKind}: ${plain(s.targetLabel)}`);
105
+ kv('Node', s.serverId === null ? 'panel host' : `#${s.serverId}`);
106
+ kv('User', s.userEmail === null ? null : plain(s.userEmail));
107
+ kv('Status', s.status);
108
+ kv('Created', s.createdAt);
109
+ kv('Started', s.startedAt);
110
+ kv('Ended', s.endedAt);
111
+ kv('Duration', duration(s.durationMs));
112
+ kv('Bytes in/out', `${fmtBytes(s.bytesIn)} / ${fmtBytes(s.bytesOut)}`);
113
+ kv('End reason', s.endReason === null ? null : plain(s.endReason));
114
+ kv('Exit code', s.exitCode);
115
+ kv('Client IP', s.clientIp);
116
+ }
117
+
118
+ /** `ninedeploy terminals show <id>` */
119
+ export async function terminalsShow(client: NineDeployClient, idRaw: string): Promise<void> {
120
+ const id = positiveInt(idRaw, 'Session id');
121
+ if (id === null) return;
122
+ try {
123
+ const s = await spinner('Loading the session', () => client.terminals.get(id));
124
+ header(`Terminal session #${s.id}`);
125
+ printSession(s);
126
+ } catch (err) {
127
+ error(message(err));
128
+ }
129
+ }
130
+
131
+ /** `ninedeploy terminals kill <id> [--yes]` */
132
+ export async function terminalsKill(client: NineDeployClient, idRaw: string, opts: { yes?: boolean } = {}): Promise<void> {
133
+ const id = positiveInt(idRaw, 'Session id');
134
+ if (id === null) return;
135
+ if (!opts.yes) {
136
+ const answer = await prompt(`Terminate terminal session #${id}? Type "yes" to confirm`);
137
+ if (answer.trim().toLowerCase() !== 'yes') return info('Cancelled');
138
+ }
139
+ try {
140
+ const res = await spinner('Terminating', () => client.terminals.terminate(id));
141
+ success(res.wasLive ? `Session #${id} terminated` : `Pending session #${id} revoked`);
142
+ } catch (err) {
143
+ error(message(err));
144
+ }
145
+ }
146
+
147
+ // ── interactive terminal ──────────────────────────────────────────────────
148
+
149
+ /** The process streams the interactive terminal drives (injectable for tests). */
150
+ export interface TerminalIo {
151
+ stdin: NodeJS.ReadStream;
152
+ stdout: NodeJS.WriteStream;
153
+ stderr: NodeJS.WriteStream;
154
+ /** Builds the attach socket; defaults to the `ws` package (sends no Origin). */
155
+ socketFactory?: TerminalSocketFactory;
156
+ }
157
+
158
+ async function wsSocketFactory(): Promise<TerminalSocketFactory> {
159
+ const { WebSocket } = await import('ws');
160
+ return (url, protocols) => new WebSocket(url, protocols) as unknown as TerminalSocketLike;
161
+ }
162
+
163
+ export interface TerminalOpenOptions {
164
+ replica?: string;
165
+ node?: string;
166
+ client?: boolean;
167
+ }
168
+
169
+ /** Build the session target from `terminal <kind> <ref>`; prints the usage error and returns null when invalid. */
170
+ export function terminalTarget(kind: string, ref: string | undefined, opts: TerminalOpenOptions = {}): CreateTerminalSessionInput['target'] | null {
171
+ switch (kind) {
172
+ case 'service': {
173
+ const serviceId = positiveInt(ref, 'Service id');
174
+ if (serviceId === null) return null;
175
+ const replica = opts.replica === undefined ? undefined : positiveInt(opts.replica, '--replica');
176
+ const serverId = opts.node === undefined ? undefined : positiveInt(opts.node, '--node');
177
+ if (replica === null || serverId === null) return null;
178
+ return { kind: 'service', serviceId, ...(replica !== undefined ? { replica } : {}), ...(serverId !== undefined ? { serverId } : {}) };
179
+ }
180
+ case 'db': {
181
+ const databaseId = positiveInt(ref, 'Database id');
182
+ if (databaseId === null) return null;
183
+ return { kind: 'database', databaseId, mode: opts.client ? 'client' : 'shell' };
184
+ }
185
+ case 'container':
186
+ if (!ref || !/^[a-zA-Z0-9][a-zA-Z0-9_.-]{1,127}$/.test(ref)) {
187
+ error('Usage: ninedeploy terminal container <name>');
188
+ return null;
189
+ }
190
+ return { kind: 'container', name: ref };
191
+ case 'host': {
192
+ if (ref === undefined) return { kind: 'host', serverId: null };
193
+ const serverId = positiveInt(ref, 'Server id');
194
+ return serverId === null ? null : { kind: 'host', serverId };
195
+ }
196
+ default:
197
+ error('Usage: ninedeploy terminal service <id> | db <id> | container <name> | host [serverId]');
198
+ return null;
199
+ }
200
+ }
201
+
202
+ /**
203
+ * `ninedeploy terminal <kind> <ref>`: an interactive shell over protocol v1.
204
+ * The local terminal is put in raw mode, window resizes are forwarded, and
205
+ * the shell's exit code becomes the CLI's.
206
+ */
207
+ export async function terminalOpen(
208
+ client: NineDeployClient,
209
+ kind: string,
210
+ ref: string | undefined,
211
+ opts: TerminalOpenOptions = {},
212
+ io: TerminalIo = { stdin: process.stdin, stdout: process.stdout, stderr: process.stderr },
213
+ ): Promise<void> {
214
+ const target = terminalTarget(kind, ref, opts);
215
+ if (target === null) return;
216
+ const { stdin, stdout, stderr } = io;
217
+ if (!stdin.isTTY || typeof stdin.setRawMode !== 'function') {
218
+ return error('An interactive terminal needs a TTY on stdin.');
219
+ }
220
+ let password: string | undefined;
221
+ if (target.kind === 'host') {
222
+ // Step-up for every host shell. SSO-only accounts: leave it empty within 10 minutes of signing in.
223
+ password = (await promptHidden('Your password (host shell re-check)')) || undefined;
224
+ }
225
+ let created: Awaited<ReturnType<NineDeployClient['terminals']['create']>>;
226
+ try {
227
+ created = await client.terminals.create({
228
+ target,
229
+ cols: Math.min(500, Math.max(10, stdout.columns || 120)),
230
+ rows: Math.min(200, Math.max(5, stdout.rows || 32)),
231
+ ...(password ? { password } : {}),
232
+ });
233
+ } catch (err) {
234
+ return error(message(err));
235
+ }
236
+ const socketFactory = io.socketFactory ?? (await wsSocketFactory());
237
+ await new Promise<void>((resolve) => {
238
+ let exitCode: number | null = null;
239
+ const onInput = (chunk: Buffer | string) => conn.write(typeof chunk === 'string' ? chunk : new Uint8Array(chunk));
240
+ const onResize = () => conn.resize(stdout.columns, stdout.rows);
241
+ const conn = client.terminals.connect(
242
+ created,
243
+ {
244
+ onReady: (ready) => {
245
+ stderr.write(c.dim(`Connected to ${plain(ready.target.label)} (session #${ready.sessionId}). The shell's exit closes it.\r\n`));
246
+ },
247
+ onData: (bytes) => stdout.write(bytes),
248
+ onNotice: (text) => stderr.write(`\r\n${c.yellow(plain(text))}\r\n`),
249
+ onExit: (exit) => {
250
+ exitCode = exit.code;
251
+ },
252
+ onClose: (close) => {
253
+ stdin.removeListener('data', onInput);
254
+ stdout.removeListener('resize', onResize);
255
+ stdin.setRawMode(false);
256
+ stdin.pause();
257
+ if (close.code === 1000) {
258
+ process.exitCode = exitCode ?? 0;
259
+ } else {
260
+ stderr.write(`\r\n${c.red(close.message)}\r\n`);
261
+ process.exitCode = 1;
262
+ }
263
+ resolve();
264
+ },
265
+ },
266
+ { socketFactory },
267
+ );
268
+ stdin.setRawMode(true);
269
+ stdin.resume();
270
+ stdin.on('data', onInput);
271
+ stdout.on('resize', onResize);
272
+ });
273
+ }
274
+
275
+ // ── traffic ───────────────────────────────────────────────────────────────
276
+
277
+ function printTrafficSettings(v: TrafficSettingsView): void {
278
+ kv('Enabled', v.enabled ? c.green('yes') : 'no');
279
+ kv('Status', v.status);
280
+ kv('Retention', `${v.retentionDays} days (hour rows; minute rows 48h)`);
281
+ kv('Last ingest', v.lastIngestAt ? fmtTime(v.lastIngestAt) : null);
282
+ kv('Log file', fmtBytes(v.logBytes));
283
+ kv('Malformed', v.malformedLines);
284
+ kv('Log driver', v.dockerLogDriver);
285
+ if (v.lastError) kv('Last error', c.red(plain(v.lastError)));
286
+ }
287
+
288
+ /** `ninedeploy traffic settings [--enable|--disable] [--retention <days>] [--yes]` */
289
+ export async function trafficSettings(
290
+ client: NineDeployClient,
291
+ opts: { enable?: boolean; disable?: boolean; retention?: string; yes?: boolean } = {},
292
+ ): Promise<void> {
293
+ if (opts.enable && opts.disable) return error('Use either --enable or --disable');
294
+ const retentionDays = opts.retention === undefined ? undefined : positiveInt(opts.retention, '--retention');
295
+ if (retentionDays === null) return;
296
+ const enabled = opts.enable ? true : opts.disable ? false : undefined;
297
+ try {
298
+ if (enabled === undefined && retentionDays === undefined) {
299
+ const v = await spinner('Reading traffic settings', () => client.traffic.settings.get());
300
+ header('Traffic analytics');
301
+ printTrafficSettings(v);
302
+ return;
303
+ }
304
+ if (enabled !== undefined && !opts.yes) {
305
+ info('Turning analytics on or off recreates Traefik once: about 1–2 seconds of refused connections on every domain.');
306
+ const answer = await prompt('Continue? Type "yes" to confirm');
307
+ if (answer.trim().toLowerCase() !== 'yes') return info('Cancelled');
308
+ }
309
+ const v = await spinner('Saving traffic settings', () =>
310
+ client.traffic.settings.set({ ...(enabled !== undefined ? { enabled } : {}), ...(retentionDays !== undefined ? { retentionDays } : {}) }),
311
+ );
312
+ success('Traffic settings saved');
313
+ printTrafficSettings(v);
314
+ } catch (err) {
315
+ error(message(err));
316
+ // A recreate can cut this very connection: say how to read the real state.
317
+ if (enabled !== undefined) info('Check the current state with `ninedeploy traffic settings`.');
318
+ }
319
+ }
320
+
321
+ function parseRange(raw: string | undefined): TrafficRange | null | undefined {
322
+ if (raw === undefined) return undefined;
323
+ if (RANGES.includes(raw as TrafficRange)) return raw as TrafficRange;
324
+ error(`--range must be one of ${RANGES.join(', ')}`);
325
+ return null;
326
+ }
327
+
328
+ const ms = (v: number | null) => (v === null ? '—' : `${Math.round(v)}ms`);
329
+ const scopeRows = (rows: TrafficScopeTotal[]) =>
330
+ rows.map((r) => ({
331
+ host: plain(r.host ?? r.scopeKey),
332
+ requests: r.requests,
333
+ '5xx': r.status5xx,
334
+ p95: ms(r.p95Ms),
335
+ sent: fmtBytes(r.bytesOut),
336
+ }));
337
+
338
+ /** `ninedeploy traffic summary [--range] [--top]` */
339
+ export async function trafficSummary(client: NineDeployClient, opts: { range?: string; top?: string } = {}): Promise<void> {
340
+ const range = parseRange(opts.range);
341
+ const top = opts.top === undefined ? undefined : positiveInt(opts.top, '--top');
342
+ if (range === null || top === null) return;
343
+ try {
344
+ const s = await spinner('Loading traffic', () =>
345
+ client.traffic.summary({ ...(range ? { range } : {}), ...(top !== undefined ? { top } : {}) }),
346
+ );
347
+ header(`Traffic (${s.range})`);
348
+ if (!s.enabled) info('Traffic analytics is off. Turn it on with `ninedeploy traffic settings --enable`.');
349
+ kv('Requests', s.totals.requests);
350
+ kv('2xx / 4xx / 5xx', `${s.totals.status2xx} / ${s.totals.status4xx} / ${s.totals.status5xx}`);
351
+ kv('p50 / p95 / p99', `${ms(s.totals.p50Ms)} / ${ms(s.totals.p95Ms)} / ${ms(s.totals.p99Ms)}`);
352
+ kv('Sent', fmtBytes(s.totals.bytesOut));
353
+ console.log();
354
+ table(scopeRows([...s.topDomains, ...[s.panel, s.custom].filter((r) => r !== null)]), ['host', 'requests', '5xx', 'p95', 'sent']);
355
+ } catch (err) {
356
+ error(message(err));
357
+ }
358
+ }
359
+
360
+ /** `ninedeploy traffic service <id> [--range]` */
361
+ export async function trafficService(client: NineDeployClient, idRaw: string, opts: { range?: string } = {}): Promise<void> {
362
+ const id = positiveInt(idRaw, 'Service id');
363
+ const range = parseRange(opts.range);
364
+ if (id === null || range === null) return;
365
+ try {
366
+ const s = await spinner('Loading traffic', () => client.traffic.service(id, range ? { range } : {}));
367
+ header(`Service #${id} traffic (${s.range})`);
368
+ if (!s.enabled) return info('Traffic analytics is off on this panel.');
369
+ kv('Requests', s.totals.requests);
370
+ kv('5xx', s.totals.status5xx);
371
+ kv('p50 / p95 / p99', `${ms(s.totals.p50Ms)} / ${ms(s.totals.p95Ms)} / ${ms(s.totals.p99Ms)}`);
372
+ console.log();
373
+ table(scopeRows(s.domains), ['host', 'requests', '5xx', 'p95', 'sent']);
374
+ } catch (err) {
375
+ error(message(err));
376
+ }
377
+ }
378
+
379
+ // ── access grants ─────────────────────────────────────────────────────────
380
+
381
+ const grantTarget = (g: AccessGrant) =>
382
+ [g.project ? `project ${plain(g.project.name)}` : null, g.environment ? `env ${plain(g.environment.name)}` : null].filter(Boolean).join(' · ');
383
+
384
+ function printGrants(grants: AccessGrant[]): void {
385
+ table(
386
+ grants.map((g) => ({
387
+ id: g.id,
388
+ workspace: g.workspaceId,
389
+ user: plain(g.user.email),
390
+ target: grantTarget(g),
391
+ role: g.role,
392
+ flags: [g.isGuest ? 'guest' : '', g.suspended ? 'suspended' : ''].filter(Boolean).join(','),
393
+ })),
394
+ ['id', 'workspace', 'user', 'target', 'role', 'flags'],
395
+ );
396
+ }
397
+
398
+ function parseRole(raw: string | undefined): AccessGrantRole | null {
399
+ if (raw && ROLES.includes(raw as AccessGrantRole)) return raw as AccessGrantRole;
400
+ error(`--role must be one of ${ROLES.join(', ')}`);
401
+ return null;
402
+ }
403
+
404
+ /** `ninedeploy access grants list --workspace <id> [--user <id>] [--project <id>]` */
405
+ export async function accessGrantsList(client: NineDeployClient, opts: { workspace?: string; user?: string; project?: string; environment?: string } = {}): Promise<void> {
406
+ const workspaceId = positiveInt(opts.workspace, '--workspace');
407
+ if (workspaceId === null) return;
408
+ const filters: Record<string, number> = {};
409
+ for (const [flag, key] of [['user', 'userId'], ['project', 'projectId'], ['environment', 'environmentId']] as const) {
410
+ if (opts[flag] === undefined) continue;
411
+ const v = positiveInt(opts[flag], `--${flag}`);
412
+ if (v === null) return;
413
+ filters[key] = v;
414
+ }
415
+ try {
416
+ const grants = await spinner('Loading access grants', () => client.accessGrants.list(workspaceId, filters));
417
+ header(`Access grants in workspace #${workspaceId}`);
418
+ printGrants(grants);
419
+ } catch (err) {
420
+ error(message(err));
421
+ }
422
+ }
423
+
424
+ /** `ninedeploy access grants add --workspace <id> (--email|--user) (--project|--environment) --role <role>` */
425
+ export async function accessGrantsAdd(
426
+ client: NineDeployClient,
427
+ opts: { workspace?: string; email?: string; user?: string; project?: string; environment?: string; role?: string } = {},
428
+ ): Promise<void> {
429
+ const workspaceId = positiveInt(opts.workspace, '--workspace');
430
+ if (workspaceId === null) return;
431
+ if ((opts.email === undefined) === (opts.user === undefined)) return error('Name the user with exactly one of --email or --user');
432
+ if (opts.project === undefined && opts.environment === undefined) return error('A grant needs --project, --environment, or both');
433
+ const role = parseRole(opts.role);
434
+ if (role === null) return;
435
+ const input: AccessGrantCreate = { role };
436
+ if (opts.email !== undefined) input.email = opts.email.trim().toLowerCase();
437
+ for (const [flag, key] of [['user', 'userId'], ['project', 'projectId'], ['environment', 'environmentId']] as const) {
438
+ if (opts[flag] === undefined) continue;
439
+ const v = positiveInt(opts[flag], `--${flag}`);
440
+ if (v === null) return;
441
+ input[key] = v;
442
+ }
443
+ try {
444
+ const g = await spinner('Granting access', () => client.accessGrants.create(workspaceId, input));
445
+ success(`Grant #${g.id}: ${plain(g.user.email)} is ${g.role} on ${grantTarget(g)}${g.isGuest ? ' (guest)' : ''}`);
446
+ } catch (err) {
447
+ error(message(err));
448
+ }
449
+ }
450
+
451
+ /**
452
+ * `ninedeploy access grants update <grantId> --workspace <id> [--role <role>] [--suspend | --reinstate]`
453
+ *
454
+ * A suspended grant stays listed but gives no access; `--reinstate` makes it
455
+ * count again (refused while the user's identity provider has them suspended).
456
+ */
457
+ export async function accessGrantsUpdate(
458
+ client: NineDeployClient,
459
+ grantRaw: string,
460
+ opts: { workspace?: string; role?: string; suspend?: boolean; reinstate?: boolean } = {},
461
+ ): Promise<void> {
462
+ const workspaceId = positiveInt(opts.workspace, '--workspace');
463
+ const grantId = workspaceId === null ? null : positiveInt(grantRaw, 'Grant id');
464
+ if (workspaceId === null || grantId === null) return;
465
+ if (opts.suspend && opts.reinstate) return error('Pass only one of --suspend or --reinstate');
466
+ const suspended = opts.suspend ? true : opts.reinstate ? false : undefined;
467
+ if (opts.role === undefined && suspended === undefined) return error('Pass --role, --suspend or --reinstate');
468
+ const input: AccessGrantUpdate = {};
469
+ if (opts.role !== undefined) {
470
+ const role = parseRole(opts.role);
471
+ if (role === null) return;
472
+ input.role = role;
473
+ }
474
+ if (suspended !== undefined) input.suspended = suspended;
475
+ try {
476
+ const g = await spinner(suspended === undefined ? 'Changing the role' : 'Updating the grant', () =>
477
+ client.accessGrants.update(workspaceId, grantId, input),
478
+ );
479
+ if (suspended === undefined) success(`Grant #${g.id} is now ${g.role}`);
480
+ else success(`Grant #${g.id} is now ${input.role !== undefined ? `${g.role}, ` : ''}${g.suspended ? 'suspended' : 'active'}`);
481
+ } catch (err) {
482
+ error(message(err));
483
+ }
484
+ }
485
+
486
+ /** `ninedeploy access grants remove <grantId> --workspace <id> [--yes]` */
487
+ export async function accessGrantsRemove(client: NineDeployClient, grantRaw: string, opts: { workspace?: string; yes?: boolean } = {}): Promise<void> {
488
+ const workspaceId = positiveInt(opts.workspace, '--workspace');
489
+ const grantId = workspaceId === null ? null : positiveInt(grantRaw, 'Grant id');
490
+ if (workspaceId === null || grantId === null) return;
491
+ if (!opts.yes) {
492
+ const answer = await prompt(`Revoke grant #${grantId}? Type "yes" to confirm`);
493
+ if (answer.trim().toLowerCase() !== 'yes') return info('Cancelled');
494
+ }
495
+ try {
496
+ await spinner('Revoking', () => client.accessGrants.delete(workspaceId, grantId));
497
+ success(`Grant #${grantId} revoked`);
498
+ } catch (err) {
499
+ error(message(err));
500
+ }
501
+ }
502
+
503
+ /** `ninedeploy access me` */
504
+ export async function accessMe(client: NineDeployClient): Promise<void> {
505
+ try {
506
+ const me = await spinner('Loading your access', () => client.access.me());
507
+ header('Your access grants');
508
+ printGrants(me.grants);
509
+ if (me.guestWorkspaces.length > 0) {
510
+ header('Guest workspaces (reached through grants only)');
511
+ table(
512
+ me.guestWorkspaces.map((w) => ({ id: w.id, name: plain(w.name), slug: plain(w.slug) })),
513
+ ['id', 'name', 'slug'],
514
+ );
515
+ }
516
+ } catch (err) {
517
+ error(message(err));
518
+ }
519
+ }
@@ -0,0 +1,167 @@
1
+ /**
2
+ * `ninedeploy system panel-backup {status,set,now,list,decrypt}` — the panel's
3
+ * own scheduled, passphrase-sealed backup (0.12). `decrypt` works offline: it
4
+ * is the restore path on a new server (decrypt, then `ninedeploy system import`).
5
+ */
6
+ import type { PanelBackupSettingsPatch, PanelBackupStatus } from '@ninedeploy/sdk';
7
+ import type { NineDeployClient } from '../client.js';
8
+ import { error, fmtBytes, fmtTime, header, info, kv, success, table } from '../lib/format.js';
9
+ import { decryptPanelBackup } from '../lib/panelBackupFile.js';
10
+ import { promptHidden } from '../prompts.js';
11
+ import { plain } from './sources.js';
12
+
13
+ // Destination errors are relayed provider text: they pass through the F1011 sanitiser.
14
+ const message = (err: unknown): string => plain(err instanceof Error ? err.message : String(err));
15
+
16
+ function printStatus(s: PanelBackupStatus): void {
17
+ kv('Enabled', s.settings.enabled ? 'yes' : 'no');
18
+ kv('Schedule', s.settings.cron);
19
+ kv('Destination', s.settings.destinationId ?? '—');
20
+ kv('Keep', s.settings.retain);
21
+ kv('Passphrase', s.settings.hasPassphrase ? 'set' : 'NOT SET');
22
+ kv('Next run', s.nextRunAt ? fmtTime(s.nextRunAt) : '—');
23
+ kv('Running', s.running ? 'yes' : 'no');
24
+ if (s.lastRun) {
25
+ kv('Last run', `${s.lastRun.status} (${s.lastRun.trigger}) ${fmtTime(s.lastRun.finishedAt ?? s.lastRun.startedAt)}`);
26
+ if (s.lastRun.error) kv('Last error', plain(s.lastRun.error));
27
+ if (s.lastRun.warning) kv('Warning', plain(s.lastRun.warning));
28
+ }
29
+ kv('Last success', s.lastSuccessAt ? fmtTime(s.lastSuccessAt) : 'never');
30
+ if (s.masterKeyFromEnv) {
31
+ info('The master key comes from NINEDEPLOY_MASTER_KEY(S): backups do NOT contain it — keep it with the recovery passphrase.');
32
+ }
33
+ }
34
+
35
+ export async function panelBackupStatusAction(client: NineDeployClient): Promise<void> {
36
+ header('Panel backup');
37
+ try {
38
+ printStatus(await client.system.panelBackup.get());
39
+ } catch (err) {
40
+ error(message(err));
41
+ }
42
+ }
43
+
44
+ export interface PanelBackupSetOptions {
45
+ enable?: boolean;
46
+ disable?: boolean;
47
+ cron?: string;
48
+ destination?: string;
49
+ retain?: string;
50
+ passphrase?: boolean;
51
+ }
52
+
53
+ /** Build the PUT body from flags; returns an error string for bad input. */
54
+ export async function panelBackupSetBody(
55
+ opts: PanelBackupSetOptions,
56
+ ask: (msg: string) => Promise<string> = promptHidden,
57
+ ): Promise<PanelBackupSettingsPatch | string> {
58
+ if (opts.enable && opts.disable) return 'Pass --enable or --disable, not both';
59
+ const body: PanelBackupSettingsPatch = {};
60
+ if (opts.enable) body.enabled = true;
61
+ if (opts.disable) body.enabled = false;
62
+ if (opts.cron !== undefined) body.cron = opts.cron;
63
+ if (opts.destination !== undefined) {
64
+ const id = Number(opts.destination);
65
+ if (!Number.isInteger(id) || id <= 0) return '--destination must be a backup destination id';
66
+ body.destinationId = id;
67
+ }
68
+ if (opts.retain !== undefined) {
69
+ const n = Number(opts.retain);
70
+ if (!Number.isInteger(n) || n < 1) return '--retain must be a whole number of at least 1';
71
+ body.retain = n;
72
+ }
73
+ if (opts.passphrase) {
74
+ const first = await ask('Recovery passphrase (keep it OFF this server)');
75
+ const again = await ask('Repeat the passphrase');
76
+ if (!first) return 'Empty passphrase';
77
+ if (first !== again) return 'The passphrases do not match';
78
+ body.passphrase = first;
79
+ }
80
+ if (Object.keys(body).length === 0) return 'Nothing to change — see `ninedeploy system panel-backup set --help`';
81
+ return body;
82
+ }
83
+
84
+ export async function panelBackupSetAction(client: NineDeployClient, opts: PanelBackupSetOptions): Promise<void> {
85
+ const body = await panelBackupSetBody(opts);
86
+ if (typeof body === 'string') return error(body);
87
+ try {
88
+ const status = await client.system.panelBackup.update(body);
89
+ success('Panel backup settings saved');
90
+ printStatus(status);
91
+ } catch (err) {
92
+ error(message(err));
93
+ }
94
+ }
95
+
96
+ export async function panelBackupNowAction(
97
+ client: NineDeployClient,
98
+ opts: { wait?: boolean },
99
+ sleep: (ms: number) => Promise<void> = (ms) => new Promise((r) => setTimeout(r, ms)),
100
+ ): Promise<void> {
101
+ try {
102
+ await client.system.panelBackup.run();
103
+ } catch (err) {
104
+ return error(message(err));
105
+ }
106
+ if (!opts.wait) {
107
+ success('Panel backup started — check `ninedeploy system panel-backup status`');
108
+ return;
109
+ }
110
+ info('Panel backup started — waiting for it to finish…');
111
+ // A backup of a large panel database can take a while; poll, bounded.
112
+ for (let i = 0; i < 720; i++) {
113
+ await sleep(2500);
114
+ let s: PanelBackupStatus;
115
+ try {
116
+ s = await client.system.panelBackup.get();
117
+ } catch (err) {
118
+ return error(message(err));
119
+ }
120
+ if (s.running) continue;
121
+ if (s.lastRun?.status === 'completed') {
122
+ success(`Uploaded ${s.lastRun.key} (${fmtBytes(s.lastRun.sizeBytes ?? 0)})`);
123
+ if (s.lastRun.warning) info(s.lastRun.warning);
124
+ return;
125
+ }
126
+ return error(`Panel backup failed: ${s.lastRun?.error ?? 'unknown error'}`);
127
+ }
128
+ error('Stopped waiting after 30 minutes — the backup may still be running.');
129
+ }
130
+
131
+ export async function panelBackupListAction(client: NineDeployClient, opts: { destination?: string }): Promise<void> {
132
+ header('Panel backups');
133
+ const id = opts.destination === undefined ? undefined : Number(opts.destination);
134
+ if (id !== undefined && (!Number.isInteger(id) || id <= 0)) return error('--destination must be a backup destination id');
135
+ try {
136
+ const { items } = await client.system.panelBackup.list(id);
137
+ if (items.length === 0) {
138
+ info('No panel backups in this destination.');
139
+ return;
140
+ }
141
+ table(
142
+ items.map((o) => ({ name: o.name, size: fmtBytes(o.sizeBytes), date: fmtTime(o.lastModified), key: o.key })),
143
+ ['name', 'size', 'date', 'key'],
144
+ );
145
+ } catch (err) {
146
+ error(message(err));
147
+ }
148
+ }
149
+
150
+ /** Offline: open a downloaded `.ndpb` into the `.tar.gz` that `system import` takes. */
151
+ export async function panelBackupDecryptAction(
152
+ file: string,
153
+ out: string | undefined,
154
+ ask: (msg: string) => Promise<string> = promptHidden,
155
+ ): Promise<void> {
156
+ if (!file) return error('Usage: ninedeploy system panel-backup decrypt <file.ndpb> [out.tar.gz]');
157
+ const target = out ?? `${file.replace(/\.ndpb$/i, '')}.tar.gz`;
158
+ const passphrase = await ask('Recovery passphrase');
159
+ if (!passphrase) return error('Empty passphrase');
160
+ try {
161
+ await decryptPanelBackup(file, target, passphrase);
162
+ } catch (err) {
163
+ return error(message(err));
164
+ }
165
+ success(`Decrypted to ${target}`);
166
+ info(`Restore it with: ninedeploy system import ${target} (then restart NineDeploy)`);
167
+ }