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
package/src/index.ts ADDED
@@ -0,0 +1,1273 @@
1
+ #!/usr/bin/env node
2
+ import { createRequire } from 'node:module';
3
+ import { Command } from 'commander';
4
+ import { getClient } from './client.js';
5
+ import { loadConfig, saveConfig } from './config.js';
6
+ import { banner, error } from './lib/format.js';
7
+ import { loginAction } from './commands/login.js';
8
+ import { setupAction } from './commands/setup.js';
9
+ import {
10
+ servicesCompose, servicesCreate, servicesDelete, servicesDeploy, servicesExport,
11
+ servicesGet, servicesInspect, servicesLifecycle, servicesList, servicesLogs,
12
+ servicesStickyAction,
13
+ } from './commands/services.js';
14
+ import {
15
+ dbCreate, dbList, deploysCancel, deploysList, deploysQueue, deploysRemove, deploysRollback,
16
+ systemDashboard, systemInfo, systemRotateKeys, systemUpdateCheck, tplDeploy, tplList,
17
+ tokenCreate, tokenList,
18
+ } from './commands/misc.js';
19
+ import {
20
+ activityList, alertsCreate, alertsList, alertsRemove,
21
+ backupsCreate, backupsDrill, backupsDrills, backupsList, backupsRestore,
22
+ deploysWatch, domainsAcceptTransfer, domainsAdd, domainsCancelTransfer, domainsList,
23
+ domainsPreviewTransfer, domainsRemove, domainsTransfer, domainsVerify,
24
+ envList, envRemove, envSet, networksCreate, networksList, networksRemove,
25
+ sessionsList, sessionsRevoke, systemExport, systemImport,
26
+ usersList, usersResetLink, volumesList, volumesRemove,
27
+ } from './commands/manage.js';
28
+ import {
29
+ notificationsCreateFcm as _notifCreateFcm,
30
+ notificationsCreateWebhook as _notifCreateWebhook,
31
+ notificationsList as _notifList,
32
+ notificationsRemove as _notifRemove,
33
+ notificationsTest as _notifTest,
34
+ } from './commands/notifications.js';
35
+ import { databasePgbouncer } from './commands/pgbouncer.js';
36
+ import { backupPolicyGet, backupPolicySet } from './commands/backupPolicy.js';
37
+ import { logsSearch } from './commands/logs.js';
38
+ import {
39
+ emailTemplatesList,
40
+ emailTemplatesPreview,
41
+ emailTemplatesReset,
42
+ emailTemplatesSet,
43
+ } from './commands/emailTemplates.js';
44
+ import {
45
+ certificatesCustomDelete,
46
+ certificatesCustomList,
47
+ certificatesCustomReplace,
48
+ certificatesCustomUpload,
49
+ certificatesExpiring,
50
+ certificatesList,
51
+ } from './commands/certificates.js';
52
+ import {
53
+ databaseImport, databaseImports, databasePublicAccess, type ImportCliOptions, type PublicAccessOptions,
54
+ } from './commands/databaseAccess.js';
55
+ import { proxyConfigClear, proxyConfigGet, proxyConfigSet, proxyConfigValidate } from './commands/proxyConfig.js';
56
+ import {
57
+ secretProvidersDelete, secretProvidersList, secretProvidersSetAws, secretProvidersSetVault, secretProvidersTest,
58
+ type AwsCliOptions, type VaultCliOptions,
59
+ } from './commands/secretProviders.js';
60
+ import {
61
+ accessGrantsAdd, accessGrantsList, accessGrantsRemove, accessGrantsUpdate, accessMe,
62
+ terminalOpen, terminalsKill, terminalsList, terminalsShow, trafficService, trafficSettings, trafficSummary,
63
+ type TerminalOpenOptions, type TerminalsListOptions,
64
+ } from './commands/operations.js';
65
+ import {
66
+ communityTemplatesImport,
67
+ communityTemplatesList,
68
+ communityTemplatesRemove,
69
+ } from './commands/communityTemplates.js';
70
+ import {
71
+ pluginsList, pluginsMarketplace, pluginsMarketplaceRefresh, pluginsInstall,
72
+ pluginsEnable, pluginsDisable, pluginsUninstall,
73
+ pluginsInspect, pluginsReload,
74
+ } from './commands/plugins.js';
75
+ import {
76
+ configCenterList, configCenterGet, configCenterSet, configCenterDelete,
77
+ } from './commands/configCenter.js';
78
+ import { demoSeed } from './commands/demo.js';
79
+ import {
80
+ workspacesList, workspacesGet, workspacesCreate, workspacesDelete,
81
+ } from './commands/workspaces.js';
82
+ import { housekeepingPrune, imagesList, imagesPrune, positiveIntOption } from './commands/housekeeping.js';
83
+ import {
84
+ serverStartAction, serverStopAction, serverStatusAction, serverLogsAction,
85
+ } from './commands/server.js';
86
+ import { doctorAction } from './commands/doctor.js';
87
+ import {
88
+ firewallStatus, firewallToggle, firewallAddRule, firewallDeleteRule, firewallApplyRecommended,
89
+ } from './commands/firewall.js';
90
+ import {
91
+ sourcesAdd, sourcesKeygen, sourcesList, sourcesRemove, sourcesShow, sourcesTest,
92
+ } from './commands/sources.js';
93
+ import {
94
+ githubAppAddManual, githubAppList, githubAppRemove, githubAppRotateKey, githubAppShow, githubAppSync, serviceGithub,
95
+ } from './commands/githubApps.js';
96
+ import {
97
+ webhooksAdd, webhooksList, webhooksRemove, webhooksShow,
98
+ } from './commands/webhooks.js';
99
+ import { deployFromGithub } from './commands/deploy.js';
100
+ import { manifestApply, manifestInit, manifestShow, manifestValidate } from './commands/manifest.js';
101
+ import { templatesInit } from './commands/templates.js';
102
+ import {
103
+ domainsPresetAddNamecheapAction,
104
+ domainsPresetApplyAction,
105
+ domainsPresetListAction,
106
+ } from './commands/domains.js';
107
+ import {
108
+ configPresetApplyAction, configPresetGetAction,
109
+ configPresetListAction, configPresetRegisterAction, configPresetRemoveAction,
110
+ } from './commands/configPresets.js';
111
+ import {
112
+ metricsFlushAction, metricsShowAction,
113
+ } from './commands/metrics.js';
114
+ import {
115
+ buildCacheStatsAction,
116
+ } from './commands/buildCache.js';
117
+ import {
118
+ brandingGetAction, brandingSetAction,
119
+ } from './commands/branding.js';
120
+ import {
121
+ egressClearAction, egressListAction, egressSetAction,
122
+ } from './commands/egress.js';
123
+ import {
124
+ ssoAddAction, ssoListAction, ssoRemoveAction,
125
+ } from './commands/sso.js';
126
+ import {
127
+ panelBackupDecryptAction, panelBackupListAction, panelBackupNowAction, panelBackupSetAction, panelBackupStatusAction,
128
+ type PanelBackupSetOptions,
129
+ } from './commands/panelBackup.js';
130
+ // ── 0.16 T8 surfaces ──
131
+ import {
132
+ serversList, serversRoles, servicesPlacement, servicesSwarm, servicesTransfers, sourcesAllowOnNodes, sourcesHostKeys, sourcesPinHostKeys, sourcesResetHostKeys,
133
+ swarmInit, swarmJoin, swarmLeave, swarmSetEnabled, swarmStatus, volumesCreate,
134
+ type PlacementOptions,
135
+ } from './commands/multiNode.js';
136
+ // ── end 0.16 T8 ──
137
+
138
+ const program = new Command();
139
+
140
+ program
141
+ .name('ninedeploy')
142
+ .description('NineDeploy — self-hosted deployment platform CLI\n\n Deploy apps from Git or Docker Hub in one click.')
143
+ // r195: read from package.json — the literal was stuck at 0.7.9.
144
+ .version((createRequire(import.meta.url)('../package.json') as { version: string }).version)
145
+ .helpOption('-h, --help', 'Display this help');
146
+
147
+ // ── Auth ──────────────────────────────────────────────────────────────────
148
+ program
149
+ .command('init')
150
+ .description('Initialize local instance and setup admin user (alias for setup)')
151
+ .option('--setup-token <token>', 'One-time setup token printed by the installer (or NINEDEPLOY_SETUP_TOKEN)')
152
+ .action((opts?: { setupToken?: string }) => setupAction(opts));
153
+
154
+ program
155
+ .command('setup')
156
+ .description('Create the first admin user on a fresh instance')
157
+ .option('--setup-token <token>', 'One-time setup token printed by the installer (or NINEDEPLOY_SETUP_TOKEN)')
158
+ .action((opts?: { setupToken?: string }) => setupAction(opts));
159
+
160
+ program
161
+ .command('login')
162
+ .description('Authenticate against a NineDeploy server')
163
+ .action(() => loginAction());
164
+
165
+ program
166
+ .command('logout')
167
+ .description('Revoke this CLI session and clear stored credentials')
168
+ .action(async () => {
169
+ const cfg = loadConfig();
170
+ // r551: revoke ONLY this CLI's session. `POST /auth/logout` bumps the
171
+ // user's tokenVersion and revokes every session — signing out of the CLI
172
+ // used to sign the user out of every browser too. It is also on the
173
+ // no-refresh list, so with an expired access token it failed silently and
174
+ // the long-lived refresh token stayed live server-side. The sessions list
175
+ // is refresh-aware and flags the caller's own row (`current`), which
176
+ // `DELETE /auth/sessions/:id` then revokes. Best-effort: local credentials
177
+ // are always cleared, and the outcome is printed either way.
178
+ let outcome = 'No stored credentials — nothing to revoke on the server.';
179
+ if (cfg.token || cfg.refreshToken) {
180
+ try {
181
+ const client = getClient();
182
+ const current = (await client.auth.sessions.list()).find((s) => s.current);
183
+ if (current) {
184
+ await client.auth.sessions.revoke(current.id);
185
+ outcome = `Revoked this CLI session (#${current.id}) on ${cfg.baseUrl}; other sessions stay signed in.`;
186
+ } else {
187
+ outcome = 'The stored credential is not a login session (API token?) — nothing was revoked server-side; delete the token from the dashboard if it should stop working.';
188
+ }
189
+ } catch (err) {
190
+ const msg = err instanceof Error ? err.message : String(err);
191
+ outcome = `Could not revoke the session on the server (${msg}). Revoke it from the dashboard or \`ninedeploy sessions revoke <id>\` after logging in again.`;
192
+ }
193
+ }
194
+ saveConfig({ baseUrl: cfg.baseUrl });
195
+ console.log(' ✓ Signed out (local credentials cleared).');
196
+ console.log(` ${outcome}`);
197
+ });
198
+
199
+ program
200
+ .command('whoami')
201
+ .description('Show the currently authenticated user and server')
202
+ .action(async () => {
203
+ const cfg = loadConfig();
204
+ if (!cfg.token) { console.log(' Not logged in. Run `ninedeploy login`.'); process.exit(1); }
205
+ try {
206
+ const user = await getClient().auth.me();
207
+ console.log(` ${user.email} (${user.isOperator ? 'operator' : 'member'}) @ ${cfg.baseUrl}`);
208
+ } catch (err) {
209
+ const msg = err instanceof Error ? err.message : String(err);
210
+ console.log(` Could not reach the server (${msg}). Check the URL/network, or run \`ninedeploy login\` if the token expired.`);
211
+ process.exit(1);
212
+ }
213
+ });
214
+
215
+ program
216
+ .command('config')
217
+ .description('Show or change the server URL')
218
+ .option('-s, --server <url>', 'Set server URL')
219
+ .action((opts: { server?: string }) => {
220
+ if (opts.server) {
221
+ const cfg = loadConfig();
222
+ // Tokens are issuer-scoped: carrying credentials (especially the
223
+ // refresh token) across a server switch would replay server A's
224
+ // bearer at B. Keep them only when the URL is genuinely unchanged.
225
+ const same = cfg.baseUrl === opts.server;
226
+ saveConfig({
227
+ baseUrl: opts.server,
228
+ token: same ? cfg.token : undefined,
229
+ refreshToken: same ? cfg.refreshToken : undefined,
230
+ });
231
+ console.log(` ✓ Server set to ${opts.server}`);
232
+ } else {
233
+ const cfg = loadConfig();
234
+ console.log(` Server: ${cfg.baseUrl}`);
235
+ console.log(` Token: ${cfg.token ? '✓ configured' : '✗ not set'}`);
236
+ }
237
+ });
238
+
239
+ // ── Services ──────────────────────────────────────────────────────────────
240
+ const services = program.command('services').alias('service').description('Manage services');
241
+
242
+ services.command('list').description('List all services').action(() => servicesList(getClient()));
243
+
244
+ services.command('create').description('Create a service (interactive wizard)').action(() => servicesCreate(getClient()));
245
+
246
+ services.command('get <id>').description('Show service details').action((id: string) => servicesGet(getClient(), id));
247
+
248
+ services.command('deploy <id>').description('Trigger a new deployment').action((id: string) => servicesDeploy(getClient(), id));
249
+
250
+ services.command('logs <id>').description('View runtime container logs').action((id: string) => servicesLogs(getClient(), id));
251
+
252
+ services.command('stop <id>').description('Stop a running service').action((id: string) => servicesLifecycle(getClient(), 'stop', id));
253
+
254
+ services.command('start <id>').description('Start a stopped service').action((id: string) => servicesLifecycle(getClient(), 'start', id));
255
+
256
+ services.command('restart <id>').description('Restart a service').action((id: string) => servicesLifecycle(getClient(), 'restart', id));
257
+
258
+ services.command('delete <id>').description('Delete a service (with confirmation)').action((id: string) => servicesDelete(getClient(), id));
259
+
260
+ services.command('export <id>').description('Export a service as a JSON bundle').action((id: string) => servicesExport(getClient(), id));
261
+
262
+ services.command('compose <id>').description('Show generated runtime Docker Compose YAML').action((id: string) => servicesCompose(getClient(), id));
263
+
264
+ services.command('inspect <id>').description('Inspect runtime container and Traefik tags').action((id: string) => servicesInspect(getClient(), id));
265
+
266
+ services.command('sticky <id>')
267
+ .description('Toggle sticky-session routing (Traefik sticky cookie, G-28)')
268
+ .option('--enable', 'Route every request to the same backend container')
269
+ .option('--disable', 'Remove the sticky-cookie middleware')
270
+ .action((id: string, opts: { enable?: boolean; disable?: boolean }) => servicesStickyAction(getClient(), id, opts));
271
+
272
+ // 0.13: the service's GitHub App link (show, feedback, migrate/finalize/revert).
273
+ services.command('github <id>')
274
+ .description('Show or change the GitHub App link (commit statuses, PR comments, migrate, finalize, unlink)')
275
+ .option('--status <onOff>', 'Commit statuses for deploys: on | off')
276
+ .option('--pr-comment <onOff>', 'PR preview comments: on | off')
277
+ .option('--migrate <sourceId>', 'Link to the GitHub App installation behind this gh-app source')
278
+ .option('--finalize', 'Detach the previous source and switch the per-service webhooks off')
279
+ .option('--unlink', 'Revert: restore the previous source and webhooks, remove the link')
280
+ .action((id: string, opts: { status?: string; prComment?: string; migrate?: string; finalize?: boolean; unlink?: boolean }) =>
281
+ serviceGithub(getClient(), id, opts));
282
+
283
+ // ── 0.16 T8: build placement, image transfers and Swarm tasks (multi-node) ──
284
+ /* v8 ignore start -- the FakeCommand in test/index.test.ts records but never invokes the action;
285
+ * the implementations are exercised by test/multiNode.test.ts. */
286
+ services.command('placement <id>')
287
+ .description('Show or (operator) change where the image is built and which orchestrator runs it')
288
+ .option('--build-on <where>', 'target (where it runs) | panel | server')
289
+ .option('--build-server <id>', 'The build server for --build-on server (none clears it)')
290
+ .option('--push-registry <sourceId>', 'Ship the image through this registry credential (none = stream relay)')
291
+ .option('--push-repo <repo>', 'Repository for --push-registry, e.g. team/app')
292
+ .option('--orchestrator <name>', 'container | swarm')
293
+ .action((id: string, opts: PlacementOptions) => servicesPlacement(getClient(), id, opts));
294
+ services.command('transfers <id>')
295
+ .description('Image transfers of a service, newest first')
296
+ .option('--limit <n>', 'How many (1-100, default 20)')
297
+ .action((id: string, opts: { limit?: string }) => servicesTransfers(getClient(), id, opts));
298
+ services.command('swarm <id>').description('Swarm tasks of a service').action((id: string) => servicesSwarm(getClient(), id));
299
+ /* v8 ignore stop */
300
+
301
+ // ── Databases ────────────────────────────────────────────────────────────
302
+ const databases = program.command('databases').description('Manage databases');
303
+
304
+ databases.command('list').description('List all databases').action(() => dbList(getClient()));
305
+
306
+ databases.command('create')
307
+ .description('Create a database (interactive)')
308
+ .option('--server <id>', 'Run it on this node (operator; fixed for its life)')
309
+ .action((opts: { server?: string }) => dbCreate(getClient(), opts));
310
+
311
+ // ── PgBouncer sidecar (G-32) ─────────────────────────────────────────────
312
+ databases
313
+ .command('pgbouncer <dbId> <action>')
314
+ .description('Manage the PgBouncer sidecar: enable | disable | status (postgres only)')
315
+ .option('--port <port>', 'Override the listen port (enable only)', (v: string) => Number(v))
316
+ .action((dbId: string, action: string, opts: { port?: number }) =>
317
+ databasePgbouncer(getClient(), dbId, action, opts),
318
+ );
319
+
320
+ // ── Per-database backup policy (0.12) ────────────────────────────────────
321
+ const backupPolicyCmd = databases
322
+ .command('backup-policy')
323
+ .description('Per-database backup schedule, retention and destination');
324
+ backupPolicyCmd
325
+ .command('get <dbId>')
326
+ .description('Show the backup policy (or the built-in daily schedule)')
327
+ .action((dbId: string) => backupPolicyGet(getClient(), dbId));
328
+ backupPolicyCmd
329
+ .command('set <dbId>')
330
+ .description('Save the backup policy; unset flags keep their current value')
331
+ .option('--cron <expr>', '5-field cron (minute hour day month weekday), server local time')
332
+ .option('--preset <name>', 'daily | 6h | weekly')
333
+ .option('--keep <n>', 'Scheduled dumps kept locally (1-365)')
334
+ .option('--keep-remote <n>', 'Remote copies kept (1-365; default: same as --keep)')
335
+ .option('--destination <dest>', 'Backup destination id, "active" or "local"')
336
+ .option('--enable', 'Turn scheduled backups on')
337
+ .option('--disable', 'Turn scheduled backups off for this database')
338
+ .action((dbId: string, opts: { cron?: string; preset?: string; keep?: string; keepRemote?: string; destination?: string; enable?: boolean; disable?: boolean }) =>
339
+ backupPolicySet(getClient(), dbId, opts),
340
+ );
341
+
342
+ // ── Public access and dump import (0.14) ────────────────────────────────
343
+ /* v8 ignore start -- the FakeCommand in test/index.test.ts records but never invokes the action;
344
+ * the implementations are exercised by test/databaseAccess.test.ts. */
345
+ databases
346
+ .command('public-access <id>')
347
+ .description('Show, enable/update (--enable) or disable (--disable) public access through a TCP sidecar (operator)')
348
+ .option('--enable', 'Enable or update public access; unset flags keep their current value')
349
+ .option('--disable', 'Remove the sidecar (the settings are kept)')
350
+ .option('--port <port>', 'Host port, 1024-65535')
351
+ .option('--allow <cidr...>', 'Allowed source addresses or CIDRs (replaces the allow-list)')
352
+ .option('--tls <mode>', 'none | terminate (not for mysql/mariadb)')
353
+ .option('--tls-host <host>', 'Hostname clients connect to (and the TLS name in terminate mode)')
354
+ .action((id: string, opts: PublicAccessOptions) => databasePublicAccess(getClient(), id, opts));
355
+ databases
356
+ .command('import <id>')
357
+ .description('Import a dump from a local file (chunked, resumable) or a backup destination object (operator)')
358
+ .option('--file <path>', 'Dump file to upload')
359
+ .option('--from-s3 <destinationId>', 'Backup destination to read the object from (operator)')
360
+ .option('--key <key>', 'Object key inside the destination prefix (with --from-s3)')
361
+ .option('--clean', 'postgres custom format: drop objects before recreating them')
362
+ .option('--no-single-transaction', 'postgres: do not wrap the restore in one transaction')
363
+ .option('--drop', 'mongo: drop each collection before restoring it')
364
+ .option('--confirm-replace', 'redis/valkey/keydb/dragonfly: required, the RDB replaces the whole dataset')
365
+ .option('--no-safety-backup', 'Skip the pre-import backup (operator, or a database created in the last 10 minutes)')
366
+ .option('--resume <importId>', 'Continue an interrupted upload of the same file')
367
+ .option('--no-wait', 'Return once the import has started')
368
+ .action((id: string, opts: ImportCliOptions) => databaseImport(getClient(), id, opts));
369
+ databases
370
+ .command('imports <id>')
371
+ .description('List a database\'s imports')
372
+ .option('--watch', 'Keep polling until no import is uploading, pending or running')
373
+ .action((id: string, opts: { watch?: boolean }) => databaseImports(getClient(), id, opts));
374
+ /* v8 ignore stop */
375
+
376
+ // ── Templates ─────────────────────────────────────────────────────────────
377
+ const templates = program.command('templates').description('Browse the template hub and scaffold starter manifests from it');
378
+
379
+ templates.command('list').description('List all templates').action(() => tplList(getClient()));
380
+
381
+ templates.command('deploy <id>').description('Deploy a template by ID').action((id: string) => tplDeploy(getClient(), id));
382
+
383
+ // ── Community templates (G-13) ───────────────────────────────────────────
384
+ const community = templates.command('community').description('Manage per-instance community template contributions');
385
+ community
386
+ .command('list')
387
+ .description('List every community template (curated + community merged in the regular `list`)')
388
+ .action(() => communityTemplatesList(getClient()));
389
+ community
390
+ .command('import <file>')
391
+ .description('Import a community template (file path, or `-` for stdin)')
392
+ .option('--replace', 'Overwrite an existing template with the same id')
393
+ .action((file: string, opts: { replace?: boolean }) => communityTemplatesImport(getClient(), file, opts));
394
+ community
395
+ .command('remove <id>')
396
+ .description('Remove a community template by id')
397
+ .action((id: string) => communityTemplatesRemove(getClient(), id));
398
+
399
+ // ── Deploys ───────────────────────────────────────────────────────────────
400
+ const deploys = program.command('deploys').description('Manage deployments');
401
+
402
+ deploys
403
+ .command('queue')
404
+ .description('List every in-flight (queued / building / deploying) deploy across all services you can see')
405
+ .action(() => deploysQueue(getClient()));
406
+
407
+ deploys.command('list <serviceId>').description('List deployments for a service').action((id: string) => deploysList(getClient(), id));
408
+
409
+ deploys.command('rollback <serviceId> <deployId>').description('Rollback to a previous deployment').action((svcId: string, depId: string) => deploysRollback(getClient(), svcId, depId));
410
+
411
+ deploys.command('cancel <serviceId> <deployId>').description('Cancel a queued or in-flight deployment').action((svcId: string, depId: string) => deploysCancel(getClient(), svcId, depId));
412
+
413
+ deploys.command('rm <serviceId> <deployId>')
414
+ .description('Remove a finished deployment from history, with its build log')
415
+ .option('-y, --yes', 'Skip the confirmation prompt')
416
+ .action((svcId: string, depId: string, opts: { yes?: boolean }) => deploysRemove(getClient(), svcId, depId, opts.yes === true));
417
+
418
+ // ── Token ─────────────────────────────────────────────────────────────────
419
+ const token = program.command('token').description('Manage API tokens');
420
+
421
+ token.command('create').description('Create a new API token').action(() => tokenCreate(getClient()));
422
+
423
+ token.command('list').description('List API tokens').action(() => tokenList(getClient()));
424
+
425
+ // ── System ────────────────────────────────────────────────────────────────
426
+ const system = program.command('system').description('System information & tools');
427
+
428
+ system.command('info').description('Show version, stats, and tech stack').action(() => systemInfo(getClient()));
429
+
430
+ system.command('dashboard').description('Live dashboard with service health').action(() => systemDashboard(getClient()));
431
+ system.command('update-check').description('Compare the running version with the latest release')
432
+ .option('-f, --force', 'Bypass the 6h cache')
433
+ .action((opts: { force?: boolean }) => systemUpdateCheck(getClient(), opts.force === true));
434
+ system.command('rotate-keys')
435
+ .description('Re-encrypt every stored secret onto the newest NINEDEPLOY_MASTER_KEYS version')
436
+ .action(() => systemRotateKeys(getClient()));
437
+
438
+ // ── Env vars ──────────────────────────────────────────────────────────────
439
+ const envCmd = program.command('env').description('Manage service environment variables');
440
+
441
+ // 0.12 `--preview`: the service's preview-only set — injected ONLY into its
442
+ // PR previews (overriding the parent's non-secret values there), never into
443
+ // the service itself. Previews still never receive the service's secrets.
444
+ const PREVIEW_FLAG = 'Target the preview-only set: values only this service\'s PR previews receive';
445
+
446
+ envCmd.command('list <serviceId>').description('List a service\'s env vars')
447
+ .option('--preview', PREVIEW_FLAG)
448
+ .action((id: string, opts?: { preview?: boolean }) => envList(getClient(), id, opts));
449
+
450
+ envCmd.command('set <serviceId> <key> <value>')
451
+ .description('Create or update an env var (secret by default)')
452
+ .option('--public', 'Store as a plain (non-secret) value')
453
+ .option('--preview', PREVIEW_FLAG)
454
+ .action((id: string, key: string, value: string, opts: { public?: boolean; preview?: boolean }) => envSet(getClient(), id, key, value, opts));
455
+
456
+ envCmd.command('rm <serviceId> <key>').description('Remove an env var by key')
457
+ .option('--preview', PREVIEW_FLAG)
458
+ .action((id: string, key: string, opts?: { preview?: boolean }) => envRemove(getClient(), id, key, opts));
459
+
460
+ // ── Domains ────────────────────────────────────────────────────────────────
461
+ const domainsCmd = program.command('domains').description('Manage domains & routing');
462
+
463
+ domainsCmd.command('list').description('List all domains').action(() => domainsList(getClient()));
464
+
465
+ domainsCmd.command('add <serviceId> <host>')
466
+ .description('Route a hostname to a service')
467
+ .option('-p, --path <path>', 'Path prefix', '/')
468
+ .option('--no-ssl', 'Serve over plain HTTP (no TLS)')
469
+ .action((id: string, host: string, opts: { path?: string; ssl?: boolean }) => domainsAdd(getClient(), id, host, opts));
470
+
471
+ domainsCmd.command('rm <serviceId> <domainId>').description('Remove a domain').action((svcId: string, domId: string) => domainsRemove(getClient(), svcId, domId));
472
+
473
+ domainsCmd.command('verify <serviceId> <domainId>')
474
+ .description('Check a pending domain TXT ownership challenge and bring it live')
475
+ .action((svcId: string, domId: string) => domainsVerify(getClient(), svcId, domId));
476
+
477
+ // Domain transfer (G-29) — start / preview / accept / cancel.
478
+ domainsCmd
479
+ .command('transfer <domainId>')
480
+ .description('Start a domain transfer to a target email; prints an accept URL')
481
+ .requiredOption('--to <email>', 'Email of the target user (existing or future)')
482
+ .action((domainId: string, opts: { to: string }) => domainsTransfer(getClient(), domainId, opts));
483
+
484
+ domainsCmd
485
+ .command('preview-transfer <token>')
486
+ .description('Preview a pending transfer by token (no auth required)')
487
+ .action((token: string) => domainsPreviewTransfer(getClient(), token));
488
+
489
+ domainsCmd
490
+ .command('accept-transfer <token>')
491
+ .description('Accept a transfer as the target user; the domain moves to the given service')
492
+ .requiredOption('--service-id <id>', 'Target service id (must be one the caller can admin)')
493
+ .action((token: string, opts: { serviceId: string }) => domainsAcceptTransfer(getClient(), token, opts));
494
+
495
+ domainsCmd
496
+ .command('cancel-transfer <token>')
497
+ .description('Cancel a pending transfer (source user or instance operator only)')
498
+ .action((token: string) => domainsCancelTransfer(getClient(), token));
499
+
500
+ const domainsPresetsCmd = domainsCmd.command('preset').description('Manage DNS presets (IDomainProvider automation)');
501
+ domainsPresetsCmd.command('list').description('List registered IDomainProvider drivers').action(() => domainsPresetListAction(getClient()));
502
+ domainsPresetsCmd.command('apply <hostname>')
503
+ .description('Create a DNS record for the given hostname via the active provider')
504
+ .option('-c, --content <content>', 'Override the record content (A record IPv4 or CNAME hostname)')
505
+ .action((hostname: string, opts: { content?: string }) => domainsPresetApplyAction(getClient(), hostname, { content: opts.content }));
506
+ // r200: the spec used to be the bare words "add namecheap" — commander reads the bare word as a
507
+ // REQUIRED ARGUMENT, so the action received the string "namecheap" as `opts`
508
+ // and posted undefined credentials. The provider is now a real argument.
509
+ domainsPresetsCmd
510
+ .command('add <provider>')
511
+ .description('Save DNS provider API credentials (supported: namecheap) so dns_records_provider can use it')
512
+ .requiredOption('--api-user <user>', 'Namecheap username (account owner)')
513
+ .requiredOption('--api-key <key>', 'Namecheap API key (the key itself; the server encrypts it at rest)')
514
+ .requiredOption('--client-ip <ip>', 'Public IPv4 of this server, already whitelisted on the Namecheap account panel')
515
+ .action((provider: string, opts: { apiUser: string; apiKey: string; clientIp: string }) => {
516
+ if (provider !== 'namecheap') {
517
+ error(`Unsupported DNS provider "${provider}" (supported: namecheap)`);
518
+ process.exitCode = 1;
519
+ return;
520
+ }
521
+ return domainsPresetAddNamecheapAction(getClient(), { apiUser: opts.apiUser, apiKey: opts.apiKey, clientIp: opts.clientIp });
522
+ });
523
+
524
+ // ── Config Presets ───────────────────────────────────────────────────────
525
+ const configPresetsCmd = program.command('config-preset').description('Manage named configCenter bundles that can be re-applied to a fresh instance');
526
+
527
+ configPresetsCmd.command('list').description('List every registered preset id').action(() => configPresetListAction(getClient()));
528
+
529
+ configPresetsCmd.command('get <id>').description('Show a preset\'s values and description').action((id: string) => configPresetGetAction(getClient(), id));
530
+
531
+ configPresetsCmd.command('register <id>')
532
+ .description('Register a preset from a JSON values file (key → value object)')
533
+ .option('-f, --file <file>', 'JSON file with key → value pairs (required, validated inside the action)')
534
+ .option('-d, --description <text>', 'Optional human description (max 500 chars)')
535
+ .action((id: string, opts: { file?: string; description?: string }) => configPresetRegisterAction(getClient(), id, { file: opts.file, description: opts.description }));
536
+
537
+ configPresetsCmd.command('apply <id>')
538
+ .description('Write every value in the preset to configCenter (idempotent)')
539
+ .option('--override <json>', 'Inline JSON object of one-shot overrides for this call only')
540
+ .action((id: string, opts: { override?: string }) => {
541
+ let override: Record<string, unknown> | undefined;
542
+ if (opts.override) {
543
+ try {
544
+ override = JSON.parse(opts.override);
545
+ } catch (err) {
546
+ error(`Invalid --override JSON: ${err instanceof Error ? err.message : String(err)}`);
547
+ process.exitCode = 1;
548
+ return;
549
+ }
550
+ }
551
+ void configPresetApplyAction(getClient(), id, { override });
552
+ });
553
+
554
+ configPresetsCmd.command('remove <id>').description('Unregister a preset (does NOT undo the live apply)').action((id: string) => configPresetRemoveAction(getClient(), id));
555
+
556
+ // ── Metrics (G-09) ────────────────────────────────────────────────────────
557
+ const metricsCmd = program.command('metrics').description('Metric history plugin — archive kernel events to a pluggable backend (G-09)');
558
+ metricsCmd.command('show').description('Show the active backend, events, and last flush marker').action(() => metricsShowAction(getClient()));
559
+ metricsCmd.command('flush').description('Run the built-in backend retention sweep').action(() => metricsFlushAction(getClient()));
560
+
561
+ // ── Build Cache (G-01) ────────────────────────────────────────────────────
562
+ const buildCacheCmd = program.command('build-cache').description('Build cache plugin — per-backend LRU stats (G-01)');
563
+ buildCacheCmd.command('stats').description('Show per-backend cache counters and the merged totals').action(() => buildCacheStatsAction(getClient()));
564
+
565
+ // ── Branding (G-30) ───────────────────────────────────────────────────────
566
+ const brandingCmd = program.command('branding').description('Override the panel logo, color, support email and footer (G-30)');
567
+ brandingCmd.command('get').description('Show the current branding overrides').action(() => brandingGetAction(getClient()));
568
+ brandingCmd
569
+ .command('set')
570
+ .description('Override one or more branding fields')
571
+ .option('--logo-url <url>', 'Logo URL (use --logo-url="" to clear)')
572
+ .option('--primary-color <hex>', 'Primary color (hex code, e.g. #1d4ed8)')
573
+ .option('--support-email <addr>', 'Support email shown in the help menu')
574
+ .option('--footer-html <html>', 'Custom footer HTML for the sign-in page')
575
+ .action((opts: { logoUrl?: string; primaryColor?: string; supportEmail?: string; footerHtml?: string }) => {
576
+ // Empty strings map to `null` so the operator can clear a value.
577
+ const cleaned: Record<string, string> = {};
578
+ if (opts.logoUrl !== undefined) cleaned.logoUrl = opts.logoUrl;
579
+ if (opts.primaryColor !== undefined) cleaned.primaryColor = opts.primaryColor;
580
+ if (opts.supportEmail !== undefined) cleaned.supportEmail = opts.supportEmail;
581
+ if (opts.footerHtml !== undefined) cleaned.footerHtml = opts.footerHtml;
582
+ void brandingSetAction(getClient(), cleaned);
583
+ });
584
+
585
+ // ── Egress IP (G-15) ─────────────────────────────────────────────────────
586
+ const egressCmd = program.command('egress').description('Manage per-project outbound IP rules (G-15)');
587
+ egressCmd.command('list').description('Show every egress IP rule across every registered driver').action(() => egressListAction(getClient()));
588
+ egressCmd
589
+ .command('set <projectId> <ip>')
590
+ .description('Attach a stable outbound IP to a project (e.g. after creating a VPS)')
591
+ .option('--driver <name>', 'Target a specific driver (default: first registered)')
592
+ .action((projectId: string, ip: string, opts: { driver?: string }) => egressSetAction(getClient(), projectId, ip, opts));
593
+ egressCmd
594
+ .command('clear <projectId>')
595
+ .description('Detach the egress rule for a project')
596
+ .option('--driver <name>', 'Target a specific driver (default: first registered)')
597
+ .action((projectId: string, opts: { driver?: string }) => egressClearAction(getClient(), projectId, opts));
598
+
599
+ // ── SSO (G-22) ───────────────────────────────────────────────────────────
600
+ const ssoCmd = program.command('sso').description('Manage OIDC / SAML SSO providers (G-22)');
601
+ ssoCmd.command('list').description('List every configured provider').action(() => ssoListAction(getClient()));
602
+ ssoCmd
603
+ .command('add <oidc|saml> <name>')
604
+ .description('Register a new OIDC or SAML provider (config flags become the config_json blob)')
605
+ .option('--issuer <url>', 'OIDC issuer URL (OIDC only)')
606
+ .option('--client-id <id>', 'OIDC client id (OIDC only)')
607
+ .option('--client-secret <secret>', 'OIDC client secret (OIDC only)')
608
+ .option('--redirect-uri <uri>', 'OIDC redirect URI (OIDC only)')
609
+ .option('--metadata-url <url>', 'SAML IdP metadata URL (SAML only)')
610
+ .action((type: string, name: string, opts: { issuer?: string; clientId?: string; clientSecret?: string; redirectUri?: string; metadataUrl?: string }) => {
611
+ const config: Record<string, unknown> = {};
612
+ if (type === 'oidc') {
613
+ if (opts.issuer) config.issuer = opts.issuer;
614
+ if (opts.clientId) config.clientId = opts.clientId;
615
+ if (opts.clientSecret) config.clientSecret = opts.clientSecret;
616
+ if (opts.redirectUri) config.redirectUri = opts.redirectUri;
617
+ } else {
618
+ if (opts.metadataUrl) config.metadataUrl = opts.metadataUrl;
619
+ }
620
+ void ssoAddAction(getClient(), type, name, config);
621
+ });
622
+ ssoCmd.command('remove <id>').description('Remove a provider by id').action((id: string) => ssoRemoveAction(getClient(), id));
623
+
624
+ // ── Volumes ────────────────────────────────────────────────────────────────
625
+ const volumesCmd = program.command('volumes').description('Manage Docker volumes');
626
+
627
+ volumesCmd.command('list')
628
+ .description('List all volumes')
629
+ .option('--server <id>', 'A node\'s volumes instead of the panel host\'s')
630
+ .action((opts: { server?: string }) => volumesList(getClient(), opts));
631
+
632
+ volumesCmd.command('rm <name>')
633
+ .description('Delete a volume (with confirmation)')
634
+ .option('--server <id>', 'Delete it on this node')
635
+ .action((name: string, opts: { server?: string }) => volumesRemove(getClient(), name, opts));
636
+
637
+ /* v8 ignore start -- exercised by test/multiNode.test.ts. */
638
+ volumesCmd.command('create <name>')
639
+ .description('Create a managed volume (nd-svc-* / nd-db-*) on the panel host or a node')
640
+ .option('--server <id>', 'Create it on this node')
641
+ .action((name: string, opts: { server?: string }) => volumesCreate(getClient(), name, opts));
642
+ /* v8 ignore stop */
643
+
644
+ // ── Networks ───────────────────────────────────────────────────────────────
645
+ const networksCmd = program.command('networks').description('Manage Docker networks');
646
+
647
+ networksCmd.command('list').description('List user-defined networks').action(() => networksList(getClient()));
648
+
649
+ networksCmd.command('create <name> [driver]').description('Create a network (bridge|overlay)').action((name: string, driver: string) => networksCreate(getClient(), name, driver === 'overlay' ? 'overlay' : 'bridge'));
650
+
651
+ networksCmd.command('rm <name>').description('Delete a network (with confirmation)').action((name: string) => networksRemove(getClient(), name));
652
+
653
+ // ── Sessions ───────────────────────────────────────────────────────────────
654
+ const sessionsCmd = program.command('sessions').description('Manage your active sessions');
655
+
656
+ sessionsCmd.command('list').description('List active sessions').action(() => sessionsList(getClient()));
657
+
658
+ sessionsCmd.command('revoke <id>').description('Revoke a session').action((id: string) => sessionsRevoke(getClient(), id));
659
+
660
+ // ── Backups ────────────────────────────────────────────────────────────────
661
+ const backupsCmd = program.command('backups').description('Manage database backups');
662
+
663
+ backupsCmd.command('list [databaseId]').description('List backups (all, or one database\'s)').action((id?: string) => backupsList(getClient(), id));
664
+
665
+ backupsCmd.command('create <databaseId>').description('Back a database up now').action((id: string) => backupsCreate(getClient(), id));
666
+
667
+ backupsCmd.command('restore <databaseId> <backupId>').description('Restore a backup (destructive)').action((id: string, bId: string) => backupsRestore(getClient(), id, bId));
668
+
669
+ backupsCmd
670
+ .command('drill <databaseId> <backupId>')
671
+ .description('Smoke-test a backup without restoring it into a live database')
672
+ .action((id: string, bId: string) => backupsDrill(getClient(), id, bId));
673
+
674
+ backupsCmd
675
+ .command('drills <databaseId>')
676
+ .description('List recent backup drills for a database')
677
+ .action((id: string) => backupsDrills(getClient(), id));
678
+
679
+ // ── Alerts ─────────────────────────────────────────────────────────────────
680
+ const alertsCmd = program.command('alerts').description('Manage alert rules');
681
+
682
+ alertsCmd.command('list').description('List alert rules').action(() => alertsList(getClient()));
683
+
684
+ alertsCmd.command('create <name> <metric> <operator> <threshold>')
685
+ .description('Create an alert rule (metric: cpu|memory|cert-expiry|disk|server_offline, operator: > or <)')
686
+ .option('-w, --windows <n>', 'Consecutive 30s samples before firing', '1')
687
+ .option('-s, --service <id>', 'Scope to a service (default: host-wide)')
688
+ .action((name: string, metric: string, op: string, threshold: string, opts: { windows?: string; service?: string }) => alertsCreate(getClient(), name, metric, op, threshold, opts));
689
+
690
+ alertsCmd.command('rm <id>').description('Delete an alert rule').action((id: string) => alertsRemove(getClient(), id));
691
+
692
+ // ── Notifications (G-06) ───────────────────────────────────────────────────
693
+ const notificationsCmd = program.command('notifications').description('Manage notification channels (admin)');
694
+ notificationsCmd.command('list').description('List configured channels').action(() => _notifList(getClient()));
695
+ notificationsCmd
696
+ .command('create-webhook <name> <url>')
697
+ .description('Create a webhook channel (HMAC-signed POST; G-06)')
698
+ .option('--secret <s>', 'HMAC secret used to sign the body')
699
+ .option('--header <h>', 'Signature header name (default: X-NineDeploy-Signature)')
700
+ .option('--algo <algo>', 'Hash algorithm: sha256 (default) or sha1', 'sha256')
701
+ .option('--event-filter <prefix>', 'Comma-separated event prefixes to match (default: all)')
702
+ .option('--template <kv>', 'Body template field (key=value, repeatable)', (v: string, prev: string[] = []) => [...prev, v])
703
+ .action(
704
+ (name: string, url: string, opts: { secret?: string; header?: string; algo?: 'sha256' | 'sha1'; eventFilter?: string; template?: string[] }) =>
705
+ _notifCreateWebhook(getClient(), name, url, opts),
706
+ );
707
+ notificationsCmd
708
+ .command('create-fcm <name> <deviceToken>')
709
+ .description('Create an FCM push channel (Firebase Cloud Messaging HTTP v1; G-22)')
710
+ .requiredOption('--service-account <file>', 'Path to a Firebase service account JSON file')
711
+ .action((name: string, deviceToken: string, opts: { serviceAccount: string }) =>
712
+ _notifCreateFcm(getClient(), name, deviceToken, opts),
713
+ );
714
+ notificationsCmd.command('test <id>').description('Fire a test event through the channel').action((id: string) => _notifTest(getClient(), id));
715
+ notificationsCmd.command('rm <id>').description('Remove a channel').action((id: string) => _notifRemove(getClient(), id));
716
+
717
+ // ── Users & activity ───────────────────────────────────────────────────────
718
+ program.command('users').description('List users (admin)').action(() => usersList(getClient()));
719
+ program.command('reset-link <idOrEmail>').description('Generate a one-time password reset link (admin)')
720
+ .action((who: string) => usersResetLink(getClient(), who));
721
+
722
+ program.command('activity').description('Show recent activity').action(() => activityList(getClient()));
723
+
724
+ // ── Plugins & Microkernel ───────────────────────────────────────────────────
725
+ const plugins = program.command('plugins').description('Manage plugins and marketplace extensions');
726
+ plugins.command('list').description('List all installed plugins').action(() => pluginsList(getClient()));
727
+ plugins.command('marketplace').description('Browse verified marketplace extensions').action(() => pluginsMarketplace(getClient()));
728
+ plugins.command('marketplace-refresh').description('Bypass the cache and re-fetch the live signed marketplace index').action(() => pluginsMarketplaceRefresh(getClient()));
729
+ plugins.command('inspect <id>').description('Inspect plugin manifest and runtime telemetry').action((id: string) => pluginsInspect(getClient(), id));
730
+ plugins.command('install <target>').description('Install a plugin (marketplace, sandbox)')
731
+ // r440: only what the server can actually load — npm/git/local were
732
+ // advertised here and always answered 400 UnsupportedPluginSourceError.
733
+ .option('-s, --source <source>', 'Source type (marketplace, sandbox)', 'marketplace')
734
+ .option('-n, --name <name>', 'Custom display name')
735
+ .option('-v, --version <version>', 'Custom version')
736
+ .option('-d, --desc <description>', 'Description')
737
+ .action((target: string, opts: any) => pluginsInstall(getClient(), target, opts));
738
+ plugins.command('enable <id>').description('Enable an installed plugin').action((id: string) => pluginsEnable(getClient(), id));
739
+ plugins.command('disable <id>').description('Disable a plugin').action((id: string) => pluginsDisable(getClient(), id));
740
+ plugins.command('reload <id>').description('Hot-reload a plugin').action((id: string) => pluginsReload(getClient(), id));
741
+ plugins.command('uninstall <id>').description('Uninstall a plugin').action((id: string) => pluginsUninstall(getClient(), id));
742
+
743
+ // ── Configuration Center ────────────────────────────────────────────────────
744
+ const configCenter = program.command('config-center').description('Manage central configuration entries and secrets');
745
+ configCenter.command('list').description('List configuration entries')
746
+ .option('-c, --category <category>', 'Filter by category')
747
+ .option('-p, --plugin <pluginId>', 'Filter by plugin id')
748
+ .option('-r, --reveal', 'Reveal decrypted secrets (admin only)')
749
+ .action((opts: any) => configCenterList(getClient(), opts));
750
+ configCenter.command('get <key>').description('Get a configuration key in detail').action((key: string) => configCenterGet(getClient(), key));
751
+ configCenter.command('set <key> <value>').description('Set or update a configuration key')
752
+ .option('-s, --secret', 'Mark as encrypted secret')
753
+ .option('-d, --desc <description>', 'Description')
754
+ .option('-t, --tags <tags>', 'Comma-separated tags')
755
+ .action((key: string, value: string, opts: any) => configCenterSet(getClient(), key, value, opts));
756
+ configCenter.command('delete <key>').description('Delete a custom configuration key').action((key: string) => configCenterDelete(getClient(), key));
757
+
758
+ // ── Workspaces & Teams ──────────────────────────────────────────────────────
759
+ const workspaces = program.command('workspaces').description('Manage workspaces and team organizations');
760
+ workspaces.command('list').description('List accessible workspaces').action(() => workspacesList(getClient()));
761
+ workspaces.command('get <id>').description('Get workspace details and team members').action((id: string) => workspacesGet(getClient(), id));
762
+ workspaces.command('create <name>').description('Create a new workspace')
763
+ .option('-d, --desc <description>', 'Workspace description')
764
+ .action((name: string, opts: { desc?: string }) => workspacesCreate(getClient(), name, { description: opts.desc }));
765
+ workspaces.command('delete <id>').description('Delete a workspace').action((id: string) => workspacesDelete(getClient(), id));
766
+
767
+ // ── Housekeeping ────────────────────────────────────────────────────────────
768
+ system.command('prune').description('Run system housekeeping prune (images, containers, build artifacts)').action(() => housekeepingPrune(getClient()));
769
+
770
+ // ── Images (G-12) ───────────────────────────────────────────────────────────
771
+ const images = program.command('images').description('Inspect and prune Docker images on the host');
772
+ images
773
+ .command('ls')
774
+ .description('List every image on the host with size / age / in-use metadata')
775
+ .option('--sort <field>', 'Sort by `size` (default) or `age`', 'size')
776
+ .action((opts: { sort?: 'size' | 'age' }) => imagesList(getClient(), { sort: opts.sort }));
777
+ images
778
+ .command('prune')
779
+ .description('Prune images. Refuses to run with no filter; pass --dry-run first.')
780
+ // r554: validated whole numbers (the server's bounds) — `Number(v)` let
781
+ // `1.5` / `abc` through to an opaque 422.
782
+ .option('--keep-last <n>', 'Keep the newest N images per repo:tag (rest are candidates)', positiveIntOption(1000))
783
+ .option('--older-than <hours>', 'Only prune images older than N hours', positiveIntOption(8760))
784
+ .option('--dangling', 'Only prune dangling images (repo/tag both <none>)')
785
+ .option('--dry-run', 'Report what would be deleted without actually deleting')
786
+ .action(
787
+ (opts: { keepLast?: number; olderThan?: number; dangling?: boolean; dryRun?: boolean }) =>
788
+ imagesPrune(getClient(), {
789
+ keepLast: opts.keepLast,
790
+ olderThan: opts.olderThan,
791
+ dangling: opts.dangling,
792
+ dryRun: opts.dryRun,
793
+ }),
794
+ );
795
+
796
+ // ── Demo Mode ──────────────────────────────────────────────────────────────
797
+ const demo = program.command('demo').description('Demo mode operations');
798
+ demo.command('seed').description('Seed Next.js Docker + PM2 demo environment with PostgreSQL database').action(() => demoSeed(getClient()));
799
+
800
+ // ── Server Management (Local Docker) ───────────────────────────────────────
801
+ const serverCmd = program.command('server').description('Manage local NineDeploy Docker server');
802
+ serverCmd.command('start')
803
+ .description('Start local NineDeploy server container')
804
+ .option('-p, --port <port>', 'Host port to bind', '3000')
805
+ .option('-i, --image <image>', 'Docker image tag', 'ghcr.io/ninedeploy/ninedeploy:latest')
806
+ .option('-n, --name <name>', 'Container name', 'ninedeploy')
807
+ .action((opts: { port?: string; image?: string; name?: string }) => serverStartAction(opts));
808
+
809
+ serverCmd.command('stop')
810
+ .description('Stop local NineDeploy server container')
811
+ .option('-n, --name <name>', 'Container name', 'ninedeploy')
812
+ .action((opts: { name?: string }) => serverStopAction(opts));
813
+
814
+ serverCmd.command('status')
815
+ .description('Check local server container and health status')
816
+ .option('-p, --port <port>', 'Host port', '3000')
817
+ .option('-n, --name <name>', 'Container name', 'ninedeploy')
818
+ .action((opts: { port?: string; name?: string }) => serverStatusAction(opts));
819
+
820
+ serverCmd.command('logs')
821
+ .description('View local server container logs')
822
+ .option('-n, --lines <lines>', 'Number of lines to show', '50')
823
+ .option('-c, --name <name>', 'Container name', 'ninedeploy')
824
+ .action((opts: { lines?: string; name?: string }) => serverLogsAction(opts));
825
+
826
+ // ── Cluster log search (G-16) ─────────────────────────────────────────────
827
+ const logsCmd = program.command('logs').description('Search the configured log drain (Loki)');
828
+ logsCmd
829
+ .command('search <query>')
830
+ .description('Full-text search across the configured Loki drain')
831
+ .option('--service <id>', 'Restrict to one service id')
832
+ .option('--since <window>', 'How far back to search (e.g. 15m, 2h, 1d)', '15m')
833
+ .option('--limit <n>', 'Max lines to return (default 200, max 1000)')
834
+ .option('--drain <id>', 'Query a specific drain (default: first enabled Loki drain)')
835
+ .option('--json', 'Print raw JSON instead of the human-readable table')
836
+ .action(
837
+ (query: string, opts: { service?: string; since?: string; limit?: string; drain?: string; json?: boolean }) =>
838
+ logsSearch(getClient(), query, opts),
839
+ );
840
+
841
+ // ── Email templates (G-30) ─────────────────────────────────────────────────
842
+ const emailTemplatesCmd = program.command('email-templates').description('Manage per-workspace email template overrides');
843
+ emailTemplatesCmd
844
+ .command('list <workspaceId>')
845
+ .description('List the built-in templates and whether the workspace has overridden each')
846
+ .action((wid: string) => emailTemplatesList(getClient(), wid));
847
+
848
+ emailTemplatesCmd
849
+ .command('preview <workspaceId> <name> [vars...]')
850
+ .description('Render a template with the supplied vars (key=value pairs)')
851
+ .action((wid: string, name: string, vars: string[]) => emailTemplatesPreview(getClient(), wid, name, vars));
852
+
853
+ emailTemplatesCmd
854
+ .command('set <workspaceId> <name>')
855
+ .description('Upsert the workspace override for a template (admin only)')
856
+ .requiredOption('--subject <subject>', 'Override subject line')
857
+ .requiredOption('--text <text>', 'Override plain-text body (use {{var}} placeholders)')
858
+ .action((wid: string, name: string, opts: { subject: string; text: string }) => emailTemplatesSet(getClient(), wid, name, opts));
859
+
860
+ emailTemplatesCmd
861
+ .command('reset <workspaceId> <name>')
862
+ .description('Drop the workspace override and fall back to the built-in default')
863
+ .action((wid: string, name: string) => emailTemplatesReset(getClient(), wid, name));
864
+
865
+ // ── System export/import + deploy log streaming ────────────────────────────
866
+ system.command('export [file]').description('Export the full system state as JSON').action((file?: string) => systemExport(file));
867
+
868
+ system.command('import <file>').description('Import a system bundle (destructive)').action((file: string) => systemImport(file));
869
+
870
+ // ── Panel self-backup (0.12) ────────────────────────────────────────────────
871
+ const panelBackupCmd = system.command('panel-backup').description("Scheduled, passphrase-sealed backups of the panel's own state");
872
+ panelBackupCmd.command('status').description('Show the schedule, destination, retention and the last run').action(() => panelBackupStatusAction(getClient()));
873
+ panelBackupCmd
874
+ .command('set')
875
+ .description('Change the panel backup settings (omitted options keep their value)')
876
+ .option('--enable', 'Turn scheduled panel backups on')
877
+ .option('--disable', 'Turn scheduled panel backups off')
878
+ .option('--cron <expr>', '5-field cron expression, e.g. "0 3 * * *"')
879
+ .option('--destination <id>', 'Backup destination id (see Settings → Integrations)')
880
+ .option('--retain <n>', 'How many panel backups to keep in the destination')
881
+ .option('--passphrase', 'Prompt for a new recovery passphrase (keep it off this server)')
882
+ .action((opts: PanelBackupSetOptions) => panelBackupSetAction(getClient(), opts));
883
+ panelBackupCmd
884
+ .command('now')
885
+ .description('Back up the panel now')
886
+ .option('--wait', 'Wait for the run to finish and report its outcome')
887
+ .action((opts: { wait?: boolean }) => panelBackupNowAction(getClient(), opts));
888
+ panelBackupCmd
889
+ .command('list')
890
+ .description('List the panel backups in the destination, newest first')
891
+ .option('--destination <id>', 'Destination to list (default: the configured one)')
892
+ .action((opts: { destination?: string }) => panelBackupListAction(getClient(), opts));
893
+ panelBackupCmd
894
+ .command('decrypt <file> [out]')
895
+ .description('Offline: open a downloaded .ndpb with the recovery passphrase into a .tar.gz for `system import`')
896
+ .action((file: string, out?: string) => panelBackupDecryptAction(file, out));
897
+
898
+ // ── Certificates (G-15) ─────────────────────────────────────────────────────
899
+ const certificates = program.command('certificates').description('Traefik certificate inventory and renewal status');
900
+ certificates
901
+ .command('list')
902
+ .description('List every certificate the panel knows about with days-to-expiry and status')
903
+ .option('--threshold <days>', 'Expiring-soon threshold in days (default 30)')
904
+ .action((opts: { threshold?: string }) => certificatesList(getClient(), opts));
905
+ certificates
906
+ .command('expiring')
907
+ .description('Focused list of certificates expiring within N days')
908
+ .option('--days <days>', 'Window in days (default 30)')
909
+ .action((opts: { days?: string }) => certificatesExpiring(getClient(), opts));
910
+ // 0.14: uploaded certificates (operator).
911
+ const customCerts = certificates.command('custom').description('Uploaded TLS certificates (operator)');
912
+ /* v8 ignore start -- exercised by test/certificates.test.ts. */
913
+ customCerts.command('list').description('List uploaded certificates, their hostnames, expiry and covered domains')
914
+ .action(() => certificatesCustomList(getClient()));
915
+ customCerts.command('upload')
916
+ .description('Upload a certificate chain and its unencrypted private key')
917
+ .option('--name <name>', 'Display name')
918
+ .option('--cert <path>', 'PEM certificate chain, leaf first')
919
+ .option('--key <path>', 'PEM private key (unencrypted)')
920
+ .action((opts: { name?: string; cert?: string; key?: string }) => certificatesCustomUpload(getClient(), opts));
921
+ customCerts.command('replace <id>')
922
+ .description('Replace a certificate (e.g. after renewal)')
923
+ .option('--name <name>', 'New display name')
924
+ .option('--cert <path>', 'PEM certificate chain, leaf first')
925
+ .option('--key <path>', 'PEM private key (unencrypted)')
926
+ .action((id: string, opts: { name?: string; cert?: string; key?: string }) => certificatesCustomReplace(getClient(), id, opts));
927
+ customCerts.command('delete <id>')
928
+ .description('Delete a certificate (covered domains fall back to ACME)')
929
+ .alias('rm')
930
+ .option('-y, --yes', 'Skip the confirmation prompt')
931
+ .action((id: string, opts: { yes?: boolean }) => certificatesCustomDelete(getClient(), id, opts));
932
+ /* v8 ignore stop */
933
+
934
+ // ── Proxy custom config (0.14) ──────────────────────────────────────────────
935
+ const proxyCmd = program.command('proxy').description('Traefik proxy management (operator)');
936
+ const proxyConfigCmd = proxyCmd.command('config').description('Custom Traefik dynamic config (custom.yml)');
937
+ /* v8 ignore start -- exercised by test/proxyConfig.test.ts. */
938
+ proxyConfigCmd.command('get').description('Show the custom config, its status and the last error').action(() => proxyConfigGet(getClient()));
939
+ proxyConfigCmd.command('validate')
940
+ .description('Validate a YAML file without applying it')
941
+ .option('--file <path>', 'YAML file')
942
+ .action((opts: { file?: string }) => proxyConfigValidate(getClient(), opts));
943
+ proxyConfigCmd.command('set')
944
+ .description('Validate and apply a YAML file')
945
+ .option('--file <path>', 'YAML file')
946
+ .action((opts: { file?: string }) => proxyConfigSet(getClient(), opts));
947
+ proxyConfigCmd.command('clear')
948
+ .description('Remove the custom config')
949
+ .option('-y, --yes', 'Skip the confirmation prompt')
950
+ .action((opts: { yes?: boolean }) => proxyConfigClear(getClient(), opts));
951
+ /* v8 ignore stop */
952
+
953
+ // ── Secret managers (0.14) ──────────────────────────────────────────────────
954
+ const secretsCmd = program.command('secrets').description('Deploy-time secret references');
955
+ const providersCmd = secretsCmd.command('providers').description('HashiCorp Vault / OpenBao and AWS Secrets Manager (operator)');
956
+ /* v8 ignore start -- exercised by test/secretProviders.test.ts. */
957
+ providersCmd.command('list').description('List the secret managers and their last test').action(() => secretProvidersList(getClient()));
958
+ providersCmd.command('set-vault')
959
+ .description('Configure Vault / OpenBao (KV v2); token from --token-file or NINEDEPLOY_VAULT_TOKEN')
960
+ .option('--address <url>', 'Vault address (https)')
961
+ .option('--namespace <ns>', 'Vault Enterprise / OpenBao namespace')
962
+ .option('--mount <path>', 'KV v2 mount (default secret)')
963
+ .option('--auth <method>', 'token | approle (default token)')
964
+ .option('--approle-mount <path>', 'AppRole auth mount (default approle)')
965
+ .option('--token-file <path>', 'File holding the token (or NINEDEPLOY_VAULT_TOKEN)')
966
+ .option('--role-id-file <path>', 'File holding the AppRole role id (or NINEDEPLOY_VAULT_ROLE_ID)')
967
+ .option('--secret-id-file <path>', 'File holding the AppRole secret id (or NINEDEPLOY_VAULT_SECRET_ID)')
968
+ .option('--enable', 'Enable the provider')
969
+ .option('--disable', 'Disable the provider (references stay literal)')
970
+ .action((opts: VaultCliOptions) => secretProvidersSetVault(getClient(), opts));
971
+ providersCmd.command('set-aws')
972
+ .description('Configure AWS Secrets Manager; secret key from --secret-access-key-file or NINEDEPLOY_AWS_SECRET_ACCESS_KEY')
973
+ .option('--region <region>', 'AWS region, e.g. eu-west-1')
974
+ .option('--endpoint <url>', 'VPC endpoint or compatible service (https)')
975
+ .option('--role-arn <arn>', 'IAM role to assume')
976
+ .option('--external-id <id>', 'External id for the role (with --role-arn)')
977
+ .option('--role-session-name <name>', 'Session name (default ninedeploy)')
978
+ .option('--access-key-id <id>', 'Access key id (or NINEDEPLOY_AWS_ACCESS_KEY_ID)')
979
+ .option('--secret-access-key-file <path>', 'File holding the secret access key (or NINEDEPLOY_AWS_SECRET_ACCESS_KEY)')
980
+ .option('--session-token-file <path>', 'File holding a session token (or NINEDEPLOY_AWS_SESSION_TOKEN)')
981
+ .option('--enable', 'Enable the provider')
982
+ .option('--disable', 'Disable the provider (references stay literal)')
983
+ .action((opts: AwsCliOptions) => secretProvidersSetAws(getClient(), opts));
984
+ providersCmd.command('test <kind>')
985
+ .description('Test a provider (vault | aws), optionally reading one secret')
986
+ .option('--probe-path <path>', 'vault: also read this path (relative to the mount)')
987
+ .option('--probe-secret-id <id>', 'aws: also read this secret')
988
+ .action((kind: string, opts: { probePath?: string; probeSecretId?: string }) => secretProvidersTest(getClient(), kind, opts));
989
+ providersCmd.command('delete <kind>')
990
+ .description('Remove a provider (vault | aws); its references stay literal at deploy')
991
+ .alias('rm')
992
+ .option('-y, --yes', 'Skip the confirmation prompt')
993
+ .action((kind: string, opts: { yes?: boolean }) => secretProvidersDelete(getClient(), kind, opts));
994
+ /* v8 ignore stop */
995
+
996
+ // ── 0.15: terminals, traffic analytics, access grants ────────────────────
997
+ const terminalsCmd = program.command('terminals').description('Terminal session history and termination (operator)');
998
+ /* v8 ignore start -- exercised by test/operations.test.ts. */
999
+ terminalsCmd.command('list')
1000
+ .description('List terminal sessions, newest first (metadata only)')
1001
+ .option('--status <status>', 'pending | active | ended | failed | expired')
1002
+ .option('--target <kind>', 'service | database | container | host')
1003
+ .option('--limit <n>', 'Page size (1-200, default 50)')
1004
+ .option('--before <id>', 'Page back from this session id')
1005
+ .action((opts: TerminalsListOptions) => terminalsList(getClient(), opts));
1006
+ terminalsCmd.command('show <id>').description('Show one terminal session').action((id: string) => terminalsShow(getClient(), id));
1007
+ terminalsCmd.command('kill <id>')
1008
+ .description('Terminate a live session (or revoke a pending one)')
1009
+ .option('-y, --yes', 'Skip the confirmation prompt')
1010
+ .action((id: string, opts: { yes?: boolean }) => terminalsKill(getClient(), id, opts));
1011
+ /* v8 ignore stop */
1012
+ const terminalCmd = program.command('terminal').alias('shell').description('Open an interactive shell (operator; needs a TTY)');
1013
+ /* v8 ignore start -- exercised by test/operations.test.ts. */
1014
+ terminalCmd.command('service <id>')
1015
+ .description('Shell into the container of a service')
1016
+ .option('--replica <n>', 'Replica number (1-based)')
1017
+ .option('--node <serverId>', 'A node the service runs on (fan-out target)')
1018
+ .action((id: string, opts: TerminalOpenOptions) => terminalOpen(getClient(), 'service', id, opts));
1019
+ terminalCmd.command('db <id>')
1020
+ .description('Shell into the container of a database')
1021
+ .option('--client', 'Open the engine client (psql, mysql, redis-cli) with the stored credentials')
1022
+ .action((id: string, opts: TerminalOpenOptions) => terminalOpen(getClient(), 'db', id, opts));
1023
+ terminalCmd.command('container <name>')
1024
+ .description('Shell into a managed container on the panel host')
1025
+ .action((name: string) => terminalOpen(getClient(), 'container', name));
1026
+ terminalCmd.command('host [serverId]')
1027
+ .description('Root shell on the panel host or a node (off by default; asks for your password)')
1028
+ .action((serverId: string | undefined) => terminalOpen(getClient(), 'host', serverId));
1029
+ /* v8 ignore stop */
1030
+ const trafficCmd = program.command('traffic').description('Traffic analytics (opt-in)');
1031
+ /* v8 ignore start -- exercised by test/operations.test.ts. */
1032
+ trafficCmd.command('settings')
1033
+ .description('Show or change traffic analytics (enabling or disabling recreates Traefik once)')
1034
+ .option('--enable', 'Turn analytics on')
1035
+ .option('--disable', 'Turn analytics off')
1036
+ .option('--retention <days>', 'Keep hourly rows this many days (1-400)')
1037
+ .option('-y, --yes', 'Skip the confirmation prompt')
1038
+ .action((opts: { enable?: boolean; disable?: boolean; retention?: string; yes?: boolean }) => trafficSettings(getClient(), opts));
1039
+ trafficCmd.command('summary')
1040
+ .description('Instance traffic: totals, percentiles and top domains (operator)')
1041
+ .option('--range <range>', '1h | 24h | 7d | 30d (default 24h)')
1042
+ .option('--top <n>', 'Top domains (1-50, default 10)')
1043
+ .action((opts: { range?: string; top?: string }) => trafficSummary(getClient(), opts));
1044
+ trafficCmd.command('service <id>')
1045
+ .description('Traffic of one service, by domain')
1046
+ .option('--range <range>', '1h | 24h | 7d | 30d (default 24h)')
1047
+ .action((id: string, opts: { range?: string }) => trafficService(getClient(), id, opts));
1048
+ /* v8 ignore stop */
1049
+ const accessCmd = program.command('access').description('Project and environment access grants');
1050
+ const grantsCmd = accessCmd.command('grants').description('Manage access grants in a workspace (workspace admin)');
1051
+ /* v8 ignore start -- exercised by test/operations.test.ts. */
1052
+ grantsCmd.command('list')
1053
+ .description('List the grants in a workspace')
1054
+ .option('--workspace <id>', 'Workspace id')
1055
+ .option('--user <id>', 'Only this user')
1056
+ .option('--project <id>', 'Only this project')
1057
+ .option('--environment <id>', 'Only this environment')
1058
+ .action((opts: { workspace?: string; user?: string; project?: string; environment?: string }) => accessGrantsList(getClient(), opts));
1059
+ grantsCmd.command('add')
1060
+ .description('Grant a role on a project, an environment, or both')
1061
+ .option('--workspace <id>', 'Workspace id')
1062
+ .option('--email <email>', 'The user, by email')
1063
+ .option('--user <id>', 'The user, by id')
1064
+ .option('--project <id>', 'Project id')
1065
+ .option('--environment <id>', 'Environment id')
1066
+ .option('--role <role>', 'viewer | member | admin')
1067
+ .action((opts: { workspace?: string; email?: string; user?: string; project?: string; environment?: string; role?: string }) =>
1068
+ accessGrantsAdd(getClient(), opts),
1069
+ );
1070
+ grantsCmd.command('update <grantId>')
1071
+ .description('Change the role of a grant, or suspend or reinstate it')
1072
+ .option('--workspace <id>', 'Workspace id')
1073
+ .option('--role <role>', 'viewer | member | admin')
1074
+ .option('--suspend', 'Keep the grant but stop it giving access')
1075
+ .option('--reinstate', 'Make a suspended grant give access again')
1076
+ .action((grantId: string, opts: { workspace?: string; role?: string; suspend?: boolean; reinstate?: boolean }) =>
1077
+ accessGrantsUpdate(getClient(), grantId, opts),
1078
+ );
1079
+ grantsCmd.command('remove <grantId>')
1080
+ .alias('rm')
1081
+ .description('Revoke a grant')
1082
+ .option('--workspace <id>', 'Workspace id')
1083
+ .option('-y, --yes', 'Skip the confirmation prompt')
1084
+ .action((grantId: string, opts: { workspace?: string; yes?: boolean }) => accessGrantsRemove(getClient(), grantId, opts));
1085
+ accessCmd.command('me').description('Your own grants and guest workspaces').action(() => accessMe(getClient()));
1086
+ /* v8 ignore stop */
1087
+
1088
+ // ── 0.16 T8: remote nodes and Swarm (operator) ───────────────────────────
1089
+ const serversCmd = program.command('servers').description('Remote nodes: agent capabilities, build-server role (operator)');
1090
+ /* v8 ignore start -- exercised by test/multiNode.test.ts. */
1091
+ serversCmd.command('list').description('List nodes with their agent version, build role, databases and Swarm membership').action(() => serversList(getClient()));
1092
+ serversCmd.command('roles <id>')
1093
+ .description('Set the build-server role of a node')
1094
+ .option('--build-server <onOff>', 'on | off')
1095
+ .option('--build-concurrency <n>', 'Builds at once on this node (1-8)')
1096
+ .action((id: string, opts: { buildServer?: string; buildConcurrency?: string }) => serversRoles(getClient(), id, opts));
1097
+ /* v8 ignore stop */
1098
+ const swarmCmd = program.command('swarm').description('Docker Swarm on the panel host (operator, opt-in per service)');
1099
+ /* v8 ignore start -- exercised by test/multiNode.test.ts. */
1100
+ swarmCmd.command('status').description('Swarm state, the join address and the nodes').action(() => swarmStatus(getClient()));
1101
+ swarmCmd.command('init')
1102
+ .description('Initialise Swarm on the panel host (asks for your password)')
1103
+ .option('--advertise-addr <ip>', 'The address nodes reach the panel host on')
1104
+ .action((opts: { advertiseAddr?: string }) => swarmInit(getClient(), opts));
1105
+ swarmCmd.command('enable').description('Allow Swarm deploys (asks for your password)').action(() => swarmSetEnabled(getClient(), true));
1106
+ swarmCmd.command('disable').description('Refuse new Swarm deploys; running stacks keep running').action(() => swarmSetEnabled(getClient(), false));
1107
+ swarmCmd.command('join <serverId>').description('Join a node to the swarm as a worker').action((serverId: string) => swarmJoin(getClient(), serverId));
1108
+ swarmCmd.command('leave <serverId>')
1109
+ .description('Drain a node and take it out of the swarm')
1110
+ .option('-y, --yes', 'Skip the confirmation prompt')
1111
+ .action((serverId: string, opts: { yes?: boolean }) => swarmLeave(getClient(), serverId, opts));
1112
+ /* v8 ignore stop */
1113
+
1114
+ deploys.command('watch <serviceId> <deployId>').description('Stream a deployment\'s build logs live').action((svcId: string, depId: string) => deploysWatch(svcId, depId));
1115
+
1116
+ // ── Diagnostics ───────────────────────────────────────────────────────────
1117
+ program
1118
+ .command('doctor')
1119
+ .description('Run system, Docker, server connectivity, and auth diagnostics')
1120
+ .option('--fix', 'Automatically attempt to heal and repair detected issues')
1121
+ .action((opts: { fix?: boolean }) => doctorAction(getClient(), opts));
1122
+
1123
+ // ── Sources (private repo credentials) ────────────────────────────────────
1124
+ const sources = program.command('sources').alias('creds').description('Manage private repository credentials (PATs / SSH keys)');
1125
+ /* v8 ignore start -- the FakeCommand in test/index.test.ts records but never invokes the action;
1126
+ * the implementation is exercised end-to-end by test/sources.test.ts. */
1127
+ sources.command('list').description('List configured sources').action(() => sourcesList(getClient()));
1128
+ sources.command('show <id>').description('Show one source in detail').action((id: string) => sourcesShow(getClient(), id));
1129
+ sources.command('add [name]')
1130
+ .description('Add a new source (interactive; PAT/SSH key from env or masked prompt)')
1131
+ .option('--base-url <url>', 'Gitea base URL, e.g. https://git.example.com (enables the live test and repo list)')
1132
+ .action((name: string | undefined, opts: { baseUrl?: string }) => sourcesAdd(getClient(), name, opts));
1133
+ sources.command('test [id]').description('Verify that a stored source token still authenticates').action((id?: string) => sourcesTest(getClient(), id));
1134
+ sources.command('keygen [id]').description('Generate an ed25519 deploy-key pair on the panel and print the public key').action((id?: string) => sourcesKeygen(getClient(), id));
1135
+ sources.command('remove [id]').description('Remove a source (with confirmation)').alias('rm').action((id?: string) => sourcesRemove(getClient(), id));
1136
+ // 0.16 T8 (multi-node): send this PAT or deploy key to nodes for clones there.
1137
+ sources.command('allow-on-nodes <id> <onOff>')
1138
+ .description('Allow (on, asks for your password) or stop (off) sending this credential to nodes for clones')
1139
+ .action((id: string, onOff: string) => sourcesAllowOnNodes(getClient(), id, onOff));
1140
+ // 0.15.8: pin the git host's SSH keys for deploy-key clones the panel makes.
1141
+ sources.command('pin-host-keys <id> <onOff>')
1142
+ .description('Pin (on) or stop pinning (off, asks for your password) the git host SSH keys of a deploy-key source')
1143
+ .action((id: string, onOff: string) => sourcesPinHostKeys(getClient(), id, onOff));
1144
+ sources.command('reset-host-keys <id>')
1145
+ .description('Forget the pinned git host keys so the next clone learns them again (asks for your password)')
1146
+ .action((id: string) => sourcesResetHostKeys(getClient(), id));
1147
+ sources.command('host-keys <id>').description('Show the git host key fingerprints a source has pinned').action((id: string) => sourcesHostKeys(getClient(), id));
1148
+ /* v8 ignore stop */
1149
+
1150
+ // ── GitHub Apps (0.13) ─────────────────────────────────────────────────────
1151
+ const githubApp = program.command('github-app').description('Manage GitHub Apps (one-click setup runs in the panel: Sources → GitHub Apps)');
1152
+ /* v8 ignore start -- see sources.ts note above; exercised by test/githubApps.test.ts. */
1153
+ githubApp.command('list').description('List registered GitHub Apps').action(() => githubAppList(getClient()));
1154
+ githubApp.command('show <id>').description('Show a GitHub App and its installations').action((id: string) => githubAppShow(getClient(), id));
1155
+ githubApp.command('add-manual')
1156
+ .description('Register an existing GitHub App or one on GitHub Enterprise Server (key from --key-file or NINEDEPLOY_GITHUB_APP_KEY)')
1157
+ .option('--name <name>', 'Display name')
1158
+ .option('--app-id <id>', 'GitHub App ID')
1159
+ .option('--key-file <path>', 'Path to the App private key (.pem)')
1160
+ .option('--web-base-url <url>', 'GHES web URL, e.g. https://github.example.com (with --api-base-url)')
1161
+ .option('--api-base-url <url>', 'GHES API URL, e.g. https://github.example.com/api/v3 (with --web-base-url)')
1162
+ .action((opts: { name?: string; appId?: string; keyFile?: string; webBaseUrl?: string; apiBaseUrl?: string }) => githubAppAddManual(getClient(), opts));
1163
+ githubApp.command('sync <id>').description('Sync installations from GitHub (creates their gh-app sources)').action((id: string) => githubAppSync(getClient(), id));
1164
+ githubApp.command('rotate-key <id>')
1165
+ .description('Replace the App private key (verified with GitHub first)')
1166
+ .option('--key-file <path>', 'Path to the new private key (.pem)')
1167
+ .action((id: string, opts: { keyFile?: string }) => githubAppRotateKey(getClient(), id, opts));
1168
+ githubApp.command('remove <id>')
1169
+ .description('Forget a GitHub App (its installation sources stay)')
1170
+ .alias('rm')
1171
+ .option('-y, --yes', 'Skip the confirmation prompt')
1172
+ .action((id: string, opts: { yes?: boolean }) => githubAppRemove(getClient(), id, opts));
1173
+ /* v8 ignore stop */
1174
+
1175
+ // ── Deploy (one-shot private GitHub deploy) ────────────────────────────────
1176
+ const deploy = program.command('deploy').description('End-to-end deploy helpers');
1177
+ /* v8 ignore start -- see sources.ts note above */
1178
+ deploy.command('create-from-github [url]')
1179
+ .description('One-shot: add source (PAT), analyse repo, create service, set env, trigger deploy, optional webhook')
1180
+ .alias('from-github')
1181
+ .action((url?: string) => deployFromGithub(getClient(), url));
1182
+ /* v8 ignore stop */
1183
+
1184
+ // ── Webhooks (auto-deploy on push) ─────────────────────────────────────────
1185
+ const webhooksCmd = program.command('webhooks').description('Manage auto-deploy webhooks');
1186
+ /* v8 ignore start -- see sources.ts note above */
1187
+ webhooksCmd.command('list <serviceId>').description('List a service\'s webhooks').action((id: string) => webhooksList(getClient(), id));
1188
+ webhooksCmd.command('show <serviceId> <hookId>').description('Show one webhook in detail').action((svc: string, hook: string) => webhooksShow(getClient(), svc, hook));
1189
+ webhooksCmd.command('add <serviceId> [branch]')
1190
+ .description('Add a webhook (returns the HMAC secret once for pasting into GitHub)')
1191
+ .action((svc: string, branch?: string) => webhooksAdd(getClient(), svc, branch));
1192
+ webhooksCmd.command('remove <serviceId> <hookId>').description('Remove a webhook (with confirmation)').alias('rm').action((svc: string, hook: string) => webhooksRemove(getClient(), svc, hook));
1193
+ /* v8 ignore stop */
1194
+
1195
+ // ── Firewall & Security ───────────────────────────────────────────────────
1196
+ const fw = program.command('firewall').description('Manage host firewall (UFW) and open ports');
1197
+ fw.command('status').description('Show host firewall status, default policies, and active rules').action(() => firewallStatus(getClient()));
1198
+ fw.command('enable').description('Enable host firewall (ensures SSH port 22 is permitted)').action(() => firewallToggle(getClient(), true));
1199
+ fw.command('disable').description('Disable host firewall').action(() => firewallToggle(getClient(), false));
1200
+ fw.command('recommended').description('Apply standard VPS profile (allows 22 SSH, 80 HTTP, 443 HTTPS and enables firewall)').action(() => firewallApplyRecommended(getClient()));
1201
+ fw.command('allow <port>')
1202
+ .description('Open a host port (e.g. 5432 or 8080)')
1203
+ .option('-p, --proto <proto>', 'Protocol (tcp|udp|any)', 'tcp')
1204
+ .option('-f, --from <ip>', 'Source IP/CIDR to restrict access to')
1205
+ .option('-c, --comment <text>', 'Rule description/comment')
1206
+ .action((port: string, opts: any) => firewallAddRule(getClient(), port, { ...opts, action: 'allow' }));
1207
+ fw.command('deny <port>')
1208
+ .description('Block a host port')
1209
+ .option('-p, --proto <proto>', 'Protocol (tcp|udp|any)', 'tcp')
1210
+ .option('-f, --from <ip>', 'Source IP/CIDR')
1211
+ .option('-c, --comment <text>', 'Rule description/comment')
1212
+ .action((port: string, opts: any) => firewallAddRule(getClient(), port, { ...opts, action: 'deny' }));
1213
+ fw.command('rm <id>').description('Delete a firewall rule by ID').action((id: string) => firewallDeleteRule(getClient(), id));
1214
+
1215
+ // ── .ninedeploy manifest (project-side) ───────────────────────────────────
1216
+ // Lives at the end of the command list so the sidebar/help shows it last;
1217
+ // operators discover it after they've explored services/sources/etc.
1218
+ const manifest = program
1219
+ .command('manifest')
1220
+ .description('Manage the .ninedeploy file in this repo');
1221
+ manifest
1222
+ .command('init')
1223
+ .description('Scaffold a starter .ninedeploy based on detected project kind')
1224
+ .action(() => manifestInit(process.cwd()));
1225
+ manifest
1226
+ .command('validate')
1227
+ .description('Parse + schema-check the .ninedeploy in the current directory')
1228
+ .action(() => manifestValidate(process.cwd()));
1229
+ manifest
1230
+ .command('show')
1231
+ .description('Print a human-readable summary of the parsed manifest')
1232
+ .action(() => manifestShow(process.cwd()));
1233
+ manifest
1234
+ .command('apply <serviceId>')
1235
+ .description('Apply the .ninedeploy manifest to a service (requires admin role)')
1236
+ .action((serviceId: string) => manifestApply(getClient(), process.cwd(), Number(serviceId)));
1237
+
1238
+ // ── Templates (one-click starter manifests from the panel registry) ───────
1239
+ templates
1240
+ .command('init <templateId>')
1241
+ .description('Fetch a template from the panel and emit a starter .ninedeploy')
1242
+ .option('--host <host>', 'Default `routes[0].host` for the starter manifest')
1243
+ .option('--filename <name>', 'Filename to write when --write is set (default .ninedeploy)')
1244
+ .option('--write', 'Write to a file in the current directory instead of stdout')
1245
+ .action((templateId: string, opts: { host?: string; filename?: string; write?: boolean }) =>
1246
+ templatesInit(getClient(), templateId, process.cwd(), {
1247
+ host: opts.host,
1248
+ filename: opts.filename,
1249
+ write: Boolean(opts.write),
1250
+ }),
1251
+ );
1252
+
1253
+ // ── Banner on bare `ninedeploy` ───────────────────────────────────────────
1254
+ if (process.argv.length <= 2) {
1255
+ banner();
1256
+ console.log(` ${'Quick start:'.padEnd(20)} ninedeploy init`);
1257
+ console.log(` ${'Server management:'.padEnd(20)} ninedeploy server start`);
1258
+ console.log(` ${'Diagnostics:'.padEnd(20)} ninedeploy doctor`);
1259
+ console.log(` ${'Browse templates:'.padEnd(20)} ninedeploy templates list`);
1260
+ console.log(` ${'Deploy a service:'.padEnd(20)} ninedeploy services create`);
1261
+ console.log(` ${'Deploy from GitHub:'.padEnd(28)} ninedeploy deploy create-from-github <url>`);
1262
+ console.log(` ${'Manage private repo creds:'.padEnd(28)} ninedeploy sources add|list|test|remove`);
1263
+ console.log(` ${'Manage auto-deploy webhooks:'.padEnd(28)} ninedeploy webhooks add|list|remove`);
1264
+ console.log(` ${'View dashboard:'.padEnd(20)} ninedeploy system dashboard`);
1265
+ console.log(` ${'Full help:'.padEnd(20)} ninedeploy --help`);
1266
+ console.log();
1267
+ process.exit(0);
1268
+ }
1269
+
1270
+ program.parseAsync().catch((err: unknown) => {
1271
+ console.error(err instanceof Error ? `\n ✗ ${err.message}\n` : String(err));
1272
+ process.exit(1);
1273
+ });