ninedeploy 0.10.6 → 0.16.1

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 (160) 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/hostIps.d.ts +21 -0
  27. package/dist/commands/hostIps.d.ts.map +1 -0
  28. package/dist/commands/hostIps.js +68 -0
  29. package/dist/commands/hostIps.js.map +1 -0
  30. package/dist/commands/housekeeping.d.ts +7 -0
  31. package/dist/commands/housekeeping.d.ts.map +1 -1
  32. package/dist/commands/housekeeping.js +16 -0
  33. package/dist/commands/housekeeping.js.map +1 -1
  34. package/dist/commands/logs.d.ts.map +1 -1
  35. package/dist/commands/logs.js +17 -2
  36. package/dist/commands/logs.js.map +1 -1
  37. package/dist/commands/manage.d.ts +26 -10
  38. package/dist/commands/manage.d.ts.map +1 -1
  39. package/dist/commands/manage.js +219 -65
  40. package/dist/commands/manage.js.map +1 -1
  41. package/dist/commands/manifest.d.ts.map +1 -1
  42. package/dist/commands/manifest.js +9 -1
  43. package/dist/commands/manifest.js.map +1 -1
  44. package/dist/commands/misc.d.ts +4 -2
  45. package/dist/commands/misc.d.ts.map +1 -1
  46. package/dist/commands/misc.js +25 -11
  47. package/dist/commands/misc.js.map +1 -1
  48. package/dist/commands/multiNode.d.ts +55 -0
  49. package/dist/commands/multiNode.d.ts.map +1 -0
  50. package/dist/commands/multiNode.js +438 -0
  51. package/dist/commands/multiNode.js.map +1 -0
  52. package/dist/commands/notifications.d.ts +1 -1
  53. package/dist/commands/operations.d.ts +89 -0
  54. package/dist/commands/operations.d.ts.map +1 -0
  55. package/dist/commands/operations.js +468 -0
  56. package/dist/commands/operations.js.map +1 -0
  57. package/dist/commands/panelBackup.d.ts +28 -0
  58. package/dist/commands/panelBackup.d.ts.map +1 -0
  59. package/dist/commands/panelBackup.js +153 -0
  60. package/dist/commands/panelBackup.js.map +1 -0
  61. package/dist/commands/plugins.d.ts +1 -1
  62. package/dist/commands/plugins.d.ts.map +1 -1
  63. package/dist/commands/plugins.js +7 -3
  64. package/dist/commands/plugins.js.map +1 -1
  65. package/dist/commands/proxyConfig.d.ts +16 -0
  66. package/dist/commands/proxyConfig.d.ts.map +1 -0
  67. package/dist/commands/proxyConfig.js +122 -0
  68. package/dist/commands/proxyConfig.js.map +1 -0
  69. package/dist/commands/secretProviders.d.ts +59 -0
  70. package/dist/commands/secretProviders.d.ts.map +1 -0
  71. package/dist/commands/secretProviders.js +212 -0
  72. package/dist/commands/secretProviders.js.map +1 -0
  73. package/dist/commands/services.d.ts +17 -0
  74. package/dist/commands/services.d.ts.map +1 -1
  75. package/dist/commands/services.js +55 -6
  76. package/dist/commands/services.js.map +1 -1
  77. package/dist/commands/setup.d.ts +3 -1
  78. package/dist/commands/setup.d.ts.map +1 -1
  79. package/dist/commands/setup.js +19 -3
  80. package/dist/commands/setup.js.map +1 -1
  81. package/dist/commands/sources.d.ts +10 -3
  82. package/dist/commands/sources.d.ts.map +1 -1
  83. package/dist/commands/sources.js +116 -18
  84. package/dist/commands/sources.js.map +1 -1
  85. package/dist/commands/templates.d.ts.map +1 -1
  86. package/dist/commands/templates.js +8 -1
  87. package/dist/commands/templates.js.map +1 -1
  88. package/dist/commands/upload.d.ts +24 -0
  89. package/dist/commands/upload.d.ts.map +1 -0
  90. package/dist/commands/upload.js +96 -0
  91. package/dist/commands/upload.js.map +1 -0
  92. package/dist/config.d.ts.map +1 -1
  93. package/dist/config.js +2 -1
  94. package/dist/config.js.map +1 -1
  95. package/dist/index.js +455 -24
  96. package/dist/index.js.map +1 -1
  97. package/dist/lib/format.d.ts.map +1 -1
  98. package/dist/lib/format.js +5 -4
  99. package/dist/lib/format.js.map +1 -1
  100. package/dist/lib/panelBackupFile.d.ts +3 -0
  101. package/dist/lib/panelBackupFile.d.ts.map +1 -0
  102. package/dist/lib/panelBackupFile.js +71 -0
  103. package/dist/lib/panelBackupFile.js.map +1 -0
  104. package/dist/lib/serverRunner.d.ts +6 -0
  105. package/dist/lib/serverRunner.d.ts.map +1 -1
  106. package/dist/lib/serverRunner.js +18 -3
  107. package/dist/lib/serverRunner.js.map +1 -1
  108. package/dist/prompts.d.ts.map +1 -1
  109. package/dist/prompts.js +9 -2
  110. package/dist/prompts.js.map +1 -1
  111. package/package.json +6 -5
  112. package/src/client.ts +137 -0
  113. package/src/commands/backupPolicy.ts +123 -0
  114. package/src/commands/branding.ts +83 -0
  115. package/src/commands/buildCache.ts +72 -0
  116. package/src/commands/certificates.ts +208 -0
  117. package/src/commands/communityTemplates.ts +97 -0
  118. package/src/commands/configCenter.ts +71 -0
  119. package/src/commands/configPresets.ts +215 -0
  120. package/src/commands/databaseAccess.ts +333 -0
  121. package/src/commands/demo.ts +22 -0
  122. package/src/commands/deploy.ts +284 -0
  123. package/src/commands/doctor.ts +170 -0
  124. package/src/commands/domains.ts +170 -0
  125. package/src/commands/egress.ts +135 -0
  126. package/src/commands/emailTemplates.ts +112 -0
  127. package/src/commands/firewall.ts +82 -0
  128. package/src/commands/githubApps.ts +292 -0
  129. package/src/commands/hostIps.ts +71 -0
  130. package/src/commands/housekeeping.ts +106 -0
  131. package/src/commands/login.ts +58 -0
  132. package/src/commands/logs.ts +91 -0
  133. package/src/commands/manage.ts +714 -0
  134. package/src/commands/manifest.ts +314 -0
  135. package/src/commands/metrics.ts +69 -0
  136. package/src/commands/misc.ts +307 -0
  137. package/src/commands/multiNode.ts +442 -0
  138. package/src/commands/notifications.ts +174 -0
  139. package/src/commands/operations.ts +519 -0
  140. package/src/commands/panelBackup.ts +167 -0
  141. package/src/commands/pgbouncer.ts +65 -0
  142. package/src/commands/plugins.ts +123 -0
  143. package/src/commands/proxyConfig.ts +117 -0
  144. package/src/commands/secretProviders.ts +228 -0
  145. package/src/commands/server.ts +126 -0
  146. package/src/commands/services.ts +299 -0
  147. package/src/commands/setup.ts +94 -0
  148. package/src/commands/sources.ts +317 -0
  149. package/src/commands/sso.ts +106 -0
  150. package/src/commands/templates.ts +158 -0
  151. package/src/commands/token.ts +66 -0
  152. package/src/commands/upload.ts +111 -0
  153. package/src/commands/webhooks.ts +93 -0
  154. package/src/commands/workspaces.ts +63 -0
  155. package/src/config.ts +53 -0
  156. package/src/index.ts +1303 -0
  157. package/src/lib/format.ts +182 -0
  158. package/src/lib/panelBackupFile.ts +67 -0
  159. package/src/lib/serverRunner.ts +223 -0
  160. package/src/prompts.ts +238 -0
