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,307 @@
1
+ import type { ManagedDatabase, TemplateSummary } from '@ninedeploy/sdk';
2
+ import type { NineDeployClient } from '../client.js';
3
+ import { c, error, fmtTime, header, info, kv, spinner, success, table, banner } from '../lib/format.js';
4
+ import { prompt } from '../prompts.js';
5
+ import { parseScopes } from './token.js';
6
+
7
+ /** `ninedeploy databases list` */
8
+ export async function dbList(client: NineDeployClient): Promise<void> {
9
+ header('Databases');
10
+ await spinner('Fetching', async () => {
11
+ const dbs = await client.databases.list();
12
+ if (dbs.length === 0) { info('No databases.'); return; }
13
+ table(dbs.map((d: ManagedDatabase) => ({ id: d.id, name: d.name, engine: d.engine, status: d.status, port: d.port ?? '—' })), ['id', 'name', 'engine', 'status', 'port']);
14
+ });
15
+ }
16
+
17
+ /** `ninedeploy databases create [--server <id>]` (0.16 T8: `--server` runs it on a node; operator) */
18
+ export async function dbCreate(client: NineDeployClient, opts: { server?: string } = {}): Promise<void> {
19
+ let serverId: number | undefined;
20
+ if (opts.server !== undefined) {
21
+ if (!/^[1-9]\d*$/.test(opts.server)) return error('--server must be a positive integer (a server id from `ninedeploy servers list`)');
22
+ serverId = Number(opts.server);
23
+ }
24
+ header(serverId === undefined ? 'Create Database' : `Create Database on server #${serverId}`);
25
+ const name = await prompt('Database name');
26
+ if (!name) return error('Name required');
27
+ console.log(' Engines: 1=PostgreSQL 2=MySQL 3=MariaDB 4=Redis 5=MongoDB 6=Valkey 7=ClickHouse 8=Meilisearch 9=RabbitMQ 10=KeyDB 11=Dragonfly');
28
+ const choice = (await prompt('Select engine (1-11 or name)', '1')).trim();
29
+ const engines = ['postgres', 'mysql', 'mariadb', 'redis', 'mongo', 'valkey', 'clickhouse', 'meilisearch', 'rabbitmq', 'keydb', 'dragonfly'] as const;
30
+ // Accept the menu number OR the engine name (`postgres`, `MYSQL`…) —
31
+ // first-time users type the name they just read in the hint above.
32
+ const engine = /^\d+$/.test(choice)
33
+ ? engines[Number(choice) - 1]
34
+ : engines.find((candidate) => candidate === choice.toLowerCase());
35
+ // A fat-fingered number used to silently provision PostgreSQL — fail
36
+ // instead of guessing.
37
+ if (!engine) return error(`Unknown engine selection: ${choice}`);
38
+ try {
39
+ const db = await spinner('Creating database', () => client.databases.create({ name, engine, ...(serverId !== undefined ? { serverId } : {}) }));
40
+ success(`Database "${db.name}" created (id: ${db.id})`);
41
+ if (db.connectionString) { info(`Connection: ${c.cyan(db.connectionString)}`); }
42
+ } catch (err) { error(err instanceof Error ? err.message : String(err)); }
43
+ }
44
+
45
+ /** `ninedeploy templates list` */
46
+ export async function tplList(client: NineDeployClient): Promise<void> {
47
+ header('Template Hub');
48
+ await spinner('Fetching templates', async () => {
49
+ const templates = await client.templates.list();
50
+ if (templates.length === 0) { info('No templates.'); return; }
51
+ table(templates.map((t: TemplateSummary) => ({ emoji: t.emoji, name: t.name, category: t.category, featured: t.featured ? '★' : '' })), ['emoji', 'name', 'category', 'featured']);
52
+ });
53
+ }
54
+
55
+ /** `ninedeploy templates deploy <id>` */
56
+ export async function tplDeploy(client: NineDeployClient, id: string): Promise<void> {
57
+ if (!id) return error('Usage: ninedeploy templates deploy <template-id>');
58
+ try {
59
+ const res = await spinner(`Deploying ${id}`, () => client.templates.deploy(id));
60
+ success(`Deployed! Service ID: ${res.serviceId}`);
61
+ info(`Watch: ninedeploy services logs ${res.serviceId}`);
62
+ } catch (err) { error(err instanceof Error ? err.message : String(err)); }
63
+ }
64
+
65
+ /** `ninedeploy deploys list <serviceId>` */
66
+ export async function deploysList(client: NineDeployClient, svcIdStr: string): Promise<void> {
67
+ const svcId = Number(svcIdStr);
68
+ if (!svcId) return error('Usage: ninedeploy deploys list <serviceId>');
69
+ header('Deployments');
70
+ await spinner('Fetching', async () => {
71
+ const deps = await client.deploys.list(svcId);
72
+ if (deps.length === 0) { info('No deployments.'); return; }
73
+ table(deps.map((d) => ({ id: d.id, status: d.status, commit: d.commitSha?.slice(0, 7) ?? '—', trigger: d.trigger, time: fmtTime(d.createdAt) })), ['id', 'status', 'commit', 'trigger', 'time']);
74
+ });
75
+ }
76
+
77
+ /**
78
+ * `ninedeploy deploys queue`
79
+ *
80
+ * Global queue view: every in-flight (queued / building / deploying) deploy
81
+ * across every service the caller can see. The same data the web panel's
82
+ * `/deploys` page renders, so an operator can audit the build pipeline
83
+ * without leaving the terminal or opening a browser tab.
84
+ */
85
+ export async function deploysQueue(client: NineDeployClient): Promise<void> {
86
+ header('Global deploy queue');
87
+ const data = await spinner('Fetching', () => client.deploys.queue());
88
+ const counts = data.byStatus ?? { queued: 0, building: 0, deploying: 0 };
89
+ const inFlight = data.items ?? [];
90
+ if (inFlight.length === 0) {
91
+ info('No in-flight deploys.');
92
+ kv('queued', counts.queued ?? 0);
93
+ kv('building', counts.building ?? 0);
94
+ kv('deploying', counts.deploying ?? 0);
95
+ return;
96
+ }
97
+ // The server returns items ordered claimed-first (building, then
98
+ // deploying, then queued). Per-service queue position is the 1-based
99
+ // index of this row among the `queued` siblings for the same
100
+ // service. In-flight rows (building / deploying) get a dash — the
101
+ // position number is only meaningful for rows waiting to start.
102
+ const queuedByService = new Map<number, number>();
103
+ table(
104
+ inFlight.map((row) => {
105
+ let pos: number;
106
+ if (row.status === 'queued') {
107
+ pos = (queuedByService.get(row.serviceId) ?? 0) + 1;
108
+ queuedByService.set(row.serviceId, pos);
109
+ } else {
110
+ pos = 0;
111
+ }
112
+ return {
113
+ service: row.serviceName || `service:${row.serviceId}`,
114
+ serviceId: row.serviceId,
115
+ deploy: row.id,
116
+ status: row.status,
117
+ pos: pos > 0 ? `${pos}` : '—',
118
+ time: fmtTime(row.createdAt),
119
+ };
120
+ }),
121
+ ['service', 'serviceId', 'deploy', 'status', 'pos', 'time'],
122
+ );
123
+ kv('queued', counts.queued ?? 0);
124
+ kv('building', counts.building ?? 0);
125
+ kv('deploying', counts.deploying ?? 0);
126
+ }
127
+
128
+ /** `ninedeploy deploys rollback <serviceId> <deployId>` */
129
+ export async function deploysRollback(client: NineDeployClient, svcIdStr: string, depIdStr: string): Promise<void> {
130
+ const svcId = Number(svcIdStr);
131
+ const depId = Number(depIdStr);
132
+ if (!svcId || !depId) return error('Usage: ninedeploy deploys rollback <serviceId> <deployId>');
133
+ try {
134
+ const res = await spinner('Rolling back', () => client.deploys.rollback(svcId, depId));
135
+ success(`Rollback to #${depId} queued (new deploy: #${res.deploymentId}).`);
136
+ } catch (err) { error(err instanceof Error ? err.message : String(err)); }
137
+ }
138
+
139
+ /**
140
+ * `ninedeploy deploys cancel <serviceId> <deployId>`
141
+ *
142
+ * The route, the SDK method and the panel button all existed; the CLI was the
143
+ * one surface without it, so a deploy started from CI could only be stopped
144
+ * from a browser.
145
+ */
146
+ export async function deploysCancel(client: NineDeployClient, svcIdStr: string, depIdStr: string): Promise<void> {
147
+ const svcId = Number(svcIdStr);
148
+ const depId = Number(depIdStr);
149
+ if (!svcId || !depId) return error('Usage: ninedeploy deploys cancel <serviceId> <deployId>');
150
+ try {
151
+ await spinner('Cancelling', () => client.deploys.cancel(svcId, depId));
152
+ // A queued deploy stops here; an in-flight one stops at the pipeline's next
153
+ // step boundary, with the previous version still serving.
154
+ success(`Deployment #${depId} cancelled.`);
155
+ } catch (err) { error(err instanceof Error ? err.message : String(err)); }
156
+ }
157
+
158
+ /**
159
+ * `ninedeploy deploys rm <serviceId> <deployId>`
160
+ *
161
+ * Removes a finished deployment from history, with its build log. Refused for
162
+ * an in-flight deployment (cancel it first) and for the one currently serving
163
+ * traffic — that row carries the digest a rollback re-deploys.
164
+ */
165
+ export async function deploysRemove(client: NineDeployClient, svcIdStr: string, depIdStr: string, yes = false): Promise<void> {
166
+ const svcId = Number(svcIdStr);
167
+ const depId = Number(depIdStr);
168
+ if (!svcId || !depId) return error('Usage: ninedeploy deploys rm <serviceId> <deployId>');
169
+ if (!yes) {
170
+ const confirm = await prompt(`Permanently remove deployment #${depId} and its build log? (yes/no)`, 'no');
171
+ if (confirm.toLowerCase() !== 'yes') return info('Aborted.');
172
+ }
173
+ try {
174
+ await spinner('Removing', () => client.deploys.remove(svcId, depId));
175
+ success(`Deployment #${depId} removed.`);
176
+ } catch (err) { error(err instanceof Error ? err.message : String(err)); }
177
+ }
178
+
179
+ /** `ninedeploy token create` */
180
+ export async function tokenCreate(client: NineDeployClient): Promise<void> {
181
+ const name = await prompt('Token name', 'ci');
182
+ // read = safe methods only · write = mutate as a non-operator ·
183
+ // operator = no extra restriction. Blank = read-only (server default).
184
+ // G-08 fine-grained: also accept
185
+ // 'nd://scope/(read|write|admin)/<resource>' for per-resource
186
+ // restriction; the server's scopeCovers() expands the legacy
187
+ // shorthand against the URI form so a `write` token still
188
+ // covers every `nd://scope/write/<r>`.
189
+ const scopes = parseScopes(
190
+ await prompt('Scopes (read,write,operator or nd://scope/(read|write|admin)/<resource> — blank = read)', 'read'),
191
+ );
192
+ try {
193
+ const tok = await spinner('Creating token', () => client.auth.tokens.create(scopes.length ? { name, scopes } : { name }));
194
+ success(`Token created: ${c.cyan(tok.token)}`);
195
+ info(`Scopes: ${tok.scopes.length ? tok.scopes.join(', ') : 'unrestricted (legacy)'}`);
196
+ info('Store this securely — it won\'t be shown again.');
197
+ } catch (err) { error(err instanceof Error ? err.message : String(err)); }
198
+ }
199
+
200
+ /** `ninedeploy token list` */
201
+ export async function tokenList(client: NineDeployClient): Promise<void> {
202
+ header('API Tokens');
203
+ await spinner('Fetching', async () => {
204
+ const tokens = await client.auth.tokens.list();
205
+ if (tokens.length === 0) { info('No tokens.'); return; }
206
+ table(tokens.map((t) => ({ id: t.id, name: t.name, last_used: fmtTime(t.lastUsedAt), created: fmtTime(t.createdAt) })), ['id', 'name', 'last_used', 'created']);
207
+ });
208
+ }
209
+
210
+ /** `ninedeploy system info` */
211
+ export async function systemInfo(client: NineDeployClient): Promise<void> {
212
+ header('System');
213
+ await spinner('Fetching', async () => {
214
+ const about = await client.about.get();
215
+ banner();
216
+ kv('Version', c.bold(`v${about.version}`));
217
+ kv('License', about.license);
218
+ kv('Services', about.stats?.services ?? '—');
219
+ kv('Databases', about.stats?.databases ?? '—');
220
+ kv('Deploys', about.stats?.deployments ?? '—');
221
+ kv('Users', about.stats?.users ?? '—');
222
+ kv('Repo', about.repo);
223
+ console.log();
224
+ header('Tech Stack');
225
+ for (const group of about.techStack) {
226
+ kv(group.category, group.items.join(', '));
227
+ }
228
+ });
229
+ }
230
+
231
+ /** `ninedeploy system update-check` */
232
+ export async function systemUpdateCheck(client: NineDeployClient, force: boolean): Promise<void> {
233
+ header('Update check');
234
+ const res = await spinner('Checking for updates', () => client.system.updateCheck(force));
235
+ kv('Current', c.bold(`v${res.current}`));
236
+ if (res.updateAvailable == null) {
237
+ info('Latest release unknown (feed unreachable or checks disabled).');
238
+ } else if (res.updateAvailable) {
239
+ kv('Latest', c.bold(`v${res.latest}`));
240
+ success(`A new release is available → ${c.cyan(res.notesUrl ?? `https://github.com/ninedeploy/ninedeploy/releases/tag/${res.latest}`)}`);
241
+ info('Upgrade: curl -fsSL https://raw.githubusercontent.com/ninedeploy/ninedeploy/main/install.sh | bash');
242
+ } else {
243
+ kv('Latest', `v${res.latest}`);
244
+ success('You are on the latest release.');
245
+ }
246
+ }
247
+
248
+ /** `ninedeploy system dashboard` */
249
+ export async function systemDashboard(client: NineDeployClient): Promise<void> {
250
+ header('Dashboard');
251
+ await spinner('Probing health', async () => {
252
+ const dash = await client.dashboard.get();
253
+ const s = dash.stats;
254
+ // r426: null health = a remote node's runtime the panel cannot probe —
255
+ // not a failure (the web banner got this in r382; the CLI predicate
256
+ // counted every healthy node service as needing attention).
257
+ const allHealthy = dash.health.every((h) => h.status !== 'running' || h.healthy !== false);
258
+ console.log(` ${allHealthy ? '✅ All systems operational' : '⚠️ Some services need attention'}`);
259
+ console.log();
260
+ kv('Services', `${s.running} running / ${s.services} total`);
261
+ kv('Databases', `${s.dbRunning} running / ${s.databases} total`);
262
+ // r470: whole-node count is operator-only telemetry — members get null.
263
+ kv('Containers', s.containers ?? '— (operator only)');
264
+ kv('Domains', s.domains);
265
+ kv('Webhooks', s.webhooks);
266
+ kv('Deployments', s.deployments);
267
+ console.log();
268
+ header('Service Health');
269
+ if (dash.health.length === 0) { info('No services.'); return; }
270
+ table(dash.health.map((h) => ({ id: h.serviceId, name: h.name, status: h.healthy === true ? 'healthy' : h.healthy === false ? 'unhealthy' : h.healthy == null && h.status === 'running' ? 'on node' : h.status, ms: h.responseMs ? `${h.responseMs}ms` : '—', type: h.type })), ['id', 'name', 'status', 'ms', 'type']);
271
+ console.log();
272
+ header('Recent Deploys');
273
+ if (dash.recentDeploys.length === 0) { info('No deployments.'); return; }
274
+ table(dash.recentDeploys.map((d) => ({ id: d.id, service: d.serviceName, status: d.status, time: fmtTime(d.createdAt) })), ['id', 'service', 'status', 'time']);
275
+ });
276
+ }
277
+
278
+ /**
279
+ * `ninedeploy system rotate-keys`
280
+ *
281
+ * The command `.env.example` has told operators to run since the key-ring
282
+ * landed. It did not exist: `lib/keyRotation.rotateSecrets` was implemented and
283
+ * tested but had no caller anywhere in the product, so anyone who followed the
284
+ * documented rotation procedure and then dropped the retired key version was
285
+ * left holding ciphertext they could no longer decrypt.
286
+ */
287
+ export async function systemRotateKeys(client: NineDeployClient): Promise<void> {
288
+ header('Rotate master key');
289
+ const status = await spinner('Reading key ring', () => client.settings.masterKey.get());
290
+ kv('Active version', c.bold(`v${status.activeVersion}`));
291
+ kv('Ring', status.knownVersions.map((v) => `v${v}`).join(', '));
292
+ if (!status.rotatable) {
293
+ return error(
294
+ 'Only one key version is loaded — there is nothing to rotate onto. Add a new 32-byte key under a higher version in NINEDEPLOY_MASTER_KEYS, restart, then run this again.',
295
+ );
296
+ }
297
+ const confirm = await prompt(`Re-encrypt every stored secret onto v${status.activeVersion}? (yes/no)`, 'no');
298
+ if (confirm.toLowerCase() !== 'yes') return info('Aborted.');
299
+
300
+ const res = await spinner('Re-encrypting', () => client.settings.masterKey.rotate());
301
+ success(`${res.rotated} secret value(s) re-encrypted under v${res.activeVersion}.`);
302
+ if (res.warning) {
303
+ info(res.warning);
304
+ } else {
305
+ info('No stored backups reference an older key version — the retired key can be removed from NINEDEPLOY_MASTER_KEYS.');
306
+ }
307
+ }