ninedeploy 0.10.5 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (149) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +1 -0
  3. package/dist/client.d.ts +34 -0
  4. package/dist/client.d.ts.map +1 -1
  5. package/dist/client.js +81 -22
  6. package/dist/client.js.map +1 -1
  7. package/dist/commands/backupPolicy.d.ts +29 -0
  8. package/dist/commands/backupPolicy.d.ts.map +1 -0
  9. package/dist/commands/backupPolicy.js +111 -0
  10. package/dist/commands/backupPolicy.js.map +1 -0
  11. package/dist/commands/certificates.d.ts +18 -7
  12. package/dist/commands/certificates.d.ts.map +1 -1
  13. package/dist/commands/certificates.js +121 -1
  14. package/dist/commands/certificates.js.map +1 -1
  15. package/dist/commands/databaseAccess.d.ts +37 -0
  16. package/dist/commands/databaseAccess.d.ts.map +1 -0
  17. package/dist/commands/databaseAccess.js +306 -0
  18. package/dist/commands/databaseAccess.js.map +1 -0
  19. package/dist/commands/deploy.d.ts.map +1 -1
  20. package/dist/commands/deploy.js +28 -7
  21. package/dist/commands/deploy.js.map +1 -1
  22. package/dist/commands/githubApps.d.ts +40 -0
  23. package/dist/commands/githubApps.d.ts.map +1 -0
  24. package/dist/commands/githubApps.js +278 -0
  25. package/dist/commands/githubApps.js.map +1 -0
  26. package/dist/commands/housekeeping.d.ts +7 -0
  27. package/dist/commands/housekeeping.d.ts.map +1 -1
  28. package/dist/commands/housekeeping.js +16 -0
  29. package/dist/commands/housekeeping.js.map +1 -1
  30. package/dist/commands/logs.d.ts.map +1 -1
  31. package/dist/commands/logs.js +17 -2
  32. package/dist/commands/logs.js.map +1 -1
  33. package/dist/commands/manage.d.ts +26 -10
  34. package/dist/commands/manage.d.ts.map +1 -1
  35. package/dist/commands/manage.js +219 -65
  36. package/dist/commands/manage.js.map +1 -1
  37. package/dist/commands/manifest.d.ts.map +1 -1
  38. package/dist/commands/manifest.js +9 -1
  39. package/dist/commands/manifest.js.map +1 -1
  40. package/dist/commands/misc.d.ts +4 -2
  41. package/dist/commands/misc.d.ts.map +1 -1
  42. package/dist/commands/misc.js +25 -11
  43. package/dist/commands/misc.js.map +1 -1
  44. package/dist/commands/multiNode.d.ts +55 -0
  45. package/dist/commands/multiNode.d.ts.map +1 -0
  46. package/dist/commands/multiNode.js +438 -0
  47. package/dist/commands/multiNode.js.map +1 -0
  48. package/dist/commands/notifications.d.ts +1 -1
  49. package/dist/commands/operations.d.ts +89 -0
  50. package/dist/commands/operations.d.ts.map +1 -0
  51. package/dist/commands/operations.js +468 -0
  52. package/dist/commands/operations.js.map +1 -0
  53. package/dist/commands/panelBackup.d.ts +28 -0
  54. package/dist/commands/panelBackup.d.ts.map +1 -0
  55. package/dist/commands/panelBackup.js +153 -0
  56. package/dist/commands/panelBackup.js.map +1 -0
  57. package/dist/commands/plugins.d.ts +1 -1
  58. package/dist/commands/plugins.d.ts.map +1 -1
  59. package/dist/commands/plugins.js +7 -3
  60. package/dist/commands/plugins.js.map +1 -1
  61. package/dist/commands/proxyConfig.d.ts +16 -0
  62. package/dist/commands/proxyConfig.d.ts.map +1 -0
  63. package/dist/commands/proxyConfig.js +122 -0
  64. package/dist/commands/proxyConfig.js.map +1 -0
  65. package/dist/commands/secretProviders.d.ts +59 -0
  66. package/dist/commands/secretProviders.d.ts.map +1 -0
  67. package/dist/commands/secretProviders.js +212 -0
  68. package/dist/commands/secretProviders.js.map +1 -0
  69. package/dist/commands/services.d.ts.map +1 -1
  70. package/dist/commands/services.js +3 -5
  71. package/dist/commands/services.js.map +1 -1
  72. package/dist/commands/setup.d.ts +3 -1
  73. package/dist/commands/setup.d.ts.map +1 -1
  74. package/dist/commands/setup.js +19 -3
  75. package/dist/commands/setup.js.map +1 -1
  76. package/dist/commands/sources.d.ts +10 -3
  77. package/dist/commands/sources.d.ts.map +1 -1
  78. package/dist/commands/sources.js +116 -18
  79. package/dist/commands/sources.js.map +1 -1
  80. package/dist/commands/templates.d.ts.map +1 -1
  81. package/dist/commands/templates.js +8 -1
  82. package/dist/commands/templates.js.map +1 -1
  83. package/dist/config.d.ts.map +1 -1
  84. package/dist/config.js +2 -1
  85. package/dist/config.js.map +1 -1
  86. package/dist/index.js +427 -23
  87. package/dist/index.js.map +1 -1
  88. package/dist/lib/format.d.ts.map +1 -1
  89. package/dist/lib/format.js +5 -4
  90. package/dist/lib/format.js.map +1 -1
  91. package/dist/lib/panelBackupFile.d.ts +3 -0
  92. package/dist/lib/panelBackupFile.d.ts.map +1 -0
  93. package/dist/lib/panelBackupFile.js +71 -0
  94. package/dist/lib/panelBackupFile.js.map +1 -0
  95. package/dist/lib/serverRunner.d.ts +6 -0
  96. package/dist/lib/serverRunner.d.ts.map +1 -1
  97. package/dist/lib/serverRunner.js +18 -3
  98. package/dist/lib/serverRunner.js.map +1 -1
  99. package/dist/prompts.d.ts.map +1 -1
  100. package/dist/prompts.js +9 -2
  101. package/dist/prompts.js.map +1 -1
  102. package/package.json +6 -5
  103. package/src/client.ts +137 -0
  104. package/src/commands/backupPolicy.ts +123 -0
  105. package/src/commands/branding.ts +83 -0
  106. package/src/commands/buildCache.ts +72 -0
  107. package/src/commands/certificates.ts +208 -0
  108. package/src/commands/communityTemplates.ts +97 -0
  109. package/src/commands/configCenter.ts +71 -0
  110. package/src/commands/configPresets.ts +215 -0
  111. package/src/commands/databaseAccess.ts +333 -0
  112. package/src/commands/demo.ts +22 -0
  113. package/src/commands/deploy.ts +284 -0
  114. package/src/commands/doctor.ts +170 -0
  115. package/src/commands/domains.ts +170 -0
  116. package/src/commands/egress.ts +135 -0
  117. package/src/commands/emailTemplates.ts +112 -0
  118. package/src/commands/firewall.ts +82 -0
  119. package/src/commands/githubApps.ts +292 -0
  120. package/src/commands/housekeeping.ts +106 -0
  121. package/src/commands/login.ts +58 -0
  122. package/src/commands/logs.ts +91 -0
  123. package/src/commands/manage.ts +714 -0
  124. package/src/commands/manifest.ts +314 -0
  125. package/src/commands/metrics.ts +69 -0
  126. package/src/commands/misc.ts +307 -0
  127. package/src/commands/multiNode.ts +442 -0
  128. package/src/commands/notifications.ts +174 -0
  129. package/src/commands/operations.ts +519 -0
  130. package/src/commands/panelBackup.ts +167 -0
  131. package/src/commands/pgbouncer.ts +65 -0
  132. package/src/commands/plugins.ts +123 -0
  133. package/src/commands/proxyConfig.ts +117 -0
  134. package/src/commands/secretProviders.ts +228 -0
  135. package/src/commands/server.ts +126 -0
  136. package/src/commands/services.ts +249 -0
  137. package/src/commands/setup.ts +94 -0
  138. package/src/commands/sources.ts +317 -0
  139. package/src/commands/sso.ts +106 -0
  140. package/src/commands/templates.ts +158 -0
  141. package/src/commands/token.ts +66 -0
  142. package/src/commands/webhooks.ts +93 -0
  143. package/src/commands/workspaces.ts +63 -0
  144. package/src/config.ts +53 -0
  145. package/src/index.ts +1273 -0
  146. package/src/lib/format.ts +182 -0
  147. package/src/lib/panelBackupFile.ts +67 -0
  148. package/src/lib/serverRunner.ts +223 -0
  149. package/src/prompts.ts +238 -0