@@ -0,0 +1,442 @@
1
+ import { AGENT_SWARM_MANAGER_VAR, type ImageTransfer, type ServicePlacementInput, type ServicePlacementView, type SwarmStatus } from '@ninedeploy/sdk';
2
+ import type { NineDeployClient } from '../client.js';
3
+ import { prompt, promptHidden } from '../prompts.js';
4
+ import { c, error, fmtBytes, fmtTime, header, info, kv, spinner, success, table } from '../lib/format.js';
5
+ import { plain } from './sources.js';
6
+
7
+ /**
8
+ * 0.16 T8: multi-node surfaces (the routes shipped in 0.15.2-0.15.4).
9
+ *
10
+ * ninedeploy servers list | roles <id> [--build-server on|off] [--build-concurrency <n>]
11
+ * ninedeploy services placement <id> [--build-on target|panel|server] [--build-server <id|none>]
12
+ * [--push-registry <sourceId|none> --push-repo <repo>] [--orchestrator container|swarm]
13
+ * ninedeploy services transfers <id> [--limit <n>] | swarm <id>
14
+ * ninedeploy swarm status | init --advertise-addr <ip> | enable | disable | join <serverId> | leave <serverId> [-y]
15
+ * ninedeploy volumes create <name> [--server <id>] (list and rm take --server too)
16
+ * ninedeploy sources allow-on-nodes <id> on|off
17
+ * ninedeploy sources pin-host-keys <id> on|off | reset-host-keys <id> | host-keys <id>
18
+ *
19
+ * Every route here is operator only except the service reads. Step-up
20
+ * passwords (swarm init, swarm enable, allow-on-nodes on) are read with a
21
+ * hidden prompt, never from argv. Server-provided strings go through the
22
+ * F1011 sanitiser.
23
+ */
24
+
25
+ const message = (err: unknown): string => plain(err instanceof Error ? err.message : String(err));
26
+ const codeOf = (err: unknown): string | undefined => {
27
+ const code = (err as { code?: unknown } | null)?.code;
28
+ return typeof code === 'string' ? code : undefined;
29
+ };
30
+
31
+ /** F537: canonical decimal ids only (`0x10`, `1e1` and `""` are refused, never reinterpreted). */
32
+ function positiveInt(raw: string | undefined, what: string): number | null {
33
+ const n = raw !== undefined && /^[1-9]\d*$/.test(raw) ? Number(raw) : Number.NaN;
34
+ if (!Number.isSafeInteger(n)) {
35
+ error(`${what} must be a positive integer`);
36
+ return null;
37
+ }
38
+ return n;
39
+ }
40
+
41
+ /** `on` / `off` (also true/false, yes/no); null after printing the usage error. */
42
+ function onOff(raw: string | undefined, what: string): boolean | null {
43
+ const v = raw?.trim().toLowerCase();
44
+ if (v === 'on' || v === 'true' || v === 'yes') return true;
45
+ if (v === 'off' || v === 'false' || v === 'no') return false;
46
+ error(`${what} must be on or off`);
47
+ return null;
48
+ }
49
+
50
+ function warn(warnings: readonly string[] | undefined): void {
51
+ for (const w of warnings ?? []) console.log(` ${c.yellow('!')} ${c.yellow(plain(w))}`);
52
+ }
53
+
54
+ /** Step-up for a sensitive change: an empty answer leaves it to a recent sign-in (SSO-only accounts). */
55
+ async function stepUpPassword(): Promise<string | undefined> {
56
+ return (await promptHidden('Your password (step-up re-check)')) || undefined;
57
+ }
58
+
59
+ // ── servers ───────────────────────────────────────────────────────────────
60
+
61
+ /** `ninedeploy servers list` */
62
+ export async function serversList(client: NineDeployClient): Promise<void> {
63
+ try {
64
+ const rows = await spinner('Loading servers', () => client.servers.list());
65
+ header('Servers');
66
+ if (rows.length === 0) return info('No servers registered. Add one in the panel (Servers) or with the bootstrap script.');
67
+ table(
68
+ rows.map((s) => ({
69
+ id: s.id,
70
+ name: plain(s.name),
71
+ host: `${plain(s.host)}:${s.port}`,
72
+ status: s.status,
73
+ agent: s.agent?.version ? plain(s.agent.version) : c.gray('—'),
74
+ build: s.isBuildServer ? `on (${s.buildConcurrency ?? 1})` : c.gray('off'),
75
+ databases: s.databases ?? 0,
76
+ swarm: s.swarmNodeId ? (s.swarmRole ?? 'member') : c.gray('—'),
77
+ })),
78
+ ['id', 'name', 'host', 'status', 'agent', 'build', 'databases', 'swarm'],
79
+ );
80
+ for (const s of rows) {
81
+ if (s.features?.reason) info(`${plain(s.name)}: ${plain(s.features.reason)}`);
82
+ }
83
+ } catch (err) {
84
+ error(message(err));
85
+ }
86
+ }
87
+
88
+ /** `ninedeploy servers roles <id> [--build-server on|off] [--build-concurrency <n>]` */
89
+ export async function serversRoles(client: NineDeployClient, idRaw: string, opts: { buildServer?: string; buildConcurrency?: string } = {}): Promise<void> {
90
+ const id = positiveInt(idRaw, 'Server id');
91
+ if (id === null) return;
92
+ if (opts.buildServer === undefined && opts.buildConcurrency === undefined) {
93
+ return error('Usage: ninedeploy servers roles <id> [--build-server on|off] [--build-concurrency 1-8]');
94
+ }
95
+ const isBuildServer = opts.buildServer === undefined ? undefined : onOff(opts.buildServer, '--build-server');
96
+ if (isBuildServer === null) return;
97
+ let buildConcurrency: number | undefined;
98
+ if (opts.buildConcurrency !== undefined) {
99
+ const n = /^[1-8]$/.test(opts.buildConcurrency) ? Number(opts.buildConcurrency) : Number.NaN;
100
+ if (Number.isNaN(n)) return error('--build-concurrency must be an integer from 1 to 8');
101
+ buildConcurrency = n;
102
+ }
103
+ try {
104
+ const res = await spinner('Saving the role', () =>
105
+ client.servers.update(id, { ...(isBuildServer !== undefined ? { isBuildServer } : {}), ...(buildConcurrency !== undefined ? { buildConcurrency } : {}) }),
106
+ );
107
+ success(`${plain(res.name)}: build server ${res.isBuildServer ? `on (concurrency ${res.buildConcurrency})` : 'off'}`);
108
+ if (!res.isBuildServer && res.buildServiceIds.length > 0) {
109
+ info(`Services still set to build here fail their next deploy until moved: ${res.buildServiceIds.join(', ')}`);
110
+ }
111
+ } catch (err) {
112
+ error(message(err));
113
+ }
114
+ }
115
+
116
+ // ── services: placement, transfers, swarm tasks ──────────────────────────
117
+
118
+ export interface PlacementOptions {
119
+ buildOn?: string;
120
+ buildServer?: string;
121
+ pushRegistry?: string;
122
+ pushRepo?: string;
123
+ orchestrator?: string;
124
+ }
125
+
126
+ const BUILD_ON = ['target', 'panel', 'server'] as const;
127
+ const ORCHESTRATORS = ['container', 'swarm'] as const;
128
+
129
+ function printPlacement(p: ServicePlacementView): void {
130
+ kv('Build on', p.buildOn ?? 'target (default)');
131
+ kv('Build server', p.buildServerId === null ? null : `#${p.buildServerId}`);
132
+ kv('Push registry', p.pushRegistrySourceId === null ? 'none (stream relay)' : `source #${p.pushRegistrySourceId} → ${plain(p.pushRepository)}`);
133
+ kv('Orchestrator', p.orchestrator ?? 'container (default)');
134
+ }
135
+
136
+ /** The PUT body from the flags; null after printing the error. */
137
+ export function placementInput(opts: PlacementOptions): ServicePlacementInput | null {
138
+ const input: ServicePlacementInput = {};
139
+ if (opts.buildOn !== undefined) {
140
+ if (!(BUILD_ON as readonly string[]).includes(opts.buildOn)) {
141
+ error(`--build-on must be one of ${BUILD_ON.join(', ')}`);
142
+ return null;
143
+ }
144
+ input.buildOn = opts.buildOn as ServicePlacementInput['buildOn'];
145
+ }
146
+ if (opts.buildServer !== undefined) {
147
+ if (opts.buildServer === 'none') input.buildServerId = null;
148
+ else {
149
+ const id = positiveInt(opts.buildServer, '--build-server');
150
+ if (id === null) return null;
151
+ input.buildServerId = id;
152
+ }
153
+ }
154
+ if (opts.pushRegistry !== undefined) {
155
+ if (opts.pushRegistry === 'none') {
156
+ input.pushRegistrySourceId = null;
157
+ input.pushRepository = null;
158
+ } else {
159
+ const id = positiveInt(opts.pushRegistry, '--push-registry');
160
+ if (id === null) return null;
161
+ if (!opts.pushRepo) {
162
+ error('--push-registry needs --push-repo <repository> (for example team/app)');
163
+ return null;
164
+ }
165
+ input.pushRegistrySourceId = id;
166
+ input.pushRepository = opts.pushRepo;
167
+ }
168
+ } else if (opts.pushRepo !== undefined) {
169
+ error('--push-repo needs --push-registry <sourceId>');
170
+ return null;
171
+ }
172
+ if (opts.orchestrator !== undefined) {
173
+ if (!(ORCHESTRATORS as readonly string[]).includes(opts.orchestrator)) {
174
+ error(`--orchestrator must be one of ${ORCHESTRATORS.join(', ')}`);
175
+ return null;
176
+ }
177
+ input.orchestrator = opts.orchestrator as ServicePlacementInput['orchestrator'];
178
+ }
179
+ return input;
180
+ }
181
+
182
+ /** `ninedeploy services placement <id> [flags]`: show, or (operator) change, the build placement. */
183
+ export async function servicesPlacement(client: NineDeployClient, idRaw: string, opts: PlacementOptions = {}): Promise<void> {
184
+ const id = positiveInt(idRaw, 'Service id');
185
+ if (id === null) return;
186
+ const input = placementInput(opts);
187
+ if (input === null) return;
188
+ try {
189
+ if (Object.keys(input).length === 0) {
190
+ const view = await spinner('Loading the placement', () => client.services.placement.get(id));
191
+ header(`Service #${id} placement`);
192
+ printPlacement(view);
193
+ return;
194
+ }
195
+ const view = await spinner('Saving the placement', () => client.services.placement.set(id, input));
196
+ success(`Placement of service #${id} saved; the next deploy uses it.`);
197
+ printPlacement(view);
198
+ } catch (err) {
199
+ error(message(err));
200
+ }
201
+ }
202
+
203
+ function transferHost(id: number | null): string {
204
+ return id === null ? 'panel' : `#${id}`;
205
+ }
206
+
207
+ /** `ninedeploy services transfers <id> [--limit <n>]` */
208
+ export async function servicesTransfers(client: NineDeployClient, idRaw: string, opts: { limit?: string } = {}): Promise<void> {
209
+ const id = positiveInt(idRaw, 'Service id');
210
+ if (id === null) return;
211
+ let limit: number | undefined;
212
+ if (opts.limit !== undefined) {
213
+ const n = /^[1-9]\d{0,2}$/.test(opts.limit) ? Number(opts.limit) : Number.NaN;
214
+ if (Number.isNaN(n) || n > 100) return error('--limit must be an integer from 1 to 100');
215
+ limit = n;
216
+ }
217
+ try {
218
+ const rows: ImageTransfer[] = await spinner('Loading image transfers', () => client.services.imageTransfers(id, limit === undefined ? undefined : { limit }));
219
+ header(`Image transfers of service #${id}`);
220
+ if (rows.length === 0) return info('No image transfers yet: the service builds where it runs.');
221
+ table(
222
+ rows.map((t) => ({
223
+ id: t.id,
224
+ deploy: t.deploymentId ?? '—',
225
+ method: t.method,
226
+ route: `${transferHost(t.sourceServerId)} → ${transferHost(t.targetServerId)}`,
227
+ size: fmtBytes(t.bytes),
228
+ time: t.durationMs === null ? '—' : `${(t.durationMs / 1000).toFixed(1)}s`,
229
+ status: t.status,
230
+ started: fmtTime(t.startedAt),
231
+ error: t.error ? plain(t.error).slice(0, 80) : '',
232
+ })),
233
+ ['id', 'deploy', 'method', 'route', 'size', 'time', 'status', 'started', 'error'],
234
+ );
235
+ } catch (err) {
236
+ error(message(err));
237
+ }
238
+ }
239
+
240
+ /** `ninedeploy services swarm <id>` */
241
+ export async function servicesSwarm(client: NineDeployClient, idRaw: string): Promise<void> {
242
+ const id = positiveInt(idRaw, 'Service id');
243
+ if (id === null) return;
244
+ try {
245
+ const view = await spinner('Loading Swarm tasks', () => client.services.swarm(id));
246
+ header(`Service #${id} on Swarm`);
247
+ if (view.stack === null) return info('Not on Swarm (or not deployed yet). Switch with: ninedeploy services placement <id> --orchestrator swarm');
248
+ kv('Stack', plain(view.stack));
249
+ kv('Replicas', `${view.running} running / ${view.desired} desired`);
250
+ table(
251
+ view.tasks.map((t) => ({ node: plain(t.node), status: plain(t.state), image: plain(t.image), error: t.error ? plain(t.error) : '' })),
252
+ ['node', 'status', 'image', 'error'],
253
+ );
254
+ } catch (err) {
255
+ error(message(err));
256
+ }
257
+ }
258
+
259
+ // ── swarm ─────────────────────────────────────────────────────────────────
260
+
261
+ function printSwarm(s: SwarmStatus): void {
262
+ kv('Swarm deploys', s.enabled ? c.green('enabled') : 'disabled');
263
+ kv('Panel host', plain(s.localState));
264
+ kv('Manager', s.controlAvailable ? 'yes' : 'no');
265
+ kv('Join address', s.managerAddr === null ? null : plain(s.managerAddr));
266
+ if (s.nodes.length > 0) {
267
+ console.log();
268
+ table(
269
+ s.nodes.map((n) => ({
270
+ id: plain(n.id).slice(0, 12),
271
+ hostname: plain(n.hostname),
272
+ role: n.role,
273
+ availability: plain(n.availability),
274
+ status: plain(n.state),
275
+ server: n.serverId === null ? c.gray('—') : `#${n.serverId}`,
276
+ })),
277
+ ['id', 'hostname', 'role', 'availability', 'status', 'server'],
278
+ );
279
+ for (const n of s.nodes) warn(n.warnings);
280
+ }
281
+ warn(s.warnings);
282
+ }
283
+
284
+ /** `ninedeploy swarm status` */
285
+ export async function swarmStatus(client: NineDeployClient): Promise<void> {
286
+ try {
287
+ const s = await spinner('Loading Swarm status', () => client.swarm.get());
288
+ header('Swarm');
289
+ printSwarm(s);
290
+ } catch (err) {
291
+ error(message(err));
292
+ }
293
+ }
294
+
295
+ /** `ninedeploy swarm init --advertise-addr <ip>`: asks for the password (step-up). */
296
+ export async function swarmInit(client: NineDeployClient, opts: { advertiseAddr?: string } = {}): Promise<void> {
297
+ const advertiseAddr = opts.advertiseAddr?.trim();
298
+ if (!advertiseAddr) return error('Usage: ninedeploy swarm init --advertise-addr <ip> (the address the nodes reach the panel host on)');
299
+ const password = await stepUpPassword();
300
+ try {
301
+ const s = await spinner('Initialising Swarm on the panel host', () => client.swarm.init({ advertiseAddr, ...(password ? { password } : {}) }));
302
+ success('Swarm initialised on the panel host. Enable Swarm deploys with: ninedeploy swarm enable');
303
+ printSwarm(s);
304
+ } catch (err) {
305
+ error(message(err));
306
+ }
307
+ }
308
+
309
+ /** `ninedeploy swarm enable|disable`: enabling asks for the password (step-up). */
310
+ export async function swarmSetEnabled(client: NineDeployClient, enabled: boolean): Promise<void> {
311
+ const password = enabled ? await stepUpPassword() : undefined;
312
+ try {
313
+ const s = await spinner(enabled ? 'Enabling Swarm deploys' : 'Disabling Swarm deploys', () =>
314
+ client.swarm.settings({ enabled, ...(password ? { password } : {}) }),
315
+ );
316
+ success(enabled ? 'Swarm deploys enabled.' : 'Swarm deploys disabled; running stacks keep running.');
317
+ printSwarm(s);
318
+ } catch (err) {
319
+ error(message(err));
320
+ }
321
+ }
322
+
323
+ /** `ninedeploy swarm join <serverId>` */
324
+ export async function swarmJoin(client: NineDeployClient, idRaw: string): Promise<void> {
325
+ const id = positiveInt(idRaw, 'Server id');
326
+ if (id === null) return;
327
+ try {
328
+ const res = await spinner(`Joining server #${id} to the swarm`, () => client.servers.swarmJoin(id));
329
+ success(`Server #${res.serverId} joined the swarm as a ${res.role} (node ${plain(res.nodeId).slice(0, 12)}).`);
330
+ warn(res.warnings);
331
+ } catch (err) {
332
+ error(message(err));
333
+ if (codeOf(err) === 'node_swarm_not_enabled') {
334
+ info(`Swarm membership is opt-in on the node: set ${AGENT_SWARM_MANAGER_VAR}=<panel advertise address>:2377 in the agent's environment, restart the agent, and run this again.`);
335
+ }
336
+ }
337
+ }
338
+
339
+ /** `ninedeploy swarm leave <serverId> [-y]`: drains the node first (up to 5 minutes). */
340
+ export async function swarmLeave(client: NineDeployClient, idRaw: string, opts: { yes?: boolean } = {}): Promise<void> {
341
+ const id = positiveInt(idRaw, 'Server id');
342
+ if (id === null) return;
343
+ if (!opts.yes) {
344
+ const answer = await prompt(`Drain server #${id} and take it out of the swarm? Its Swarm tasks move to other nodes. Type "yes" to confirm`);
345
+ if (answer.trim().toLowerCase() !== 'yes') return info('Cancelled');
346
+ }
347
+ try {
348
+ const res = await spinner(`Draining server #${id} and leaving the swarm`, () => client.servers.swarmLeave(id));
349
+ success(`Server #${res.serverId} left the swarm.`);
350
+ if (!res.drained) info('Its tasks had not moved within 5 minutes; Swarm reschedules them on the remaining nodes.');
351
+ warn(res.warnings);
352
+ } catch (err) {
353
+ error(message(err));
354
+ }
355
+ }
356
+
357
+ // ── volumes on a node ─────────────────────────────────────────────────────
358
+
359
+ /** The `--server <id>` of the volume commands; undefined = the panel host, null = invalid (error printed). */
360
+ export function serverOption(raw: string | undefined): number | undefined | null {
361
+ return raw === undefined ? undefined : positiveInt(raw, '--server');
362
+ }
363
+
364
+ /** `ninedeploy volumes create <name> [--server <id>]` */
365
+ export async function volumesCreate(client: NineDeployClient, name: string, opts: { server?: string } = {}): Promise<void> {
366
+ if (!name) return error('Usage: ninedeploy volumes create <nd-svc-…|nd-db-…> [--server <id>]');
367
+ const serverId = serverOption(opts.server);
368
+ if (serverId === null) return;
369
+ try {
370
+ const res = await spinner('Creating the volume', () => client.volumes.create({ name, ...(serverId !== undefined ? { serverId } : {}) }));
371
+ success(`Volume ${c.cyan(plain(res.name))} created on ${res.serverId === null ? 'the panel host' : `server #${res.serverId}`}.`);
372
+ } catch (err) {
373
+ error(message(err));
374
+ }
375
+ }
376
+
377
+ // ── sources on nodes ─────────────────────────────────────────────────────
378
+
379
+ /** `ninedeploy sources allow-on-nodes <id> on|off`: turning it on asks for the password (step-up). */
380
+ export async function sourcesAllowOnNodes(client: NineDeployClient, idRaw: string, value: string): Promise<void> {
381
+ const id = positiveInt(idRaw, 'Source id');
382
+ if (id === null) return;
383
+ const allow = onOff(value, 'The setting');
384
+ if (allow === null) return;
385
+ if (allow) info('The PAT or deploy key will be sent (sealed) to a node for each clone there. A node refuses it with NINEDEPLOY_AGENT_STATIC_CREDENTIALS=off.');
386
+ const password = allow ? await stepUpPassword() : undefined;
387
+ try {
388
+ const src = await spinner('Saving', () => client.sources.update(id, { allowOnNodes: allow, ...(password ? { password } : {}) }));
389
+ success(`${plain(src.name)}: ${allow ? 'allowed on nodes' : 'panel host only'}`);
390
+ } catch (err) {
391
+ error(message(err));
392
+ }
393
+ }
394
+
395
+ // ── source host key pinning (0.15.8) ─────────────────────────────────────
396
+
397
+ /** `ninedeploy sources pin-host-keys <id> on|off`: off asks for the password (step-up); on only tightens. */
398
+ export async function sourcesPinHostKeys(client: NineDeployClient, idRaw: string, value: string): Promise<void> {
399
+ const id = positiveInt(idRaw, 'Source id');
400
+ if (id === null) return;
401
+ const pin = onOff(value, 'The setting');
402
+ if (pin === null) return;
403
+ if (pin) info('The first clone after this learns the git host keys; every later clone refuses a host whose key changed. Applies to clones the panel makes with the SSH deploy key; a node keeps its per-job policy.');
404
+ const password = pin ? undefined : await stepUpPassword();
405
+ try {
406
+ const src = await spinner('Saving', () => client.sources.update(id, { pinHostKeys: pin, ...(password ? { password } : {}) }));
407
+ success(`${plain(src.name)}: host key pinning ${pin ? 'on' : 'off'}`);
408
+ } catch (err) {
409
+ error(message(err));
410
+ }
411
+ }
412
+
413
+ /** `ninedeploy sources reset-host-keys <id>`: forget the pinned keys so the next clone learns them again (step-up). */
414
+ export async function sourcesResetHostKeys(client: NineDeployClient, idRaw: string): Promise<void> {
415
+ const id = positiveInt(idRaw, 'Source id');
416
+ if (id === null) return;
417
+ const password = await stepUpPassword();
418
+ try {
419
+ const src = await spinner('Resetting', () => client.sources.update(id, { resetHostKeys: true, ...(password ? { password } : {}) }));
420
+ success(`${plain(src.name)}: pinned host keys forgotten; the next clone learns them again`);
421
+ } catch (err) {
422
+ error(message(err));
423
+ }
424
+ }
425
+
426
+ /** `ninedeploy sources host-keys <id>`: the fingerprints a source has pinned. */
427
+ export async function sourcesHostKeys(client: NineDeployClient, idRaw: string): Promise<void> {
428
+ const id = positiveInt(idRaw, 'Source id');
429
+ if (id === null) return;
430
+ try {
431
+ const all = await spinner('Loading sources', () => client.sources.list());
432
+ const src = all.find((s) => s.id === id);
433
+ if (!src) return error(`No source #${id}`);
434
+ header(`${plain(src.name)}: host keys`);
435
+ if (!src.pinHostKeys) return info('This source is not pinning host keys (sources pin-host-keys <id> on).');
436
+ const keys = src.pinnedHostKeys ?? [];
437
+ if (keys.length === 0) return info('Pinning is on; the first clone will learn the host keys.');
438
+ table(keys.map((k) => ({ host: plain(k.host), type: plain(k.type), fingerprint: plain(k.fingerprint ?? '(unreadable)') })), ['host', 'type', 'fingerprint']);
439
+ } catch (err) {
440
+ error(message(err));
441
+ }
442
+ }
@@ -0,0 +1,174 @@
1
+ /**
2
+ * `ninedeploy notifications {list,create-webhook,test,rm}` — operator
3
+ * UI for the notification channel registry. PR #49 (G-06) adds
4
+ * the HMAC + body-template fields for the `webhook` channel type;
5
+ * the other types (slack, discord, telegram, teams, ntfy, email, resend) keep
6
+ * their existing surface and accept an opaque `configJson` blob.
7
+ */
8
+ import type { NineDeployClient } from '../client.js';
9
+ import { c, error, header, info, spinner, success, table } from '../lib/format.js';
10
+
11
+ const num = (v: string, usage: string): number => {
12
+ const n = Number(v);
13
+ if (Number.isNaN(n)) {
14
+ error(usage);
15
+ throw new Error(usage);
16
+ }
17
+ return n;
18
+ };
19
+
20
+ const fail = (err: unknown): void => {
21
+ error(err instanceof Error ? err.message : String(err));
22
+ };
23
+
24
+ /** `ninedeploy notifications list` */
25
+ export async function notificationsList(client: NineDeployClient): Promise<void> {
26
+ header('Notification Channels');
27
+ const rows = await spinner('Fetching', () => client.notifications.listChannels());
28
+ if (rows.length === 0) {
29
+ info('No notification channels configured.');
30
+ return;
31
+ }
32
+ table(
33
+ rows.map((r) => ({
34
+ id: r.id,
35
+ name: r.name,
36
+ type: r.type,
37
+ events: r.eventFilter || c.dim('(all)'),
38
+ active: r.active ? 'yes' : 'no',
39
+ })),
40
+ ['id', 'name', 'type', 'events', 'active'],
41
+ );
42
+ }
43
+
44
+ /**
45
+ * `ninedeploy notifications create-webhook <name> <url> [--secret s]
46
+ * [--header h] [--algo sha256|sha1]
47
+ * [--template k=v ...]`
48
+ *
49
+ * The `webhook` channel type is the G-06 outbound integration
50
+ * (HMAC-signed POST). The CLI builds a `configJson` blob from
51
+ * the flags and sends it through the existing channel create
52
+ * endpoint. `--template` accepts a flat key=value list (e.g.
53
+ * `--template title=Deploy message='${event} done'`) and
54
+ * assembles a JSON object that the server's `renderWebhookBody`
55
+ * expands at send time.
56
+ */
57
+ export async function notificationsCreateWebhook(
58
+ client: NineDeployClient,
59
+ name: string,
60
+ url: string,
61
+ opts: {
62
+ secret?: string;
63
+ header?: string;
64
+ algo?: 'sha256' | 'sha1';
65
+ eventFilter?: string;
66
+ template?: string[];
67
+ } = {},
68
+ ): Promise<void> {
69
+ if (!name || !url) {
70
+ error('Usage: ninedeploy notifications create-webhook <name> <url> [--secret s] [--header h] [--algo sha256|sha1] [--template k=v ...]');
71
+ return;
72
+ }
73
+ const config: Record<string, unknown> = {};
74
+ if (opts.secret) config.secret = opts.secret;
75
+ if (opts.header) config.headerName = opts.header;
76
+ if (opts.algo) config.algorithm = opts.algo;
77
+ if (opts.template && opts.template.length > 0) {
78
+ const tpl: Record<string, string> = {};
79
+ for (const raw of opts.template) {
80
+ const eq = raw.indexOf('=');
81
+ if (eq <= 0) {
82
+ error(`--template expects key=value pairs, got: ${raw}`);
83
+ return;
84
+ }
85
+ tpl[raw.slice(0, eq)] = raw.slice(eq + 1);
86
+ }
87
+ config.template = tpl;
88
+ }
89
+ try {
90
+ const ch = await client.notifications.createChannel({
91
+ name,
92
+ type: 'webhook',
93
+ target: url,
94
+ eventFilter: opts.eventFilter,
95
+ configJson: Object.keys(config).length > 0 ? JSON.stringify(config) : undefined,
96
+ });
97
+ success(`Webhook channel "${ch.name}" created (id: ${ch.id}).`);
98
+ if (opts.secret) {
99
+ info(`HMAC signature header: ${opts.header ?? 'X-NineDeploy-Signature'} (${opts.algo ?? 'sha256'})`);
100
+ }
101
+ if (opts.template && opts.template.length > 0) {
102
+ info(`Custom body template: ${opts.template.length} field(s)`);
103
+ }
104
+ } catch (err) { fail(err); }
105
+ }
106
+
107
+ /**
108
+ * `ninedeploy notifications create-fcm <name> <deviceToken>
109
+ * --service-account <file>`
110
+ *
111
+ * The `fcm` channel type is the G-22 push-notification
112
+ * integration (Firebase Cloud Messaging HTTP v1). The
113
+ * device token is the FCM registration token of a
114
+ * target device; the service account JSON is the file
115
+ * Firebase issues when the operator creates a project
116
+ * service account with the `Firebase Cloud Messaging
117
+ * API` role. The CLI reads the file from disk so the
118
+ * service account never lands on the operator's shell
119
+ * history.
120
+ *
121
+ * Caveat: the service account's `private_key` is a PEM
122
+ * block; the SDK will store the rendered `configJson`
123
+ * encrypted at rest by `notificationChannels`. Treat
124
+ * the service account JSON as a secret — same posture
125
+ * as an SMTP password.
126
+ */
127
+ export async function notificationsCreateFcm(
128
+ client: NineDeployClient,
129
+ name: string,
130
+ deviceToken: string,
131
+ opts: { serviceAccount: string },
132
+ ): Promise<void> {
133
+ if (!name || !deviceToken || !opts.serviceAccount) {
134
+ error('Usage: ninedeploy notifications create-fcm <name> <deviceToken> --service-account <file.json>');
135
+ return;
136
+ }
137
+ const fs = await import('node:fs/promises');
138
+ let sa: string;
139
+ try {
140
+ sa = await fs.readFile(opts.serviceAccount, 'utf8');
141
+ } catch (err) {
142
+ error(`Failed to read service account JSON: ${err instanceof Error ? err.message : String(err)}`);
143
+ return;
144
+ }
145
+ try {
146
+ const ch = await client.notifications.createChannel({
147
+ name,
148
+ type: 'fcm',
149
+ target: deviceToken,
150
+ configJson: sa,
151
+ });
152
+ success(`FCM channel "${ch.name}" created (id: ${ch.id}).`);
153
+ info('Send a test event with `ninedeploy notifications test <id>` to verify wiring.');
154
+ } catch (err) { fail(err); }
155
+ }
156
+
157
+ /** `ninedeploy notifications test <id>` — fire a test event
158
+ * through the channel so the operator can verify wiring. */
159
+ export async function notificationsTest(client: NineDeployClient, idStr: string): Promise<void> {
160
+ const id = num(idStr, 'Usage: ninedeploy notifications test <id>');
161
+ try {
162
+ await client.notifications.testChannel(id);
163
+ success(`Test event fired through channel #${id}.`);
164
+ } catch (err) { fail(err); }
165
+ }
166
+
167
+ /** `ninedeploy notifications rm <id>` */
168
+ export async function notificationsRemove(client: NineDeployClient, idStr: string): Promise<void> {
169
+ const id = num(idStr, 'Usage: ninedeploy notifications rm <id>');
170
+ try {
171
+ await client.notifications.removeChannel(id);
172
+ success(`Channel #${id} removed.`);
173
+ } catch (err) { fail(err); }
174
+ }