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.
- 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/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.map +1 -1
- package/dist/commands/services.js +3 -5
- 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/config.d.ts.map +1 -1
- package/dist/config.js +2 -1
- package/dist/config.js.map +1 -1
- package/dist/index.js +427 -23
- 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/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 +249 -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/webhooks.ts +93 -0
- package/src/commands/workspaces.ts +63 -0
- package/src/config.ts +53 -0
- package/src/index.ts +1273 -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,292 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import process from 'node:process';
|
|
3
|
+
import type { GithubApp, GithubAppInstallation, ServiceGithubLink } from '@ninedeploy/sdk';
|
|
4
|
+
import type { NineDeployClient } from '../client.js';
|
|
5
|
+
import { loadConfig } from '../config.js';
|
|
6
|
+
import { prompt } from '../prompts.js';
|
|
7
|
+
import { c, error, header, info, kv, spinner, success, table } from '../lib/format.js';
|
|
8
|
+
import { plain } from './sources.js';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* 0.13: GitHub App registration (`ninedeploy github-app …`) and a service's
|
|
12
|
+
* App link (`ninedeploy services github <id>`). App names, slugs, owners,
|
|
13
|
+
* account logins and repository names come from GitHub, so every one of them
|
|
14
|
+
* goes through the F1011 terminal sanitiser before it is printed.
|
|
15
|
+
*
|
|
16
|
+
* Secrets never travel on argv: the private key is read from a file
|
|
17
|
+
* (`--key-file`) or `NINEDEPLOY_GITHUB_APP_KEY`, and a webhook secret only
|
|
18
|
+
* from `NINEDEPLOY_GITHUB_APP_WEBHOOK_SECRET`.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const KEY_ENV = 'NINEDEPLOY_GITHUB_APP_KEY';
|
|
22
|
+
const WEBHOOK_SECRET_ENV = 'NINEDEPLOY_GITHUB_APP_WEBHOOK_SECRET';
|
|
23
|
+
|
|
24
|
+
/** Canonical decimal id only, as the server's parseId accepts it (F598). */
|
|
25
|
+
function parseId(raw: string | undefined): number {
|
|
26
|
+
const t = (raw ?? '').trim();
|
|
27
|
+
return /^[1-9]\d*$/.test(t) && Number.isSafeInteger(Number(t)) ? Number(t) : 0;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const message = (err: unknown): string => plain(err instanceof Error ? err.message : String(err));
|
|
31
|
+
|
|
32
|
+
function installationState(inst: GithubAppInstallation): string {
|
|
33
|
+
if (inst.removedAt) return c.red('removed');
|
|
34
|
+
if (inst.suspendedAt) return c.yellow('suspended');
|
|
35
|
+
return c.green('active');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function printInstallations(rows: GithubAppInstallation[]): void {
|
|
39
|
+
if (rows.length === 0) {
|
|
40
|
+
info('No installations yet. Install the App on GitHub, then run `ninedeploy github-app sync <id>`.');
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
table(
|
|
44
|
+
rows.map((i) => ({
|
|
45
|
+
id: i.id,
|
|
46
|
+
account: plain(i.accountLogin ?? `installation ${i.installationId}`),
|
|
47
|
+
type: plain(i.accountType ?? '—'),
|
|
48
|
+
repos: i.repositorySelection,
|
|
49
|
+
state: installationState(i),
|
|
50
|
+
source: i.sourceId ?? '—',
|
|
51
|
+
})),
|
|
52
|
+
['id', 'account', 'type', 'repos', 'state', 'source'],
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** `ninedeploy github-app list` */
|
|
57
|
+
export async function githubAppList(client: NineDeployClient): Promise<void> {
|
|
58
|
+
header('GitHub Apps');
|
|
59
|
+
const apps = await spinner('Fetching GitHub Apps', () => client.githubApps.list());
|
|
60
|
+
if (apps.length === 0) {
|
|
61
|
+
const panel = loadConfig().baseUrl.replace(/\/+$/, '');
|
|
62
|
+
info(`No GitHub App registered. One-click setup runs in the browser: ${panel}/sources → GitHub Apps → Create GitHub App.`);
|
|
63
|
+
info('For GitHub Enterprise Server or an existing App: `ninedeploy github-app add-manual`.');
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
table(
|
|
67
|
+
apps.map((a) => ({
|
|
68
|
+
id: a.id,
|
|
69
|
+
name: plain(a.name),
|
|
70
|
+
appId: a.appId,
|
|
71
|
+
owner: plain(a.ownerLogin ?? '—'),
|
|
72
|
+
host: plain(new URL(a.webBaseUrl).host),
|
|
73
|
+
})),
|
|
74
|
+
['id', 'name', 'appId', 'owner', 'host'],
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function printApp(app: GithubApp): void {
|
|
79
|
+
kv('Name', plain(app.name));
|
|
80
|
+
kv('App ID', app.appId);
|
|
81
|
+
kv('Slug', app.slug ? plain(app.slug) : null);
|
|
82
|
+
kv('Owner', app.ownerLogin ? `${plain(app.ownerLogin)}${app.ownerType ? ` (${plain(app.ownerType)})` : ''}` : null);
|
|
83
|
+
kv('GitHub', plain(app.webBaseUrl));
|
|
84
|
+
kv('API', plain(app.apiBaseUrl));
|
|
85
|
+
kv('Webhook URL', app.webhookUrl);
|
|
86
|
+
kv('Install URL', app.installUrl ? plain(app.installUrl) : null);
|
|
87
|
+
kv('Private key', app.hasPrivateKey ? c.green('✓ set') : c.red('missing'));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** `ninedeploy github-app show <id>` */
|
|
91
|
+
export async function githubAppShow(client: NineDeployClient, idArg: string): Promise<void> {
|
|
92
|
+
const id = parseId(idArg);
|
|
93
|
+
if (!id) return error('Usage: ninedeploy github-app show <id>');
|
|
94
|
+
header(`GitHub App #${id}`);
|
|
95
|
+
try {
|
|
96
|
+
const [app, installations] = await spinner('Fetching', () => Promise.all([client.githubApps.get(id), client.githubApps.installations(id)]));
|
|
97
|
+
printApp(app);
|
|
98
|
+
console.log();
|
|
99
|
+
printInstallations(installations);
|
|
100
|
+
} catch (err) {
|
|
101
|
+
error(message(err));
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** The private key from `--key-file`, else NINEDEPLOY_GITHUB_APP_KEY (with `\n` escapes turned back into newlines). */
|
|
106
|
+
function readPrivateKey(keyFile: string | undefined): string | null {
|
|
107
|
+
if (keyFile) return readFileSync(keyFile, 'utf8').trim();
|
|
108
|
+
const fromEnv = process.env[KEY_ENV]?.trim();
|
|
109
|
+
return fromEnv ? fromEnv.replace(/(?:\\r)?\\n/g, '\n') : null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export interface AddManualOptions {
|
|
113
|
+
name?: string;
|
|
114
|
+
appId?: string;
|
|
115
|
+
keyFile?: string;
|
|
116
|
+
webBaseUrl?: string;
|
|
117
|
+
apiBaseUrl?: string;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** `ninedeploy github-app add-manual` — an existing App, or one on GitHub Enterprise Server. */
|
|
121
|
+
export async function githubAppAddManual(client: NineDeployClient, opts: AddManualOptions = {}): Promise<void> {
|
|
122
|
+
header('Add GitHub App');
|
|
123
|
+
const name = (opts.name ?? (await prompt('Display name (e.g. NineDeploy GHES)'))).trim();
|
|
124
|
+
if (!name) return error('A name is required');
|
|
125
|
+
const appId = parseId(opts.appId ?? (await prompt('GitHub App ID')));
|
|
126
|
+
if (!appId) return error('A numeric GitHub App ID is required');
|
|
127
|
+
if ((opts.webBaseUrl === undefined) !== (opts.apiBaseUrl === undefined)) {
|
|
128
|
+
return error('--web-base-url and --api-base-url go together (omit both for github.com)');
|
|
129
|
+
}
|
|
130
|
+
let privateKey: string | null;
|
|
131
|
+
try {
|
|
132
|
+
privateKey = readPrivateKey(opts.keyFile);
|
|
133
|
+
} catch (err) {
|
|
134
|
+
return error(`Could not read the private key: ${message(err)}`);
|
|
135
|
+
}
|
|
136
|
+
if (!privateKey) return error(`A private key is required: pass --key-file <path.pem> or set ${KEY_ENV}`);
|
|
137
|
+
const key = privateKey;
|
|
138
|
+
const webhookSecret = process.env[WEBHOOK_SECRET_ENV]?.trim() || undefined;
|
|
139
|
+
try {
|
|
140
|
+
const app = await spinner('Verifying the key with GitHub and saving', () =>
|
|
141
|
+
client.githubApps.create({
|
|
142
|
+
name,
|
|
143
|
+
appId,
|
|
144
|
+
privateKey: key,
|
|
145
|
+
...(webhookSecret ? { webhookSecret } : {}),
|
|
146
|
+
...(opts.webBaseUrl !== undefined ? { webBaseUrl: opts.webBaseUrl, apiBaseUrl: opts.apiBaseUrl } : {}),
|
|
147
|
+
}),
|
|
148
|
+
);
|
|
149
|
+
success(`GitHub App "${plain(app.name)}" registered (id: ${app.id})`);
|
|
150
|
+
if (!webhookSecret) info('The panel generated a webhook secret and pointed the App\'s webhook at itself.');
|
|
151
|
+
if (app.installUrl) info(`Install it: ${plain(app.installUrl)} — then run \`ninedeploy github-app sync ${app.id}\`.`);
|
|
152
|
+
} catch (err) {
|
|
153
|
+
error(message(err));
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** `ninedeploy github-app sync <id>` */
|
|
158
|
+
export async function githubAppSync(client: NineDeployClient, idArg: string): Promise<void> {
|
|
159
|
+
const id = parseId(idArg);
|
|
160
|
+
if (!id) return error('Usage: ninedeploy github-app sync <id>');
|
|
161
|
+
header(`Sync installations of GitHub App #${id}`);
|
|
162
|
+
try {
|
|
163
|
+
const res = await spinner('Syncing installations from GitHub', () => client.githubApps.syncInstallations(id));
|
|
164
|
+
success(`${res.created} new, ${res.updated} updated, ${res.removed} removed; ${res.sourcesCreated} source(s) created`);
|
|
165
|
+
if (res.truncated) info('The installation list hit the page cap; nothing was marked removed.');
|
|
166
|
+
printInstallations(res.installations);
|
|
167
|
+
} catch (err) {
|
|
168
|
+
error(message(err));
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** `ninedeploy github-app rotate-key <id> [--key-file <path>]` */
|
|
173
|
+
export async function githubAppRotateKey(client: NineDeployClient, idArg: string, opts: { keyFile?: string } = {}): Promise<void> {
|
|
174
|
+
const id = parseId(idArg);
|
|
175
|
+
if (!id) return error('Usage: ninedeploy github-app rotate-key <id> --key-file <path.pem>');
|
|
176
|
+
header(`Rotate the private key of GitHub App #${id}`);
|
|
177
|
+
let privateKey: string | null;
|
|
178
|
+
try {
|
|
179
|
+
privateKey = readPrivateKey(opts.keyFile);
|
|
180
|
+
} catch (err) {
|
|
181
|
+
return error(`Could not read the private key: ${message(err)}`);
|
|
182
|
+
}
|
|
183
|
+
if (!privateKey) return error(`A private key is required: pass --key-file <path.pem> or set ${KEY_ENV}`);
|
|
184
|
+
const key = privateKey;
|
|
185
|
+
try {
|
|
186
|
+
await spinner('Verifying the new key with GitHub', () => client.githubApps.rotateKey(id, key));
|
|
187
|
+
success('Private key replaced. Revoke the old key on GitHub.');
|
|
188
|
+
} catch (err) {
|
|
189
|
+
error(message(err));
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** `ninedeploy github-app remove <id> [--yes]` */
|
|
194
|
+
export async function githubAppRemove(client: NineDeployClient, idArg: string, opts: { yes?: boolean } = {}): Promise<void> {
|
|
195
|
+
const id = parseId(idArg);
|
|
196
|
+
if (!id) return error('Usage: ninedeploy github-app remove <id>');
|
|
197
|
+
header(`Remove GitHub App #${id}`);
|
|
198
|
+
if (!opts.yes) {
|
|
199
|
+
const confirm = await prompt(`Type "delete" to forget GitHub App #${id}`, '');
|
|
200
|
+
if (confirm.trim() !== 'delete') {
|
|
201
|
+
info('Aborted.');
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
try {
|
|
206
|
+
await spinner('Removing', () => client.githubApps.remove(id));
|
|
207
|
+
success(`GitHub App #${id} removed.`);
|
|
208
|
+
info('Its installation sources stay; clones through them fail until the App is registered again.');
|
|
209
|
+
} catch (err) {
|
|
210
|
+
error(message(err));
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export interface ServiceGithubOptions {
|
|
215
|
+
status?: string;
|
|
216
|
+
prComment?: string;
|
|
217
|
+
migrate?: string;
|
|
218
|
+
finalize?: boolean;
|
|
219
|
+
unlink?: boolean;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** `on` / `off` → boolean; anything else is a usage error (null). */
|
|
223
|
+
function onOff(value: string | undefined): boolean | undefined | null {
|
|
224
|
+
if (value === undefined) return undefined;
|
|
225
|
+
const v = value.trim().toLowerCase();
|
|
226
|
+
if (v === 'on') return true;
|
|
227
|
+
if (v === 'off') return false;
|
|
228
|
+
return null;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function printLink(link: ServiceGithubLink | null): void {
|
|
232
|
+
if (!link) {
|
|
233
|
+
info('Not linked to a GitHub App installation. Link it with `--migrate <sourceId>` (a gh-app:… source).');
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
kv('Repository', plain(link.repoFullName));
|
|
237
|
+
kv('State', link.active ? c.green('active (App webhooks deploy it)') : c.yellow(link.enabled ? 'inactive (installation suspended or removed)' : 'disabled'));
|
|
238
|
+
kv('Token scope', link.tokenScope);
|
|
239
|
+
kv('Commit status', link.reportStatus ? 'on' : 'off');
|
|
240
|
+
kv('PR comment', link.prComment ? 'on' : 'off');
|
|
241
|
+
kv('Source', link.sourceId ?? null);
|
|
242
|
+
kv('Previous src', link.previousSourceId ?? null);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* `ninedeploy services github <id> [--status on|off] [--pr-comment on|off]
|
|
247
|
+
* [--migrate <sourceId>] [--finalize] [--unlink]` — without flags it shows
|
|
248
|
+
* the link. Steps run in order: migrate, feedback, finalize. `--unlink`
|
|
249
|
+
* (revert) runs alone; the server answers 409 with the reason when the
|
|
250
|
+
* service clones through the App source itself.
|
|
251
|
+
*/
|
|
252
|
+
export async function serviceGithub(client: NineDeployClient, idArg: string, opts: ServiceGithubOptions = {}): Promise<void> {
|
|
253
|
+
const id = parseId(idArg);
|
|
254
|
+
if (!id) return error('Usage: ninedeploy services github <id> [--status on|off] [--pr-comment on|off] [--migrate <sourceId>] [--finalize] [--unlink]');
|
|
255
|
+
const reportStatus = onOff(opts.status);
|
|
256
|
+
const prComment = onOff(opts.prComment);
|
|
257
|
+
if (reportStatus === null) return error('--status takes on or off');
|
|
258
|
+
if (prComment === null) return error('--pr-comment takes on or off');
|
|
259
|
+
const migrateTo = opts.migrate === undefined ? undefined : parseId(opts.migrate);
|
|
260
|
+
if (migrateTo === 0) return error(`Invalid --migrate source id: ${opts.migrate}`);
|
|
261
|
+
const changes = migrateTo !== undefined || reportStatus !== undefined || prComment !== undefined || opts.finalize === true;
|
|
262
|
+
if (opts.unlink && changes) return error('--unlink runs on its own');
|
|
263
|
+
header(`GitHub link of service #${id}`);
|
|
264
|
+
try {
|
|
265
|
+
if (opts.unlink) {
|
|
266
|
+
const res = await spinner('Reverting to the previous source', () => client.services.github.unlink(id));
|
|
267
|
+
success(`Unlinked. Source is now ${res.sourceId ?? 'none'}; ${res.webhooksReactivated} webhook(s) switched back on.`);
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
if (migrateTo !== undefined) {
|
|
271
|
+
await spinner('Linking to the GitHub App installation', () => client.services.github.migrate(id, migrateTo));
|
|
272
|
+
success('Linked. The previous source and webhook stay as a fallback until --finalize.');
|
|
273
|
+
}
|
|
274
|
+
if (reportStatus !== undefined || prComment !== undefined) {
|
|
275
|
+
await spinner('Updating GitHub feedback', () =>
|
|
276
|
+
client.services.github.feedback(id, {
|
|
277
|
+
...(reportStatus !== undefined ? { reportStatus } : {}),
|
|
278
|
+
...(prComment !== undefined ? { prComment } : {}),
|
|
279
|
+
}),
|
|
280
|
+
);
|
|
281
|
+
success('GitHub feedback updated.');
|
|
282
|
+
}
|
|
283
|
+
if (opts.finalize) {
|
|
284
|
+
const res = await spinner('Finalizing', () => client.services.github.finalize(id));
|
|
285
|
+
success(`Finalized; ${res.webhooksDeactivated} webhook(s) switched off.`);
|
|
286
|
+
}
|
|
287
|
+
const { link } = await spinner('Fetching the link', () => client.services.github.get(id));
|
|
288
|
+
printLink(link);
|
|
289
|
+
} catch (err) {
|
|
290
|
+
error(message(err));
|
|
291
|
+
}
|
|
292
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import type { NineDeployClient } from '@ninedeploy/sdk';
|
|
2
|
+
import { InvalidArgumentError } from 'commander';
|
|
3
|
+
import { error, fmtBytes, header, info, spinner, success, table } from '../lib/format.js';
|
|
4
|
+
|
|
5
|
+
export async function housekeepingPrune(client: NineDeployClient): Promise<void> {
|
|
6
|
+
const res = await spinner('Running system prune', () => client.housekeeping.runPrune());
|
|
7
|
+
console.log(' ✓ System housekeeping prune completed.');
|
|
8
|
+
console.log(` Space reclaimed: ${fmtBytes(res.freedBytes)}`);
|
|
9
|
+
console.log(` Disk used after: ${res.diskUsedPercentAfter}%`);
|
|
10
|
+
if (res.details?.imagesFreed) {
|
|
11
|
+
console.log(` Images freed: ${res.details.imagesFreed}`);
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* `ninedeploy images ls` — every image on the host, with
|
|
17
|
+
* repo / tag / size / age / dangling / in-use columns. The
|
|
18
|
+
* default sort is by size descending so the operator sees
|
|
19
|
+
* the biggest offenders first; `--sort age` switches to
|
|
20
|
+
* "oldest first" which is what the panel usually wants for
|
|
21
|
+
* a prune sweep.
|
|
22
|
+
*/
|
|
23
|
+
export async function imagesList(
|
|
24
|
+
client: NineDeployClient,
|
|
25
|
+
opts: { sort?: 'size' | 'age' } = {},
|
|
26
|
+
): Promise<void> {
|
|
27
|
+
header('Images');
|
|
28
|
+
const { images, totalBytes } = await spinner('Listing', () => client.housekeeping.listImages());
|
|
29
|
+
if (images.length === 0) {
|
|
30
|
+
info('No images on the host.');
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
const sorted = [...images].sort((a, b) => {
|
|
34
|
+
if (opts.sort === 'age') return b.ageHours - a.ageHours;
|
|
35
|
+
return b.sizeBytes - a.sizeBytes;
|
|
36
|
+
});
|
|
37
|
+
table(
|
|
38
|
+
sorted.map((i) => ({
|
|
39
|
+
repository: i.repository,
|
|
40
|
+
tag: i.tag,
|
|
41
|
+
size: i.size,
|
|
42
|
+
ageHours: i.ageHours.toFixed(1),
|
|
43
|
+
dangling: i.dangling ? 'yes' : '',
|
|
44
|
+
inUse: i.inUse ? 'yes' : '',
|
|
45
|
+
})),
|
|
46
|
+
['repository', 'tag', 'size', 'ageHours', 'dangling', 'inUse'],
|
|
47
|
+
);
|
|
48
|
+
info(`${images.length} images, total ${fmtBytes(totalBytes)}.`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* `ninedeploy images prune` — fine-grained retention.
|
|
53
|
+
* The CLI builds a small opts object from flags and posts
|
|
54
|
+
* to `/v1/housekeeping/images/prune`. `--dry-run` is the
|
|
55
|
+
* default expectation on a first run: the operator
|
|
56
|
+
* eyeballs the would-delete set, then re-runs without it.
|
|
57
|
+
*/
|
|
58
|
+
/**
|
|
59
|
+
* r554: commander option parser for a whole-number flag in `1..max`.
|
|
60
|
+
* `images prune` used to pass `Number(v)` straight through — `--older-than 1.5`
|
|
61
|
+
* or `--keep-last abc` sent 1.5 / NaN (JSON `null`) and the server answered an
|
|
62
|
+
* opaque 422. Commander prints this message and exits 1 before any request.
|
|
63
|
+
*/
|
|
64
|
+
export function positiveIntOption(max: number): (value: string) => number {
|
|
65
|
+
return (value: string) => {
|
|
66
|
+
const n = Number(value);
|
|
67
|
+
if (!/^\d+$/.test(value.trim()) || n < 1 || n > max) {
|
|
68
|
+
throw new InvalidArgumentError(`Expected a whole number between 1 and ${max} (got "${value}").`);
|
|
69
|
+
}
|
|
70
|
+
return n;
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export async function imagesPrune(
|
|
75
|
+
client: NineDeployClient,
|
|
76
|
+
opts: {
|
|
77
|
+
keepLast?: number;
|
|
78
|
+
olderThan?: number;
|
|
79
|
+
dangling?: boolean;
|
|
80
|
+
dryRun?: boolean;
|
|
81
|
+
} = {},
|
|
82
|
+
): Promise<void> {
|
|
83
|
+
header('Image prune');
|
|
84
|
+
if (!opts.dangling && !opts.keepLast && !opts.olderThan) {
|
|
85
|
+
error('Refusing to prune with no filter. Set --dangling, --keep-last <N>, or --older-than <hours>.');
|
|
86
|
+
process.exitCode = 1;
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
const res = await spinner(opts.dryRun ? 'Computing candidate set' : 'Pruning', () =>
|
|
90
|
+
client.housekeeping.pruneImages({
|
|
91
|
+
keepLast: opts.keepLast,
|
|
92
|
+
olderThanHours: opts.olderThan,
|
|
93
|
+
danglingOnly: opts.dangling,
|
|
94
|
+
dryRun: opts.dryRun,
|
|
95
|
+
}),
|
|
96
|
+
);
|
|
97
|
+
if (res.dryRun) {
|
|
98
|
+
info(`dryRun: would remove ${res.removed.length} images, freeing ${fmtBytes(res.freedBytes)}.`);
|
|
99
|
+
for (const label of res.removedLabels.slice(0, 25)) console.log(` ${label}`);
|
|
100
|
+
if (res.removedLabels.length > 25) info(` …and ${res.removedLabels.length - 25} more.`);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
success(`Removed ${res.removed.length} images, freed ${fmtBytes(res.freedBytes)}.`);
|
|
104
|
+
console.log(res.output);
|
|
105
|
+
}
|
|
106
|
+
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { createClient, NineDeployError } from '@ninedeploy/sdk';
|
|
2
|
+
import { loadConfig, saveConfig } from '../config.js';
|
|
3
|
+
import { prompt, promptHidden } from '../prompts.js';
|
|
4
|
+
import { normalizeServerUrl } from '../lib/serverRunner.js';
|
|
5
|
+
|
|
6
|
+
/** `ninedeploy login` — authenticate and persist the access token locally. */
|
|
7
|
+
export async function loginAction(): Promise<void> {
|
|
8
|
+
const existing = loadConfig();
|
|
9
|
+
const rawUrl = await prompt('Server URL', existing.baseUrl);
|
|
10
|
+
const baseUrl = normalizeServerUrl(rawUrl);
|
|
11
|
+
const email = await prompt('Email');
|
|
12
|
+
if (!email) {
|
|
13
|
+
console.error('Email is required.');
|
|
14
|
+
process.exitCode = 1;
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
const password = await promptHidden('Password');
|
|
18
|
+
if (!password) {
|
|
19
|
+
console.error('Password is required.');
|
|
20
|
+
process.exitCode = 1;
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const client = createClient({ baseUrl });
|
|
25
|
+
try {
|
|
26
|
+
// r197: a 2FA account answers `totp_required`; the CLI used to print
|
|
27
|
+
// "Login failed (401)" with no way to supply the code, so any operator who
|
|
28
|
+
// enabled 2FA could not use the CLI at all.
|
|
29
|
+
let session: Awaited<ReturnType<typeof client.auth.login>>;
|
|
30
|
+
try {
|
|
31
|
+
session = await client.auth.login({ email, password });
|
|
32
|
+
} catch (err) {
|
|
33
|
+
if (!(err instanceof NineDeployError) || err.code !== 'totp_required') throw err;
|
|
34
|
+
const totpCode = (await prompt('Two-factor code')).trim();
|
|
35
|
+
if (!totpCode) {
|
|
36
|
+
console.error('Two-factor code is required.');
|
|
37
|
+
process.exitCode = 1;
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
session = await client.auth.login({ email, password, totpCode });
|
|
41
|
+
}
|
|
42
|
+
// Both tokens: the access token alone dies with the 15-minute TTL and
|
|
43
|
+
// every scripted session used to die with it (client.ts now refreshes).
|
|
44
|
+
saveConfig({ baseUrl, token: session.tokens.accessToken, refreshToken: session.tokens.refreshToken });
|
|
45
|
+
console.log(`✓ Logged in as ${session.user.email} (${session.user.isOperator ? 'operator' : 'member'})`);
|
|
46
|
+
} catch (err) {
|
|
47
|
+
if (err instanceof NineDeployError) {
|
|
48
|
+
console.error(`✗ Login failed (${err.status}): ${err.message}`);
|
|
49
|
+
} else {
|
|
50
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
51
|
+
console.error('✗ Login failed:', msg);
|
|
52
|
+
if (msg.includes('fetch failed') || msg.includes('ECONNREFUSED') || msg.includes('ENOTFOUND')) {
|
|
53
|
+
console.error(` Could not reach NineDeploy server at ${baseUrl}. Check your URL or ensure the server is running.`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
process.exitCode = 1;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `ninedeploy logs search` — G-16 cluster log search via
|
|
3
|
+
* the configured Loki drain. The CLI is a thin renderer
|
|
4
|
+
* around `client.logDrains.search(...)`: the heavy
|
|
5
|
+
* lifting is the upstream Loki query.
|
|
6
|
+
*/
|
|
7
|
+
import type { NineDeployClient } from '../client.js';
|
|
8
|
+
import { c, error, header, info, spinner } from '../lib/format.js';
|
|
9
|
+
|
|
10
|
+
const num = (v: string, usage: string): number => {
|
|
11
|
+
const n = Number(v);
|
|
12
|
+
if (Number.isNaN(n)) {
|
|
13
|
+
error(usage);
|
|
14
|
+
throw new Error(usage);
|
|
15
|
+
}
|
|
16
|
+
return n;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* F880: a searched line is container stdout of any tenant's service (and its
|
|
21
|
+
* `service` label comes from the shipping pipeline), so it must not drive the
|
|
22
|
+
* operator's terminal. Same filter as F538 (`deploys watch`, manage.ts): SGR
|
|
23
|
+
* colours are kept; OSC/DCS strings (OSC 52 clipboard write, window title),
|
|
24
|
+
* non-SGR CSI (cursor moves, erase, clear screen), stray escapes and C0/DEL/C1
|
|
25
|
+
* controls other than tab, LF and CR are dropped.
|
|
26
|
+
*/
|
|
27
|
+
const ESC = String.fromCharCode(27);
|
|
28
|
+
const TERMINAL_CONTROL = new RegExp(
|
|
29
|
+
`${ESC}[\\]PX^_][\\s\\S]*?(?:${String.fromCharCode(7)}|${ESC}\\\\|$)` +
|
|
30
|
+
`|${ESC}\\[[0-?]*[ -/]*[@-~]` +
|
|
31
|
+
`|${ESC}[\\s\\S]?` +
|
|
32
|
+
'|[\\u0000-\\u0008\\u000b\\u000c\\u000e-\\u001f\\u007f-\\u009f]',
|
|
33
|
+
'g',
|
|
34
|
+
);
|
|
35
|
+
const SGR_ONLY = new RegExp(`^${ESC}\\[[0-9;]*m$`);
|
|
36
|
+
const terminalSafe = (s: string): string => s.replace(TERMINAL_CONTROL, (m) => (SGR_ONLY.test(m) ? m : ''));
|
|
37
|
+
|
|
38
|
+
export async function logsSearch(
|
|
39
|
+
client: NineDeployClient,
|
|
40
|
+
query: string,
|
|
41
|
+
opts: { service?: string; since?: string; limit?: string; drain?: string; json?: boolean } = {},
|
|
42
|
+
): Promise<void> {
|
|
43
|
+
if (!query) {
|
|
44
|
+
error('Usage: ninedeploy logs search <query> [--service <id>] [--since 15m] [--drain <id>]');
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
const sinceMinutes = parseSinceMinutes(opts.since ?? '15m');
|
|
48
|
+
const limit = opts.limit ? num(opts.limit, 'Usage: --limit <N>') : undefined;
|
|
49
|
+
const drainId = opts.drain ? num(opts.drain, 'Usage: --drain <id>') : undefined;
|
|
50
|
+
const serviceId = opts.service ? num(opts.service, 'Usage: --service <id>') : undefined;
|
|
51
|
+
const result = await spinner('Searching logs', () =>
|
|
52
|
+
client.logDrains.search({ query, serviceId, sinceMinutes, limit, drainId }),
|
|
53
|
+
);
|
|
54
|
+
if (opts.json) {
|
|
55
|
+
console.log(JSON.stringify(result, null, 2));
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
header('Log search');
|
|
59
|
+
info(`Drain: ${result.drain.name} (${result.drain.type})`);
|
|
60
|
+
info(`Window: ${result.window.since} → ${result.window.until}`);
|
|
61
|
+
if (result.serviceId != null) info(`Service ID: ${result.serviceId}`);
|
|
62
|
+
info(`Matches: ${result.lines.length}`);
|
|
63
|
+
if (result.lines.length === 0) {
|
|
64
|
+
info('(no matches in the window)');
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
console.log();
|
|
68
|
+
for (const line of result.lines) {
|
|
69
|
+
const ts = new Date(line.ts).toISOString();
|
|
70
|
+
const svc = line.service ? c.dim(`[${terminalSafe(line.service)}]`) : '';
|
|
71
|
+
console.log(`${c.dim(ts)} ${svc} ${terminalSafe(line.line)}`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Parse `15m` / `2h` / `30s` / `1d` into minutes. The
|
|
76
|
+
* search route caps at 7d, so anything larger is clamped. */
|
|
77
|
+
function parseSinceMinutes(input: string): number {
|
|
78
|
+
const m = /^(\d+)([smhd])$/.exec(input.trim());
|
|
79
|
+
if (!m) {
|
|
80
|
+
error(`Invalid --since value "${input}" (expected e.g. 15m, 2h, 1d)`);
|
|
81
|
+
throw new Error(`Invalid --since value: ${input}`);
|
|
82
|
+
}
|
|
83
|
+
const n = Number(m[1]);
|
|
84
|
+
switch (m[2]) {
|
|
85
|
+
case 's': return Math.max(1, Math.ceil(n / 60));
|
|
86
|
+
case 'm': return n;
|
|
87
|
+
case 'h': return Math.min(7 * 24 * 60, n * 60);
|
|
88
|
+
case 'd': return Math.min(7 * 24 * 60, n * 24 * 60);
|
|
89
|
+
default: return 15;
|
|
90
|
+
}
|
|
91
|
+
}
|