@@ -0,0 +1,182 @@
1
+ /** CLI formatting utilities — colors, spinners, tables, banners. */
2
+
3
+ const RESET = '\x1b[0m';
4
+ const BOLD = '\x1b[1m';
5
+ const DIM = '\x1b[2m';
6
+ const RED = '\x1b[31m';
7
+ const GREEN = '\x1b[32m';
8
+ const YELLOW = '\x1b[33m';
9
+ const BLUE = '\x1b[34m';
10
+ const MAGENTA = '\x1b[35m';
11
+ const CYAN = '\x1b[36m';
12
+ const GRAY = '\x1b[90m';
13
+
14
+ export const c = {
15
+ reset: (s: string) => `${RESET}${s}${RESET}`,
16
+ bold: (s: string) => `${BOLD}${s}${RESET}`,
17
+ dim: (s: string) => `${DIM}${s}${RESET}`,
18
+ red: (s: string) => `${RED}${s}${RESET}`,
19
+ green: (s: string) => `${GREEN}${s}${RESET}`,
20
+ yellow: (s: string) => `${YELLOW}${s}${RESET}`,
21
+ blue: (s: string) => `${BLUE}${s}${RESET}`,
22
+ magenta: (s: string) => `${MAGENTA}${s}${RESET}`,
23
+ cyan: (s: string) => `${CYAN}${s}${RESET}`,
24
+ gray: (s: string) => `${GRAY}${s}${RESET}`,
25
+ };
26
+
27
+ const STATUS_COLORS: Record<string, (s: string) => string> = {
28
+ running: c.green,
29
+ healthy: c.green,
30
+ active: c.green,
31
+ sent: c.green,
32
+ ok: c.green,
33
+ stopped: c.gray,
34
+ idle: c.gray,
35
+ deploying: c.yellow,
36
+ building: c.yellow,
37
+ queued: c.yellow,
38
+ pending: c.yellow,
39
+ creating: c.yellow,
40
+ error: c.red,
41
+ failed: c.red,
42
+ unhealthy: c.red,
43
+ };
44
+
45
+ export function statusColor(status: string): string {
46
+ const color = Object.hasOwn(STATUS_COLORS, status) ? STATUS_COLORS[status] : undefined;
47
+ return color ? color(status) : status;
48
+ }
49
+
50
+ export function banner(): void {
51
+ const gradient = `${MAGENTA}╔${'═'.repeat(44)}╗${RESET}
52
+ ${MAGENTA}║${RESET} ${BOLD} 9 NineDeploy${RESET} ${DIM}Self-hosted PaaS${RESET} ${MAGENTA}║${RESET}
53
+ ${MAGENTA}║${RESET} ${GRAY}Deploy from Git or Docker in one click${RESET} ${MAGENTA}║${RESET}
54
+ ${MAGENTA}╚${'═'.repeat(44)}╝${RESET}`;
55
+ console.log(gradient);
56
+ console.log();
57
+ }
58
+
59
+ /** Simple spinner that shows a message while a promise resolves. */
60
+ export async function spinner<T>(msg: string, fn: () => Promise<T>): Promise<T> {
61
+ // Piped/CI stdout is not a TTY: `\r`-frames would smear braille garbage
62
+ // through captured logs. Non-TTY output stays to a single plain line.
63
+ const isTTY = process.stdout.isTTY === true;
64
+ const frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
65
+ let i = 0;
66
+ const interval = isTTY
67
+ ? setInterval(() => {
68
+ process.stdout.write(`\r${CYAN}${frames[i % frames.length]}${RESET} ${msg}...`);
69
+ i++;
70
+ }, 80)
71
+ : null;
72
+ if (!isTTY) process.stdout.write(`${msg}... `);
73
+ try {
74
+ const result = await fn();
75
+ clearInterval(interval ?? undefined);
76
+ if (isTTY) {
77
+ process.stdout.write(`\r${GREEN}✓${RESET} ${msg}\n`);
78
+ } else {
79
+ process.stdout.write(`✓\n`);
80
+ }
81
+ return result;
82
+ } catch (err) {
83
+ clearInterval(interval ?? undefined);
84
+ if (isTTY) {
85
+ process.stdout.write(`\r${RED}✗${RESET} ${msg}\n`);
86
+ } else {
87
+ process.stdout.write(`✗\n`);
88
+ }
89
+ throw err;
90
+ }
91
+ }
92
+
93
+ /** ANSI SGR escape sequence — built from a fragment so the source carries no
94
+ * literal control character and the regex stays a literal (biome-friendly). */
95
+ const ANSI_SGR = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, 'g');
96
+
97
+ /** Visible length: ANSI SGR escapes render as zero width, so they must not
98
+ * count toward column widths or padding budgets. */
99
+ function visibleLength(s: string): number {
100
+ return s.replace(ANSI_SGR, '').length;
101
+ }
102
+
103
+ /** Pad a (possibly colored) cell to `width` VISIBLE characters — padding is
104
+ * appended after any reset escape, so escape bytes never consume the budget. */
105
+ function padVisible(s: string, width: number): string {
106
+ return s + ' '.repeat(Math.max(0, width - visibleLength(s)));
107
+ }
108
+
109
+ /** Print a formatted table from an array of objects. */
110
+ export function table(rows: Record<string, unknown>[], columns?: string[]): void {
111
+ if (rows.length === 0) { console.log(c.gray(' (empty)')); return; }
112
+ const cols = columns ?? Object.keys(rows[0] ?? {});
113
+ const widths = cols.map((col) => Math.max(col.length, ...rows.map((r) => visibleLength(String(r[col] ?? '')))));
114
+
115
+ // Header
116
+ const header = cols.map((col, i) => col.padEnd(widths[i]!)).join(' ');
117
+ console.log(` ${c.bold(c.cyan(header))}`);
118
+ console.log(` ${'─'.repeat(widths.reduce((a, b) => a + b + 2, -2))}`);
119
+
120
+ // Rows
121
+ for (const row of rows) {
122
+ const cells = cols.map((col, i) => {
123
+ const val = String(row[col] ?? '');
124
+ // statusColor AFTER padding would shift shorter cells; padVisible works
125
+ // for BOTH colored and pre-colored-by-the-caller values (call sites
126
+ // pass c.dim('—') / c.gray('never') straight into table()).
127
+ if (col === 'status' || col === 'health') return padVisible(statusColor(val), widths[i]!);
128
+ return padVisible(val, widths[i]!);
129
+ });
130
+ console.log(` ${cells.join(' ')}`);
131
+ }
132
+ console.log();
133
+ }
134
+
135
+ /** Print a key-value pair with alignment. */
136
+ export function kv(key: string, value: unknown, indent = 2): void {
137
+ const padded = ' '.repeat(indent);
138
+ const val = value === null || value === undefined ? c.gray('—') : String(value);
139
+ console.log(`${padded}${c.gray(key.padEnd(16))} ${val}`);
140
+ }
141
+
142
+ /** Print a section header. */
143
+ export function header(title: string): void {
144
+ console.log();
145
+ console.log(` ${c.bold(c.cyan(title))}`);
146
+ console.log(` ${c.gray('─'.repeat(Math.max(title.length + 2, 30)))}`);
147
+ }
148
+
149
+ /** Print an error and mark the process as failed (exit code 1, no hard exit). */
150
+ export function error(msg: string, code = 1): void {
151
+ console.error(`\n ${c.red('✗')} ${msg}\n`);
152
+ process.exitCode = code;
153
+ }
154
+
155
+ /** Print a success message. */
156
+ export function success(msg: string): void {
157
+ console.log(` ${c.green('✓')} ${c.green(msg)}`);
158
+ }
159
+
160
+ /** Print an info message. */
161
+ export function info(msg: string): void {
162
+ console.log(` ${c.blue('ℹ')} ${c.dim(msg)}`);
163
+ }
164
+
165
+ /** Format bytes to human readable. */
166
+ export function fmtBytes(b: number): string {
167
+ if (!b) return '0 B';
168
+ const mb = b / 1048576;
169
+ if (mb < 1) return `${(b / 1024).toFixed(0)} KB`;
170
+ if (mb >= 1024) return `${(mb / 1024).toFixed(2)} GB`;
171
+ return `${mb.toFixed(1)} MB`;
172
+ }
173
+
174
+ /** Format relative time. */
175
+ export function fmtTime(ts: string | null | undefined): string {
176
+ if (!ts) return c.gray('never');
177
+ const diff = Date.now() - new Date(ts).getTime();
178
+ if (diff < 60000) return 'just now';
179
+ if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`;
180
+ if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`;
181
+ return new Date(ts).toLocaleDateString();
182
+ }
@@ -0,0 +1,67 @@
1
+ import { createDecipheriv, scrypt as scryptCb } from 'node:crypto';
2
+ import { createReadStream, createWriteStream, renameSync, unlinkSync } from 'node:fs';
3
+ import { open } from 'node:fs/promises';
4
+ import { Readable } from 'node:stream';
5
+ import { pipeline } from 'node:stream/promises';
6
+
7
+ /**
8
+ * Offline reader for a sealed panel backup (`.ndpb`, format NDPB1) — the
9
+ * restore path that needs no running panel. Kept to node builtins only: it is
10
+ * the CLI twin of `decryptPanelBackupFile` in apps/server/src/lib/panelBackup.ts,
11
+ * and the server's tests open real server-written backups with this reader.
12
+ *
13
+ * Layout: "NDPB1:scrypt:<N>:<r>:<p>:<b64 salt>:<b64 iv>\n", AES-256-GCM
14
+ * ciphertext, 16-byte GCM tag. The header line is the GCM AAD; the key is
15
+ * scrypt(passphrase, salt, 32 bytes, N, r, p).
16
+ */
17
+ const HEADER_RE = /^NDPB1:scrypt:(\d+):(\d+):(\d+):([A-Za-z0-9+/=]+):([A-Za-z0-9+/=]+)\n$/;
18
+ const TAG_BYTES = 16;
19
+
20
+ export const PANEL_BACKUP_DECRYPT_ERROR =
21
+ 'Could not decrypt the panel backup: the recovery passphrase is wrong, or the file is damaged';
22
+
23
+ export async function decryptPanelBackup(input: string, output: string, passphrase: string): Promise<void> {
24
+ const handle = await open(input, 'r');
25
+ let header = '';
26
+ const tag = Buffer.alloc(TAG_BYTES);
27
+ let size: number;
28
+ try {
29
+ size = (await handle.stat()).size;
30
+ const prefix = Buffer.alloc(Math.min(256, size));
31
+ await handle.read(prefix, 0, prefix.length, 0);
32
+ const nl = prefix.indexOf(0x0a);
33
+ if (nl >= 0) header = prefix.subarray(0, nl + 1).toString('utf8');
34
+ if (size >= TAG_BYTES) await handle.read(tag, 0, TAG_BYTES, size - TAG_BYTES);
35
+ } finally {
36
+ await handle.close();
37
+ }
38
+ const m = HEADER_RE.exec(header);
39
+ if (!m) throw new Error('Not a NineDeploy panel backup (missing NDPB1 header)');
40
+ const [N, r, p] = [Number(m[1]), Number(m[2]), Number(m[3])];
41
+ if (!(N >= 1 << 14 && N <= 1 << 20 && (N & (N - 1)) === 0) || r < 1 || r > 32 || p < 1 || p > 16) {
42
+ throw new Error('Unsupported panel backup key-derivation parameters');
43
+ }
44
+ const salt = Buffer.from(m[4]!, 'base64');
45
+ const iv = Buffer.from(m[5]!, 'base64');
46
+ const headerBytes = Buffer.byteLength(header);
47
+ if (iv.length !== 12 || salt.length < 16 || size < headerBytes + TAG_BYTES) throw new Error(PANEL_BACKUP_DECRYPT_ERROR);
48
+ const key = await new Promise<Buffer>((resolve, reject) =>
49
+ scryptCb(passphrase, salt, 32, { N, r, p, maxmem: 256 * N * r + 1024 * 1024 }, (err, k) => (err ? reject(err) : resolve(k))),
50
+ );
51
+ const decipher = createDecipheriv('aes-256-gcm', key, iv);
52
+ decipher.setAAD(Buffer.from(header));
53
+ decipher.setAuthTag(tag);
54
+ const dataEnd = size - TAG_BYTES - 1;
55
+ // The plaintext goes to a sibling file and replaces `output` only once the tag has verified: a wrong
56
+ // passphrase must not destroy a file already at `output` (or the sealed input, when it is the same path).
57
+ const partial = `${output}.${process.pid}.partial`;
58
+ try {
59
+ const source = dataEnd < headerBytes ? Readable.from([]) : createReadStream(input, { start: headerBytes, end: dataEnd });
60
+ await pipeline(source, decipher, createWriteStream(partial, { mode: 0o600, flags: 'wx' }));
61
+ renameSync(partial, output);
62
+ } catch {
63
+ // Nothing unauthenticated may be left behind for someone to import.
64
+ try { unlinkSync(partial); } catch { /* absent */ }
65
+ throw new Error(PANEL_BACKUP_DECRYPT_ERROR);
66
+ }
67
+ }
@@ -0,0 +1,223 @@
1
+ import { execFile } from 'node:child_process';
2
+ import crypto from 'node:crypto';
3
+ import { promises as fs } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { promisify } from 'node:util';
7
+
8
+ const execFileAsync = promisify(execFile);
9
+ const randomBytes = crypto.randomBytes.bind(crypto);
10
+
11
+ export interface ServerRunnerOptions {
12
+ port?: number | string;
13
+ image?: string;
14
+ secret?: string;
15
+ containerName?: string;
16
+ /** Group id owning the host docker socket; resolved when omitted. */
17
+ dockerGid?: number | null;
18
+ }
19
+
20
+ /**
21
+ * r247: the group that owns the host's docker socket, on Linux. The image
22
+ * runs as the non-root `ninedeploy` user and the socket is typically
23
+ * root:docker 0660, so without `--group-add <gid>` every `docker` call the
24
+ * panel makes fails with "permission denied" (the compose file and the
25
+ * Dockerfile both document this; the CLI's own `docker run` skipped it).
26
+ * Docker Desktop (macOS/Windows) proxies the socket and needs nothing.
27
+ */
28
+ export async function dockerSocketGid(platform: NodeJS.Platform = process.platform): Promise<number | null> {
29
+ if (platform !== 'linux') return null;
30
+ try {
31
+ return (await fs.stat('/var/run/docker.sock')).gid;
32
+ } catch {
33
+ return null;
34
+ }
35
+ }
36
+
37
+ export interface ContainerState {
38
+ exists: boolean;
39
+ running: boolean;
40
+ status: string;
41
+ id?: string;
42
+ }
43
+
44
+ /** Normalize server URLs (e.g. "localhost:3000" -> "http://localhost:3000"). */
45
+ export function normalizeServerUrl(rawUrl: string): string {
46
+ const trimmed = rawUrl.trim();
47
+ if (!trimmed) return 'http://localhost:3000';
48
+ // Scheme-less input defaults to http for localhost, https elsewhere — a
49
+ // bare "panel.example.com" is almost certainly TLS-terminated, and the
50
+ // bearer token must not ride plaintext HTTP to a remote host.
51
+ const withProtocol = /^https?:\/\//i.test(trimmed)
52
+ ? trimmed
53
+ : /^(localhost|127\.0\.0\.1|\[::1\])(:\d+)?$/i.test(trimmed)
54
+ ? `http://${trimmed}`
55
+ : `https://${trimmed}`;
56
+ return withProtocol.replace(/\/+$/, '');
57
+ }
58
+
59
+ /** Diagnoses common Docker errors and returns actionable recommendations. */
60
+ export function formatDockerError(err: unknown): string {
61
+ const msg = err instanceof Error ? err.message : String(err);
62
+ if (/permission denied/i.test(msg) || /EACCES/i.test(msg)) {
63
+ return 'Docker socket permission denied. On Linux, run: `sudo usermod -aG docker $USER` and log back in, or run with sudo.';
64
+ }
65
+ if (/port is already allocated|address already in use/i.test(msg)) {
66
+ return 'Port conflict: The host port is already allocated. Use `--port <number>` to select another port.';
67
+ }
68
+ if (/already in use/i.test(msg) || /Conflict/i.test(msg)) {
69
+ return "Container conflict: A 'ninedeploy' container already exists. Run `ninedeploy server stop` or `docker rm -f ninedeploy`.";
70
+ }
71
+ return msg;
72
+ }
73
+
74
+ /** Check whether the Docker CLI is available on the current machine. */
75
+ export async function isDockerAvailable(): Promise<boolean> {
76
+ try {
77
+ await execFileAsync('docker', ['--version']);
78
+ return true;
79
+ } catch {
80
+ return false;
81
+ }
82
+ }
83
+
84
+ /** Probe if a NineDeploy server is responding on the given base URL. */
85
+ export async function isServerReachable(baseUrl: string, timeoutMs = 1500): Promise<boolean> {
86
+ try {
87
+ const cleanUrl = normalizeServerUrl(baseUrl);
88
+ const url = new URL('/health', cleanUrl);
89
+ const res = await fetch(url.toString(), { signal: AbortSignal.timeout(timeoutMs) }).catch(() => null);
90
+ return res?.status === 200;
91
+ } catch {
92
+ return false;
93
+ }
94
+ }
95
+
96
+ /** Get Docker container status for the NineDeploy daemon. */
97
+ export async function getContainerState(containerName = 'ninedeploy'): Promise<ContainerState> {
98
+ try {
99
+ const { stdout } = await execFileAsync('docker', [
100
+ 'inspect',
101
+ '--format',
102
+ '{{.Id}}|{{.State.Running}}|{{.State.Status}}',
103
+ containerName,
104
+ ]);
105
+ const [id, runningStr, status] = stdout.trim().split('|');
106
+ return {
107
+ exists: true,
108
+ running: runningStr === 'true',
109
+ status: status || 'unknown',
110
+ id: id ? id.slice(0, 12) : undefined,
111
+ };
112
+ } catch {
113
+ return { exists: false, running: false, status: 'not found' };
114
+ }
115
+ }
116
+
117
+ /** Start or run the NineDeploy server Docker container. */
118
+ /**
119
+ * The setup token a local panel container generated (0.16: a production
120
+ * panel without NINEDEPLOY_SETUP_TOKEN writes it to /data/setup-token).
121
+ * Null when the container is not there or has no token file.
122
+ */
123
+ export async function readLocalSetupToken(containerName = 'ninedeploy'): Promise<string | null> {
124
+ try {
125
+ const { stdout } = await execFileAsync('docker', ['exec', containerName, 'cat', '/data/setup-token']);
126
+ const token = String(stdout).trim();
127
+ return token || null;
128
+ } catch {
129
+ return null;
130
+ }
131
+ }
132
+
133
+ export async function startServerContainer(
134
+ opts: ServerRunnerOptions = {},
135
+ ): Promise<{ port: number; newlyCreated: boolean }> {
136
+ const containerName = opts.containerName ?? 'ninedeploy';
137
+ const port = Number(opts.port ?? 3000);
138
+ const image = opts.image ?? 'ghcr.io/ninedeploy/ninedeploy:latest';
139
+ const secret = opts.secret ?? crypto.randomBytes(32).toString('hex');
140
+
141
+ const state = await getContainerState(containerName);
142
+ if (state.running) {
143
+ return { port, newlyCreated: false };
144
+ }
145
+
146
+ if (state.exists) {
147
+ try {
148
+ await execFileAsync('docker', ['start', containerName]);
149
+ return { port, newlyCreated: false };
150
+ } catch (err) {
151
+ throw new Error(formatDockerError(err));
152
+ }
153
+ }
154
+
155
+ // The JWT secret travels via --env-file (0600 temp file), NOT `-e` argv:
156
+ // values passed as `-e NAME=value` stay visible in `ps` and `docker inspect`
157
+ // forever, while the env file exists only for the duration of `docker run`.
158
+ const gid = opts.dockerGid !== undefined ? opts.dockerGid : await dockerSocketGid();
159
+ const envFile = join(tmpdir(), `nd_env_${randomBytes(8).toString('hex')}`);
160
+ await fs.writeFile(envFile, `NINEDEPLOY_JWT_SECRET=${secret}\n`, { mode: 0o600 });
161
+ const args = [
162
+ 'run',
163
+ '-d',
164
+ '--name',
165
+ containerName,
166
+ '-v',
167
+ '/var/run/docker.sock:/var/run/docker.sock',
168
+ ...(gid != null ? ['--group-add', String(gid)] : []),
169
+ '-v',
170
+ 'ninedeploy-data:/data',
171
+ '-p',
172
+ `${port}:3000`,
173
+ '--env-file',
174
+ envFile,
175
+ '-e',
176
+ 'NINEDEPLOY_PORT=3000',
177
+ image,
178
+ ];
179
+
180
+ try {
181
+ await execFileAsync('docker', args);
182
+ return { port, newlyCreated: true };
183
+ } catch (err) {
184
+ throw new Error(formatDockerError(err));
185
+ } finally {
186
+ await fs.unlink(envFile).catch(() => undefined);
187
+ }
188
+ }
189
+
190
+ /** Stop the running NineDeploy server Docker container. */
191
+ export async function stopServerContainer(containerName = 'ninedeploy'): Promise<void> {
192
+ try {
193
+ await execFileAsync('docker', ['stop', containerName]);
194
+ } catch (err) {
195
+ throw new Error(formatDockerError(err));
196
+ }
197
+ }
198
+
199
+ /** Get stdout logs from the NineDeploy server container. */
200
+ export async function getServerLogs(containerName = 'ninedeploy', lines = 50): Promise<string> {
201
+ try {
202
+ const { stdout, stderr } = await execFileAsync('docker', ['logs', '--tail', String(lines), containerName]);
203
+ return stdout || stderr || '';
204
+ } catch (err) {
205
+ throw new Error(formatDockerError(err));
206
+ }
207
+ }
208
+
209
+ /** Polling loop waiting for the server to report reachable. */
210
+ export async function waitForServerReady(
211
+ baseUrl: string,
212
+ maxAttempts = 30,
213
+ intervalMs = 1000,
214
+ ): Promise<boolean> {
215
+ const cleanUrl = normalizeServerUrl(baseUrl);
216
+ for (let i = 0; i < maxAttempts; i++) {
217
+ if (await isServerReachable(cleanUrl, 1000)) {
218
+ return true;
219
+ }
220
+ await new Promise((r) => setTimeout(r, intervalMs));
221
+ }
222
+ return false;
223
+ }