nexusapp-cli 2.2.2 → 3.0.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/dist/index.js +4 -0
- package/package.json +1 -1
- package/src/commands/deploy.ts +126 -4
- package/src/commands/exec.ts +154 -0
- package/src/commands/managedDb.ts +170 -0
- package/src/index.ts +4 -0
- package/nexusapp-cli-2.0.0.tgz +0 -0
package/dist/index.js
CHANGED
|
@@ -12,6 +12,8 @@ const member_js_1 = require("./commands/member.js");
|
|
|
12
12
|
const database_js_1 = require("./commands/database.js");
|
|
13
13
|
const volume_js_1 = require("./commands/volume.js");
|
|
14
14
|
const bucket_js_1 = require("./commands/bucket.js");
|
|
15
|
+
const managedDb_js_1 = require("./commands/managedDb.js");
|
|
16
|
+
const exec_js_1 = require("./commands/exec.js");
|
|
15
17
|
const program = new commander_1.Command();
|
|
16
18
|
program
|
|
17
19
|
.name('nexus')
|
|
@@ -27,6 +29,8 @@ program
|
|
|
27
29
|
(0, database_js_1.registerDatabase)(program);
|
|
28
30
|
(0, volume_js_1.registerVolume)(program);
|
|
29
31
|
(0, bucket_js_1.registerBucket)(program);
|
|
32
|
+
(0, managedDb_js_1.registerManagedDb)(program);
|
|
33
|
+
(0, exec_js_1.registerExec)(program);
|
|
30
34
|
program.parseAsync(process.argv).catch((err) => {
|
|
31
35
|
console.error(err.message || String(err));
|
|
32
36
|
process.exit(1);
|
package/package.json
CHANGED
package/src/commands/deploy.ts
CHANGED
|
@@ -30,6 +30,24 @@ function isInternalDeploymentImage(image?: string): boolean {
|
|
|
30
30
|
return /^deploy-[0-9a-f-]+(?::|$)/i.test(image.trim());
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
function parseDurationToDate(value: string): Date {
|
|
34
|
+
const match = value.match(/^(\d+(?:\.\d+)?)(m|h|d)$/);
|
|
35
|
+
if (!match) {
|
|
36
|
+
throw new Error(`Invalid duration "${value}". Use format like: 30m, 4h, 2d`);
|
|
37
|
+
}
|
|
38
|
+
const amount = Number(match[1]);
|
|
39
|
+
const unit = match[2];
|
|
40
|
+
const msMap: Record<string, number> = { m: 60_000, h: 3_600_000, d: 86_400_000 };
|
|
41
|
+
return new Date(Date.now() + amount * msMap[unit]);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function formatAutoDestroy(value?: string | null): string {
|
|
45
|
+
if (!value) return 'disabled';
|
|
46
|
+
const date = new Date(value);
|
|
47
|
+
if (Number.isNaN(date.getTime())) return String(value);
|
|
48
|
+
return `${date.toISOString()} (${timeAgo(value)})`;
|
|
49
|
+
}
|
|
50
|
+
|
|
33
51
|
async function pollUntilDone(deploymentId: string, spin: ReturnType<typeof spinner>): Promise<void> {
|
|
34
52
|
const terminal = new Set(['RUNNING', 'FAILED', 'TERMINATED', 'STOPPED']);
|
|
35
53
|
while (true) {
|
|
@@ -146,6 +164,7 @@ export function registerDeploy(program: Command): void {
|
|
|
146
164
|
['Port', String(d.port || '—')],
|
|
147
165
|
['URL', d.url || d.serviceUrl || '—'],
|
|
148
166
|
['Replicas', String(d.replicas ?? '—')],
|
|
167
|
+
['Auto Destroy', formatAutoDestroy(d.autoDestroyAt)],
|
|
149
168
|
['Project', d.projectId || '—'],
|
|
150
169
|
['Created', d.createdAt ? timeAgo(d.createdAt) : '—'],
|
|
151
170
|
['Updated', d.updatedAt ? timeAgo(d.updatedAt) : '—'],
|
|
@@ -211,6 +230,7 @@ export function registerDeploy(program: Command): void {
|
|
|
211
230
|
.option('--name <name>', 'Deployment name')
|
|
212
231
|
.option('--branch <branch>', 'Git branch')
|
|
213
232
|
.option('--provider <provider>', 'Provider (docker|gcp_cloud_run|aws_ecs_fargate|azure_container_apps)')
|
|
233
|
+
.option('--region <region>', 'Cloud region to deploy into (e.g. us-central1, canadacentral)')
|
|
214
234
|
.option('--env <pairs...>', 'Environment variables as KEY=VALUE')
|
|
215
235
|
.option('--env-file <file>', 'Load environment variables from a .env file')
|
|
216
236
|
.option('--framework <framework>', 'Framework hint (e.g. node, python, go)')
|
|
@@ -222,7 +242,11 @@ export function registerDeploy(program: Command): void {
|
|
|
222
242
|
.option('--repo-secret <name>', 'Secret name containing private repo token')
|
|
223
243
|
.option('--environment <env>', 'Deployment environment (DEVELOPMENT|STAGING|PRODUCTION)', 'DEVELOPMENT')
|
|
224
244
|
.option('--auto-destroy <hours>', 'Auto-destroy after N hours', parseInt)
|
|
225
|
-
.option('--services <types>', 'Comma-separated
|
|
245
|
+
.option('--services <types>', 'Comma-separated services (e.g. postgres,redis). On cloud providers a database (postgres|mysql) is provisioned as a managed DB; on docker it runs as a sidecar container')
|
|
246
|
+
.option('--create-db <engine>', 'Alias for --services with a single database (postgres|mysql); provisions a managed cloud DB and injects its connection env')
|
|
247
|
+
.option('--managed-db <id>', 'Attach an existing managed database by ID and inject its connection env')
|
|
248
|
+
.option('--db-version <version>', 'Engine version for --create-db (default: postgres 17 / mysql 8.0)')
|
|
249
|
+
.option('--db-region <region>', 'Cloud region for --create-db (defaults to the deploy region)')
|
|
226
250
|
.option('--worker-command <cmd>', 'Run a background worker sidecar with this command')
|
|
227
251
|
.option('--worker-name <name>', 'Worker service name', 'worker')
|
|
228
252
|
.option('--no-health-check', 'Disable health checks for this deployment')
|
|
@@ -241,6 +265,7 @@ export function registerDeploy(program: Command): void {
|
|
|
241
265
|
if (opts.name) payload.name = opts.name;
|
|
242
266
|
if (opts.branch) payload.repoBranch = opts.branch;
|
|
243
267
|
if (opts.provider) payload.provider = opts.provider;
|
|
268
|
+
if (opts.region) payload.region = opts.region;
|
|
244
269
|
if (opts.environment) payload.environment = opts.environment;
|
|
245
270
|
if (opts.framework) payload.framework = opts.framework;
|
|
246
271
|
if (opts.buildCommand) payload.buildCommand = opts.buildCommand;
|
|
@@ -251,6 +276,10 @@ export function registerDeploy(program: Command): void {
|
|
|
251
276
|
if (opts.repoSecret) payload.repoSecretName = opts.repoSecret;
|
|
252
277
|
if (opts.autoDestroy) payload.autoDestroyHours = opts.autoDestroy;
|
|
253
278
|
if (opts.healthCheck === false) payload.healthCheckEnabled = false;
|
|
279
|
+
if (opts.createDb) payload.createDb = opts.createDb;
|
|
280
|
+
if (opts.managedDb) payload.managedDbId = opts.managedDb;
|
|
281
|
+
if (opts.dbVersion) payload.dbEngineVersion = opts.dbVersion;
|
|
282
|
+
if (opts.dbRegion) payload.dbRegion = opts.dbRegion;
|
|
254
283
|
if (opts.services) {
|
|
255
284
|
payload.services = opts.services.split(',').map((s: string) => ({ type: s.trim() }));
|
|
256
285
|
}
|
|
@@ -311,17 +340,60 @@ export function registerDeploy(program: Command): void {
|
|
|
311
340
|
if (!confirm) { console.log('Cancelled.'); return; }
|
|
312
341
|
}
|
|
313
342
|
|
|
314
|
-
|
|
315
|
-
|
|
343
|
+
// Env the USER explicitly provides on this redeploy (--env-file / --env).
|
|
344
|
+
// Kept separate from the deployment's existing env: the API masks secret
|
|
345
|
+
// values in responses, so re-sending deployment.envVars would overwrite real
|
|
346
|
+
// values with "***". The in-place redeploy preserves existing env
|
|
347
|
+
// server-side, so we only send explicit overrides.
|
|
348
|
+
const explicitOverrides: Record<string, string> = {};
|
|
349
|
+
if (opts.envFile) Object.assign(explicitOverrides, parseEnvFile(opts.envFile));
|
|
316
350
|
if (opts.env) {
|
|
317
351
|
for (const pair of (opts.env as string[])) {
|
|
318
352
|
const idx = pair.indexOf('=');
|
|
319
|
-
if (idx > 0)
|
|
353
|
+
if (idx > 0) explicitOverrides[pair.slice(0, idx)] = pair.slice(idx + 1);
|
|
320
354
|
}
|
|
321
355
|
}
|
|
356
|
+
// For the legacy "reconstruct as a new deployment" fallback only, start from
|
|
357
|
+
// the deployment's env. The API masks secret values ("***" or user:***@ in
|
|
358
|
+
// connection strings); re-sending those would bake literal "***" into the
|
|
359
|
+
// clone, so drop masked entries (the server regenerates service credentials
|
|
360
|
+
// and DATABASE_URL for attached services anyway).
|
|
361
|
+
const baseEnvVars: Record<string, string> = {};
|
|
362
|
+
for (const [k, v] of Object.entries((deployment.envVars || {}) as Record<string, string>)) {
|
|
363
|
+
if (v === '***' || /:\/\/[^:/@\s]+:\*\*\*@/.test(String(v))) continue;
|
|
364
|
+
baseEnvVars[k] = v;
|
|
365
|
+
}
|
|
366
|
+
Object.assign(baseEnvVars, explicitOverrides);
|
|
322
367
|
const provider = opts.provider || undefined;
|
|
323
368
|
|
|
324
369
|
try {
|
|
370
|
+
// Default: rebuild the SAME deployment in place. This preserves
|
|
371
|
+
// everything keyed by deployment id — attached managed databases,
|
|
372
|
+
// volumes, secrets — so "deploy -> attach db -> redeploy" injects the DB
|
|
373
|
+
// on the rebuild. Renaming or switching provider can't be done in place,
|
|
374
|
+
// so only the no-override path uses it; anything the server rejects
|
|
375
|
+
// (zip/folder source, no prior job) falls through to the legacy flow.
|
|
376
|
+
if (!opts.name && !provider) {
|
|
377
|
+
try {
|
|
378
|
+
const body: Record<string, any> = {};
|
|
379
|
+
// Only send explicit overrides — the server preserves existing env.
|
|
380
|
+
if (Object.keys(explicitOverrides).length) body.envVars = explicitOverrides;
|
|
381
|
+
const res = await client.post(`/api/deployments/${deployment.id}/redeploy`, body);
|
|
382
|
+
const d = unwrap(res.data);
|
|
383
|
+
if (opts.json) { printJson(d); return; }
|
|
384
|
+
if (opts.wait) {
|
|
385
|
+
await pollUntilDone(d.id, spinner(`Rebuilding ${d.name || nameOrId}...`));
|
|
386
|
+
} else {
|
|
387
|
+
success(`Redeploy queued (in place): ${d.name || d.id}`);
|
|
388
|
+
console.log(` Run 'nexus deploy status ${d.id} --watch' to track progress`);
|
|
389
|
+
}
|
|
390
|
+
return;
|
|
391
|
+
} catch (err: any) {
|
|
392
|
+
const status = err?.response?.status;
|
|
393
|
+
if (status !== 400 && status !== 404) throw err;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
325
397
|
let project: any = null;
|
|
326
398
|
if (deployment.projectId) {
|
|
327
399
|
const projectRes = await client.get(`/api/projects/${deployment.projectId}`);
|
|
@@ -763,6 +835,7 @@ export function registerDeploy(program: Command): void {
|
|
|
763
835
|
['Provider', d.provider || '—'],
|
|
764
836
|
['URL', d.url || d.serviceUrl || '—'],
|
|
765
837
|
['Replicas', String(d.replicas ?? '—')],
|
|
838
|
+
['Auto Destroy', formatAutoDestroy(d.autoDestroyAt)],
|
|
766
839
|
['Updated', d.updatedAt ? timeAgo(d.updatedAt) : '—'],
|
|
767
840
|
]);
|
|
768
841
|
};
|
|
@@ -779,4 +852,53 @@ export function registerDeploy(program: Command): void {
|
|
|
779
852
|
process.exit(1);
|
|
780
853
|
}
|
|
781
854
|
});
|
|
855
|
+
|
|
856
|
+
deploy
|
|
857
|
+
.command('auto-destroy <name-or-id>')
|
|
858
|
+
.description('Set, extend, reduce, or disable deployment auto-destroy without restarting the deployment')
|
|
859
|
+
.option('--in <duration>', 'Destroy after a relative duration, e.g. 30m, 4h, 2d')
|
|
860
|
+
.option('--at <iso-date>', 'Destroy at an absolute ISO timestamp')
|
|
861
|
+
.option('--off', 'Disable auto-destroy')
|
|
862
|
+
.option('--json', 'Output raw JSON')
|
|
863
|
+
.action(async (nameOrId, opts) => {
|
|
864
|
+
const selected = [opts.in, opts.at, opts.off].filter(Boolean);
|
|
865
|
+
if (selected.length !== 1) {
|
|
866
|
+
errorMsg('Choose exactly one of --in, --at, or --off');
|
|
867
|
+
process.exit(1);
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
try {
|
|
871
|
+
const deployment = await resolveDeployment(nameOrId);
|
|
872
|
+
const payload: Record<string, any> = {};
|
|
873
|
+
|
|
874
|
+
if (opts.off) {
|
|
875
|
+
payload.autoDestroyAt = null;
|
|
876
|
+
} else if (opts.in) {
|
|
877
|
+
payload.autoDestroyAt = parseDurationToDate(opts.in).toISOString();
|
|
878
|
+
} else if (opts.at) {
|
|
879
|
+
const date = new Date(opts.at);
|
|
880
|
+
if (Number.isNaN(date.getTime())) {
|
|
881
|
+
throw new Error(`Invalid ISO date: ${opts.at}`);
|
|
882
|
+
}
|
|
883
|
+
payload.autoDestroyAt = date.toISOString();
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
const res = await client.patch(`/api/deployments/${deployment.id}/auto-destroy`, payload);
|
|
887
|
+
const updated = unwrap(res.data);
|
|
888
|
+
|
|
889
|
+
if (opts.json) {
|
|
890
|
+
printJson(updated);
|
|
891
|
+
return;
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
success(
|
|
895
|
+
updated.autoDestroyAt
|
|
896
|
+
? `Auto-destroy set for ${updated.displayName || updated.name}: ${formatAutoDestroy(updated.autoDestroyAt)}`
|
|
897
|
+
: `Auto-destroy disabled for ${updated.displayName || updated.name}`
|
|
898
|
+
);
|
|
899
|
+
} catch (err) {
|
|
900
|
+
errorMsg(apiError(err));
|
|
901
|
+
process.exit(1);
|
|
902
|
+
}
|
|
903
|
+
});
|
|
782
904
|
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import * as fs from 'fs';
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
import { client, apiError, unwrap } from '../client.js';
|
|
5
|
+
import { success, errorMsg, printJson } from '../output.js';
|
|
6
|
+
|
|
7
|
+
function formatBytes(n: number): string {
|
|
8
|
+
if (n < 1024) return `${n} B`;
|
|
9
|
+
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
|
|
10
|
+
if (n < 1024 * 1024 * 1024) return `${(n / (1024 * 1024)).toFixed(1)} MB`;
|
|
11
|
+
return `${(n / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Parse a "<deploymentId>:<path>" argument. Returns null if the arg has no
|
|
16
|
+
* colon (i.e. it's a local path). UUIDs contain hyphens but no colons, so the
|
|
17
|
+
* first ":" cleanly separates the two halves.
|
|
18
|
+
*/
|
|
19
|
+
function parseRemoteRef(arg: string): { deploymentId: string; remotePath: string } | null {
|
|
20
|
+
const idx = arg.indexOf(':');
|
|
21
|
+
if (idx < 0) return null;
|
|
22
|
+
const deploymentId = arg.slice(0, idx);
|
|
23
|
+
const remotePath = arg.slice(idx + 1);
|
|
24
|
+
if (!deploymentId || !remotePath) return null;
|
|
25
|
+
return { deploymentId, remotePath };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function registerExec(program: Command): void {
|
|
29
|
+
program
|
|
30
|
+
.command('exec <deploymentId> [command...]')
|
|
31
|
+
.description('Run a command inside the running deployment container (LOCAL_DOCKER only)')
|
|
32
|
+
.option('--timeout <seconds>', 'Max seconds before the command is killed (default 60, max 1800)', (v) => parseInt(v, 10))
|
|
33
|
+
.option('--workdir <path>', 'Working directory inside the container')
|
|
34
|
+
.option('--json', 'Output raw JSON result')
|
|
35
|
+
.allowUnknownOption(false)
|
|
36
|
+
.action(async (deploymentId, commandParts, opts) => {
|
|
37
|
+
if (!commandParts || commandParts.length === 0) {
|
|
38
|
+
errorMsg('Provide a command to run, e.g. `nexus exec <id> ls -la /app`');
|
|
39
|
+
process.exit(2);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
const res = await client.post(
|
|
44
|
+
`/api/deployments/${deploymentId}/exec`,
|
|
45
|
+
{
|
|
46
|
+
command: commandParts,
|
|
47
|
+
timeoutSeconds: opts.timeout,
|
|
48
|
+
workingDir: opts.workdir,
|
|
49
|
+
},
|
|
50
|
+
{ timeout: 0 }
|
|
51
|
+
);
|
|
52
|
+
const result = unwrap(res.data);
|
|
53
|
+
|
|
54
|
+
if (opts.json) {
|
|
55
|
+
printJson(result);
|
|
56
|
+
process.exit(result.exitCode === 0 ? 0 : 1);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (result.stdout) process.stdout.write(result.stdout);
|
|
60
|
+
if (result.stderr) process.stderr.write(result.stderr);
|
|
61
|
+
if (result.truncated) {
|
|
62
|
+
process.stderr.write('\n[output truncated at 2MB per stream]\n');
|
|
63
|
+
}
|
|
64
|
+
process.exit(result.exitCode === 0 ? 0 : result.exitCode || 1);
|
|
65
|
+
} catch (err) {
|
|
66
|
+
errorMsg(apiError(err));
|
|
67
|
+
process.exit(1);
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
program
|
|
72
|
+
.command('cp <source> <destination>')
|
|
73
|
+
.description(
|
|
74
|
+
'Copy a single file between a local path and a running deployment container.\n' +
|
|
75
|
+
' Upload: nexus cp ./index.html <deploymentId>:/app/public/index.html\n' +
|
|
76
|
+
' Download: nexus cp <deploymentId>:/app/config.json ./config.json'
|
|
77
|
+
)
|
|
78
|
+
.action(async (source, destination) => {
|
|
79
|
+
const srcRemote = parseRemoteRef(source);
|
|
80
|
+
const dstRemote = parseRemoteRef(destination);
|
|
81
|
+
|
|
82
|
+
if (srcRemote && dstRemote) {
|
|
83
|
+
errorMsg('Container-to-container copy is not supported. Download first, then upload.');
|
|
84
|
+
process.exit(2);
|
|
85
|
+
}
|
|
86
|
+
if (!srcRemote && !dstRemote) {
|
|
87
|
+
errorMsg('At least one side must be a container reference (e.g. `<deploymentId>:/app/file.txt`).');
|
|
88
|
+
process.exit(2);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
try {
|
|
92
|
+
if (!srcRemote && dstRemote) {
|
|
93
|
+
// Upload: local -> container
|
|
94
|
+
const localPath = path.resolve(source);
|
|
95
|
+
if (!fs.existsSync(localPath)) {
|
|
96
|
+
errorMsg(`Local file not found: ${localPath}`);
|
|
97
|
+
process.exit(1);
|
|
98
|
+
}
|
|
99
|
+
const stat = fs.statSync(localPath);
|
|
100
|
+
if (!stat.isFile()) {
|
|
101
|
+
errorMsg(`Not a regular file: ${localPath} (directory uploads not supported — tar + exec instead)`);
|
|
102
|
+
process.exit(1);
|
|
103
|
+
}
|
|
104
|
+
const stream = fs.createReadStream(localPath);
|
|
105
|
+
const res = await client.post(
|
|
106
|
+
`/api/deployments/${dstRemote.deploymentId}/files`,
|
|
107
|
+
stream,
|
|
108
|
+
{
|
|
109
|
+
params: { path: dstRemote.remotePath },
|
|
110
|
+
headers: {
|
|
111
|
+
'Content-Type': 'application/octet-stream',
|
|
112
|
+
'Content-Length': String(stat.size),
|
|
113
|
+
},
|
|
114
|
+
timeout: 0,
|
|
115
|
+
maxBodyLength: Infinity,
|
|
116
|
+
maxContentLength: Infinity,
|
|
117
|
+
}
|
|
118
|
+
);
|
|
119
|
+
const data = unwrap(res.data);
|
|
120
|
+
success(`Uploaded ${formatBytes(data.bytesWritten || stat.size)} → ${dstRemote.deploymentId}:${data.targetPath || dstRemote.remotePath}`);
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Download: container -> local
|
|
125
|
+
const remote = srcRemote!;
|
|
126
|
+
const localPath = path.resolve(destination);
|
|
127
|
+
const dir = path.dirname(localPath);
|
|
128
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
129
|
+
|
|
130
|
+
const res = await client.get(`/api/deployments/${remote.deploymentId}/files`, {
|
|
131
|
+
params: { path: remote.remotePath },
|
|
132
|
+
responseType: 'stream',
|
|
133
|
+
timeout: 0,
|
|
134
|
+
maxContentLength: Infinity,
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
let bytes = 0;
|
|
138
|
+
res.data.on('data', (chunk: Buffer) => { bytes += chunk.length; });
|
|
139
|
+
|
|
140
|
+
await new Promise<void>((resolve, reject) => {
|
|
141
|
+
const writer = fs.createWriteStream(localPath);
|
|
142
|
+
res.data.pipe(writer);
|
|
143
|
+
writer.on('finish', () => resolve());
|
|
144
|
+
writer.on('error', reject);
|
|
145
|
+
res.data.on('error', reject);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
success(`Downloaded ${formatBytes(bytes)} → ${localPath}`);
|
|
149
|
+
} catch (err) {
|
|
150
|
+
errorMsg(apiError(err));
|
|
151
|
+
process.exit(1);
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import { client, apiError } from '../client.js';
|
|
3
|
+
import { printTable, printJson, success, errorMsg, timeAgo } from '../output.js';
|
|
4
|
+
|
|
5
|
+
export function registerManagedDb(program: Command): void {
|
|
6
|
+
const md = program
|
|
7
|
+
.command('managed-db')
|
|
8
|
+
.description('Managed cloud databases (AWS RDS instances)');
|
|
9
|
+
|
|
10
|
+
md
|
|
11
|
+
.command('list')
|
|
12
|
+
.description('List managed databases')
|
|
13
|
+
.option('--json', 'Output raw JSON')
|
|
14
|
+
.action(async (opts) => {
|
|
15
|
+
try {
|
|
16
|
+
const res = await client.get('/api/managed-databases');
|
|
17
|
+
const dbs: any[] = res.data?.managedDatabases || [];
|
|
18
|
+
if (opts.json) { printJson(dbs); return; }
|
|
19
|
+
if (!dbs.length) { console.log('No managed databases found.'); return; }
|
|
20
|
+
printTable(
|
|
21
|
+
['ID', 'NAME', 'ENGINE', 'STATUS', 'ENDPOINT', 'CREATED'],
|
|
22
|
+
dbs.map((d: any) => [
|
|
23
|
+
d.id,
|
|
24
|
+
d.displayName ? `${d.name} (${d.displayName})` : d.name,
|
|
25
|
+
`${d.engine} ${d.engineVersion}`,
|
|
26
|
+
d.status,
|
|
27
|
+
d.endpointHost ? `${d.endpointHost}:${d.endpointPort}` : '—',
|
|
28
|
+
d.createdAt ? timeAgo(d.createdAt) : '—',
|
|
29
|
+
])
|
|
30
|
+
);
|
|
31
|
+
} catch (err) { errorMsg(apiError(err)); process.exit(1); }
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
md
|
|
35
|
+
.command('create <name>')
|
|
36
|
+
.description('Provision a new managed database')
|
|
37
|
+
.requiredOption('--engine <engine>', 'POSTGRES or MYSQL')
|
|
38
|
+
.requiredOption('--engine-version <version>', 'e.g. 17.10 (postgres), 8.0.46 (mysql)')
|
|
39
|
+
.option('--provider <provider>', 'AWS_RDS, GCP_CLOUD_SQL, or AZURE_DATABASE', 'AWS_RDS')
|
|
40
|
+
.option('--network-mode <mode>', 'public or vpc (AWS only; vpc = private, App Runner-only)')
|
|
41
|
+
.option('--display-name <name>', 'Friendly display name')
|
|
42
|
+
.option('--instance-class <class>', 'e.g. db.t3.micro (AWS) / db-custom-1-3840 (GCP)')
|
|
43
|
+
.option('--allocated-gb <gb>', 'Storage in GB (20-1000)')
|
|
44
|
+
.option('--region <region>', 'Cloud region')
|
|
45
|
+
.option('--json', 'Output raw JSON')
|
|
46
|
+
.action(async (name, opts) => {
|
|
47
|
+
try {
|
|
48
|
+
const res = await client.post('/api/managed-databases', {
|
|
49
|
+
name,
|
|
50
|
+
provider: opts.provider,
|
|
51
|
+
networkMode: opts.networkMode,
|
|
52
|
+
engine: opts.engine,
|
|
53
|
+
engineVersion: opts.engineVersion,
|
|
54
|
+
displayName: opts.displayName,
|
|
55
|
+
instanceClass: opts.instanceClass,
|
|
56
|
+
allocatedGb: opts.allocatedGb ? Number(opts.allocatedGb) : undefined,
|
|
57
|
+
region: opts.region,
|
|
58
|
+
});
|
|
59
|
+
const db = res.data?.managedDatabase;
|
|
60
|
+
if (opts.json) { printJson(db); return; }
|
|
61
|
+
success(`Managed database created: ${db?.id} (${db?.status})`);
|
|
62
|
+
console.log('Provisioning is async — run `nexus managed-db list` until status is AVAILABLE.');
|
|
63
|
+
} catch (err) { errorMsg(apiError(err)); process.exit(1); }
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
md
|
|
67
|
+
.command('delete <id>')
|
|
68
|
+
.description('Delete a managed database (destroys data)')
|
|
69
|
+
.option('--json', 'Output raw JSON')
|
|
70
|
+
.action(async (id, opts) => {
|
|
71
|
+
try {
|
|
72
|
+
await client.delete(`/api/managed-databases/${id}`);
|
|
73
|
+
if (opts.json) { printJson({ success: true }); return; }
|
|
74
|
+
success(`Managed database ${id} deleted.`);
|
|
75
|
+
} catch (err) { errorMsg(apiError(err)); process.exit(1); }
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
md
|
|
79
|
+
.command('attach <id>')
|
|
80
|
+
.description('Attach a managed database to a deployment')
|
|
81
|
+
.requiredOption('--deployment <deploymentId>', 'Deployment ID')
|
|
82
|
+
.option('--env-prefix <prefix>', 'Env var namespace (default DATABASE)')
|
|
83
|
+
.option('--json', 'Output raw JSON')
|
|
84
|
+
.action(async (id, opts) => {
|
|
85
|
+
try {
|
|
86
|
+
const res = await client.post(`/api/managed-databases/${id}/attach`, {
|
|
87
|
+
deploymentId: opts.deployment,
|
|
88
|
+
envPrefix: opts.envPrefix,
|
|
89
|
+
});
|
|
90
|
+
if (opts.json) { printJson(res.data?.attachment); return; }
|
|
91
|
+
success(`Attached to ${opts.deployment}. Redeploy for DATABASE_* env vars to take effect.`);
|
|
92
|
+
} catch (err) { errorMsg(apiError(err)); process.exit(1); }
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
md
|
|
96
|
+
.command('detach <id>')
|
|
97
|
+
.description('Detach a managed database from a deployment')
|
|
98
|
+
.requiredOption('--deployment <deploymentId>', 'Deployment ID')
|
|
99
|
+
.option('--json', 'Output raw JSON')
|
|
100
|
+
.action(async (id, opts) => {
|
|
101
|
+
try {
|
|
102
|
+
await client.post(`/api/managed-databases/${id}/detach`, { deploymentId: opts.deployment });
|
|
103
|
+
if (opts.json) { printJson({ success: true }); return; }
|
|
104
|
+
success(`Detached from ${opts.deployment}.`);
|
|
105
|
+
} catch (err) { errorMsg(apiError(err)); process.exit(1); }
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
md
|
|
109
|
+
.command('snapshot <id>')
|
|
110
|
+
.description('Create a snapshot backup of a managed database')
|
|
111
|
+
.option('--notes <notes>', 'Optional notes')
|
|
112
|
+
.option('--json', 'Output raw JSON')
|
|
113
|
+
.action(async (id, opts) => {
|
|
114
|
+
try {
|
|
115
|
+
const res = await client.post(`/api/managed-databases/${id}/snapshots`, { notes: opts.notes });
|
|
116
|
+
const snap = res.data?.snapshot;
|
|
117
|
+
if (opts.json) { printJson(snap); return; }
|
|
118
|
+
success(`Snapshot ${snap?.id} started (${snap?.status}).`);
|
|
119
|
+
} catch (err) { errorMsg(apiError(err)); process.exit(1); }
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
md
|
|
123
|
+
.command('snapshots <id>')
|
|
124
|
+
.description('List snapshot backups for a managed database')
|
|
125
|
+
.option('--json', 'Output raw JSON')
|
|
126
|
+
.action(async (id, opts) => {
|
|
127
|
+
try {
|
|
128
|
+
const res = await client.get(`/api/managed-databases/${id}/snapshots`);
|
|
129
|
+
const snaps: any[] = res.data?.snapshots || [];
|
|
130
|
+
if (opts.json) { printJson(snaps); return; }
|
|
131
|
+
if (!snaps.length) { console.log('No snapshots found.'); return; }
|
|
132
|
+
printTable(
|
|
133
|
+
['ID', 'STATUS', 'SIZE (B)', 'NOTES', 'CREATED'],
|
|
134
|
+
snaps.map((s: any) => [s.id, s.status, String(s.sizeBytes), s.notes || '—', s.createdAt ? timeAgo(s.createdAt) : '—'])
|
|
135
|
+
);
|
|
136
|
+
} catch (err) { errorMsg(apiError(err)); process.exit(1); }
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
md
|
|
140
|
+
.command('query <id> <sql>')
|
|
141
|
+
.description('Run a SQL statement against a managed database')
|
|
142
|
+
.option('--json', 'Output raw JSON')
|
|
143
|
+
.action(async (id, sql, opts) => {
|
|
144
|
+
try {
|
|
145
|
+
const res = await client.post(`/api/managed-databases/${id}/query`, { sql });
|
|
146
|
+
if (opts.json) { printJson(res.data); return; }
|
|
147
|
+
const rows = res.data?.rows || [];
|
|
148
|
+
console.log(`${res.data?.classification}: ${res.data?.rowCount} row(s)`);
|
|
149
|
+
if (rows.length) printJson(rows);
|
|
150
|
+
} catch (err) { errorMsg(apiError(err)); process.exit(1); }
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
md
|
|
154
|
+
.command('restore <id>')
|
|
155
|
+
.description('Restore a snapshot into a NEW managed database (non-destructive)')
|
|
156
|
+
.requiredOption('--snapshot <snapshotId>', 'Snapshot ID to restore from')
|
|
157
|
+
.requiredOption('--new-name <name>', 'Name for the restored database')
|
|
158
|
+
.option('--json', 'Output raw JSON')
|
|
159
|
+
.action(async (id, opts) => {
|
|
160
|
+
try {
|
|
161
|
+
const res = await client.post(`/api/managed-databases/${id}/restore`, {
|
|
162
|
+
snapshotId: opts.snapshot,
|
|
163
|
+
newName: opts.newName,
|
|
164
|
+
});
|
|
165
|
+
const db = res.data?.managedDatabase;
|
|
166
|
+
if (opts.json) { printJson(db); return; }
|
|
167
|
+
success(`Restore started: new database ${db?.id} (${db?.status}). Run \`nexus managed-db list\` until AVAILABLE.`);
|
|
168
|
+
} catch (err) { errorMsg(apiError(err)); process.exit(1); }
|
|
169
|
+
});
|
|
170
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -10,6 +10,8 @@ import { registerMember } from './commands/member.js';
|
|
|
10
10
|
import { registerDatabase } from './commands/database.js';
|
|
11
11
|
import { registerVolume } from './commands/volume.js';
|
|
12
12
|
import { registerBucket } from './commands/bucket.js';
|
|
13
|
+
import { registerManagedDb } from './commands/managedDb.js';
|
|
14
|
+
import { registerExec } from './commands/exec.js';
|
|
13
15
|
|
|
14
16
|
const program = new Command();
|
|
15
17
|
|
|
@@ -28,6 +30,8 @@ registerMember(program);
|
|
|
28
30
|
registerDatabase(program);
|
|
29
31
|
registerVolume(program);
|
|
30
32
|
registerBucket(program);
|
|
33
|
+
registerManagedDb(program);
|
|
34
|
+
registerExec(program);
|
|
31
35
|
|
|
32
36
|
program.parseAsync(process.argv).catch((err) => {
|
|
33
37
|
console.error(err.message || String(err));
|
package/nexusapp-cli-2.0.0.tgz
DELETED
|
Binary file
|