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,714 @@
|
|
|
1
|
+
import type { NineDeployClient } from '../client.js';
|
|
2
|
+
import { c, error, fmtTime, header, info, spinner, success, table } from '../lib/format.js';
|
|
3
|
+
import { prompt } from '../prompts.js';
|
|
4
|
+
|
|
5
|
+
/** F537: canonical decimal ids only — the server's `parseId` rule (r260).
|
|
6
|
+
* Bare `Number()` turned `0x10`/`1e1`/`0b11` into 16/10/3 and `""` into 0,
|
|
7
|
+
* so a destructive call (rm, restore, revoke) hit an id nobody typed. */
|
|
8
|
+
const CANONICAL_ID = /^[1-9]\d*$/;
|
|
9
|
+
|
|
10
|
+
const num = (v: string, usage: string): number => {
|
|
11
|
+
const n = CANONICAL_ID.test(v) ? Number(v) : Number.NaN;
|
|
12
|
+
if (!Number.isSafeInteger(n)) {
|
|
13
|
+
error(usage);
|
|
14
|
+
// error() sets the exit code; throw so execution never continues with
|
|
15
|
+
// NaN (and tests can observe the failure).
|
|
16
|
+
throw new Error(usage);
|
|
17
|
+
}
|
|
18
|
+
return n;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const fail = (err: unknown): void => {
|
|
22
|
+
error(err instanceof Error ? err.message : String(err));
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
// ── env ────────────────────────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* 0.12: `--preview` targets the service's preview-only set — values only its
|
|
29
|
+
* PR previews receive — instead of the service's own (production) env.
|
|
30
|
+
*/
|
|
31
|
+
interface EnvTarget { preview?: boolean }
|
|
32
|
+
const envApi = (client: NineDeployClient, opts: EnvTarget = {}) => (opts.preview ? client.previewEnv : client.env);
|
|
33
|
+
|
|
34
|
+
/** `ninedeploy env list <serviceId> [--preview]` */
|
|
35
|
+
export async function envList(client: NineDeployClient, idStr: string, opts: EnvTarget = {}): Promise<void> {
|
|
36
|
+
const id = num(idStr, 'Usage: ninedeploy env list <serviceId>');
|
|
37
|
+
header(opts.preview ? 'Preview-only Environment Variables' : 'Environment Variables');
|
|
38
|
+
await spinner('Fetching', async () => {
|
|
39
|
+
const vars = await envApi(client, opts).list(id);
|
|
40
|
+
if (vars.length === 0) { info('No env vars.'); return; }
|
|
41
|
+
table(vars.map((v) => ({ id: v.id, key: v.key, secret: v.isSecret ? '•••' : 'plain', value: v.isSecret ? '—' : v.value })), ['id', 'key', 'secret', 'value']);
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** `ninedeploy env set <serviceId> <key> <value> [--public] [--preview]` */
|
|
46
|
+
export async function envSet(client: NineDeployClient, idStr: string, key: string, value: string, opts: { public?: boolean } & EnvTarget): Promise<void> {
|
|
47
|
+
const id = num(idStr, 'Usage: ninedeploy env set <serviceId> <key> <value>');
|
|
48
|
+
if (!key || value === undefined) return error('Usage: ninedeploy env set <serviceId> <key> <value>');
|
|
49
|
+
const input = { key, value, isSecret: !opts.public };
|
|
50
|
+
const api = envApi(client, opts);
|
|
51
|
+
try {
|
|
52
|
+
const vars = await api.list(id);
|
|
53
|
+
const existing = vars.find((v) => v.key === key);
|
|
54
|
+
if (existing) {
|
|
55
|
+
await api.update(id, existing.id, input);
|
|
56
|
+
} else {
|
|
57
|
+
await api.create(id, input);
|
|
58
|
+
}
|
|
59
|
+
success(`Set ${c.cyan(key)}${input.isSecret ? ' (secret)' : ''}${opts.preview ? ' for PR previews only' : ''}.`);
|
|
60
|
+
} catch (err) { fail(err); }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** `ninedeploy env rm <serviceId> <key> [--preview]` */
|
|
64
|
+
export async function envRemove(client: NineDeployClient, idStr: string, key: string, opts: EnvTarget = {}): Promise<void> {
|
|
65
|
+
const id = num(idStr, 'Usage: ninedeploy env rm <serviceId> <key>');
|
|
66
|
+
if (!key) return error('Usage: ninedeploy env rm <serviceId> <key>');
|
|
67
|
+
const api = envApi(client, opts);
|
|
68
|
+
try {
|
|
69
|
+
const vars = await api.list(id);
|
|
70
|
+
const existing = vars.find((v) => v.key === key);
|
|
71
|
+
if (!existing) return info(`No env var named "${key}".`);
|
|
72
|
+
await api.remove(id, existing.id);
|
|
73
|
+
success(`Removed ${c.cyan(key)}.`);
|
|
74
|
+
} catch (err) { fail(err); }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ── domains ────────────────────────────────────────────────────────────────
|
|
78
|
+
|
|
79
|
+
/** `ninedeploy domains list` */
|
|
80
|
+
export async function domainsList(client: NineDeployClient): Promise<void> {
|
|
81
|
+
header('Domains');
|
|
82
|
+
await spinner('Fetching', async () => {
|
|
83
|
+
const rows = await client.domains.all();
|
|
84
|
+
if (rows.length === 0) { info('No domains yet.'); return; }
|
|
85
|
+
table(rows.map((d) => ({
|
|
86
|
+
id: d.id,
|
|
87
|
+
host: d.hostname,
|
|
88
|
+
path: d.path,
|
|
89
|
+
ssl: d.ssl ? '🔒' : '',
|
|
90
|
+
cert: d.certExpiresAt ? `${Math.ceil((new Date(d.certExpiresAt).getTime() - Date.now()) / 86_400_000)}d` : '—',
|
|
91
|
+
service: d.serviceName ?? '—',
|
|
92
|
+
})), ['id', 'host', 'path', 'ssl', 'cert', 'service']);
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** `ninedeploy domains add <serviceId> <host> [--path p] [--no-ssl]` */
|
|
97
|
+
export async function domainsAdd(client: NineDeployClient, idStr: string, host: string, opts: { path?: string; ssl?: boolean }): Promise<void> {
|
|
98
|
+
const id = num(idStr, 'Usage: ninedeploy domains add <serviceId> <host>');
|
|
99
|
+
if (!host) return error('Usage: ninedeploy domains add <serviceId> <host>');
|
|
100
|
+
try {
|
|
101
|
+
const d = await client.domains.create(id, { hostname: host, path: opts.path ?? '/', ssl: opts.ssl !== false });
|
|
102
|
+
success(`Domain ${c.cyan(d.hostname)} added (id: ${d.id}).`);
|
|
103
|
+
// r332: a hostname outside the instance zone is stored `pending` and is
|
|
104
|
+
// not routed until its DNS owner publishes the TXT challenge — say so,
|
|
105
|
+
// and how to finish, instead of leaving a silently dead domain.
|
|
106
|
+
if (d.verification) {
|
|
107
|
+
info('Pending ownership proof — this domain is not routed yet. Publish this DNS record:');
|
|
108
|
+
info(` ${d.verification.recordType} ${c.cyan(d.verification.recordName)} "${d.verification.recordValue}"`);
|
|
109
|
+
info(`then run: ninedeploy domains verify ${id} ${d.id}`);
|
|
110
|
+
}
|
|
111
|
+
} catch (err) { fail(err); }
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** `ninedeploy domains verify <serviceId> <domainId>` — r332: check the TXT
|
|
115
|
+
* ownership challenge of a pending domain and bring it live. Safe to re-run
|
|
116
|
+
* while DNS propagates. */
|
|
117
|
+
export async function domainsVerify(client: NineDeployClient, idStr: string, domainIdStr: string): Promise<void> {
|
|
118
|
+
const serviceId = num(idStr, 'Usage: ninedeploy domains verify <serviceId> <domainId>');
|
|
119
|
+
const domainId = num(domainIdStr, 'Usage: ninedeploy domains verify <serviceId> <domainId>');
|
|
120
|
+
try {
|
|
121
|
+
const res = await spinner('Checking DNS', () => client.domains.verify(serviceId, domainId));
|
|
122
|
+
if (res.verified) {
|
|
123
|
+
success(`Domain ${c.cyan(res.hostname)} verified — it is live.`);
|
|
124
|
+
if (res.dnsWarning) info(`DNS provider: ${res.dnsWarning}`);
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
error(res.error ?? `Domain ${res.hostname} is not verified yet.`);
|
|
128
|
+
if (res.found?.length) info(`TXT values found: ${res.found.join(', ')}`);
|
|
129
|
+
if (res.verification) {
|
|
130
|
+
info(`Expected: ${res.verification.recordType} ${c.cyan(res.verification.recordName)} "${res.verification.recordValue}"`);
|
|
131
|
+
}
|
|
132
|
+
} catch (err) { fail(err); }
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** `ninedeploy domains rm <domainId>` */
|
|
136
|
+
export async function domainsRemove(client: NineDeployClient, idStr: string, domainIdStr: string): Promise<void> {
|
|
137
|
+
const serviceId = num(idStr, 'Usage: ninedeploy domains rm <serviceId> <domainId>');
|
|
138
|
+
const domainId = num(domainIdStr, 'Usage: ninedeploy domains rm <serviceId> <domainId>');
|
|
139
|
+
try {
|
|
140
|
+
await client.domains.remove(serviceId, domainId);
|
|
141
|
+
success('Domain removed.');
|
|
142
|
+
} catch (err) { fail(err); }
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* `ninedeploy domains transfer <domainId> --to <email>` —
|
|
147
|
+
* start a transfer. Prints the accept URL so the operator
|
|
148
|
+
* can forward it to the target user out-of-band (email,
|
|
149
|
+
* chat, etc.). The token in the URL is the only secret;
|
|
150
|
+
* the database stores only its SHA-256.
|
|
151
|
+
*/
|
|
152
|
+
export async function domainsTransfer(
|
|
153
|
+
client: NineDeployClient,
|
|
154
|
+
domainIdStr: string,
|
|
155
|
+
opts: { to: string },
|
|
156
|
+
): Promise<void> {
|
|
157
|
+
const domainId = num(domainIdStr, 'Usage: ninedeploy domains transfer <domainId> --to <email>');
|
|
158
|
+
if (!opts.to) return error('Usage: ninedeploy domains transfer <domainId> --to <email>');
|
|
159
|
+
let res: Awaited<ReturnType<NineDeployClient['domains']['transfer']>>;
|
|
160
|
+
try {
|
|
161
|
+
res = await spinner('Starting transfer', () => client.domains.transfer(domainId, { targetEmail: opts.to }));
|
|
162
|
+
} catch (err) { fail(err); return; }
|
|
163
|
+
success(`Transfer started. Forward this URL to ${opts.to}:`);
|
|
164
|
+
console.log();
|
|
165
|
+
console.log(` ${c.cyan(res.acceptUrl)}`);
|
|
166
|
+
console.log();
|
|
167
|
+
const expiresIn = Math.max(0, res.expiresAt - Math.floor(Date.now() / 1000));
|
|
168
|
+
info(`Token expires in ${Math.ceil(expiresIn / 86400)} day(s). The target user accepts with`);
|
|
169
|
+
info(` ninedeploy domains accept-transfer <token> --service-id <id>`);
|
|
170
|
+
info(`(the token is the last URL segment above).`);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* `ninedeploy domains preview-transfer <token>` — read-only
|
|
175
|
+
* preview of a transfer. No auth required (the token is
|
|
176
|
+
* the secret); used by the panel's accept page.
|
|
177
|
+
*/
|
|
178
|
+
export async function domainsPreviewTransfer(
|
|
179
|
+
client: NineDeployClient,
|
|
180
|
+
token: string,
|
|
181
|
+
): Promise<void> {
|
|
182
|
+
if (!token) return error('Usage: ninedeploy domains preview-transfer <token>');
|
|
183
|
+
let res: Awaited<ReturnType<NineDeployClient['domains']['previewTransfer']>>;
|
|
184
|
+
try {
|
|
185
|
+
res = await client.domains.previewTransfer(token);
|
|
186
|
+
} catch (err) { fail(err); return; }
|
|
187
|
+
if (res.status === 'expired' || res.effectivelyExpired) {
|
|
188
|
+
header('Transfer (expired)');
|
|
189
|
+
info(`This transfer expired and can no longer be accepted.`);
|
|
190
|
+
info(`Source: ${res.sourceEmail} -> Target: ${res.targetEmail} (${res.hostname})`);
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
header(`Transfer (${res.status})`);
|
|
194
|
+
info(`Domain: ${c.cyan(res.hostname)}`);
|
|
195
|
+
info(`From: ${res.sourceEmail}`);
|
|
196
|
+
info(`To: ${res.targetEmail}`);
|
|
197
|
+
info(`Status: ${res.status}`);
|
|
198
|
+
const expiresIn = Math.max(0, res.expiresAt - Math.floor(Date.now() / 1000));
|
|
199
|
+
info(`Expires: in ${Math.ceil(expiresIn / 86400)} day(s)`);
|
|
200
|
+
if (res.acceptedAt) info(`Accepted: ${new Date(res.acceptedAt * 1000).toISOString()}`);
|
|
201
|
+
if (res.cancelledAt) info(`Cancelled: ${new Date(res.cancelledAt * 1000).toISOString()}`);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* `ninedeploy domains accept-transfer <token> --service-id <id>` —
|
|
206
|
+
* accept a transfer as the target user. The CLI refuses if
|
|
207
|
+
* the caller's panel email does not match the transfer's
|
|
208
|
+
* `targetEmail`; the same check is enforced server-side.
|
|
209
|
+
*/
|
|
210
|
+
export async function domainsAcceptTransfer(
|
|
211
|
+
client: NineDeployClient,
|
|
212
|
+
token: string,
|
|
213
|
+
opts: { serviceId: string },
|
|
214
|
+
): Promise<void> {
|
|
215
|
+
if (!token) return error('Usage: ninedeploy domains accept-transfer <token> --service-id <id>');
|
|
216
|
+
if (!opts.serviceId) return error('Usage: ninedeploy domains accept-transfer <token> --service-id <id>');
|
|
217
|
+
const serviceId = num(opts.serviceId, 'Usage: ninedeploy domains accept-transfer <token> --service-id <id>');
|
|
218
|
+
let res: Awaited<ReturnType<NineDeployClient['domains']['acceptTransfer']>>;
|
|
219
|
+
try {
|
|
220
|
+
res = await spinner('Accepting transfer', () =>
|
|
221
|
+
client.domains.acceptTransfer(token, { targetServiceId: serviceId }),
|
|
222
|
+
);
|
|
223
|
+
} catch (err) { fail(err); return; }
|
|
224
|
+
success(`Domain ${c.cyan(res.hostname)} transferred to service ${res.serviceId}.`);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* `ninedeploy domains cancel-transfer <token>` — cancel a
|
|
229
|
+
* pending transfer. Only the source user (or an instance
|
|
230
|
+
* operator) can cancel; the server enforces the same gate.
|
|
231
|
+
*/
|
|
232
|
+
export async function domainsCancelTransfer(
|
|
233
|
+
client: NineDeployClient,
|
|
234
|
+
token: string,
|
|
235
|
+
): Promise<void> {
|
|
236
|
+
if (!token) return error('Usage: ninedeploy domains cancel-transfer <token>');
|
|
237
|
+
try {
|
|
238
|
+
await client.domains.cancelTransfer(token);
|
|
239
|
+
success('Transfer cancelled.');
|
|
240
|
+
} catch (err) { fail(err); }
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// ── volumes ────────────────────────────────────────────────────────────────
|
|
244
|
+
|
|
245
|
+
/** `ninedeploy volumes list [--server <id>]` (0.16 T8: `--server` lists a node's volumes through its agent) */
|
|
246
|
+
export async function volumesList(client: NineDeployClient, opts: { server?: string } = {}): Promise<void> {
|
|
247
|
+
const serverId = opts.server === undefined ? undefined : num(opts.server, 'Usage: ninedeploy volumes list [--server <id>]');
|
|
248
|
+
header(serverId === undefined ? 'Volumes' : `Volumes on server #${serverId}`);
|
|
249
|
+
await spinner('Fetching', async () => {
|
|
250
|
+
const rows = await client.volumes.list(serverId === undefined ? undefined : { serverId });
|
|
251
|
+
if (rows.length === 0) { info('No volumes.'); return; }
|
|
252
|
+
table(rows.map((v) => ({ name: v.name, owner: v.owner ? `${v.owner.kind}: ${v.owner.name}` : '—', size: v.sizeBytes })), ['name', 'owner', 'size']);
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** `ninedeploy volumes rm <name> [--server <id>]` (0.16 T8: `--server` deletes it on that node) */
|
|
257
|
+
export async function volumesRemove(client: NineDeployClient, name: string, opts: { server?: string } = {}): Promise<void> {
|
|
258
|
+
if (!name) return error('Usage: ninedeploy volumes rm <name> [--server <id>]');
|
|
259
|
+
const serverId = opts.server === undefined ? undefined : num(opts.server, 'Usage: ninedeploy volumes rm <name> [--server <id>]');
|
|
260
|
+
const where = serverId === undefined ? '' : ` on server #${serverId}`;
|
|
261
|
+
const confirm = await prompt(`Type the volume name (${name}) to confirm deletion${where}`);
|
|
262
|
+
if (confirm !== name) return error('Cancelled.');
|
|
263
|
+
try {
|
|
264
|
+
await spinner('Removing volume', () => (serverId === undefined ? client.volumes.remove(name) : client.volumes.remove(name, { serverId })));
|
|
265
|
+
success(`Volume ${c.cyan(name)} removed${where}.`);
|
|
266
|
+
} catch (err) { fail(err); }
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// ── networks ───────────────────────────────────────────────────────────────
|
|
270
|
+
|
|
271
|
+
/** `ninedeploy networks list` */
|
|
272
|
+
export async function networksList(client: NineDeployClient): Promise<void> {
|
|
273
|
+
header('Docker Networks');
|
|
274
|
+
await spinner('Fetching', async () => {
|
|
275
|
+
const { networks } = await client.networks.list();
|
|
276
|
+
if (networks.length === 0) { info('No user-defined networks.'); return; }
|
|
277
|
+
table(networks.map((n) => ({ name: n.name, driver: n.driver, members: n.members.join(', ') || '—' })), ['name', 'driver', 'members']);
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** `ninedeploy networks create <name> [driver]` */
|
|
282
|
+
export async function networksCreate(client: NineDeployClient, name: string, driver: 'bridge' | 'overlay'): Promise<void> {
|
|
283
|
+
try {
|
|
284
|
+
await spinner('Creating network', () => client.networks.create({ name, driver }));
|
|
285
|
+
success(`Network ${c.cyan(name)} created.`);
|
|
286
|
+
} catch (err) { fail(err); }
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** `ninedeploy networks rm <name>` */
|
|
290
|
+
export async function networksRemove(client: NineDeployClient, name: string): Promise<void> {
|
|
291
|
+
if (!name) return error('Usage: ninedeploy networks rm <name>');
|
|
292
|
+
const confirm = await prompt(`Type the network name (${name}) to confirm deletion`);
|
|
293
|
+
if (confirm !== name) return error('Cancelled.');
|
|
294
|
+
try {
|
|
295
|
+
await spinner('Removing network', () => client.networks.remove(name));
|
|
296
|
+
success(`Network ${c.cyan(name)} removed.`);
|
|
297
|
+
} catch (err) { fail(err); }
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// ── sessions ───────────────────────────────────────────────────────────────
|
|
301
|
+
|
|
302
|
+
/** `ninedeploy sessions list` */
|
|
303
|
+
export async function sessionsList(client: NineDeployClient): Promise<void> {
|
|
304
|
+
header('Active Sessions');
|
|
305
|
+
await spinner('Fetching', async () => {
|
|
306
|
+
const rows = await client.auth.sessions.list();
|
|
307
|
+
if (rows.length === 0) { info('No active sessions.'); return; }
|
|
308
|
+
table(rows.map((s) => ({
|
|
309
|
+
id: s.id,
|
|
310
|
+
current: s.current ? 'yes' : '',
|
|
311
|
+
ip: s.ip ?? '—',
|
|
312
|
+
lastUsed: s.lastUsedAt ?? s.createdAt,
|
|
313
|
+
userAgent: (s.userAgent ?? '—').slice(0, 60),
|
|
314
|
+
})), ['id', 'current', 'ip', 'lastUsed', 'userAgent']);
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** `ninedeploy sessions revoke <id>` */
|
|
319
|
+
export async function sessionsRevoke(client: NineDeployClient, idStr: string): Promise<void> {
|
|
320
|
+
const id = Number(idStr);
|
|
321
|
+
if (!CANONICAL_ID.test(idStr) || !Number.isSafeInteger(id)) return error('Usage: ninedeploy sessions revoke <id>');
|
|
322
|
+
try {
|
|
323
|
+
await spinner('Revoking session', () => client.auth.sessions.revoke(id));
|
|
324
|
+
success(`Session #${id} revoked.`);
|
|
325
|
+
} catch (err) { fail(err); }
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// ── backups ────────────────────────────────────────────────────────────────
|
|
329
|
+
|
|
330
|
+
/** Human label for a backup row: prefer the database name, fall back to its id. */
|
|
331
|
+
function backupDbLabel(b: { databaseName?: string; databaseId?: number | null }): string {
|
|
332
|
+
if (b.databaseName) return b.databaseName;
|
|
333
|
+
if (b.databaseId != null) return String(b.databaseId);
|
|
334
|
+
return '—';
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** `ninedeploy backups list [databaseId]` */
|
|
338
|
+
export async function backupsList(client: NineDeployClient, idStr?: string): Promise<void> {
|
|
339
|
+
header('Backups');
|
|
340
|
+
const fetchRows = () => (idStr ? client.backups.listForDb(Number(idStr)) : client.backups.list());
|
|
341
|
+
await spinner('Fetching', async () => {
|
|
342
|
+
const rows = await fetchRows();
|
|
343
|
+
if (rows.length === 0) { info('No backups.'); return; }
|
|
344
|
+
table(rows.map((b) => ({ id: b.id, db: backupDbLabel(b), status: b.status, size: b.sizeBytes, created: fmtTime(b.createdAt) })), ['id', 'db', 'status', 'size', 'created']);
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/** `ninedeploy backups create <databaseId>` */
|
|
349
|
+
export async function backupsCreate(client: NineDeployClient, idStr: string): Promise<void> {
|
|
350
|
+
const id = num(idStr, 'Usage: ninedeploy backups create <databaseId>');
|
|
351
|
+
try {
|
|
352
|
+
const b = await spinner('Backing up', () => client.backups.backupNow(id));
|
|
353
|
+
success(`Backup #${b.id} ${b.status}.`);
|
|
354
|
+
} catch (err) { fail(err); }
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** `ninedeploy backups restore <databaseId> <backupId>` */
|
|
358
|
+
export async function backupsRestore(client: NineDeployClient, idStr: string, backupIdStr: string): Promise<void> {
|
|
359
|
+
const id = num(idStr, 'Usage: ninedeploy backups restore <databaseId> <backupId>');
|
|
360
|
+
const backupId = num(backupIdStr, 'Usage: ninedeploy backups restore <databaseId> <backupId>');
|
|
361
|
+
const confirm = await prompt('Restore OVERWRITES the database. Type "yes" to continue');
|
|
362
|
+
if (confirm.toLowerCase() !== 'yes') return error('Cancelled.');
|
|
363
|
+
try {
|
|
364
|
+
await spinner('Restoring', () => client.backups.restore(id, backupId));
|
|
365
|
+
success(`Backup #${backupId} restored.`);
|
|
366
|
+
} catch (err) { fail(err); }
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* `ninedeploy backups drill <databaseId> <backupId>` — prove a
|
|
371
|
+
* backup is at least restorable without overwriting the live
|
|
372
|
+
* database. The drill is an engine-specific smoke check
|
|
373
|
+
* (pg_restore --list, redis-check-rdb, mysqldump header parse,
|
|
374
|
+
* ...) and is safe to run on production backups because it
|
|
375
|
+
* never touches the live container.
|
|
376
|
+
*/
|
|
377
|
+
export async function backupsDrill(client: NineDeployClient, idStr: string, backupIdStr: string): Promise<void> {
|
|
378
|
+
const id = num(idStr, 'Usage: ninedeploy backups drill <databaseId> <backupId>');
|
|
379
|
+
const backupId = num(backupIdStr, 'Usage: ninedeploy backups drill <databaseId> <backupId>');
|
|
380
|
+
let result: Awaited<ReturnType<NineDeployClient['databases']['drillBackup']>>;
|
|
381
|
+
try {
|
|
382
|
+
result = await spinner('Drilling', () => client.databases.drillBackup(id, { backupId }));
|
|
383
|
+
} catch (err) { fail(err); return; }
|
|
384
|
+
if (result.status === 'passed') {
|
|
385
|
+
success(`Backup #${backupId} passed the drill in ${result.durationMs} ms.`);
|
|
386
|
+
} else if (result.status === 'unverifiable') {
|
|
387
|
+
// r356: the check could not run (docker / engine image / tool missing) —
|
|
388
|
+
// not a verdict on the backup. Exit 2 keeps it apart from a real failure.
|
|
389
|
+
console.log(` ${c.yellow('!')} ${c.yellow(`Backup #${backupId} could not be verified: ${result.error ?? 'tool unavailable'}`)}`);
|
|
390
|
+
} else {
|
|
391
|
+
error(`Backup #${backupId} FAILED the drill: ${result.error ?? 'unknown reason'}`);
|
|
392
|
+
}
|
|
393
|
+
if (result.details) {
|
|
394
|
+
for (const [k, v] of Object.entries(result.details)) {
|
|
395
|
+
console.log(` ${c.dim(`${k}:`)} ${typeof v === 'string' ? v : JSON.stringify(v)}`);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
process.exitCode = result.status === 'passed' ? 0 : result.status === 'unverifiable' ? 2 : 1;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* `ninedeploy backups drills <databaseId>` — list the most
|
|
403
|
+
* recent drill results for a database, newest first.
|
|
404
|
+
*/
|
|
405
|
+
export async function backupsDrills(client: NineDeployClient, idStr: string): Promise<void> {
|
|
406
|
+
const id = num(idStr, 'Usage: ninedeploy backups drills <databaseId>');
|
|
407
|
+
const rows = await client.databases.drills(id);
|
|
408
|
+
if (rows.length === 0) {
|
|
409
|
+
info('No drills yet.');
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
table(
|
|
413
|
+
rows.map((r) => ({
|
|
414
|
+
id: r.id,
|
|
415
|
+
backup: r.backupId,
|
|
416
|
+
engine: r.engine,
|
|
417
|
+
status: r.status,
|
|
418
|
+
durationMs: r.durationMs,
|
|
419
|
+
// The server serializes startedAt as epoch milliseconds (Date.getTime());
|
|
420
|
+
// scale defensively so a seconds-precision value still renders correctly.
|
|
421
|
+
started: fmtTime(new Date(r.startedAt < 1e12 ? r.startedAt * 1000 : r.startedAt).toISOString()),
|
|
422
|
+
error: r.error ?? '',
|
|
423
|
+
})),
|
|
424
|
+
['id', 'backup', 'engine', 'status', 'durationMs', 'started', 'error'],
|
|
425
|
+
);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// ── alerts ─────────────────────────────────────────────────────────────────
|
|
429
|
+
|
|
430
|
+
/** `ninedeploy alerts list` */
|
|
431
|
+
export async function alertsList(client: NineDeployClient): Promise<void> {
|
|
432
|
+
header('Alert Rules');
|
|
433
|
+
await spinner('Fetching', async () => {
|
|
434
|
+
const rules = await client.alerts.list();
|
|
435
|
+
if (rules.length === 0) { info('No alert rules.'); return; }
|
|
436
|
+
table(rules.map((r) => ({
|
|
437
|
+
id: r.id,
|
|
438
|
+
name: r.name,
|
|
439
|
+
rule: `${r.metric} ${r.operator} ${r.threshold}`,
|
|
440
|
+
status: r.status,
|
|
441
|
+
current: r.lastValue ?? '—',
|
|
442
|
+
enabled: r.enabled ? '✓' : '',
|
|
443
|
+
})), ['id', 'name', 'rule', 'status', 'current', 'enabled']);
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/** `ninedeploy alerts create <name> <metric> <operator> <threshold> [--windows n] [--service id]` */
|
|
448
|
+
export async function alertsCreate(client: NineDeployClient, name: string, metric: string, operator: string, thresholdStr: string, opts: { windows?: string; service?: string }): Promise<void> {
|
|
449
|
+
if (!name || !metric || !operator || !thresholdStr) {
|
|
450
|
+
return error('Usage: ninedeploy alerts create <name> <cpu|memory|cert-expiry|disk|server_offline> <|<> <threshold>');
|
|
451
|
+
}
|
|
452
|
+
const threshold = Number(thresholdStr);
|
|
453
|
+
// F536: 0 is a valid threshold; only a non-number (or blank) is refused.
|
|
454
|
+
if (thresholdStr.trim() === '' || !Number.isFinite(threshold)) return error('Threshold must be a number.');
|
|
455
|
+
// F536: `Number('web')` is NaN, which JSON-serializes to null — the server
|
|
456
|
+
// reads null as "host-wide", so a typoed scope silently watched the host.
|
|
457
|
+
if (opts.service !== undefined && !CANONICAL_ID.test(opts.service)) return error('--service must be a service id.');
|
|
458
|
+
try {
|
|
459
|
+
const rule = await client.alerts.create({
|
|
460
|
+
name,
|
|
461
|
+
metric: metric as Parameters<NineDeployClient['alerts']['create']>[0]['metric'],
|
|
462
|
+
operator: operator as '>' | '<',
|
|
463
|
+
threshold,
|
|
464
|
+
serviceId: opts.service !== undefined ? Number(opts.service) : null,
|
|
465
|
+
durationWindows: opts.windows ? Number(opts.windows) : undefined,
|
|
466
|
+
});
|
|
467
|
+
success(`Alert "${rule.name}" created (id: ${rule.id}).`);
|
|
468
|
+
} catch (err) { fail(err); }
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/** `ninedeploy alerts rm <id>` */
|
|
472
|
+
export async function alertsRemove(client: NineDeployClient, idStr: string): Promise<void> {
|
|
473
|
+
const id = num(idStr, 'Usage: ninedeploy alerts rm <id>');
|
|
474
|
+
try {
|
|
475
|
+
await client.alerts.remove(id);
|
|
476
|
+
success(`Alert #${id} removed.`);
|
|
477
|
+
} catch (err) { fail(err); }
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// ── users & activity ───────────────────────────────────────────────────────
|
|
481
|
+
|
|
482
|
+
/** `ninedeploy users list` */
|
|
483
|
+
export async function usersList(client: NineDeployClient): Promise<void> {
|
|
484
|
+
header('Users');
|
|
485
|
+
await spinner('Fetching', async () => {
|
|
486
|
+
const users = await client.users.list();
|
|
487
|
+
if (users.length === 0) { info('No users.'); return; }
|
|
488
|
+
table(
|
|
489
|
+
users.map((u) => ({
|
|
490
|
+
id: u.id,
|
|
491
|
+
email: u.email,
|
|
492
|
+
name: u.name ?? '—',
|
|
493
|
+
role: u.isOperator ? 'operator' : 'member',
|
|
494
|
+
workspaces: u.workspaceCount,
|
|
495
|
+
})),
|
|
496
|
+
['id', 'email', 'name', 'role', 'workspaces'],
|
|
497
|
+
);
|
|
498
|
+
});
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/** `ninedeploy users reset-link <id|email>` — mint a one-time reset link. */
|
|
502
|
+
export async function usersResetLink(client: NineDeployClient, who: string): Promise<void> {
|
|
503
|
+
header('Password reset link');
|
|
504
|
+
try {
|
|
505
|
+
const link = await spinner('Resolving user', async () => {
|
|
506
|
+
const users = await client.users.list();
|
|
507
|
+
const target = users.find((u) => String(u.id) === who || u.email === who);
|
|
508
|
+
if (!target) throw new Error(`No user matches "${who}"`);
|
|
509
|
+
return client.users.resetLink(target.id);
|
|
510
|
+
});
|
|
511
|
+
success('One-time reset link (copy it now — shown once):');
|
|
512
|
+
console.log(` ${c.cyan(link.url)}`);
|
|
513
|
+
info(`Expires ${new Date(link.expiresAt).toLocaleString()}`);
|
|
514
|
+
} catch (err) { fail(err); }
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/** `ninedeploy activity list` */
|
|
518
|
+
export async function activityList(client: NineDeployClient): Promise<void> {
|
|
519
|
+
header('Activity');
|
|
520
|
+
await spinner('Fetching', async () => {
|
|
521
|
+
const { entries } = await client.activity.list();
|
|
522
|
+
if (entries.length === 0) { info('No activity yet.'); return; }
|
|
523
|
+
table(entries.slice(0, 30).map((a) => ({ id: a.id, action: a.action, entity: a.entity ?? '—', time: fmtTime(a.ts) })), ['id', 'action', 'entity', 'time']);
|
|
524
|
+
});
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// ── system export / import ─────────────────────────────────────────────────
|
|
528
|
+
|
|
529
|
+
/** `ninedeploy system export [file]` — downloads the full system bundle. */
|
|
530
|
+
export async function systemExport(file?: string): Promise<void> {
|
|
531
|
+
const { writeFileSync } = await import('node:fs');
|
|
532
|
+
const { authedFetch } = await import('../client.js');
|
|
533
|
+
// r192: the export is a gzip'd tar (`application/gzip`). It used to be read
|
|
534
|
+
// with `res.text()` — UTF-8 decoding replaces every invalid byte with U+FFFD —
|
|
535
|
+
// and saved as `.json`, so the file was corrupt and could never be imported.
|
|
536
|
+
const filename = file ?? `ninedeploy-export-${new Date().toISOString().slice(0, 10)}.tar.gz`;
|
|
537
|
+
try {
|
|
538
|
+
const data = await spinner('Exporting system', async () => {
|
|
539
|
+
// r550: refresh-aware and base-path preserving (was a bare fetch).
|
|
540
|
+
const res = await authedFetch('/v1/system/export');
|
|
541
|
+
if (!res.ok) throw new Error(`Export failed (${res.status})`);
|
|
542
|
+
return Buffer.from(await res.arrayBuffer());
|
|
543
|
+
});
|
|
544
|
+
writeFileSync(filename, data);
|
|
545
|
+
success(`Exported to ${filename}`);
|
|
546
|
+
} catch (err) { fail(err); }
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/**
|
|
550
|
+
* F538: a build log is repo-controlled text (Dockerfile RUN output, install
|
|
551
|
+
* scripts), so its frames must not drive the operator's terminal. SGR colour
|
|
552
|
+
* sequences (`ESC [ … m`) are kept; every other control is dropped — OSC
|
|
553
|
+
* strings (OSC 52 clipboard write, window title, hyperlinks) and the other
|
|
554
|
+
* ESC P/X/^/_ strings, non-SGR CSI (cursor moves / line erase that hide
|
|
555
|
+
* output), stray escapes, and C0/DEL/C1 controls other than tab, LF and CR.
|
|
556
|
+
*/
|
|
557
|
+
const ESC = String.fromCharCode(27);
|
|
558
|
+
const TERMINAL_CONTROL = new RegExp(
|
|
559
|
+
`${ESC}[\\]PX^_][\\s\\S]*?(?:${String.fromCharCode(7)}|${ESC}\\\\|$)` +
|
|
560
|
+
`|${ESC}\\[[0-?]*[ -/]*[@-~]` +
|
|
561
|
+
`|${ESC}[\\s\\S]?` +
|
|
562
|
+
'|[\\u0000-\\u0008\\u000b\\u000c\\u000e-\\u001f\\u007f-\\u009f]',
|
|
563
|
+
'g',
|
|
564
|
+
);
|
|
565
|
+
const SGR_ONLY = new RegExp(`^${ESC}\\[[0-9;]*m$`);
|
|
566
|
+
const terminalSafe = (s: string): string => s.replace(TERMINAL_CONTROL, (m) => (SGR_ONLY.test(m) ? m : ''));
|
|
567
|
+
|
|
568
|
+
/** `ninedeploy deploys watch <serviceId> <deployId>` — stream deploy logs over WebSocket. */
|
|
569
|
+
export async function deploysWatch(serviceIdStr: string, deployIdStr: string, timeoutMs = 30 * 60_000): Promise<void> {
|
|
570
|
+
const serviceId = Number(serviceIdStr);
|
|
571
|
+
const deployId = Number(deployIdStr);
|
|
572
|
+
if (!serviceId || !deployId) return error('Usage: ninedeploy deploys watch <serviceId> <deployId>');
|
|
573
|
+
const { loadConfig } = await import('../config.js');
|
|
574
|
+
const { apiUrl, refreshAccessToken, tokenExpiresSoon } = await import('../client.js');
|
|
575
|
+
const { WebSocket } = await import('ws');
|
|
576
|
+
// r550: built by concatenation so a sub-path base URL keeps its prefix
|
|
577
|
+
// (`new URL('/v1/…', base)` dropped it), and the token is refreshed before
|
|
578
|
+
// connecting — a WebSocket handshake never went through the client's 401
|
|
579
|
+
// refresh-and-retry, so `deploys watch` failed once the 15-minute access
|
|
580
|
+
// token expired.
|
|
581
|
+
const url = apiUrl(`/v1/services/${serviceId}/deploys/${deployId}/logs`).replace(/^http/, 'ws');
|
|
582
|
+
if (tokenExpiresSoon(loadConfig().token)) await refreshAccessToken();
|
|
583
|
+
let closed = false;
|
|
584
|
+
let ws: InstanceType<typeof WebSocket> | undefined;
|
|
585
|
+
/** 'failed' / 'cancelled' when this deploy ended badly, else null (also when unreadable). */
|
|
586
|
+
const finalStatus = async (): Promise<string | null> => {
|
|
587
|
+
try {
|
|
588
|
+
const { authedFetch } = await import('../client.js');
|
|
589
|
+
const res = await authedFetch(`/v1/services/${serviceId}/deploys`);
|
|
590
|
+
if (!res.ok) return null;
|
|
591
|
+
const body = (await res.json()) as unknown;
|
|
592
|
+
const all = (Array.isArray(body) ? body : ((body as { deploys?: unknown } | null)?.deploys ?? [])) as Array<{ id?: number; status?: string }>;
|
|
593
|
+
const status = all.find((d) => d.id === deployId)?.status;
|
|
594
|
+
return status === 'failed' || status === 'cancelled' ? status : null;
|
|
595
|
+
} catch {
|
|
596
|
+
return null;
|
|
597
|
+
}
|
|
598
|
+
};
|
|
599
|
+
const connect = (retryOn401: boolean): void => {
|
|
600
|
+
// L-3: the token travels as a WebSocket subprotocol, not `?token=`. A query
|
|
601
|
+
// string lands in Traefik's access log (`accessLog: {}` is on by default)
|
|
602
|
+
// and in any intermediate proxy's log; a subprotocol is a header.
|
|
603
|
+
const socket = new WebSocket(url, [`ninedeploy.bearer.${loadConfig().token ?? ''}`]);
|
|
604
|
+
ws = socket;
|
|
605
|
+
let retrying = false;
|
|
606
|
+
// r550: the server accepts the upgrade and then closes 1008 "unauthorized"
|
|
607
|
+
// for a dead token (a proxy may instead answer the handshake with 401).
|
|
608
|
+
// Refresh once and reconnect; a second rejection is final.
|
|
609
|
+
const retryAfterRefresh = (why: string): boolean => {
|
|
610
|
+
if (!retryOn401) return false;
|
|
611
|
+
retrying = true;
|
|
612
|
+
void refreshAccessToken().then((ok) => {
|
|
613
|
+
if (closed) return; // Ctrl-C while refreshing
|
|
614
|
+
if (ok) return connect(false);
|
|
615
|
+
closed = true;
|
|
616
|
+
error(`Stream rejected (${why}): the session expired. Run \`ninedeploy login\` again.`);
|
|
617
|
+
});
|
|
618
|
+
return true;
|
|
619
|
+
};
|
|
620
|
+
socket.on('message', (data) => process.stdout.write(terminalSafe(String(data))));
|
|
621
|
+
socket.on('close', (code?: number, reason?: Buffer) => {
|
|
622
|
+
if (retrying) return;
|
|
623
|
+
if (code === 1008 && String(reason ?? '') === 'unauthorized') {
|
|
624
|
+
if (retryAfterRefresh('unauthorized')) return;
|
|
625
|
+
closed = true;
|
|
626
|
+
error('Stream rejected (unauthorized). Run `ninedeploy login` again.');
|
|
627
|
+
return;
|
|
628
|
+
}
|
|
629
|
+
// F956/F980: only a normal close (1000, the server's 'deploy finished')
|
|
630
|
+
// is a clean end. A policy close (1008 'not found' for a wrong or foreign
|
|
631
|
+
// deploy id, 'forbidden' for a scoped token, 'session revoked' / 'access
|
|
632
|
+
// revoked' mid-stream) or an abnormal one (1006: server crash or network
|
|
633
|
+
// drop, which ws reports without an 'error' event) must fail, or
|
|
634
|
+
// `deploys watch && next-step` carries on as if the deploy had finished.
|
|
635
|
+
// A close we started (Ctrl-C, the hard cap) or one following an already
|
|
636
|
+
// reported error is not re-reported.
|
|
637
|
+
if (code !== 1000 && !closed) {
|
|
638
|
+
closed = true;
|
|
639
|
+
const why = String(reason ?? '');
|
|
640
|
+
error(code === 1008 ? `Stream rejected (${why || 'policy violation'}).` : `Log stream ended abnormally (${code ?? 'no close code'}${why ? `: ${why}` : ''}).`);
|
|
641
|
+
return;
|
|
642
|
+
}
|
|
643
|
+
// A normal close only says the stream ended, not how the deploy did.
|
|
644
|
+
// Read its final status so `deploys watch && next-step` stops after a
|
|
645
|
+
// failed or cancelled deploy. If the status cannot be read the old
|
|
646
|
+
// behaviour stands: the stream ended cleanly, exit 0.
|
|
647
|
+
void finalStatus().then((bad) => {
|
|
648
|
+
closed = true;
|
|
649
|
+
if (bad) error(`Deploy #${deployId} ${bad}.`);
|
|
650
|
+
else info('Log stream closed.');
|
|
651
|
+
});
|
|
652
|
+
});
|
|
653
|
+
// `close` normally follows `error`, but `unexpected-response` (handshake
|
|
654
|
+
// rejection) never reaches the close event on some ws versions — flag the
|
|
655
|
+
// loop here too or it spins to the hard cap printing nothing.
|
|
656
|
+
socket.on('error', (err: Error) => {
|
|
657
|
+
if (retrying) return;
|
|
658
|
+
closed = true;
|
|
659
|
+
error(`Stream error: ${err.message}`);
|
|
660
|
+
});
|
|
661
|
+
socket.on('unexpected-response', (_req, res) => {
|
|
662
|
+
if (res.statusCode === 401 && retryAfterRefresh('401')) return;
|
|
663
|
+
closed = true;
|
|
664
|
+
error(`Stream rejected (${res.statusCode}).`);
|
|
665
|
+
});
|
|
666
|
+
};
|
|
667
|
+
connect(true);
|
|
668
|
+
// Exit when the server closes the stream (deploy finished) or on Ctrl-C.
|
|
669
|
+
process.on('SIGINT', () => {
|
|
670
|
+
closed = true;
|
|
671
|
+
ws?.close();
|
|
672
|
+
process.exitCode = 0;
|
|
673
|
+
});
|
|
674
|
+
// Hard cap so a stuck stream can never hang the CLI forever.
|
|
675
|
+
const deadline = Date.now() + timeoutMs;
|
|
676
|
+
while (!closed && Date.now() < deadline) {
|
|
677
|
+
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
678
|
+
}
|
|
679
|
+
if (!closed) {
|
|
680
|
+
closed = true; // F956/F980: our own close is not a server rejection
|
|
681
|
+
ws?.close();
|
|
682
|
+
// F997 (D8): the cap is not a finished deploy — exit 1 like every other
|
|
683
|
+
// unfinished end (F956/F980), or `deploys watch && next-step` carries on.
|
|
684
|
+
const waited = timeoutMs % 60_000 === 0 ? `${timeoutMs / 60_000} min` : `${Math.ceil(timeoutMs / 1000)} s`;
|
|
685
|
+
error(
|
|
686
|
+
`Stopped waiting after ${waited} — the deploy may still be running. ` +
|
|
687
|
+
`Check \`ninedeploy deploys list ${serviceId}\` or re-attach with \`ninedeploy deploys watch ${serviceId} ${deployId}\`.`,
|
|
688
|
+
);
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
/** `ninedeploy system import <file>` — restores a system bundle (destructive). */
|
|
693
|
+
export async function systemImport(file: string): Promise<void> {
|
|
694
|
+
if (!file) return error('Usage: ninedeploy system import <file>');
|
|
695
|
+
const { readFileSync } = await import('node:fs');
|
|
696
|
+
const { authedFetch } = await import('../client.js');
|
|
697
|
+
const confirm = await prompt('Import OVERWRITES the current system state. Type "yes" to continue');
|
|
698
|
+
if (confirm.toLowerCase() !== 'yes') return error('Cancelled.');
|
|
699
|
+
try {
|
|
700
|
+
// r192: the server reads the raw archive (`application/octet-stream`),
|
|
701
|
+
// as the web panel sends it. A multipart form was refused (415) or, at
|
|
702
|
+
// best, restored from the multipart envelope instead of the archive.
|
|
703
|
+
// r550: refresh-aware and base-path preserving (was a bare fetch).
|
|
704
|
+
const res = await spinner('Importing system', () =>
|
|
705
|
+
authedFetch('/v1/system/import', {
|
|
706
|
+
method: 'POST',
|
|
707
|
+
headers: { 'Content-Type': 'application/octet-stream' },
|
|
708
|
+
body: readFileSync(file),
|
|
709
|
+
}),
|
|
710
|
+
);
|
|
711
|
+
if (!res.ok) throw new Error(`Import failed (${res.status})`);
|
|
712
|
+
success('System imported.');
|
|
713
|
+
} catch (err) { fail(err); }
|
|
714
|
+
}
|