ninedeploy 0.10.6 → 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,249 @@
1
+ import { NineDeployError } from '@ninedeploy/sdk';
2
+ import type { NineDeployClient } from '../client.js';
3
+ import { c, error, fmtTime, header, info, kv, spinner, statusColor, success, table } from '../lib/format.js';
4
+ import { prompt } from '../prompts.js';
5
+
6
+ /** `ninedeploy services list` */
7
+ export async function servicesList(client: NineDeployClient): Promise<void> {
8
+ header('Services');
9
+ await spinner('Fetching services', async () => {
10
+ const services = await client.services.list();
11
+ if (services.length === 0) { info('No services yet. Run `ninedeploy services create`.'); return; }
12
+ table(
13
+ services.map((s) => ({
14
+ id: s.id,
15
+ name: s.name,
16
+ type: s.type,
17
+ status: s.status,
18
+ port: s.port ?? '—',
19
+ updated: fmtTime(s.updatedAt),
20
+ })),
21
+ ['id', 'name', 'type', 'status', 'port', 'updated'],
22
+ );
23
+ });
24
+ }
25
+
26
+ /** `ninedeploy services create` — interactive wizard */
27
+ export async function servicesCreate(client: NineDeployClient): Promise<void> {
28
+ header('Create Service');
29
+ console.log(c.dim(' Answer a few questions to deploy your app.\n'));
30
+
31
+ const name = await prompt('Service name');
32
+ if (!name) return error('Name is required');
33
+
34
+ const mode = await prompt('Deploy from (1) Git repo (2) Docker image', '1');
35
+ const useRepo = mode !== '2';
36
+
37
+ let repoUrl = '';
38
+ let image = '';
39
+ let branch = 'main';
40
+ if (useRepo) {
41
+ repoUrl = await prompt('Repository URL', '');
42
+ if (!repoUrl) return error('Repository URL is required');
43
+ branch = await prompt('Branch', 'main');
44
+ } else {
45
+ image = await prompt('Docker image (e.g. nginx:alpine)');
46
+ if (!image) return error('Image is required');
47
+ }
48
+
49
+ const portStr = await prompt('Port (optional)', '');
50
+ const port = portStr ? Number(portStr) : undefined;
51
+ const pubPortStr = await prompt('Direct host port (optional)', '');
52
+ const publishedPort = pubPortStr ? Number(pubPortStr) : undefined;
53
+ const volume = await prompt('Persistent volume path (optional)', '');
54
+
55
+ console.log();
56
+ try {
57
+ const svc = await spinner('Creating service', () =>
58
+ client.services.create({
59
+ name, type: 'docker',
60
+ repoUrl: useRepo ? repoUrl : undefined,
61
+ image: useRepo ? undefined : image,
62
+ branch, port, publishedPort, volumeMount: volume || undefined,
63
+ }),
64
+ );
65
+ success(`Service "${svc.name}" created (id: ${svc.id})`);
66
+
67
+ const deployNow = await prompt('Deploy now? (y/n)', 'y');
68
+ if (deployNow.toLowerCase().startsWith('y')) {
69
+ const res = await spinner('Deploying', () => client.deploys.trigger(svc.id));
70
+ success(`Deployment #${res.deploymentId} queued. Check logs: ninedeploy services logs ${svc.id}`);
71
+ }
72
+ } catch (err) {
73
+ error(err instanceof NineDeployError ? err.message : String(err));
74
+ }
75
+ }
76
+
77
+ /** `ninedeploy services get <id>` */
78
+ export async function servicesGet(client: NineDeployClient, idStr: string): Promise<void> {
79
+ const id = Number(idStr);
80
+ if (!id) return error('Usage: ninedeploy services get <id>');
81
+ header('Service Details');
82
+ await spinner('Fetching', async () => {
83
+ const svc = await client.services.get(id);
84
+ kv('ID', svc.id);
85
+ kv('Name', svc.name);
86
+ kv('Slug', svc.slug);
87
+ kv('Type', svc.type);
88
+ kv('Status', statusColor(svc.status));
89
+ kv('Repo', svc.repoUrl ?? '—');
90
+ kv('Branch', svc.branch);
91
+ kv('Image', svc.image ?? '—');
92
+ kv('Port', svc.port ?? '—');
93
+ if (svc.publishedPort) kv('Host Port', c.green(`:${svc.publishedPort}`));
94
+ kv('Volume', svc.volumeMount ?? '—');
95
+ kv('Health', svc.healthPath);
96
+ kv('Runtime', svc.runtimeId ?? '—');
97
+ kv('Commit', svc.commitSha ?? '—');
98
+ if (svc.autoUrl) kv('URL', c.cyan(`http://${svc.autoUrl}`));
99
+ kv('Created', fmtTime(svc.createdAt));
100
+ kv('Updated', fmtTime(svc.updatedAt));
101
+ });
102
+ }
103
+
104
+ /** `ninedeploy services deploy <id>` */
105
+ export async function servicesDeploy(client: NineDeployClient, idStr: string): Promise<void> {
106
+ const id = Number(idStr);
107
+ if (!id) return error('Usage: ninedeploy services deploy <id>');
108
+ try {
109
+ const res = await spinner('Triggering deploy', () => client.deploys.trigger(id));
110
+ success(`Deployment #${res.deploymentId} queued.`);
111
+ info(`Watch logs: ninedeploy services logs ${id}`);
112
+ } catch (err) { error(err instanceof Error ? err.message : String(err)); }
113
+ }
114
+
115
+ /** `ninedeploy services logs <id>` */
116
+ export async function servicesLogs(client: NineDeployClient, idStr: string): Promise<void> {
117
+ const id = Number(idStr);
118
+ if (!id) return error('Usage: ninedeploy services logs <id>');
119
+ header('Runtime Logs');
120
+ try {
121
+ const { lines } = await client.services.logs(id);
122
+ if (!lines.trim()) { info('No logs yet.'); return; }
123
+ console.log(lines.split('\n').slice(0, 50).map((l) => ` ${c.dim(l)}`).join('\n'));
124
+ } catch (err) { error(err instanceof Error ? err.message : String(err)); }
125
+ }
126
+
127
+ /** `ninedeploy services stop|start|restart <id>` */
128
+ export async function servicesLifecycle(client: NineDeployClient, action: 'stop' | 'start' | 'restart', idStr: string): Promise<void> {
129
+ const id = Number(idStr);
130
+ if (!id) return error(`Usage: ninedeploy services ${action} <id>`);
131
+ try {
132
+ await spinner(`${action}ing service`, () => client.services[action](id));
133
+ success(`Service ${action}ed.`);
134
+ } catch (err) { error(err instanceof Error ? err.message : String(err)); }
135
+ }
136
+
137
+ /** `ninedeploy services delete <id>` */
138
+ export async function servicesDelete(client: NineDeployClient, idStr: string): Promise<void> {
139
+ const id = Number(idStr);
140
+ if (!id) return error('Usage: ninedeploy services delete <id>');
141
+ const confirmText = await prompt(`Type the service ID (${id}) to confirm deletion`);
142
+ if (confirmText !== String(id)) return error('Cancelled.');
143
+ try {
144
+ await spinner('Deleting service', () => client.services.remove(id));
145
+ success('Service deleted.');
146
+ } catch (err) { error(err instanceof Error ? err.message : String(err)); }
147
+ }
148
+
149
+ /** Pure entry point for `ninedeploy services sticky <id> --enable|--disable` (G-28). */
150
+ export async function servicesSticky(
151
+ client: NineDeployClient,
152
+ idStr: string,
153
+ opts: { enable?: boolean; disable?: boolean } = {},
154
+ ): Promise<{ id: number; enabled: boolean; active: boolean }> {
155
+ const id = Number(idStr);
156
+ if (!id) throw new Error('Usage: ninedeploy services sticky <id> --enable|--disable');
157
+ if (opts.enable === undefined && opts.disable === undefined) {
158
+ throw new Error('Specify either --enable or --disable');
159
+ }
160
+ if (opts.enable && opts.disable) {
161
+ throw new Error('Specify only one of --enable or --disable');
162
+ }
163
+ return await client.domains.setStickySession(id, Boolean(opts.enable));
164
+ }
165
+
166
+ /** Formatted wrapper for the CLI. */
167
+ export async function servicesStickyAction(
168
+ client: NineDeployClient,
169
+ idStr: string,
170
+ opts: { enable?: boolean; disable?: boolean } = {},
171
+ ): Promise<void> {
172
+ header('Sticky session');
173
+ let result: Awaited<ReturnType<typeof servicesSticky>>;
174
+ try {
175
+ result = await servicesSticky(client, idStr, opts);
176
+ } catch (err) {
177
+ error(err instanceof Error ? err.message : String(err));
178
+ process.exitCode = 1;
179
+ return;
180
+ }
181
+ success(result.enabled
182
+ ? `Sticky session enabled for service #${result.id} (Traefik will pick this up on the next reload)`
183
+ : `Sticky session disabled for service #${result.id}`);
184
+ }
185
+
186
+ /** `ninedeploy services export <id>` */
187
+ export async function servicesExport(client: NineDeployClient, idStr: string): Promise<void> {
188
+ const id = Number(idStr);
189
+ if (!id) return error('Usage: ninedeploy services export <id>');
190
+ const { writeFileSync } = await import('node:fs');
191
+ const { authedFetch } = await import('../client.js');
192
+ try {
193
+ const svc = await client.services.get(id);
194
+ const data = await spinner('Exporting', () =>
195
+ (async () => {
196
+ // r550: refresh-aware and base-path preserving (was a bare fetch).
197
+ const res = await authedFetch(`/v1/services/${id}/export`);
198
+ if (!res.ok) throw new Error(`Export failed (HTTP ${res.status})`);
199
+ return res.text();
200
+ })(),
201
+ );
202
+ const filename = `${svc.slug}-export.json`;
203
+ writeFileSync(filename, data);
204
+ success(`Exported to ${filename}`);
205
+ } catch (err) { error(err instanceof Error ? err.message : String(err)); }
206
+ }
207
+
208
+ /** `ninedeploy services compose <id>` */
209
+ export async function servicesCompose(client: NineDeployClient, idStr: string): Promise<void> {
210
+ const id = Number(idStr);
211
+ if (!id) return error('Usage: ninedeploy services compose <id>');
212
+ header('Runtime Docker Compose Manifest');
213
+ try {
214
+ const svc = await client.services.get(id);
215
+ const container = svc.runtimeId || `nd-svc-${svc.slug}-1`;
216
+ const res = await spinner('Generating manifest', () => client.containers.compose(container));
217
+ console.log(c.dim(`\n # Container: ${container}\n`));
218
+ console.log(res.yaml.split('\n').map((l) => ` ${c.cyan(l)}`).join('\n'));
219
+ console.log();
220
+ } catch (err) { error(err instanceof Error ? err.message : String(err)); }
221
+ }
222
+
223
+ /** `ninedeploy services inspect <id>` */
224
+ export async function servicesInspect(client: NineDeployClient, idStr: string): Promise<void> {
225
+ const id = Number(idStr);
226
+ if (!id) return error('Usage: ninedeploy services inspect <id>');
227
+ header('Container Runtime Inspection');
228
+ try {
229
+ const svc = await client.services.get(id);
230
+ const container = svc.runtimeId || `nd-svc-${svc.slug}-1`;
231
+ const data = await spinner('Inspecting container', () => client.containers.inspect(container));
232
+ kv('Container ID', data.id);
233
+ kv('Name', data.name);
234
+ kv('Image', data.image);
235
+ kv('Status', statusColor(data.state.status));
236
+ kv('Running', data.state.running ? c.green('true') : c.red('false'));
237
+ if (data.resources.memoryLimitBytes > 0) kv('Memory Limit', `${Math.round(data.resources.memoryLimitBytes / (1024 * 1024))} MB`);
238
+ if (data.resources.cpuShares > 0) kv('CPU Shares', `${data.resources.cpuShares}`);
239
+ kv('Restart Policy', data.resources.restartPolicy);
240
+
241
+ if (Object.keys(data.traefikTags).length > 0) {
242
+ console.log(c.dim('\n Traefik Dynamic Ingress Tags:'));
243
+ for (const [k, v] of Object.entries(data.traefikTags)) {
244
+ console.log(` ${c.yellow(k)}: ${c.dim(v)}`);
245
+ }
246
+ }
247
+ console.log();
248
+ } catch (err) { error(err instanceof Error ? err.message : String(err)); }
249
+ }
@@ -0,0 +1,94 @@
1
+ import { createClient, NineDeployError } from '@ninedeploy/sdk';
2
+ import { loadConfig, saveConfig } from '../config.js';
3
+ import { prompt, promptHidden } from '../prompts.js';
4
+ import {
5
+ isDockerAvailable,
6
+ isServerReachable,
7
+ normalizeServerUrl,
8
+ readLocalSetupToken,
9
+ startServerContainer,
10
+ waitForServerReady,
11
+ } from '../lib/serverRunner.js';
12
+ import { spinner, success } from '../lib/format.js';
13
+
14
+ /** `ninedeploy setup` — create the first admin user on a fresh instance. */
15
+ export async function setupAction(opts: { setupToken?: string } = {}): Promise<void> {
16
+ const existing = loadConfig();
17
+ const rawUrl = await prompt('Server URL', existing.baseUrl);
18
+ const baseUrl = normalizeServerUrl(rawUrl);
19
+
20
+ // If local host and not currently reachable, check if user wants to bootstrap it via Docker
21
+ const isLocal = baseUrl.includes('localhost') || baseUrl.includes('127.0.0.1');
22
+ if (isLocal) {
23
+ const reachable = await isServerReachable(baseUrl, 600);
24
+ if (!reachable) {
25
+ const dockerOk = await isDockerAvailable();
26
+ if (dockerOk) {
27
+ console.log(`\n ℹ No NineDeploy server detected at ${baseUrl}`);
28
+ const startDocker = (await prompt(' Start a local NineDeploy server with Docker now? (Y/n)', 'Y')).toLowerCase().startsWith('y');
29
+ if (startDocker) {
30
+ try {
31
+ await spinner('Starting local NineDeploy server container', async () => {
32
+ const url = new URL(baseUrl);
33
+ const port = url.port ? Number(url.port) : 3000;
34
+ await startServerContainer({ port });
35
+ const ready = await waitForServerReady(baseUrl, 30, 1000);
36
+ if (!ready) throw new Error('Server container did not become ready in 30s');
37
+ });
38
+ success(`Server is ready at ${baseUrl}\n`);
39
+ } catch (err) {
40
+ console.error(` ✗ Failed to start Docker container: ${err instanceof Error ? err.message : String(err)}`);
41
+ }
42
+ }
43
+ }
44
+ }
45
+ }
46
+
47
+ const email = await prompt('Admin email');
48
+ if (!email) {
49
+ console.error('Email is required.');
50
+ process.exitCode = 1;
51
+ return;
52
+ }
53
+ const name = (await prompt('Display name (optional)')) || undefined;
54
+ const password = await promptHidden('Password (min 8 chars)');
55
+ if (!password || password.length < 8) {
56
+ console.error('Password must be at least 8 characters.');
57
+ process.exitCode = 1;
58
+ return;
59
+ }
60
+
61
+ const client = createClient({ baseUrl });
62
+ // 0.16: a fresh production instance gates the bootstrap on the installer's
63
+ // one-time setup token. Ask only when the server says it needs one (an
64
+ // older server has no such field and takes none).
65
+ let setupToken = (opts.setupToken ?? process.env['NINEDEPLOY_SETUP_TOKEN'] ?? '').trim();
66
+ if (!setupToken) {
67
+ const status = await Promise.resolve().then(() => client.auth.status()).catch(() => null);
68
+ if (status?.setupTokenRequired) {
69
+ // A panel this CLI started locally keeps its generated token in the container.
70
+ if (isLocal) setupToken = (await readLocalSetupToken()) ?? '';
71
+ if (!setupToken) setupToken = (await prompt('Setup token (printed by the installer)')).trim();
72
+ }
73
+ }
74
+ try {
75
+ const session = await client.auth.setup(setupToken ? { email, password, name, setupToken } : { email, password, name });
76
+ // Both tokens: the access token alone dies with the 15-minute TTL.
77
+ saveConfig({ baseUrl, token: session.tokens.accessToken, refreshToken: session.tokens.refreshToken });
78
+ console.log(`✓ Admin account created: ${session.user.email}`);
79
+ console.log(' Credentials saved — you are logged in.');
80
+ } catch (err) {
81
+ if (err instanceof NineDeployError) {
82
+ console.error(`✗ Setup failed (${err.status}): ${err.message}`);
83
+ if (err.status === 409) console.error(' The instance already has an admin user.');
84
+ if (err.status === 403) console.error(' Pass the setup token the installer printed: ninedeploy setup --setup-token <token>');
85
+ } else {
86
+ const msg = err instanceof Error ? err.message : String(err);
87
+ console.error('✗ Setup failed:', msg);
88
+ if (msg.includes('fetch failed') || msg.includes('ECONNREFUSED') || msg.includes('ENOTFOUND')) {
89
+ console.error(` Could not connect to NineDeploy server at ${baseUrl}. Ensure the server is running.`);
90
+ }
91
+ }
92
+ process.exitCode = 1;
93
+ }
94
+ }
@@ -0,0 +1,317 @@
1
+ import process from 'node:process';
2
+ import type { NineDeployClient } from '../client.js';
3
+ import { prompt, promptHidden } from '../prompts.js';
4
+ import { c, error, header, info, kv, spinner, success, table } from '../lib/format.js';
5
+
6
+ const PROVIDERS = ['github', 'gitlab', 'gitea', 'registry', 'custom'] as const;
7
+ type Provider = (typeof PROVIDERS)[number];
8
+
9
+ /** `ninedeploy sources list` */
10
+ export async function sourcesList(client: NineDeployClient): Promise<void> {
11
+ header('Sources');
12
+ const rows = await spinner('Fetching sources', () => client.sources.list());
13
+ if (rows.length === 0) {
14
+ info('No sources yet. Run `ninedeploy sources add` to add a GitHub/GitLab PAT or SSH key.');
15
+ return;
16
+ }
17
+ table(
18
+ rows.map((s) => ({
19
+ id: s.id,
20
+ name: s.name,
21
+ type: s.type,
22
+ token: s.hasToken ? c.green('✓') : c.gray('—'),
23
+ deployKey: s.hasDeployKey ? c.green('✓') : c.gray('—'),
24
+ branch: s.defaultBranch ?? 'main',
25
+ })),
26
+ ['id', 'name', 'type', 'token', 'deployKey', 'branch'],
27
+ );
28
+ }
29
+
30
+ /**
31
+ * 0.13: a Gitea base URL from `--base-url`. Shape only (http(s), no
32
+ * credentials, query or fragment); the server re-checks it and refuses
33
+ * `http:` unless private egress is allowed.
34
+ */
35
+ function parseBaseUrl(raw: string): string | null {
36
+ const trimmed = raw.trim().replace(/\/+$/, '');
37
+ try {
38
+ const url = new URL(trimmed);
39
+ if ((url.protocol !== 'https:' && url.protocol !== 'http:') || url.username || url.password || url.search || url.hash) return null;
40
+ return trimmed;
41
+ } catch {
42
+ return null;
43
+ }
44
+ }
45
+
46
+ /**
47
+ * Resolve a credential with a strict, documented precedence:
48
+ * 1. explicit CLI flag (already handled by the caller)
49
+ * 2. NINEDEPLOY_GITHUB_TOKEN / NINEDEPLOY_GITLAB_TOKEN / NINEDEPLOY_SOURCE_TOKEN env var
50
+ * 3. interactive masked prompt
51
+ *
52
+ * The env fallback is the documented "no-stdin / CI" path: an operator
53
+ * piping `echo '' | ninedeploy …` keeps working without hanging.
54
+ */
55
+ async function resolveSecret(envName: string, label: string): Promise<string> {
56
+ const fromEnv = process.env[envName]?.trim();
57
+ if (fromEnv) return fromEnv;
58
+ // Last resort: masked prompt. Honors a piped-stdin default (empty).
59
+ return await promptHidden(label);
60
+ }
61
+
62
+ /**
63
+ * F596: the generic provider-token env var for gitea / registry / custom.
64
+ * NOT `NINEDEPLOY_TOKEN` — that is the documented panel API token (MCP / API
65
+ * clients); reading it here stored the panel credential on the source and
66
+ * sent it to the third-party git host / registry, without a prompt.
67
+ */
68
+ const SOURCE_TOKEN_ENV = 'NINEDEPLOY_SOURCE_TOKEN';
69
+
70
+ /**
71
+ * F597: the prompt asks for the key on one line with escaped newlines (a
72
+ * hidden prompt ends at the first real newline). Turn the escapes back into
73
+ * the newlines the key file needs — a PEM/OpenSSH key never holds a backslash.
74
+ */
75
+ async function resolveDeployKey(): Promise<string> {
76
+ const key = await resolveSecret('NINEDEPLOY_SSH_KEY', 'SSH private key (PEM, single line — escape newlines)');
77
+ return key.replace(/(?:\\r)?\\n/g, '\n');
78
+ }
79
+
80
+ /**
81
+ * F598: canonical decimal id only, as the server's parseId accepts it.
82
+ * `Number()` mapped `1e1`→10 and `0x4`→4, and NaN fell into the picker whose
83
+ * default is the first listed source — keygen then rewrote the wrong source.
84
+ */
85
+ function parseSourceId(raw: string): number {
86
+ const t = raw.trim();
87
+ return /^[1-9]\d*$/.test(t) && Number.isSafeInteger(Number(t)) ? Number(t) : 0;
88
+ }
89
+
90
+ /** `ninedeploy sources add [name] [--base-url <url>]` */
91
+ export async function sourcesAdd(client: NineDeployClient, nameArg?: string, opts: { baseUrl?: string } = {}): Promise<void> {
92
+ header('Add Source');
93
+ let baseUrl: string | undefined;
94
+ if (opts.baseUrl !== undefined) {
95
+ const parsed = parseBaseUrl(opts.baseUrl);
96
+ if (!parsed) return error(`Invalid --base-url: ${opts.baseUrl} (expected https://host[/path])`);
97
+ baseUrl = parsed;
98
+ }
99
+ const name = nameArg ?? (await prompt('Display name (e.g. github-personal)'));
100
+ /* v8 ignore next -- the dedicated "rejects when no name" test covers the truthy/falsy split, but
101
+ * v8 counts the early-return as a separate branch that the existing test only ticks on one side. */
102
+ if (!name) return error('Name is required');
103
+ const type = (await prompt('Provider (github | gitlab | gitea | registry | custom)', 'github')) as Provider;
104
+ if (!PROVIDERS.includes(type)) {
105
+ return error(`Unknown provider: ${type}. Choose one of: ${PROVIDERS.join(', ')}`);
106
+ }
107
+ if (baseUrl && type !== 'gitea') return error('--base-url applies to gitea sources only');
108
+ const defaultBranch = (await prompt('Default branch (used as suggestion only)', 'main')) || 'main';
109
+
110
+ let token: string | undefined;
111
+ let deployKey: string | undefined;
112
+ let registryUsername: string | undefined;
113
+
114
+ if (type === 'registry') {
115
+ registryUsername = (await prompt('Registry username (e.g. ci-bot)')) || undefined;
116
+ token = await resolveSecret(SOURCE_TOKEN_ENV, 'Registry password / access token (PAT)');
117
+ } else if (type === 'gitea') {
118
+ if (!baseUrl) {
119
+ const typed = (await prompt('Gitea base URL (e.g. https://git.example.com; empty = no live test or repo list)')).trim();
120
+ if (typed) {
121
+ const parsed = parseBaseUrl(typed);
122
+ if (!parsed) return error(`Invalid Gitea base URL: ${typed}`);
123
+ baseUrl = parsed;
124
+ }
125
+ }
126
+ token = await resolveSecret(SOURCE_TOKEN_ENV, 'Gitea access token');
127
+ } else if (type === 'custom') {
128
+ // Custom source: token or SSH key, your choice
129
+ const authKind = (await prompt('Auth kind (token | ssh)', 'token')) === 'ssh' ? 'ssh' : 'token';
130
+ if (authKind === 'ssh') {
131
+ deployKey = await resolveDeployKey();
132
+ } else {
133
+ token = await resolveSecret(SOURCE_TOKEN_ENV, 'Access token (PAT)');
134
+ }
135
+ } else {
136
+ // github / gitlab — both accept token or SSH
137
+ const authKind = (await prompt('Auth kind (token | ssh)', 'token')) === 'ssh' ? 'ssh' : 'token';
138
+ if (authKind === 'ssh') {
139
+ deployKey = await resolveDeployKey();
140
+ } else {
141
+ const envName = type === 'github' ? 'NINEDEPLOY_GITHUB_TOKEN' : 'NINEDEPLOY_GITLAB_TOKEN';
142
+ token = await resolveSecret(envName, `${type === 'github' ? 'GitHub' : 'GitLab'} Personal Access Token (PAT)`);
143
+ }
144
+ }
145
+
146
+ if (!token && !deployKey) {
147
+ return error('Either a token or an SSH deploy key is required');
148
+ }
149
+
150
+ try {
151
+ const created = await spinner('Creating source', () =>
152
+ client.sources.create({
153
+ name,
154
+ type,
155
+ token: token || undefined,
156
+ deployKey: deployKey || undefined,
157
+ registryUsername,
158
+ defaultBranch,
159
+ ...(baseUrl ? { baseUrl } : {}),
160
+ }),
161
+ );
162
+ success(`Source "${created.name}" created (id: ${created.id})`);
163
+ // Live validation right away — saves the user a DeployWizard round-trip.
164
+ if ((type === 'github' || type === 'gitlab' || (type === 'gitea' && baseUrl)) && token) {
165
+ await sourcesTest(client, String(created.id));
166
+ }
167
+ } catch (err) {
168
+ /* v8 ignore next -- exercised by the "API error" test which routes the error through error() */
169
+ error(err instanceof Error ? err.message : String(err));
170
+ }
171
+ }
172
+
173
+ /**
174
+ * F1011: the login, name, scopes, warnings and failure text come from the Git
175
+ * provider's API response (relayed by the server), so they must not drive the
176
+ * operator's terminal — the class F538 (`deploys watch`) and F880
177
+ * (`logs search`) closed for log lines. Same pattern set, but these are
178
+ * one-line metadata fields, so nothing is kept: OSC/DCS/APC/PM/SOS strings,
179
+ * every CSI (SGR included), stray escapes, and all C0/DEL/C1 controls
180
+ * (tab/LF/CR too — a newline in a login could forge a second status line).
181
+ */
182
+ const ESC = String.fromCharCode(27);
183
+ const PROVIDER_TEXT_CONTROL = new RegExp(
184
+ `${ESC}[\\]PX^_][\\s\\S]*?(?:${String.fromCharCode(7)}|${ESC}\\\\|$)` +
185
+ `|${ESC}\\[[0-?]*[ -/]*[@-~]` +
186
+ `|${ESC}[\\s\\S]?` +
187
+ '|[\\u0000-\\u001f\\u007f-\\u009f]',
188
+ 'g',
189
+ );
190
+ export const plain = (value: unknown): string => String(value).replace(PROVIDER_TEXT_CONTROL, '');
191
+
192
+ /**
193
+ * `ninedeploy sources test [id]` — verify the stored token still authenticates.
194
+ * GitHub successes also print what the token can see (F1008/F1011): its kind,
195
+ * its classic/OAuth scopes and each warning about missing private repositories.
196
+ */
197
+ export async function sourcesTest(client: NineDeployClient, idArg?: string): Promise<void> {
198
+ header('Test Source Credentials');
199
+ if (idArg !== undefined && !parseSourceId(idArg)) return error(`Invalid source id: ${idArg}`);
200
+ let id = idArg === undefined ? 0 : parseSourceId(idArg);
201
+ if (!id) {
202
+ const sources = await spinner('Fetching sources', () => client.sources.list());
203
+ if (sources.length === 0) {
204
+ info('No sources yet. Add one with `ninedeploy sources add`.');
205
+ return;
206
+ }
207
+ table(
208
+ sources.map((s) => ({ id: s.id, name: s.name, type: s.type })),
209
+ ['id', 'name', 'type'],
210
+ );
211
+ const picked = await prompt('Source id to test', String(sources[0]!.id));
212
+ id = parseSourceId(picked);
213
+ if (!id) return error('A numeric source id is required');
214
+ }
215
+ const result = await spinner('Verifying credentials', () => client.sources.test(id));
216
+ if (result.ok) {
217
+ success(`${plain(result.provider)} token authenticates as ${plain(result.login)}${result.name ? ` (${plain(result.name)})` : ''}`);
218
+ if (result.tokenKind) kv('Token type', plain(result.tokenKind));
219
+ if (Array.isArray(result.scopes)) kv('Scopes', result.scopes.length > 0 ? result.scopes.map(plain).join(', ') : c.gray('none'));
220
+ // 0.13: a GitHub App source reports its installation's repository selection.
221
+ if (result.repositorySelection) kv('Repositories', plain(result.repositorySelection));
222
+ for (const warning of result.warnings ?? []) {
223
+ console.log(` ${c.yellow('!')} ${c.yellow(`warning: ${plain(warning)}`)}`);
224
+ }
225
+ } else {
226
+ error(`${plain(result.provider ?? 'source')} check failed (status ${plain(result.status ?? 'n/a')}): ${plain(result.error ?? 'unknown error')}`);
227
+ }
228
+ }
229
+
230
+ /**
231
+ * `ninedeploy sources keygen [id]` — ask the panel server to generate an
232
+ * ed25519 deploy key pair for the source, then print the public key so it
233
+ * can be pasted into the Git host's "Deploy keys" UI.
234
+ */
235
+ export async function sourcesKeygen(client: NineDeployClient, idArg?: string): Promise<void> {
236
+ header('Generate Deploy Key');
237
+ if (idArg !== undefined && !parseSourceId(idArg)) return error(`Invalid source id: ${idArg}`);
238
+ let id = idArg === undefined ? 0 : parseSourceId(idArg);
239
+ if (!id) {
240
+ const sources = await spinner('Fetching sources', () => client.sources.list());
241
+ if (sources.length === 0) {
242
+ info('No sources yet. Add one with `ninedeploy sources add` first.');
243
+ return;
244
+ }
245
+ table(
246
+ sources.map((s) => ({ id: s.id, name: s.name, type: s.type })),
247
+ ['id', 'name', 'type'],
248
+ );
249
+ const picked = await prompt('Source id to generate a deploy key for', String(sources[0]!.id));
250
+ id = parseSourceId(picked);
251
+ if (!id) return error('A numeric source id is required');
252
+ }
253
+ const result = await spinner('Generating ed25519 key pair on the panel server', () => client.sources.generateDeployKey(id));
254
+ success(`Deploy key generated (fingerprint ${result.fingerprint}).`);
255
+ console.log();
256
+ console.log(` ${c.bold('Public key — paste this into your Git host\'s Deploy keys:')}`);
257
+ console.log(` ${c.cyan(result.publicKey)}`);
258
+ console.log();
259
+ console.log(` ${c.gray('GitHub: repo → Settings → Security → Deploy keys → Add deploy key')}`);
260
+ console.log(` ${c.gray('GitLab: project → Settings → Repository → Deploy keys')}`);
261
+ console.log(` ${c.gray('Gitea: repo → Settings → Deploy keys')}`);
262
+ console.log();
263
+ info('The private key is encrypted at rest on the panel and never shown.');
264
+ }
265
+
266
+ /** `ninedeploy sources remove [id]` */
267
+ export async function sourcesRemove(client: NineDeployClient, idArg?: string): Promise<void> {
268
+ header('Remove Source');
269
+ if (idArg !== undefined && !parseSourceId(idArg)) return error(`Invalid source id: ${idArg}`);
270
+ let id = idArg === undefined ? 0 : parseSourceId(idArg);
271
+ if (!id) {
272
+ const sources = await spinner('Fetching sources', () => client.sources.list());
273
+ if (sources.length === 0) {
274
+ info('No sources to remove.');
275
+ return;
276
+ }
277
+ table(
278
+ sources.map((s) => ({ id: s.id, name: s.name, type: s.type })),
279
+ ['id', 'name', 'type'],
280
+ );
281
+ const picked = await prompt('Source id to remove', String(sources[0]!.id));
282
+ id = parseSourceId(picked);
283
+ if (!id) return error('A numeric source id is required');
284
+ }
285
+ const confirm = await prompt(`Type "delete" to confirm removal of source #${id}`, '');
286
+ if (confirm.trim() !== 'delete') {
287
+ info('Aborted.');
288
+ return;
289
+ }
290
+ try {
291
+ await spinner('Removing source', () => client.sources.remove(id));
292
+ success(`Source #${id} removed.`);
293
+ info('Services deployed from it keep working, but new clones of private repos will fail until it is re-added.');
294
+ } catch (err) {
295
+ /* v8 ignore next -- exercised by the "API error" test which routes the error through error() */
296
+ error(err instanceof Error ? err.message : String(err));
297
+ }
298
+ }
299
+
300
+ /** `ninedeploy sources show <id>` */
301
+ export async function sourcesShow(client: NineDeployClient, idArg: string): Promise<void> {
302
+ const id = Number(idArg);
303
+ if (!id) return error('Usage: ninedeploy sources show <id>');
304
+ header(`Source #${id}`);
305
+ const sources = await spinner('Fetching', () => client.sources.list());
306
+ const s = sources.find((x) => x.id === id);
307
+ if (!s) return error(`Source #${id} not found`);
308
+ kv('Name', s.name);
309
+ kv('Type', s.type);
310
+ kv('Token', s.hasToken ? c.green('✓ set') : c.gray('—'));
311
+ kv('Deploy key', s.hasDeployKey ? c.green('✓ set') : c.gray('—'));
312
+ kv('Registry user', s.registryUsername ?? '—');
313
+ if (s.type === 'gitea') kv('Base URL', s.baseUrl ?? c.gray('not set (no live test or repo list)'));
314
+ kv('Default branch', s.defaultBranch ?? 'main');
315
+ kv('Created', s.createdAt);
316
+ kv('Updated', s.updatedAt);
317
+ }