nexusapp-cli 2.1.1 → 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 +8 -0
- package/package.json +8 -2
- package/src/commands/bucket.ts +261 -0
- package/src/commands/database.ts +35 -0
- package/src/commands/deploy.ts +138 -4
- package/src/commands/exec.ts +154 -0
- package/src/commands/managedDb.ts +170 -0
- package/src/commands/volume.ts +113 -0
- package/src/index.ts +8 -0
- package/dist/client.d.ts +0 -6
- package/dist/client.d.ts.map +0 -1
- package/dist/client.js +0 -63
- package/dist/client.js.map +0 -1
- package/dist/commands/auth.d.ts +0 -3
- package/dist/commands/auth.d.ts.map +0 -1
- package/dist/commands/auth.js +0 -178
- package/dist/commands/auth.js.map +0 -1
- package/dist/commands/database.d.ts +0 -3
- package/dist/commands/database.d.ts.map +0 -1
- package/dist/commands/database.js +0 -312
- package/dist/commands/database.js.map +0 -1
- package/dist/commands/deploy.d.ts +0 -3
- package/dist/commands/deploy.d.ts.map +0 -1
- package/dist/commands/deploy.js +0 -868
- package/dist/commands/deploy.js.map +0 -1
- package/dist/commands/domain.d.ts +0 -3
- package/dist/commands/domain.d.ts.map +0 -1
- package/dist/commands/domain.js +0 -174
- package/dist/commands/domain.js.map +0 -1
- package/dist/commands/member.d.ts +0 -3
- package/dist/commands/member.d.ts.map +0 -1
- package/dist/commands/member.js +0 -175
- package/dist/commands/member.js.map +0 -1
- package/dist/commands/project.d.ts +0 -3
- package/dist/commands/project.d.ts.map +0 -1
- package/dist/commands/project.js +0 -92
- package/dist/commands/project.js.map +0 -1
- package/dist/commands/secret.d.ts +0 -3
- package/dist/commands/secret.d.ts.map +0 -1
- package/dist/commands/secret.js +0 -121
- package/dist/commands/secret.js.map +0 -1
- package/dist/commands/token.d.ts +0 -3
- package/dist/commands/token.d.ts.map +0 -1
- package/dist/commands/token.js +0 -179
- package/dist/commands/token.js.map +0 -1
- package/dist/config.d.ts +0 -10
- package/dist/config.d.ts.map +0 -1
- package/dist/config.js +0 -53
- package/dist/config.js.map +0 -1
- package/dist/index.d.ts +0 -3
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/output.d.ts +0 -9
- package/dist/output.d.ts.map +0 -1
- package/dist/output.js +0 -71
- package/dist/output.js.map +0 -1
package/dist/index.js
CHANGED
|
@@ -10,6 +10,10 @@ const domain_js_1 = require("./commands/domain.js");
|
|
|
10
10
|
const token_js_1 = require("./commands/token.js");
|
|
11
11
|
const member_js_1 = require("./commands/member.js");
|
|
12
12
|
const database_js_1 = require("./commands/database.js");
|
|
13
|
+
const volume_js_1 = require("./commands/volume.js");
|
|
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");
|
|
13
17
|
const program = new commander_1.Command();
|
|
14
18
|
program
|
|
15
19
|
.name('nexus')
|
|
@@ -23,6 +27,10 @@ program
|
|
|
23
27
|
(0, token_js_1.registerToken)(program);
|
|
24
28
|
(0, member_js_1.registerMember)(program);
|
|
25
29
|
(0, database_js_1.registerDatabase)(program);
|
|
30
|
+
(0, volume_js_1.registerVolume)(program);
|
|
31
|
+
(0, bucket_js_1.registerBucket)(program);
|
|
32
|
+
(0, managedDb_js_1.registerManagedDb)(program);
|
|
33
|
+
(0, exec_js_1.registerExec)(program);
|
|
26
34
|
program.parseAsync(process.argv).catch((err) => {
|
|
27
35
|
console.error(err.message || String(err));
|
|
28
36
|
process.exit(1);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nexusapp-cli",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "3.0.0",
|
|
4
4
|
"description": "NEXUS AI command-line interface",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -11,7 +11,12 @@
|
|
|
11
11
|
"dev": "ts-node src/index.ts",
|
|
12
12
|
"prepublishOnly": "npm run build"
|
|
13
13
|
},
|
|
14
|
-
"keywords": [
|
|
14
|
+
"keywords": [
|
|
15
|
+
"nexusai",
|
|
16
|
+
"cli",
|
|
17
|
+
"deployments",
|
|
18
|
+
"cloud"
|
|
19
|
+
],
|
|
15
20
|
"license": "MIT",
|
|
16
21
|
"dependencies": {
|
|
17
22
|
"axios": "^1.6.0",
|
|
@@ -19,6 +24,7 @@
|
|
|
19
24
|
"cli-table3": "^0.6.3",
|
|
20
25
|
"commander": "^12.0.0",
|
|
21
26
|
"inquirer": "^9.2.0",
|
|
27
|
+
"nexusapp-cli": "^2.1.1",
|
|
22
28
|
"ora": "^8.0.0"
|
|
23
29
|
},
|
|
24
30
|
"devDependencies": {
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import inquirer from 'inquirer';
|
|
3
|
+
import * as fs from 'fs';
|
|
4
|
+
import * as path from 'path';
|
|
5
|
+
import { client, apiError, unwrap } from '../client.js';
|
|
6
|
+
import { printTable, printJson, success, errorMsg, timeAgo } from '../output.js';
|
|
7
|
+
|
|
8
|
+
function formatBytes(bytes: number | bigint): string {
|
|
9
|
+
const n = typeof bytes === 'bigint' ? Number(bytes) : bytes;
|
|
10
|
+
if (n < 1024) return `${n} B`;
|
|
11
|
+
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
|
|
12
|
+
if (n < 1024 * 1024 * 1024) return `${(n / (1024 * 1024)).toFixed(1)} MB`;
|
|
13
|
+
return `${(n / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function registerBucket(program: Command): void {
|
|
17
|
+
const bk = program
|
|
18
|
+
.command('bucket')
|
|
19
|
+
.description('Object storage buckets (S3-compatible MinIO buckets)');
|
|
20
|
+
|
|
21
|
+
bk
|
|
22
|
+
.command('list')
|
|
23
|
+
.description('List buckets')
|
|
24
|
+
.option('--json', 'Output raw JSON')
|
|
25
|
+
.action(async (opts) => {
|
|
26
|
+
try {
|
|
27
|
+
const res = await client.get('/api/buckets');
|
|
28
|
+
const buckets: any[] = unwrap(res.data) || [];
|
|
29
|
+
if (opts.json) { printJson(buckets); return; }
|
|
30
|
+
if (!buckets.length) { console.log('No buckets found.'); return; }
|
|
31
|
+
|
|
32
|
+
printTable(
|
|
33
|
+
['ID', 'NAME', 'SIZE', 'OBJECTS', 'ATTACHED TO', 'CREATED'],
|
|
34
|
+
buckets.map((b: any) => [
|
|
35
|
+
b.id,
|
|
36
|
+
b.displayName ? `${b.name} (${b.displayName})` : b.name,
|
|
37
|
+
formatBytes(b.sizeBytes),
|
|
38
|
+
String(b.objectCount),
|
|
39
|
+
b.attachments?.length ? b.attachments.map((a: any) => a.deploymentName).join(', ') : '—',
|
|
40
|
+
b.createdAt ? timeAgo(b.createdAt) : '—',
|
|
41
|
+
])
|
|
42
|
+
);
|
|
43
|
+
} catch (err) { errorMsg(apiError(err)); process.exit(1); }
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
bk
|
|
47
|
+
.command('create <name>')
|
|
48
|
+
.description('Create a new bucket (org-scoped)')
|
|
49
|
+
.option('--display-name <name>', 'Friendly display name')
|
|
50
|
+
.option('--region <region>', 'Region', 'us-east-1')
|
|
51
|
+
.option('--json', 'Output raw JSON')
|
|
52
|
+
.action(async (name, opts) => {
|
|
53
|
+
try {
|
|
54
|
+
const res = await client.post('/api/buckets', {
|
|
55
|
+
name,
|
|
56
|
+
displayName: opts.displayName,
|
|
57
|
+
region: opts.region,
|
|
58
|
+
});
|
|
59
|
+
const b = unwrap(res.data);
|
|
60
|
+
if (opts.json) { printJson(b); return; }
|
|
61
|
+
success(`Bucket created: ${b.id} (${b.name})`);
|
|
62
|
+
} catch (err) { errorMsg(apiError(err)); process.exit(1); }
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
bk
|
|
66
|
+
.command('delete <id>')
|
|
67
|
+
.description('Delete a bucket (must be detached first; deletes all objects)')
|
|
68
|
+
.option('--yes', 'Skip confirmation prompt')
|
|
69
|
+
.action(async (id, opts) => {
|
|
70
|
+
if (!opts.yes) {
|
|
71
|
+
const { confirm } = await inquirer.prompt([
|
|
72
|
+
{ type: 'confirm', name: 'confirm', message: `Delete bucket "${id}" and ALL its objects?`, default: false },
|
|
73
|
+
]);
|
|
74
|
+
if (!confirm) { console.log('Cancelled.'); return; }
|
|
75
|
+
}
|
|
76
|
+
try {
|
|
77
|
+
await client.delete(`/api/buckets/${id}`);
|
|
78
|
+
success(`Bucket ${id} deleted.`);
|
|
79
|
+
} catch (err) { errorMsg(apiError(err)); process.exit(1); }
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
bk
|
|
83
|
+
.command('attach <id> <deployment-id>')
|
|
84
|
+
.description('Attach a bucket to a deployment (injects S3_* env vars on next deploy)')
|
|
85
|
+
.action(async (id, deploymentId) => {
|
|
86
|
+
try {
|
|
87
|
+
await client.post(`/api/buckets/${id}/attach`, { deploymentId });
|
|
88
|
+
success(`Attached bucket ${id} to deployment ${deploymentId}.`);
|
|
89
|
+
console.log(" Run 'nexus deploy redeploy <id>' for env vars to take effect.");
|
|
90
|
+
} catch (err) { errorMsg(apiError(err)); process.exit(1); }
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
bk
|
|
94
|
+
.command('detach <id> <deployment-id>')
|
|
95
|
+
.description('Detach a bucket from a deployment')
|
|
96
|
+
.action(async (id, deploymentId) => {
|
|
97
|
+
try {
|
|
98
|
+
await client.post(`/api/buckets/${id}/detach`, { deploymentId });
|
|
99
|
+
success(`Bucket ${id} detached from ${deploymentId}.`);
|
|
100
|
+
} catch (err) { errorMsg(apiError(err)); process.exit(1); }
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
bk
|
|
104
|
+
.command('credentials <id>')
|
|
105
|
+
.description('Reveal the S3-compatible credentials for an external client (audit-logged)')
|
|
106
|
+
.option('--json', 'Output raw JSON')
|
|
107
|
+
.action(async (id, opts) => {
|
|
108
|
+
try {
|
|
109
|
+
const res = await client.get(`/api/buckets/${id}/credentials`);
|
|
110
|
+
const c = unwrap(res.data);
|
|
111
|
+
if (opts.json) { printJson(c); return; }
|
|
112
|
+
console.log(`Endpoint: ${c.endpoint}`);
|
|
113
|
+
console.log(`Region: ${c.region}`);
|
|
114
|
+
console.log(`Bucket: ${c.bucket}`);
|
|
115
|
+
console.log(`Access key: ${c.accessKey}`);
|
|
116
|
+
console.log(`Secret key: ${c.secretKey}`);
|
|
117
|
+
} catch (err) { errorMsg(apiError(err)); process.exit(1); }
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
bk
|
|
121
|
+
.command('rotate-credentials <id>')
|
|
122
|
+
.description('Rotate the per-bucket S3 access key (use to migrate legacy buckets to scoped IAM)')
|
|
123
|
+
.option('--yes', 'Skip confirmation prompt')
|
|
124
|
+
.action(async (id, opts) => {
|
|
125
|
+
if (!opts.yes) {
|
|
126
|
+
const { confirm } = await inquirer.prompt([
|
|
127
|
+
{
|
|
128
|
+
type: 'confirm',
|
|
129
|
+
name: 'confirm',
|
|
130
|
+
message: 'Rotate credentials? Attached deployments must be redeployed to pick up new S3_* env vars.',
|
|
131
|
+
default: false,
|
|
132
|
+
},
|
|
133
|
+
]);
|
|
134
|
+
if (!confirm) { console.log('Cancelled.'); return; }
|
|
135
|
+
}
|
|
136
|
+
try {
|
|
137
|
+
await client.post(`/api/buckets/${id}/rotate-credentials`);
|
|
138
|
+
success(`Credentials rotated. Redeploy any attached deployments.`);
|
|
139
|
+
} catch (err) { errorMsg(apiError(err)); process.exit(1); }
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
bk
|
|
143
|
+
.command('refresh-usage <id>')
|
|
144
|
+
.description('Refresh size and object count for a bucket')
|
|
145
|
+
.option('--json', 'Output raw JSON')
|
|
146
|
+
.action(async (id, opts) => {
|
|
147
|
+
try {
|
|
148
|
+
const res = await client.post(`/api/buckets/${id}/refresh-usage`);
|
|
149
|
+
const b = unwrap(res.data);
|
|
150
|
+
if (opts.json) { printJson(b); return; }
|
|
151
|
+
success(`${b.name}: ${formatBytes(b.sizeBytes)} across ${b.objectCount} object(s)`);
|
|
152
|
+
} catch (err) { errorMsg(apiError(err)); process.exit(1); }
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
// ---- file ops ------------------------------------------------------------
|
|
156
|
+
|
|
157
|
+
bk
|
|
158
|
+
.command('files <id>')
|
|
159
|
+
.description('List files in a bucket')
|
|
160
|
+
.option('--prefix <prefix>', 'Filter by key prefix')
|
|
161
|
+
.option('--limit <n>', 'Max keys to return (default 1000)', '1000')
|
|
162
|
+
.option('--json', 'Output raw JSON')
|
|
163
|
+
.action(async (id, opts) => {
|
|
164
|
+
try {
|
|
165
|
+
const params: Record<string, string> = { limit: opts.limit };
|
|
166
|
+
if (opts.prefix) params.prefix = opts.prefix;
|
|
167
|
+
const res = await client.get(`/api/buckets/${id}/files`, { params });
|
|
168
|
+
const data = unwrap(res.data);
|
|
169
|
+
if (opts.json) { printJson(data); return; }
|
|
170
|
+
if (!data.objects?.length) { console.log('Empty.'); return; }
|
|
171
|
+
printTable(
|
|
172
|
+
['KEY', 'SIZE', 'MODIFIED'],
|
|
173
|
+
data.objects.map((o: any) => [
|
|
174
|
+
o.key,
|
|
175
|
+
formatBytes(o.sizeBytes),
|
|
176
|
+
o.lastModified ? timeAgo(o.lastModified) : '—',
|
|
177
|
+
])
|
|
178
|
+
);
|
|
179
|
+
if (data.truncated) console.log(`(truncated — pass --limit to see more)`);
|
|
180
|
+
} catch (err) { errorMsg(apiError(err)); process.exit(1); }
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
bk
|
|
184
|
+
.command('upload <id> <local-file>')
|
|
185
|
+
.description('Upload a local file into the bucket')
|
|
186
|
+
.option('--key <key>', 'Object key (default: filename of local file)')
|
|
187
|
+
.action(async (id, localFile, opts) => {
|
|
188
|
+
try {
|
|
189
|
+
if (!fs.existsSync(localFile)) {
|
|
190
|
+
errorMsg(`File not found: ${localFile}`);
|
|
191
|
+
process.exit(1);
|
|
192
|
+
}
|
|
193
|
+
const key = opts.key || path.basename(localFile);
|
|
194
|
+
const stat = fs.statSync(localFile);
|
|
195
|
+
const stream = fs.createReadStream(localFile);
|
|
196
|
+
await client.put(
|
|
197
|
+
`/api/buckets/${id}/files/${encodeURIComponent(key)}`,
|
|
198
|
+
stream,
|
|
199
|
+
{ headers: { 'Content-Type': 'application/octet-stream', 'Content-Length': String(stat.size) } }
|
|
200
|
+
);
|
|
201
|
+
success(`Uploaded ${formatBytes(stat.size)} → ${key}`);
|
|
202
|
+
} catch (err) { errorMsg(apiError(err)); process.exit(1); }
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
bk
|
|
206
|
+
.command('download <id> <key>')
|
|
207
|
+
.description('Download a file from the bucket')
|
|
208
|
+
.option('--out <path>', 'Local output path (default: key basename)')
|
|
209
|
+
.option('--share', 'Print a short-lived signed URL instead of downloading')
|
|
210
|
+
.option('--ttl <seconds>', 'Lifetime for --share URL in seconds (30-3600, default 300)', '300')
|
|
211
|
+
.option('--json', 'Output raw JSON (only with --share)')
|
|
212
|
+
.action(async (id, key, opts) => {
|
|
213
|
+
try {
|
|
214
|
+
if (opts.share) {
|
|
215
|
+
const res = await client.post(
|
|
216
|
+
`/api/buckets/${id}/files/${encodeURIComponent(key)}/download-url`,
|
|
217
|
+
{ ttlSeconds: parseInt(opts.ttl, 10) || 300 }
|
|
218
|
+
);
|
|
219
|
+
const data = unwrap(res.data);
|
|
220
|
+
if (opts.json) { printJson(data); return; }
|
|
221
|
+
console.log(`URL: ${data.url}`);
|
|
222
|
+
console.log(`Key: ${data.key}`);
|
|
223
|
+
console.log(`Expires at: ${data.expiresAt}`);
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const res = await client.get(
|
|
228
|
+
`/api/buckets/${id}/files/${encodeURIComponent(key)}/download`,
|
|
229
|
+
{ responseType: 'stream' }
|
|
230
|
+
);
|
|
231
|
+
const outPath = path.resolve(opts.out || path.basename(key));
|
|
232
|
+
const dir = path.dirname(outPath);
|
|
233
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
234
|
+
await new Promise<void>((resolve, reject) => {
|
|
235
|
+
const writer = fs.createWriteStream(outPath);
|
|
236
|
+
res.data.pipe(writer);
|
|
237
|
+
writer.on('finish', () => resolve());
|
|
238
|
+
writer.on('error', reject);
|
|
239
|
+
res.data.on('error', reject);
|
|
240
|
+
});
|
|
241
|
+
success(`Downloaded → ${outPath}`);
|
|
242
|
+
} catch (err) { errorMsg(apiError(err)); process.exit(1); }
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
bk
|
|
246
|
+
.command('rm <id> <key>')
|
|
247
|
+
.description('Delete a file from the bucket')
|
|
248
|
+
.option('--yes', 'Skip confirmation prompt')
|
|
249
|
+
.action(async (id, key, opts) => {
|
|
250
|
+
if (!opts.yes) {
|
|
251
|
+
const { confirm } = await inquirer.prompt([
|
|
252
|
+
{ type: 'confirm', name: 'confirm', message: `Delete "${key}"?`, default: false },
|
|
253
|
+
]);
|
|
254
|
+
if (!confirm) { console.log('Cancelled.'); return; }
|
|
255
|
+
}
|
|
256
|
+
try {
|
|
257
|
+
await client.delete(`/api/buckets/${id}/files/${encodeURIComponent(key)}`);
|
|
258
|
+
success(`Deleted ${key}`);
|
|
259
|
+
} catch (err) { errorMsg(apiError(err)); process.exit(1); }
|
|
260
|
+
});
|
|
261
|
+
}
|
package/src/commands/database.ts
CHANGED
|
@@ -216,6 +216,41 @@ export function registerDatabase(program: Command): void {
|
|
|
216
216
|
}
|
|
217
217
|
});
|
|
218
218
|
|
|
219
|
+
// restore-to (cross-service restore)
|
|
220
|
+
db
|
|
221
|
+
.command('restore-to <target-service-id> <backup-id>')
|
|
222
|
+
.description('Restore a backup into a DIFFERENT deployment service in the same org (engines must match)')
|
|
223
|
+
.option('--yes', 'Skip confirmation prompt')
|
|
224
|
+
.option('--json', 'Output raw JSON')
|
|
225
|
+
.action(async (targetServiceId, backupId, opts) => {
|
|
226
|
+
if (!opts.yes) {
|
|
227
|
+
const { confirm } = await inquirer.prompt([
|
|
228
|
+
{
|
|
229
|
+
type: 'confirm',
|
|
230
|
+
name: 'confirm',
|
|
231
|
+
message: `Restore backup ${backupId} into service ${targetServiceId}? Existing data on the target will be overwritten.`,
|
|
232
|
+
default: false,
|
|
233
|
+
},
|
|
234
|
+
]);
|
|
235
|
+
if (!confirm) { console.log('Cancelled.'); return; }
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const spin = spinner('Restoring database into target service...');
|
|
239
|
+
try {
|
|
240
|
+
const res = await client.post(
|
|
241
|
+
`/api/deployment-services/${targetServiceId}/restore-from/${backupId}`
|
|
242
|
+
);
|
|
243
|
+
const result = unwrap(res.data);
|
|
244
|
+
spin.stop();
|
|
245
|
+
if (opts.json) { printJson(result); return; }
|
|
246
|
+
success('Database restored successfully into target service.');
|
|
247
|
+
} catch (err) {
|
|
248
|
+
spin.stop();
|
|
249
|
+
errorMsg(apiError(err));
|
|
250
|
+
process.exit(1);
|
|
251
|
+
}
|
|
252
|
+
});
|
|
253
|
+
|
|
219
254
|
// backup-delete
|
|
220
255
|
db
|
|
221
256
|
.command('backup-delete <service-id> <backup-id>')
|
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,13 @@ 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)')
|
|
250
|
+
.option('--worker-command <cmd>', 'Run a background worker sidecar with this command')
|
|
251
|
+
.option('--worker-name <name>', 'Worker service name', 'worker')
|
|
226
252
|
.option('--no-health-check', 'Disable health checks for this deployment')
|
|
227
253
|
.option('--wait', 'Wait until deployment is RUNNING or FAILED')
|
|
228
254
|
.option('--json', 'Output raw JSON')
|
|
@@ -239,6 +265,7 @@ export function registerDeploy(program: Command): void {
|
|
|
239
265
|
if (opts.name) payload.name = opts.name;
|
|
240
266
|
if (opts.branch) payload.repoBranch = opts.branch;
|
|
241
267
|
if (opts.provider) payload.provider = opts.provider;
|
|
268
|
+
if (opts.region) payload.region = opts.region;
|
|
242
269
|
if (opts.environment) payload.environment = opts.environment;
|
|
243
270
|
if (opts.framework) payload.framework = opts.framework;
|
|
244
271
|
if (opts.buildCommand) payload.buildCommand = opts.buildCommand;
|
|
@@ -249,9 +276,23 @@ export function registerDeploy(program: Command): void {
|
|
|
249
276
|
if (opts.repoSecret) payload.repoSecretName = opts.repoSecret;
|
|
250
277
|
if (opts.autoDestroy) payload.autoDestroyHours = opts.autoDestroy;
|
|
251
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;
|
|
252
283
|
if (opts.services) {
|
|
253
284
|
payload.services = opts.services.split(',').map((s: string) => ({ type: s.trim() }));
|
|
254
285
|
}
|
|
286
|
+
if (opts.workerCommand) {
|
|
287
|
+
payload.services = [
|
|
288
|
+
...(payload.services || []),
|
|
289
|
+
{
|
|
290
|
+
type: 'worker',
|
|
291
|
+
displayName: opts.workerName || 'worker',
|
|
292
|
+
command: opts.workerCommand,
|
|
293
|
+
},
|
|
294
|
+
];
|
|
295
|
+
}
|
|
255
296
|
if (Object.keys(envVars).length) payload.envVars = envVars;
|
|
256
297
|
|
|
257
298
|
try {
|
|
@@ -299,17 +340,60 @@ export function registerDeploy(program: Command): void {
|
|
|
299
340
|
if (!confirm) { console.log('Cancelled.'); return; }
|
|
300
341
|
}
|
|
301
342
|
|
|
302
|
-
|
|
303
|
-
|
|
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));
|
|
304
350
|
if (opts.env) {
|
|
305
351
|
for (const pair of (opts.env as string[])) {
|
|
306
352
|
const idx = pair.indexOf('=');
|
|
307
|
-
if (idx > 0)
|
|
353
|
+
if (idx > 0) explicitOverrides[pair.slice(0, idx)] = pair.slice(idx + 1);
|
|
308
354
|
}
|
|
309
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);
|
|
310
367
|
const provider = opts.provider || undefined;
|
|
311
368
|
|
|
312
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
|
+
|
|
313
397
|
let project: any = null;
|
|
314
398
|
if (deployment.projectId) {
|
|
315
399
|
const projectRes = await client.get(`/api/projects/${deployment.projectId}`);
|
|
@@ -751,6 +835,7 @@ export function registerDeploy(program: Command): void {
|
|
|
751
835
|
['Provider', d.provider || '—'],
|
|
752
836
|
['URL', d.url || d.serviceUrl || '—'],
|
|
753
837
|
['Replicas', String(d.replicas ?? '—')],
|
|
838
|
+
['Auto Destroy', formatAutoDestroy(d.autoDestroyAt)],
|
|
754
839
|
['Updated', d.updatedAt ? timeAgo(d.updatedAt) : '—'],
|
|
755
840
|
]);
|
|
756
841
|
};
|
|
@@ -767,4 +852,53 @@ export function registerDeploy(program: Command): void {
|
|
|
767
852
|
process.exit(1);
|
|
768
853
|
}
|
|
769
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
|
+
});
|
|
770
904
|
}
|