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.
Files changed (56) hide show
  1. package/dist/index.js +8 -0
  2. package/package.json +8 -2
  3. package/src/commands/bucket.ts +261 -0
  4. package/src/commands/database.ts +35 -0
  5. package/src/commands/deploy.ts +138 -4
  6. package/src/commands/exec.ts +154 -0
  7. package/src/commands/managedDb.ts +170 -0
  8. package/src/commands/volume.ts +113 -0
  9. package/src/index.ts +8 -0
  10. package/dist/client.d.ts +0 -6
  11. package/dist/client.d.ts.map +0 -1
  12. package/dist/client.js +0 -63
  13. package/dist/client.js.map +0 -1
  14. package/dist/commands/auth.d.ts +0 -3
  15. package/dist/commands/auth.d.ts.map +0 -1
  16. package/dist/commands/auth.js +0 -178
  17. package/dist/commands/auth.js.map +0 -1
  18. package/dist/commands/database.d.ts +0 -3
  19. package/dist/commands/database.d.ts.map +0 -1
  20. package/dist/commands/database.js +0 -312
  21. package/dist/commands/database.js.map +0 -1
  22. package/dist/commands/deploy.d.ts +0 -3
  23. package/dist/commands/deploy.d.ts.map +0 -1
  24. package/dist/commands/deploy.js +0 -868
  25. package/dist/commands/deploy.js.map +0 -1
  26. package/dist/commands/domain.d.ts +0 -3
  27. package/dist/commands/domain.d.ts.map +0 -1
  28. package/dist/commands/domain.js +0 -174
  29. package/dist/commands/domain.js.map +0 -1
  30. package/dist/commands/member.d.ts +0 -3
  31. package/dist/commands/member.d.ts.map +0 -1
  32. package/dist/commands/member.js +0 -175
  33. package/dist/commands/member.js.map +0 -1
  34. package/dist/commands/project.d.ts +0 -3
  35. package/dist/commands/project.d.ts.map +0 -1
  36. package/dist/commands/project.js +0 -92
  37. package/dist/commands/project.js.map +0 -1
  38. package/dist/commands/secret.d.ts +0 -3
  39. package/dist/commands/secret.d.ts.map +0 -1
  40. package/dist/commands/secret.js +0 -121
  41. package/dist/commands/secret.js.map +0 -1
  42. package/dist/commands/token.d.ts +0 -3
  43. package/dist/commands/token.d.ts.map +0 -1
  44. package/dist/commands/token.js +0 -179
  45. package/dist/commands/token.js.map +0 -1
  46. package/dist/config.d.ts +0 -10
  47. package/dist/config.d.ts.map +0 -1
  48. package/dist/config.js +0 -53
  49. package/dist/config.js.map +0 -1
  50. package/dist/index.d.ts +0 -3
  51. package/dist/index.d.ts.map +0 -1
  52. package/dist/index.js.map +0 -1
  53. package/dist/output.d.ts +0 -9
  54. package/dist/output.d.ts.map +0 -1
  55. package/dist/output.js +0 -71
  56. 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": "2.1.1",
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": ["nexusai", "cli", "deployments", "cloud"],
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
+ }
@@ -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>')
@@ -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 database services to provision (e.g. postgresql,redis)')
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
- const baseEnvVars: Record<string, string> = { ...(deployment.envVars || {}) };
303
- if (opts.envFile) Object.assign(baseEnvVars, parseEnvFile(opts.envFile));
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) baseEnvVars[pair.slice(0, idx)] = pair.slice(idx + 1);
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
  }