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.
- package/LICENSE +21 -21
- package/README.md +1 -0
- package/dist/client.d.ts +34 -0
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +81 -22
- package/dist/client.js.map +1 -1
- package/dist/commands/backupPolicy.d.ts +29 -0
- package/dist/commands/backupPolicy.d.ts.map +1 -0
- package/dist/commands/backupPolicy.js +111 -0
- package/dist/commands/backupPolicy.js.map +1 -0
- package/dist/commands/certificates.d.ts +18 -7
- package/dist/commands/certificates.d.ts.map +1 -1
- package/dist/commands/certificates.js +121 -1
- package/dist/commands/certificates.js.map +1 -1
- package/dist/commands/databaseAccess.d.ts +37 -0
- package/dist/commands/databaseAccess.d.ts.map +1 -0
- package/dist/commands/databaseAccess.js +306 -0
- package/dist/commands/databaseAccess.js.map +1 -0
- package/dist/commands/deploy.d.ts.map +1 -1
- package/dist/commands/deploy.js +28 -7
- package/dist/commands/deploy.js.map +1 -1
- package/dist/commands/githubApps.d.ts +40 -0
- package/dist/commands/githubApps.d.ts.map +1 -0
- package/dist/commands/githubApps.js +278 -0
- package/dist/commands/githubApps.js.map +1 -0
- package/dist/commands/hostIps.d.ts +21 -0
- package/dist/commands/hostIps.d.ts.map +1 -0
- package/dist/commands/hostIps.js +68 -0
- package/dist/commands/hostIps.js.map +1 -0
- package/dist/commands/housekeeping.d.ts +7 -0
- package/dist/commands/housekeeping.d.ts.map +1 -1
- package/dist/commands/housekeeping.js +16 -0
- package/dist/commands/housekeeping.js.map +1 -1
- package/dist/commands/logs.d.ts.map +1 -1
- package/dist/commands/logs.js +17 -2
- package/dist/commands/logs.js.map +1 -1
- package/dist/commands/manage.d.ts +26 -10
- package/dist/commands/manage.d.ts.map +1 -1
- package/dist/commands/manage.js +219 -65
- package/dist/commands/manage.js.map +1 -1
- package/dist/commands/manifest.d.ts.map +1 -1
- package/dist/commands/manifest.js +9 -1
- package/dist/commands/manifest.js.map +1 -1
- package/dist/commands/misc.d.ts +4 -2
- package/dist/commands/misc.d.ts.map +1 -1
- package/dist/commands/misc.js +25 -11
- package/dist/commands/misc.js.map +1 -1
- package/dist/commands/multiNode.d.ts +55 -0
- package/dist/commands/multiNode.d.ts.map +1 -0
- package/dist/commands/multiNode.js +438 -0
- package/dist/commands/multiNode.js.map +1 -0
- package/dist/commands/notifications.d.ts +1 -1
- package/dist/commands/operations.d.ts +89 -0
- package/dist/commands/operations.d.ts.map +1 -0
- package/dist/commands/operations.js +468 -0
- package/dist/commands/operations.js.map +1 -0
- package/dist/commands/panelBackup.d.ts +28 -0
- package/dist/commands/panelBackup.d.ts.map +1 -0
- package/dist/commands/panelBackup.js +153 -0
- package/dist/commands/panelBackup.js.map +1 -0
- package/dist/commands/plugins.d.ts +1 -1
- package/dist/commands/plugins.d.ts.map +1 -1
- package/dist/commands/plugins.js +7 -3
- package/dist/commands/plugins.js.map +1 -1
- package/dist/commands/proxyConfig.d.ts +16 -0
- package/dist/commands/proxyConfig.d.ts.map +1 -0
- package/dist/commands/proxyConfig.js +122 -0
- package/dist/commands/proxyConfig.js.map +1 -0
- package/dist/commands/secretProviders.d.ts +59 -0
- package/dist/commands/secretProviders.d.ts.map +1 -0
- package/dist/commands/secretProviders.js +212 -0
- package/dist/commands/secretProviders.js.map +1 -0
- package/dist/commands/services.d.ts +17 -0
- package/dist/commands/services.d.ts.map +1 -1
- package/dist/commands/services.js +55 -6
- package/dist/commands/services.js.map +1 -1
- package/dist/commands/setup.d.ts +3 -1
- package/dist/commands/setup.d.ts.map +1 -1
- package/dist/commands/setup.js +19 -3
- package/dist/commands/setup.js.map +1 -1
- package/dist/commands/sources.d.ts +10 -3
- package/dist/commands/sources.d.ts.map +1 -1
- package/dist/commands/sources.js +116 -18
- package/dist/commands/sources.js.map +1 -1
- package/dist/commands/templates.d.ts.map +1 -1
- package/dist/commands/templates.js +8 -1
- package/dist/commands/templates.js.map +1 -1
- package/dist/commands/upload.d.ts +24 -0
- package/dist/commands/upload.d.ts.map +1 -0
- package/dist/commands/upload.js +96 -0
- package/dist/commands/upload.js.map +1 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +2 -1
- package/dist/config.js.map +1 -1
- package/dist/index.js +455 -24
- package/dist/index.js.map +1 -1
- package/dist/lib/format.d.ts.map +1 -1
- package/dist/lib/format.js +5 -4
- package/dist/lib/format.js.map +1 -1
- package/dist/lib/panelBackupFile.d.ts +3 -0
- package/dist/lib/panelBackupFile.d.ts.map +1 -0
- package/dist/lib/panelBackupFile.js +71 -0
- package/dist/lib/panelBackupFile.js.map +1 -0
- package/dist/lib/serverRunner.d.ts +6 -0
- package/dist/lib/serverRunner.d.ts.map +1 -1
- package/dist/lib/serverRunner.js +18 -3
- package/dist/lib/serverRunner.js.map +1 -1
- package/dist/prompts.d.ts.map +1 -1
- package/dist/prompts.js +9 -2
- package/dist/prompts.js.map +1 -1
- package/package.json +6 -5
- package/src/client.ts +137 -0
- package/src/commands/backupPolicy.ts +123 -0
- package/src/commands/branding.ts +83 -0
- package/src/commands/buildCache.ts +72 -0
- package/src/commands/certificates.ts +208 -0
- package/src/commands/communityTemplates.ts +97 -0
- package/src/commands/configCenter.ts +71 -0
- package/src/commands/configPresets.ts +215 -0
- package/src/commands/databaseAccess.ts +333 -0
- package/src/commands/demo.ts +22 -0
- package/src/commands/deploy.ts +284 -0
- package/src/commands/doctor.ts +170 -0
- package/src/commands/domains.ts +170 -0
- package/src/commands/egress.ts +135 -0
- package/src/commands/emailTemplates.ts +112 -0
- package/src/commands/firewall.ts +82 -0
- package/src/commands/githubApps.ts +292 -0
- package/src/commands/hostIps.ts +71 -0
- package/src/commands/housekeeping.ts +106 -0
- package/src/commands/login.ts +58 -0
- package/src/commands/logs.ts +91 -0
- package/src/commands/manage.ts +714 -0
- package/src/commands/manifest.ts +314 -0
- package/src/commands/metrics.ts +69 -0
- package/src/commands/misc.ts +307 -0
- package/src/commands/multiNode.ts +442 -0
- package/src/commands/notifications.ts +174 -0
- package/src/commands/operations.ts +519 -0
- package/src/commands/panelBackup.ts +167 -0
- package/src/commands/pgbouncer.ts +65 -0
- package/src/commands/plugins.ts +123 -0
- package/src/commands/proxyConfig.ts +117 -0
- package/src/commands/secretProviders.ts +228 -0
- package/src/commands/server.ts +126 -0
- package/src/commands/services.ts +299 -0
- package/src/commands/setup.ts +94 -0
- package/src/commands/sources.ts +317 -0
- package/src/commands/sso.ts +106 -0
- package/src/commands/templates.ts +158 -0
- package/src/commands/token.ts +66 -0
- package/src/commands/upload.ts +111 -0
- package/src/commands/webhooks.ts +93 -0
- package/src/commands/workspaces.ts +63 -0
- package/src/config.ts +53 -0
- package/src/index.ts +1303 -0
- package/src/lib/format.ts +182 -0
- package/src/lib/panelBackupFile.ts +67 -0
- package/src/lib/serverRunner.ts +223 -0
- package/src/prompts.ts +238 -0
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `ninedeploy sso {list,add,remove}` — Sprint 5, Gap G-22.
|
|
3
|
+
*
|
|
4
|
+
* Operator-side wrapper for the SSO provider list. The CLI is the
|
|
5
|
+
* canonical write path; the panel is the read + login surface.
|
|
6
|
+
*/
|
|
7
|
+
import type { NineDeployClient } from '../client.js';
|
|
8
|
+
import { error, header, info, success } from '../lib/format.js';
|
|
9
|
+
|
|
10
|
+
export interface SsoProviderListItem {
|
|
11
|
+
id: number;
|
|
12
|
+
type: 'oidc' | 'saml';
|
|
13
|
+
name: string;
|
|
14
|
+
createdAt: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// ── `ninedeploy sso list` ─────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
/** Pure entry point — same shape used by the CLI action and the unit test. */
|
|
20
|
+
export async function ssoList(client: NineDeployClient): Promise<{ providers: SsoProviderListItem[] }> {
|
|
21
|
+
return await client.sso.listProviders();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function ssoListAction(client: NineDeployClient): Promise<void> {
|
|
25
|
+
header('SSO providers');
|
|
26
|
+
let result: { providers: SsoProviderListItem[] };
|
|
27
|
+
try {
|
|
28
|
+
result = await ssoList(client);
|
|
29
|
+
} catch (err) {
|
|
30
|
+
error(err instanceof Error ? err.message : String(err));
|
|
31
|
+
process.exitCode = 1;
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
if (result.providers.length === 0) {
|
|
35
|
+
info('No SSO providers configured. Run `ninedeploy sso add oidc --name <id> --issuer <url> --client-id <id> --client-secret <secret>`.');
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
for (const p of result.providers) {
|
|
39
|
+
info(`#${p.id} ${p.name} (${p.type})`);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ── `ninedeploy sso add` ──────────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
/** Pure entry point — same shape used by the CLI action and the unit test. */
|
|
46
|
+
export async function ssoAdd(
|
|
47
|
+
client: NineDeployClient,
|
|
48
|
+
type: 'oidc' | 'saml',
|
|
49
|
+
name: string,
|
|
50
|
+
config: Record<string, unknown>,
|
|
51
|
+
): Promise<{ ok: boolean; id?: number; error?: string }> {
|
|
52
|
+
return await client.sso.addProvider({ type, name, config });
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function ssoAddAction(
|
|
56
|
+
client: NineDeployClient,
|
|
57
|
+
type: string,
|
|
58
|
+
name: string,
|
|
59
|
+
config: Record<string, unknown>,
|
|
60
|
+
): Promise<void> {
|
|
61
|
+
if (type !== 'oidc' && type !== 'saml') {
|
|
62
|
+
error('Usage: ninedeploy sso add <oidc|saml> <name> --config-key value --config-key value ...');
|
|
63
|
+
process.exitCode = 1;
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
let result: Awaited<ReturnType<typeof ssoAdd>>;
|
|
67
|
+
try {
|
|
68
|
+
result = await ssoAdd(client, type, name, config);
|
|
69
|
+
} catch (err) {
|
|
70
|
+
error(err instanceof Error ? err.message : String(err));
|
|
71
|
+
process.exitCode = 1;
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
if (!result.ok) {
|
|
75
|
+
error(result.error ?? 'unknown error');
|
|
76
|
+
process.exitCode = 1;
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
success(`Added SSO provider "${name}" (id=${result.id})`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ── `ninedeploy sso remove` ───────────────────────────────────────────────
|
|
83
|
+
|
|
84
|
+
/** Pure entry point — same shape used by the CLI action and the unit test. */
|
|
85
|
+
export async function ssoRemove(client: NineDeployClient, id: number): Promise<{ ok: boolean }> {
|
|
86
|
+
return await client.sso.removeProvider(id);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export async function ssoRemoveAction(client: NineDeployClient, idStr: string): Promise<void> {
|
|
90
|
+
const id = Number(idStr);
|
|
91
|
+
if (!Number.isFinite(id)) {
|
|
92
|
+
error('Usage: ninedeploy sso remove <id>');
|
|
93
|
+
process.exitCode = 1;
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
let result: Awaited<ReturnType<typeof ssoRemove>>;
|
|
97
|
+
try {
|
|
98
|
+
result = await ssoRemove(client, id);
|
|
99
|
+
} catch (err) {
|
|
100
|
+
error(err instanceof Error ? err.message : String(err));
|
|
101
|
+
process.exitCode = 1;
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
success(`Removed SSO provider #${id}`);
|
|
105
|
+
void result;
|
|
106
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `ninedeploy templates {init}` — Sprint 1, Gap G-04, PR #4.
|
|
3
|
+
*
|
|
4
|
+
* `init <template-id>` asks the panel for one template, runs the shared
|
|
5
|
+
* `buildManifestFromTemplate` mapper (the same pure helper the server's
|
|
6
|
+
* `manifest-generator` plugin uses), and either prints the YAML to stdout
|
|
7
|
+
* or writes it to a `.ninedeploy` file in the current directory.
|
|
8
|
+
*
|
|
9
|
+
* The split is deliberate:
|
|
10
|
+
* - Pulling the template from the panel guarantees the operator sees the
|
|
11
|
+
* same port/volume/env the deploy will see, including any panel-side
|
|
12
|
+
* overrides (image tag pin, runtime version, etc.).
|
|
13
|
+
* - Building the manifest on the client keeps `ninedeploy` usable
|
|
14
|
+
* without a running panel: `ninedeploy templates init n8n` requires
|
|
15
|
+
* only an auth token, not a live deploy.
|
|
16
|
+
* - All mapping logic lives in `@ninedeploy/sdk` so the panel's
|
|
17
|
+
* `manifest-generator` plugin, this CLI command, and any future
|
|
18
|
+
* consumer share one implementation.
|
|
19
|
+
*/
|
|
20
|
+
import { existsSync, writeFileSync } from 'node:fs';
|
|
21
|
+
import { resolve } from 'node:path';
|
|
22
|
+
import { buildManifestFromTemplate, formatManifestYaml, type TemplateRegistryEntry } from '@ninedeploy/sdk';
|
|
23
|
+
import type { NineDeployClient } from '../client.js';
|
|
24
|
+
import { error, header, info, success } from '../lib/format.js';
|
|
25
|
+
|
|
26
|
+
const MANIFEST_FILENAMES = ['.ninedeploy', '.ninedeploy.yml', '.ninedeploy.yaml'] as const;
|
|
27
|
+
|
|
28
|
+
function existingManifest(cwd: string): string | null {
|
|
29
|
+
for (const name of MANIFEST_FILENAMES) {
|
|
30
|
+
const candidate = resolve(cwd, name);
|
|
31
|
+
if (existsSync(candidate)) return candidate;
|
|
32
|
+
}
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Pull a single template's registry entry from the panel. The response
|
|
38
|
+
* carries every field we need; anything beyond `TemplateRegistryEntry` is
|
|
39
|
+
* ignored. The cast is safe because the server's `Template` type is a
|
|
40
|
+
* superset of the SDK's `TemplateRegistryEntry` shape.
|
|
41
|
+
*/
|
|
42
|
+
async function fetchTemplateEntry(
|
|
43
|
+
client: NineDeployClient,
|
|
44
|
+
templateId: string,
|
|
45
|
+
): Promise<TemplateRegistryEntry> {
|
|
46
|
+
// The SDK exposes typed `client.templates.get(id)` returning `Template`.
|
|
47
|
+
// We rely on the cast here so a single helper definition lives in the
|
|
48
|
+
// SDK; if the server's response shape ever drifts, this is the seam.
|
|
49
|
+
const full = await (client as unknown as {
|
|
50
|
+
templates: { get: (id: string) => Promise<TemplateRegistryEntry> };
|
|
51
|
+
}).templates.get(templateId);
|
|
52
|
+
if (!full) throw new Error(`Panel returned no template for id "${templateId}"`);
|
|
53
|
+
return {
|
|
54
|
+
id: full.id,
|
|
55
|
+
name: full.name,
|
|
56
|
+
image: full.image,
|
|
57
|
+
port: full.port,
|
|
58
|
+
volumeMount: full.volumeMount,
|
|
59
|
+
env: full.env,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ── `ninedeploy templates init <id>` ─────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
export interface TemplatesInitOptions {
|
|
66
|
+
/** Default host to bake into the starter `routes[0].host`. */
|
|
67
|
+
host?: string;
|
|
68
|
+
/** Filename to write when `--write` is set; defaults to `.ninedeploy`. */
|
|
69
|
+
filename?: string;
|
|
70
|
+
/** When true, write to disk; when false (default), print to stdout. */
|
|
71
|
+
write?: boolean;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Pure entry point — same shape used by both the CLI action and the
|
|
76
|
+
* unit test. Given a client and a template id, return the rendered YAML.
|
|
77
|
+
* Does not touch the filesystem or stdin; the caller decides what to do
|
|
78
|
+
* with the result.
|
|
79
|
+
*/
|
|
80
|
+
export async function renderTemplateManifest(
|
|
81
|
+
client: NineDeployClient,
|
|
82
|
+
templateId: string,
|
|
83
|
+
opts: TemplatesInitOptions = {},
|
|
84
|
+
): Promise<{ yaml: string; entry: TemplateRegistryEntry }> {
|
|
85
|
+
const entry = await fetchTemplateEntry(client, templateId);
|
|
86
|
+
const host = opts.host?.trim() ?? '';
|
|
87
|
+
const manifest = buildManifestFromTemplate(entry, host);
|
|
88
|
+
// F929: the mapper always emits a starter route, but `routes[].host` is
|
|
89
|
+
// required (a hostname, min 3 chars) while `routes` itself is optional. With
|
|
90
|
+
// no --host, drop the route instead of writing `host: ""` that `manifest
|
|
91
|
+
// validate` and the deploy-time loader reject (mirrors the server's F336).
|
|
92
|
+
if (!host) delete manifest.routes;
|
|
93
|
+
const yaml = formatManifestYaml(manifest);
|
|
94
|
+
return { yaml, entry };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export async function templatesInit(
|
|
98
|
+
client: NineDeployClient,
|
|
99
|
+
templateId: string,
|
|
100
|
+
cwd: string = process.cwd(),
|
|
101
|
+
opts: TemplatesInitOptions = {},
|
|
102
|
+
): Promise<void> {
|
|
103
|
+
header('Templates init');
|
|
104
|
+
|
|
105
|
+
if (!templateId) {
|
|
106
|
+
error('Usage: ninedeploy templates init <template-id>');
|
|
107
|
+
process.exitCode = 1;
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (opts.write) {
|
|
112
|
+
const existing = existingManifest(cwd);
|
|
113
|
+
if (existing) {
|
|
114
|
+
error(`A manifest already exists at ${existing}. Refusing to overwrite.`);
|
|
115
|
+
process.exitCode = 1;
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
let yaml: string;
|
|
121
|
+
let entry: TemplateRegistryEntry;
|
|
122
|
+
try {
|
|
123
|
+
({ yaml, entry } = await renderTemplateManifest(client, templateId, opts));
|
|
124
|
+
} catch (err) {
|
|
125
|
+
error(
|
|
126
|
+
err instanceof Error
|
|
127
|
+
? `Could not fetch template "${templateId}": ${err.message}`
|
|
128
|
+
: `Could not fetch template "${templateId}"`,
|
|
129
|
+
);
|
|
130
|
+
process.exitCode = 1;
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (opts.write) {
|
|
135
|
+
const filename = opts.filename ?? '.ninedeploy';
|
|
136
|
+
const out = resolve(cwd, filename);
|
|
137
|
+
writeFileSync(out, yaml, 'utf8');
|
|
138
|
+
success(`Wrote ${filename}`);
|
|
139
|
+
// v8 ignore next -- the post-write banner lines are reached only
|
|
140
|
+
// when the user runs the action via the CLI; the only test that
|
|
141
|
+
// does that is the end-to-end smoke in test/index.test.ts, which v8
|
|
142
|
+
// attributes to the call site rather than this branch.
|
|
143
|
+
info(`Image: ${entry.image}`);
|
|
144
|
+
if (entry.port) info(`Port: ${entry.port}`);
|
|
145
|
+
if (entry.volumeMount) info(`Mount: ${entry.volumeMount}`);
|
|
146
|
+
info('Edit the file to match your project, then commit it.');
|
|
147
|
+
info('Run `ninedeploy manifest validate` to check it before pushing.');
|
|
148
|
+
} else {
|
|
149
|
+
// Print to stdout so the operator can pipe to `tee`, redirect into a
|
|
150
|
+
// file, or diff against an existing one. The header/footer banners
|
|
151
|
+
// are only useful interactively, so they go to stderr.
|
|
152
|
+
process.stdout.write(yaml);
|
|
153
|
+
if (!process.stdout.isTTY) {
|
|
154
|
+
// v8 ignore next -- see note above.
|
|
155
|
+
info(`(rendered from template "${entry.id}" — pipe to a file or diff with --write)`);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { NineDeployError } from '@ninedeploy/sdk';
|
|
2
|
+
import { getClient } from '../client.js';
|
|
3
|
+
import { prompt } from '../prompts.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Normalise a typed scope answer. Accepts the legacy
|
|
7
|
+
* `read | write | operator` shorthand AND the fine-grained
|
|
8
|
+
* `nd://scope/(read|write|admin)/<resource>` form
|
|
9
|
+
* introduced in G-08. Anything unrecognised is dropped
|
|
10
|
+
* rather than silently widening the token. An empty
|
|
11
|
+
* result means "no scopes given": callers omit the field
|
|
12
|
+
* and the server applies its read-only default (the
|
|
13
|
+
* server refuses an explicit `[]`, which would be stored
|
|
14
|
+
* as a legacy unrestricted token).
|
|
15
|
+
*/
|
|
16
|
+
export function parseScopes(answer: string): string[] {
|
|
17
|
+
const legacy = new Set(['read', 'write', 'operator']);
|
|
18
|
+
const resourceScope = /^nd:\/\/scope\/(read|write|admin)\/[a-z][a-z0-9_]{0,63}$/;
|
|
19
|
+
return answer
|
|
20
|
+
.split(',')
|
|
21
|
+
.map((s) => s.trim())
|
|
22
|
+
.filter((s) => legacy.has(s) || resourceScope.test(s));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** `ninedeploy token create` — mint an API token (shown once). */
|
|
26
|
+
export async function tokenCreateAction(): Promise<void> {
|
|
27
|
+
const name = (await prompt('Token name', 'ci')) || 'ci';
|
|
28
|
+
// read = safe methods only · write = mutate as a non-operator ·
|
|
29
|
+
// operator = no extra restriction. Blank = read-only (server default).
|
|
30
|
+
// r194: the default must match the label — it was 'write', so pressing
|
|
31
|
+
// Enter at "blank = read" minted a write token.
|
|
32
|
+
const scopes = parseScopes(await prompt('Scopes (read,write,operator — blank = read)', 'read'));
|
|
33
|
+
try {
|
|
34
|
+
const created = await getClient().auth.tokens.create(scopes.length ? { name, scopes } : { name });
|
|
35
|
+
console.log(`✓ Token "${created.name}" created (id: ${created.id}).`);
|
|
36
|
+
console.log(` Scopes: ${created.scopes.length ? created.scopes.join(', ') : 'unrestricted (legacy)'}`);
|
|
37
|
+
console.log(' This token is shown ONCE — store it securely:');
|
|
38
|
+
console.log(` ${created.token}`);
|
|
39
|
+
console.log(' Use it as: Authorization: Bearer <token>');
|
|
40
|
+
} catch (err) {
|
|
41
|
+
console.error(
|
|
42
|
+
'✗ Could not create token:',
|
|
43
|
+
err instanceof NineDeployError ? err.message : err instanceof Error ? err.message : err,
|
|
44
|
+
);
|
|
45
|
+
process.exitCode = 1;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** `ninedeploy token list` — list API tokens (without secrets). */
|
|
50
|
+
export async function tokenListAction(): Promise<void> {
|
|
51
|
+
const tokens = await getClient().auth.tokens.list();
|
|
52
|
+
if (tokens.length === 0) {
|
|
53
|
+
console.log('No API tokens.');
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
console.table(
|
|
57
|
+
tokens.map((t) => ({
|
|
58
|
+
id: t.id,
|
|
59
|
+
name: t.name,
|
|
60
|
+
scopes: t.scopes.length ? t.scopes.join(',') : 'unrestricted',
|
|
61
|
+
lastUsed: t.lastUsedAt ?? 'never',
|
|
62
|
+
expires: t.expiresAt ?? 'never',
|
|
63
|
+
created: t.createdAt,
|
|
64
|
+
})),
|
|
65
|
+
);
|
|
66
|
+
}
|