@inquir/compute-cli 0.2.2 → 0.3.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 (62) hide show
  1. package/README.md +133 -35
  2. package/dist/commands/apps.d.ts +42 -5
  3. package/dist/commands/apps.d.ts.map +1 -1
  4. package/dist/commands/apps.js +878 -42
  5. package/dist/commands/apps.js.map +1 -1
  6. package/dist/commands/deploy.d.ts +38 -0
  7. package/dist/commands/deploy.d.ts.map +1 -1
  8. package/dist/commands/deploy.js +165 -12
  9. package/dist/commands/deploy.js.map +1 -1
  10. package/dist/commands/doctor.js +2 -2
  11. package/dist/commands/init.d.ts.map +1 -1
  12. package/dist/commands/init.js +3 -0
  13. package/dist/commands/init.js.map +1 -1
  14. package/dist/commands/login.d.ts.map +1 -1
  15. package/dist/commands/login.js +44 -13
  16. package/dist/commands/login.js.map +1 -1
  17. package/dist/commands/projects.d.ts +2 -0
  18. package/dist/commands/projects.d.ts.map +1 -0
  19. package/dist/commands/projects.js +98 -0
  20. package/dist/commands/projects.js.map +1 -0
  21. package/dist/index.js +3 -0
  22. package/dist/index.js.map +1 -1
  23. package/dist/utils/api.d.ts +45 -4
  24. package/dist/utils/api.d.ts.map +1 -1
  25. package/dist/utils/api.js +66 -5
  26. package/dist/utils/api.js.map +1 -1
  27. package/dist/utils/apps-presentation.d.ts +10 -0
  28. package/dist/utils/apps-presentation.d.ts.map +1 -1
  29. package/dist/utils/apps-presentation.js +27 -0
  30. package/dist/utils/apps-presentation.js.map +1 -1
  31. package/dist/utils/args.d.ts.map +1 -1
  32. package/dist/utils/args.js +8 -1
  33. package/dist/utils/args.js.map +1 -1
  34. package/dist/utils/assets.d.ts +48 -1
  35. package/dist/utils/assets.d.ts.map +1 -1
  36. package/dist/utils/assets.js +185 -7
  37. package/dist/utils/assets.js.map +1 -1
  38. package/dist/utils/auth.d.ts +9 -2
  39. package/dist/utils/auth.d.ts.map +1 -1
  40. package/dist/utils/auth.js +51 -11
  41. package/dist/utils/auth.js.map +1 -1
  42. package/dist/utils/context.d.ts.map +1 -1
  43. package/dist/utils/context.js +2 -0
  44. package/dist/utils/context.js.map +1 -1
  45. package/dist/utils/deploy-follow.d.ts +4 -3
  46. package/dist/utils/deploy-follow.d.ts.map +1 -1
  47. package/dist/utils/deploy-follow.js +76 -17
  48. package/dist/utils/deploy-follow.js.map +1 -1
  49. package/dist/utils/detect.d.ts +49 -1
  50. package/dist/utils/detect.d.ts.map +1 -1
  51. package/dist/utils/detect.js +153 -12
  52. package/dist/utils/detect.js.map +1 -1
  53. package/dist/utils/ensure-auth.d.ts.map +1 -1
  54. package/dist/utils/ensure-auth.js +6 -2
  55. package/dist/utils/ensure-auth.js.map +1 -1
  56. package/dist/utils/package-metadata.js +1 -1
  57. package/dist/utils/project-context.d.ts +17 -0
  58. package/dist/utils/project-context.d.ts.map +1 -0
  59. package/dist/utils/project-context.js +94 -0
  60. package/dist/utils/project-context.js.map +1 -0
  61. package/package.json +10 -7
  62. package/skill/SKILL.md +7 -0
@@ -1,17 +1,23 @@
1
1
  import path from 'node:path';
2
- import { hasFlag, readFlagValue, readJsonFlag, readMultiFlag, readPositionalArgs } from '../utils/args.js';
3
- import { addApplicationDomain, archiveApplication, cancelBuild, createApplication, getBuild, listApplicationDomains, listBuilds, queueBuild, removeApplicationDomain, createRelease, execInRelease, getApplication, getRelease, listApplications, promoteRelease, requireAuth, resolveApplication, rollbackApplication, stopRelease, streamBuildLogs, streamReleaseLogs, updateApplication, verifyApplicationDomain, } from '../utils/api.js';
2
+ import { hasFlag, readFlagValue, readJsonFlag, readLimitFlag, readMultiFlag, readPositionalArgs, } from '../utils/args.js';
3
+ import { listAppBackups, getAppBackup, createAppBackup, deleteAppBackup, restoreAppBackup, getAppConnectionUrl, connectApps, addApplicationDomain, archiveApplication, cancelBuild, createApplication, getBuild, listApplicationDomains, listBuilds, queueBuild, removeApplicationDomain, createRelease, redeployApplication, execInApplication, getAppLimits, getApplication, getRelease, listApplications, promoteRelease, requireAuth, resolveApplication, rollbackApplication, stopRelease, streamBuildLogs, streamReleaseLogs, updateApplication, wakeApplication, pauseApplication, verifyApplicationDomain, } from '../utils/api.js';
4
4
  import { createZipBase64, isSettledReleaseStatus, isTerminalBuildStatus } from '../sdk.js';
5
- import { formatElapsed, humanizeApiError, presentRelease } from '../utils/apps-presentation.js';
5
+ import { formatBytes, formatElapsed, humanizeApiError, permanentAddress, presentRelease, } from '../utils/apps-presentation.js';
6
6
  import { buildApiOptions, runtime } from '../utils/context.js';
7
+ import { confirm } from '../utils/prompt.js';
8
+ import { resolveCreateProject } from '../utils/project-context.js';
7
9
  import { CliError, ExitCode, usageError } from '../utils/errors.js';
8
- import { redact } from '../utils/redact.js';
9
- import { dim, formatTable, printCommandHelp, printInfo, printLine, printSuccess } from '../utils/output.js';
10
+ import { redact, registerSecret } from '../utils/redact.js';
11
+ import { dim, formatTable, printCommandHelp, printError, printInfo, printLine, printSuccess } from '../utils/output.js';
10
12
  /** Value flags across every subcommand — readPositionalArgs must know them all. */
11
13
  const VALUE_FLAGS = [
14
+ '--project',
15
+ '--template',
12
16
  '--image',
13
17
  '--port',
14
18
  '--tcp',
19
+ '--volume',
20
+ '--volume-runner',
15
21
  '--env',
16
22
  '--set',
17
23
  '--memory',
@@ -22,6 +28,7 @@ const VALUE_FLAGS = [
22
28
  '--tail',
23
29
  '--timeout',
24
30
  '--limit',
31
+ '--cursor',
25
32
  '--dir',
26
33
  '--dockerfile',
27
34
  '--context',
@@ -29,9 +36,22 @@ const VALUE_FLAGS = [
29
36
  '--build-arg',
30
37
  '--build',
31
38
  '--egress',
39
+ '--ingress',
40
+ '--activation',
41
+ '--mode',
42
+ '--idle',
43
+ '--idle-timeout',
44
+ '--activation-timeout',
45
+ '--stabilization',
46
+ '--scale-down-stabilization',
47
+ '--replicas',
32
48
  ];
33
49
  export async function appsCommand(args) {
34
50
  const [subcommand, ...rest] = readPositionalArgs(args, { valueFlags: VALUE_FLAGS });
51
+ if (subcommand && (hasFlag(args, '--help') || hasFlag(args, '-h')) && SUBCOMMAND_HELP[subcommand]) {
52
+ SUBCOMMAND_HELP[subcommand].forEach((line) => printLine(line));
53
+ return;
54
+ }
35
55
  if (!subcommand || hasFlag(args, '--help') || hasFlag(args, '-h')) {
36
56
  printAppsHelp();
37
57
  if (!subcommand && !hasFlag(args, '--help') && !hasFlag(args, '-h')) {
@@ -40,6 +60,10 @@ export async function appsCommand(args) {
40
60
  return;
41
61
  }
42
62
  const apiOptions = buildApiOptions(args);
63
+ const flagArgs = args.includes('--') ? args.slice(0, args.indexOf('--')) : args;
64
+ if (subcommand !== 'create' && hasFlag(flagArgs, '--project')) {
65
+ throw usageError('--project is only supported by apps create', 'inquir projects add <project> <app>');
66
+ }
43
67
  switch (subcommand) {
44
68
  case 'list':
45
69
  return listAppsSubcommand(args, apiOptions);
@@ -47,6 +71,8 @@ export async function appsCommand(args) {
47
71
  return createAppSubcommand(args, rest, apiOptions);
48
72
  case 'deploy':
49
73
  return deploySubcommand(args, rest, apiOptions);
74
+ case 'redeploy':
75
+ return redeploySubcommand(args, rest, apiOptions);
50
76
  case 'status':
51
77
  return statusSubcommand(args, rest, apiOptions);
52
78
  case 'logs':
@@ -65,9 +91,21 @@ export async function appsCommand(args) {
65
91
  return buildsSubcommand(args, rest, apiOptions);
66
92
  case 'update':
67
93
  return updateSubcommand(args, rest, apiOptions);
94
+ case 'scale':
95
+ return scaleSubcommand(args, rest, apiOptions);
96
+ case 'wake':
97
+ return wakeSubcommand(args, rest, apiOptions);
98
+ case 'pause':
99
+ return pauseSubcommand(args, rest, apiOptions);
68
100
  case 'domain':
69
101
  case 'domains':
70
102
  return domainSubcommand(args, rest, apiOptions);
103
+ case 'backups':
104
+ return backupsSubcommand(args, rest, apiOptions);
105
+ case 'connect':
106
+ return connectSubcommand(args, rest, apiOptions);
107
+ case 'connection-url':
108
+ return connectionUrlSubcommand(args, rest, apiOptions);
71
109
  case 'delete':
72
110
  return deleteSubcommand(args, rest, apiOptions);
73
111
  default:
@@ -79,6 +117,10 @@ function printAppsHelp() {
79
117
  'list List applications',
80
118
  'create <name> Create an application',
81
119
  'deploy <app> [--image <ref>] Release from an image, or build the current directory',
120
+ 'redeploy <app> Release what production runs again; promote when healthy',
121
+ 'backups <app> [list|create|restore <id>|delete <id>] Volume backups (databases)',
122
+ 'connect <db> <app> Put the database URL into another app\'s env',
123
+ 'connection-url <db> Print a private URL, or public fallback (--private/--public)',
82
124
  'build <app> [--dir <path>] Build an image from source (Dockerfile) without releasing',
83
125
  'builds <app> List image builds',
84
126
  'status <app> Endpoints and recent releases',
@@ -88,47 +130,68 @@ function printAppsHelp() {
88
130
  'stop <release> Stop a release that is not serving production',
89
131
  'exec <release> -- <cmd> Run a command inside the container',
90
132
  'update <app> Change env vars, ports, resources, egress',
133
+ 'scale <app> [mode] Set always/http/manual scaling (--replicas for always)',
134
+ 'wake <app> Start a sleeping application',
135
+ 'pause <app> Stop an http/manual app (HTTP wakes only http mode)',
91
136
  'domain <app> [hostname] Bind a hostname (or list them); --verify / --remove',
92
137
  'delete <app> Archive an application (stops every release)',
93
138
  '',
94
139
  'Create/deploy flags:',
140
+ '--project <id|slug> create: use this project (overrides projects use)',
95
141
  '--image <ref> Container image, e.g. nginx:alpine (omit to build from source)',
142
+ '--promote deploy: take production by itself once healthy (needs apps:promote)',
143
+ '--template <name> postgres | redis: volume, stable TCP endpoint, generated password, first release',
96
144
  '--dir <path> Source directory to build (default: current directory)',
97
145
  '--dockerfile <path> Dockerfile relative to the context (default: Dockerfile)',
98
146
  '--context <path> Build context inside the source tree (default: .)',
99
147
  '--build-arg KEY=VALUE Docker build argument (repeatable)',
100
148
  '--build <buildId> Release an existing successful build',
101
149
  '--port <n> HTTP port (omit for a TCP-only service)',
102
- '--tcp <name:port[:public][:hostPort]> Extra TCP port (repeatable)',
150
+ '--tcp <name:port[:public]> Extra TCP port; endpoint port is platform-managed (repeatable)',
151
+ '--volume <name:/path[:ro]> Persistent volume mount (repeatable)',
152
+ '--volume-runner <id> Pin volumes (and the container) to a worker runner',
103
153
  '--set KEY=VALUE Environment variable (repeatable)',
104
154
  '--memory <mb> Memory ceiling (default 512)',
105
155
  '--cpu <vcpus> CPU ceiling (default 0.5)',
106
156
  '--egress <none|full> Outbound network policy',
157
+ '--ingress <private|public> Public Networking: public creates the generated domain (new apps are private)',
107
158
  '--health-path </path> HTTP health check path',
108
159
  '--health-command <cmd> Health command inside the container (databases)',
109
160
  '--no-wait Return as soon as the release is queued',
110
161
  '--json Machine-readable JSON output',
162
+ '',
163
+ 'Scaling flags:',
164
+ '--activation <mode> always, http (sleep when idle), or manual',
165
+ '--replicas <n> Horizontal replicas for always mode (allocation plane; 1..8)',
166
+ '--idle-timeout <duration> HTTP idle time before sleep (minimum 5m)',
167
+ '--activation-timeout <duration> Maximum wake time (5s–5m)',
168
+ '--stabilization <duration> Quiet period before sleeping (minimum 0s)',
111
169
  ], [
112
170
  'inquir apps create web --port 3000',
113
171
  'inquir apps deploy web # builds ./Dockerfile, then releases it',
114
172
  'inquir apps deploy web --image nginx:alpine',
115
- 'inquir apps deploy cache --image redis:7-alpine --tcp redis:6379:public:26379 \\',
173
+ 'inquir apps deploy cache --image redis:7-alpine --tcp redis:6379:public \\',
116
174
  ' --health-command "redis-cli ping"',
175
+ 'inquir apps deploy db --image postgres:16 --volume data:/var/lib/postgresql/data',
176
+ 'inquir apps scale web http --idle-timeout 15m',
177
+ 'inquir apps wake web',
178
+ 'inquir apps pause web',
117
179
  'inquir apps logs web --tail 100',
118
180
  'inquir apps promote <releaseId>',
119
181
  'inquir apps domain web shop.example.com && inquir apps domain web shop.example.com --verify',
120
182
  ]);
121
183
  }
122
184
  /**
123
- * `--tcp name:port[:public][:hostPort]` — the shorthand people actually want
124
- * to type for a database or SSH endpoint.
185
+ * `--tcp name:port[:public][:hostPort]` — ordinary usage stops at `public` and
186
+ * lets the platform lease a stable endpoint port. The final numeric modifier
187
+ * remains an API/CLI compatibility escape hatch for an existing installation.
125
188
  */
126
189
  export function parseTcpPortFlag(value) {
127
190
  const parts = value.split(':');
128
191
  const [name, portRaw, ...flags] = parts;
129
192
  const port = Number(portRaw);
130
193
  if (!name || !Number.isInteger(port) || port < 1 || port > 65535) {
131
- throw usageError(`Invalid --tcp value '${value}'`, 'inquir apps deploy <app> --tcp postgres:5432:public:25432');
194
+ throw usageError(`Invalid --tcp value '${value}'`, 'inquir apps deploy <app> --tcp postgres:5432:public');
132
195
  }
133
196
  const spec = { name, port, protocol: 'tcp' };
134
197
  for (const flag of flags) {
@@ -143,7 +206,7 @@ export function parseTcpPortFlag(value) {
143
206
  spec.public = true;
144
207
  continue;
145
208
  }
146
- throw usageError(`Invalid --tcp modifier '${flag}' in '${value}'`, 'inquir apps deploy <app> --tcp postgres:5432:public:25432');
209
+ throw usageError(`Invalid --tcp modifier '${flag}' in '${value}'`, 'inquir apps deploy <app> --tcp postgres:5432:public');
147
210
  }
148
211
  return spec;
149
212
  }
@@ -158,27 +221,192 @@ export function parseSetFlags(values) {
158
221
  }
159
222
  return env;
160
223
  }
161
- /** Runtime spec assembled from flags; undefined when nothing was specified. */
162
- export function buildRuntimeFromFlags(args) {
224
+ const MAX_SCALING_DELAY_SECONDS = 30 * 24 * 60 * 60;
225
+ const SCALING_ACTIVATIONS = new Set(['always', 'http', 'manual']);
226
+ /** Parse a CLI duration such as `90`, `90s`, `15m`, `1h30m`, or `1.5m`. */
227
+ export function parseDurationSeconds(value) {
228
+ if (!value || value !== value.trim()) {
229
+ throw usageError(`Invalid duration '${value}'`, 'Use seconds or a duration such as 30s, 15m, or 1h30m');
230
+ }
231
+ if (/^\d+$/.test(value)) {
232
+ const seconds = Number(value);
233
+ if (Number.isSafeInteger(seconds))
234
+ return seconds;
235
+ throw usageError(`Invalid duration '${value}'`, 'Duration is too large');
236
+ }
237
+ const unitSeconds = { s: 1, m: 60, h: 3600, d: 86_400 };
238
+ const component = /(\d+(?:\.\d+)?)([smhd])/gy;
239
+ let position = 0;
240
+ let seconds = 0;
241
+ while (position < value.length) {
242
+ component.lastIndex = position;
243
+ const match = component.exec(value);
244
+ if (!match) {
245
+ throw usageError(`Invalid duration '${value}'`, 'Use seconds or a duration such as 30s, 15m, or 1h30m');
246
+ }
247
+ seconds += Number(match[1]) * unitSeconds[match[2]];
248
+ position = component.lastIndex;
249
+ }
250
+ if (!Number.isSafeInteger(seconds)) {
251
+ throw usageError(`Duration '${value}' must resolve to a whole number of seconds`, 'Use an exact duration such as 90s or 1.5m');
252
+ }
253
+ return seconds;
254
+ }
255
+ function readAliasedFlagValue(args, flags) {
256
+ const present = flags.filter((flag) => hasFlag(args, flag));
257
+ if (present.length > 1) {
258
+ throw usageError(`Use only one of ${flags.join(', ')}`, `inquir apps scale <app> ${flags[0]} <duration>`);
259
+ }
260
+ if (present.length === 0)
261
+ return undefined;
262
+ const value = readFlagValue(args, present[0]);
263
+ if (value === undefined) {
264
+ throw usageError(`Missing value for ${present[0]}`, `inquir apps scale <app> ${present[0]} <value>`);
265
+ }
266
+ return value;
267
+ }
268
+ function parseScalingDuration(raw, flag, min, max) {
269
+ if (raw === undefined)
270
+ return undefined;
271
+ const seconds = parseDurationSeconds(raw);
272
+ if (seconds < min || seconds > max) {
273
+ throw usageError(`Invalid ${flag} '${raw}' (${seconds}s); expected ${min}s–${max}s`, `inquir apps scale <app> ${flag} <duration>`);
274
+ }
275
+ return seconds;
276
+ }
277
+ /** Build the partial policy sent by `apps scale`; omitted values remain unchanged server-side. */
278
+ export function buildScalingPatchFromFlags(args, positionalActivation) {
279
+ const flagActivation = readAliasedFlagValue(args, ['--activation', '--mode']);
280
+ if (positionalActivation && flagActivation && positionalActivation !== flagActivation) {
281
+ throw usageError(`Conflicting scaling modes '${positionalActivation}' and '${flagActivation}'`, 'inquir apps scale <app> <always|http|manual>');
282
+ }
283
+ const activationRaw = positionalActivation ?? flagActivation;
284
+ if (activationRaw && !SCALING_ACTIVATIONS.has(activationRaw)) {
285
+ throw usageError(`Invalid scaling mode '${activationRaw}'`, 'inquir apps scale <app> <always|http|manual>');
286
+ }
287
+ const idleTimeoutSeconds = parseScalingDuration(readAliasedFlagValue(args, ['--idle-timeout', '--idle']), '--idle-timeout', 300, MAX_SCALING_DELAY_SECONDS);
288
+ const activationTimeoutSeconds = parseScalingDuration(readAliasedFlagValue(args, ['--activation-timeout']), '--activation-timeout', 5, 300);
289
+ const scaleDownStabilizationSeconds = parseScalingDuration(readAliasedFlagValue(args, ['--stabilization', '--scale-down-stabilization']), '--stabilization', 0, MAX_SCALING_DELAY_SECONDS);
290
+ const replicasRaw = readAliasedFlagValue(args, ['--replicas']);
291
+ let maxReplicas;
292
+ if (replicasRaw !== undefined) {
293
+ const parsed = Number.parseInt(replicasRaw, 10);
294
+ if (!Number.isSafeInteger(parsed) || parsed < 1 || parsed > 8) {
295
+ throw usageError(`Invalid --replicas '${replicasRaw}' (1..8; above 1 needs always mode and the allocation plane)`, 'inquir apps scale <app> always --replicas <n>');
296
+ }
297
+ maxReplicas = parsed;
298
+ }
299
+ return {
300
+ ...(activationRaw ? { activation: activationRaw } : {}),
301
+ ...(maxReplicas !== undefined ? { maxReplicas } : {}),
302
+ ...(idleTimeoutSeconds !== undefined ? { idleTimeoutSeconds } : {}),
303
+ ...(activationTimeoutSeconds !== undefined ? { activationTimeoutSeconds } : {}),
304
+ ...(scaleDownStabilizationSeconds !== undefined ? { scaleDownStabilizationSeconds } : {}),
305
+ };
306
+ }
307
+ const VOLUME_NAME_RE = /^[a-z0-9]([a-z0-9-]{0,30}[a-z0-9])?$/;
308
+ const MAX_APP_VOLUMES = 16;
309
+ const CONTROL_CHAR_RE = /[\u0000-\u001f\u007f]/;
310
+ /** `--volume name:/absolute/container/path[:ro]`; host paths are never accepted. */
311
+ export function parseVolumeFlag(value) {
312
+ const separator = value.indexOf(':');
313
+ const name = separator > 0 ? value.slice(0, separator) : '';
314
+ let mountPath = separator > 0 ? value.slice(separator + 1) : '';
315
+ let readOnly = false;
316
+ if (/^[A-Za-z]:[\\/]/.test(mountPath)) {
317
+ throw usageError(`Invalid --volume mount path '${mountPath}'`, 'Volume targets are absolute paths inside the Linux container, for example data:/var/lib/data');
318
+ }
319
+ const modifierAt = mountPath.lastIndexOf(':');
320
+ if (modifierAt >= 0) {
321
+ const modifier = mountPath.slice(modifierAt + 1);
322
+ if (modifier !== 'ro') {
323
+ throw usageError(`Invalid --volume modifier '${modifier}' in '${value}'`, 'inquir apps deploy <app> --volume data:/var/lib/data[:ro]');
324
+ }
325
+ readOnly = true;
326
+ mountPath = mountPath.slice(0, modifierAt);
327
+ }
328
+ if (!VOLUME_NAME_RE.test(name)) {
329
+ throw usageError(`Invalid --volume name '${name || value}'`, 'inquir apps deploy <app> --volume data:/var/lib/data[:ro]');
330
+ }
331
+ const pathSegments = mountPath.split('/');
332
+ if (mountPath !== mountPath.trim() ||
333
+ mountPath.length < 2 ||
334
+ mountPath.length > 1024 ||
335
+ !mountPath.startsWith('/') ||
336
+ mountPath.includes('\\') ||
337
+ pathSegments.includes('..') ||
338
+ CONTROL_CHAR_RE.test(mountPath)) {
339
+ throw usageError(`Invalid --volume mount path '${mountPath || value}'`, 'inquir apps deploy <app> --volume data:/var/lib/data[:ro]');
340
+ }
341
+ const normalizedPath = path.posix.normalize(mountPath);
342
+ const normalized = normalizedPath.length > 1 ? normalizedPath.replace(/\/+$/, '') : normalizedPath;
343
+ if (normalized === '/' ||
344
+ ['/dev', '/proc', '/sys'].some((root) => normalized === root || normalized.startsWith(`${root}/`))) {
345
+ throw usageError(`Invalid --volume mount path '${mountPath}'`, 'Mount below an application data directory, for example data:/var/lib/data');
346
+ }
347
+ return { name, mountPath: normalized, ...(readOnly ? { readOnly: true } : {}) };
348
+ }
349
+ function assertVolumeSet(volumes) {
350
+ if (volumes.length > MAX_APP_VOLUMES) {
351
+ throw usageError(`At most ${MAX_APP_VOLUMES} --volume entries are supported`, 'inquir apps deploy --help');
352
+ }
353
+ const names = new Set();
354
+ const targets = new Set();
355
+ for (const volume of volumes) {
356
+ if (names.has(volume.name)) {
357
+ throw usageError(`Duplicate --volume name '${volume.name}'`, 'Use each volume name once');
358
+ }
359
+ if (targets.has(volume.mountPath)) {
360
+ throw usageError(`Duplicate --volume mount path '${volume.mountPath}'`, 'Use each mount path once');
361
+ }
362
+ names.add(volume.name);
363
+ targets.add(volume.mountPath);
364
+ }
365
+ }
366
+ function mergeVolumes(base, overrides) {
367
+ const overrideNames = new Set();
368
+ for (const volume of overrides) {
369
+ if (overrideNames.has(volume.name)) {
370
+ throw usageError(`Duplicate --volume name '${volume.name}'`, 'Use each volume name once');
371
+ }
372
+ overrideNames.add(volume.name);
373
+ }
374
+ const merged = [...(base ?? []).filter((volume) => !overrideNames.has(volume.name)), ...overrides];
375
+ assertVolumeSet(merged);
376
+ return merged;
377
+ }
378
+ /** Runtime flags patch the saved application runtime rather than replacing unrelated fields. */
379
+ export function buildRuntimeFromFlags(args, baseRuntime) {
163
380
  const portRaw = readFlagValue(args, '--port');
164
381
  const tcpValues = readMultiFlag(args, '--tcp');
382
+ const volumeValues = readMultiFlag(args, '--volume');
165
383
  const memory = readFlagValue(args, '--memory');
166
384
  const cpu = readFlagValue(args, '--cpu');
167
385
  const healthPath = readFlagValue(args, '--health-path');
168
386
  const healthCommand = readFlagValue(args, '--health-command');
169
387
  const ports = tcpValues.map(parseTcpPortFlag);
388
+ const volumes = volumeValues.map(parseVolumeFlag);
170
389
  const port = portRaw ? Number(portRaw) : undefined;
171
390
  if (portRaw && (!Number.isInteger(port) || port < 1 || port > 65535)) {
172
391
  throw usageError(`Invalid --port value '${portRaw}'`, 'inquir apps create <name> --port 3000');
173
392
  }
174
- if (port === undefined && ports.length === 0 && !memory && !cpu && !healthPath && !healthCommand) {
393
+ if (!portRaw && tcpValues.length === 0 && volumeValues.length === 0 && !memory && !cpu && !healthPath && !healthCommand) {
175
394
  return undefined;
176
395
  }
177
396
  const spec = {
178
- ...(port !== undefined ? { port } : {}),
179
- ...(ports.length > 0 ? { ports } : {}),
180
- resources: { cpuVCpus: cpu ? Number(cpu) : 0.5, memoryMb: memory ? Number(memory) : 512 },
397
+ ...(baseRuntime ?? {}),
398
+ ...(portRaw ? { port } : {}),
399
+ ...(tcpValues.length > 0 ? { ports } : {}),
400
+ ...(volumeValues.length > 0 ? { volumes: mergeVolumes(baseRuntime?.volumes, volumes) } : {}),
181
401
  };
402
+ if (memory || cpu) {
403
+ const parsedMemory = memory ? Number(memory) : (baseRuntime?.resources?.memoryMb ?? 512);
404
+ const parsedCpu = cpu ? Number(cpu) : (baseRuntime?.resources?.cpuVCpus ?? 0.5);
405
+ if (!Number.isInteger(parsedMemory) || parsedMemory < 1 || !Number.isFinite(parsedCpu) || parsedCpu <= 0) {
406
+ throw usageError('Invalid --memory or --cpu value', 'inquir apps update <app> --memory 512 --cpu 0.5');
407
+ }
408
+ spec.resources = { cpuVCpus: parsedCpu, memoryMb: parsedMemory };
409
+ }
182
410
  if (healthCommand) {
183
411
  spec.healthcheck = {
184
412
  type: 'command',
@@ -187,7 +415,7 @@ export function buildRuntimeFromFlags(args) {
187
415
  timeoutSeconds: 5,
188
416
  };
189
417
  }
190
- else if (port !== undefined) {
418
+ else if (healthPath || (portRaw && !baseRuntime?.healthcheck)) {
191
419
  spec.healthcheck = {
192
420
  type: 'http',
193
421
  path: healthPath || '/',
@@ -224,23 +452,346 @@ async function createAppSubcommand(args, positionals, apiOptions) {
224
452
  const [name] = positionals;
225
453
  if (!name)
226
454
  throw usageError('Missing application name', 'inquir apps create <name> --port 3000');
455
+ if (positionals.length > 1)
456
+ throw usageError('Expected one application name', 'inquir apps create <name> --project <id|slug>');
227
457
  await requireAuth(apiOptions);
458
+ const template = readFlagValue(args, '--template');
459
+ if (template && !TEMPLATE_NAMES.includes(template)) {
460
+ throw usageError(`Unknown template '${template}'. Available: ${TEMPLATE_NAMES.join(', ')}`, `inquir apps create ${name} --template postgres`);
461
+ }
228
462
  const envVars = parseSetFlags(readMultiFlag(args, '--set'));
463
+ const volumeRunner = readFlagValue(args, '--volume-runner');
464
+ // Omitted flags leave the server defaults in force: full egress, and a
465
+ // private-only service until a public domain is asked for.
466
+ const networkPolicy = buildNetworkPolicyPatch({
467
+ egress: readFlagValue(args, '--egress'),
468
+ ingress: readFlagValue(args, '--ingress'),
469
+ });
470
+ const project = await resolveCreateProject(args, apiOptions);
229
471
  const app = await createApplication({
230
472
  name,
473
+ ...(project.projectId ? { projectId: project.projectId } : {}),
231
474
  description: readFlagValue(args, '--description'),
475
+ ...(template ? { template } : {}),
476
+ ...(volumeRunner ? { volumeRunner } : {}),
232
477
  runtime: buildRuntimeFromFlags(args),
233
478
  ...(Object.keys(envVars).length > 0 ? { envVars } : {}),
234
- }, apiOptions);
479
+ ...(networkPolicy ? { networkPolicy } : {}),
480
+ }, project.apiOptions);
481
+ const firstRelease = app.firstRelease;
235
482
  if (readJsonFlag(args)) {
236
- printLine(JSON.stringify(app, null, 2));
483
+ // A scripted create must answer the same question the human one does:
484
+ // "is it up, and where do I reach it?" — so it waits for the release too.
485
+ const settled = template && firstRelease ? await waitForRelease(firstRelease.releaseId, project.apiOptions) : null;
486
+ printLine(JSON.stringify(settled ? { ...app, firstRelease: settled } : app, null, 2));
487
+ if (settled && !isHealthyRelease(settled))
488
+ process.exitCode = ExitCode.ERROR;
237
489
  return;
238
490
  }
239
491
  printSuccess(`Created application ${app.name}`);
240
492
  printInfo(dim(` id: ${app.id}`));
493
+ if (app.projectIds?.length)
494
+ printInfo(dim(` project: ${app.projectIds.join(', ')}`));
495
+ if (template && firstRelease) {
496
+ printInfo(dim(` template: ${template} — first release ${firstRelease.releaseId.slice(0, 8)} is starting`));
497
+ const release = await waitForRelease(firstRelease.releaseId, project.apiOptions);
498
+ renderRelease(release);
499
+ if (!isHealthyRelease(release)) {
500
+ process.exitCode = ExitCode.ERROR;
501
+ return;
502
+ }
503
+ printInfo(dim(` connection string: inquir apps connection-url ${app.name}`));
504
+ printInfo(dim(` wire an app to it: inquir apps connect ${app.name} <target-app>`));
505
+ return;
506
+ }
241
507
  printInfo(dim(` deploy it: inquir apps deploy ${app.name} (builds the Dockerfile in the current directory)`));
242
508
  printInfo(dim(` or: inquir apps deploy ${app.name} --image <ref>`));
243
509
  }
510
+ const TEMPLATE_NAMES = ['postgres', 'redis'];
511
+ /** `apps <sub> --help` answered per subcommand; the global page is the fallback. */
512
+ const SUBCOMMAND_HELP = {
513
+ redeploy: [
514
+ 'Usage: inquir apps redeploy <app> [--no-promote] [--no-wait]',
515
+ '',
516
+ ' Releases what production runs now — the same build, or the same image',
517
+ " reference pulled again — under the application's current env, ports and",
518
+ ' resources, and promotes it once it is healthy. Use it after `apps update`',
519
+ ' to apply changed settings, or to start over from a clean container.',
520
+ ' --no-promote Only queue the release; promote it yourself later',
521
+ ' --no-wait Return as soon as the release is queued',
522
+ '',
523
+ 'Applications with volumes are recreated: the service is down for the swap.',
524
+ ],
525
+ create: [
526
+ 'Usage: inquir apps create <name> [--template postgres|redis] [flags]',
527
+ '',
528
+ ' --project <id|slug> Add to this project. Otherwise uses projects use;',
529
+ ' without a selection, creates a project named after the app.',
530
+ ' --template <name> postgres | redis: volume, pinned public TCP port,',
531
+ ' generated password, and the first release — one command',
532
+ ' from nothing to a running database.',
533
+ ' --volume-runner <id> Pin volumes + container to a worker runner (id or',
534
+ ' unique prefix); the data lives on that machine.',
535
+ ' --port, --tcp, --volume, --memory, --cpu, --health-command, --set KEY=VALUE',
536
+ '',
537
+ 'Examples:',
538
+ ' inquir apps create mydb --template postgres',
539
+ ' inquir apps create web --port 3000',
540
+ ],
541
+ backups: [
542
+ 'Usage: inquir apps backups <app> [list|create|restore <backupId>|delete <backupId>]',
543
+ '',
544
+ ' list (default) Snapshots of this app\'s volumes, newest first',
545
+ ' --volume <name> only that volume',
546
+ ' --limit <n>, --cursor <c> page through older ones',
547
+ ' create Snapshot every mounted volume now, then follow the',
548
+ ' rows until they settle (--no-wait returns at once)',
549
+ ' restore <backupId> OVERWRITE a volume from a snapshot — asks for',
550
+ ' confirmation, or pass --yes in a script',
551
+ ' delete <backupId> Drop a restore point for good — asks for confirmation',
552
+ '',
553
+ 'Backups are crash-consistent and only exist for applications with volumes.',
554
+ 'Restore stops the application while it runs.',
555
+ ],
556
+ connect: [
557
+ 'Usage: inquir apps connect <database-app> <target-app> [--overwrite]',
558
+ '',
559
+ 'Writes the database connection URL into the target app\'s environment',
560
+ '(DATABASE_URL for postgres, REDIS_URL for redis). The password never',
561
+ 'passes through your terminal. Takes effect on the target\'s next release.',
562
+ '',
563
+ ' --overwrite Replace a DATABASE_URL/REDIS_URL the target already has —',
564
+ ' e.g. to repoint it at the database\'s private address',
565
+ ' (<slug>.apps.internal) after a hand-written hostname.',
566
+ ],
567
+ 'connection-url': [
568
+ 'Usage: inquir apps connection-url <database-app> [--private|--public]',
569
+ '',
570
+ 'Prints the private URL when available, otherwise the explicit Public',
571
+ 'Access URL. Use --private or --public to require one visibility.',
572
+ 'Public TCP exposure does not add database TLS. Prefer',
573
+ '`inquir apps connect` when the consumer is another app on the platform.',
574
+ ],
575
+ };
576
+ const BACKUPS_USAGE = 'inquir apps backups <app> [list|create|restore <backupId>|delete <backupId>]';
577
+ /** The one backup table: `create` prints it too, once the rows settle. */
578
+ function renderBackupTable(items) {
579
+ printLine(formatTable(items.map((b) => ({
580
+ id: b.id.slice(0, 8),
581
+ volume: b.volumeName,
582
+ status: b.status,
583
+ trigger: b.trigger,
584
+ size: formatBytes(b.sizeBytes ?? b.artifactSizeBytes),
585
+ created: b.createdAt.replace('T', ' ').slice(0, 19),
586
+ })), ['id', 'volume', 'status', 'trigger', 'size', 'created']));
587
+ }
588
+ /** Accept a short id, the way the release commands do. */
589
+ async function resolveBackup(applicationId, needle, identifier, apiOptions) {
590
+ const { items } = await listAppBackups(applicationId, { limit: 100 }, apiOptions);
591
+ const match = items.find((b) => b.id === needle || b.id.startsWith(needle));
592
+ if (!match)
593
+ throw usageError(`No backup matching '${needle}'`, `inquir apps backups ${identifier} list`);
594
+ return match;
595
+ }
596
+ /**
597
+ * Follow the rows a backup run created until none is PENDING. The copy outlives
598
+ * the request that started it (the API answers 202), so the rows — not the
599
+ * response — carry the outcome.
600
+ */
601
+ async function waitForBackups(applicationId, started, apiOptions, json, timeoutMs = 30 * 60_000) {
602
+ const deadline = Date.now() + timeoutMs;
603
+ const startedAt = Date.now();
604
+ const settled = new Map();
605
+ const announce = (line) => {
606
+ // Under --json only stdout must stay parseable; progress goes to stderr.
607
+ if (json)
608
+ process.stderr.write(`${line}\n`);
609
+ else
610
+ printInfo(dim(line));
611
+ };
612
+ announce(`— copying ${started.length} volume(s); a large volume takes minutes —`);
613
+ for (;;) {
614
+ for (const row of started) {
615
+ if (settled.has(row.id))
616
+ continue;
617
+ const current = await getAppBackup(applicationId, row.id, apiOptions);
618
+ if (current.status === 'PENDING')
619
+ continue;
620
+ settled.set(row.id, current);
621
+ const size = current.status === 'READY' ? ` ${formatBytes(current.sizeBytes ?? current.artifactSizeBytes)}` : '';
622
+ announce(`— ${current.volumeName} ${current.status}${size} (${formatElapsed(Date.now() - startedAt)}) —`);
623
+ }
624
+ if (settled.size === started.length)
625
+ return started.map((row) => settled.get(row.id));
626
+ if (Date.now() > deadline) {
627
+ throw new CliError(`Backup of ${applicationId} did not settle within ${Math.round(timeoutMs / 60_000)}m`, {
628
+ code: 'BACKUP_TIMEOUT',
629
+ exitCode: ExitCode.ERROR,
630
+ fix: 'The copy keeps running server-side; check the rows later.',
631
+ command: `inquir apps backups ${applicationId} list`,
632
+ });
633
+ }
634
+ await new Promise((resolve) => setTimeout(resolve, 2000));
635
+ }
636
+ }
637
+ async function backupsSubcommand(args, positionals, apiOptions) {
638
+ const [identifier, verb = 'list', backupId] = positionals;
639
+ if (!identifier) {
640
+ throw usageError('Missing application', BACKUPS_USAGE);
641
+ }
642
+ await requireAuth(apiOptions);
643
+ const app = await resolveApplication(identifier, apiOptions);
644
+ const json = readJsonFlag(args);
645
+ if (verb === 'list') {
646
+ const cursor = readFlagValue(args, '--cursor');
647
+ const volumeName = readFlagValue(args, '--volume');
648
+ const page = await listAppBackups(app.id, {
649
+ // Each flag is sent only when given, so an unset one keeps the API's
650
+ // own page size instead of pinning a second default here.
651
+ ...(readFlagValue(args, '--limit') ? { limit: readLimitFlag(args, 0) } : {}),
652
+ ...(cursor ? { cursor } : {}),
653
+ ...(volumeName ? { volumeName } : {}),
654
+ }, apiOptions);
655
+ if (json)
656
+ return printLine(JSON.stringify(page, null, 2));
657
+ if (page.items.length === 0) {
658
+ printInfo(`No backups for ${app.name} yet — create one: inquir apps backups ${app.name} create`);
659
+ return;
660
+ }
661
+ renderBackupTable(page.items);
662
+ if (page.nextCursor) {
663
+ printInfo(dim(` older: inquir apps backups ${app.name} list --cursor ${page.nextCursor}`));
664
+ }
665
+ return;
666
+ }
667
+ if (verb === 'create') {
668
+ const run = await createAppBackup(app.id, apiOptions);
669
+ if (hasFlag(args, '--no-wait')) {
670
+ if (json)
671
+ return printLine(JSON.stringify(run, null, 2));
672
+ renderBackupTable(run.items);
673
+ printInfo(dim(` follow them: inquir apps backups ${app.name} list`));
674
+ return;
675
+ }
676
+ const items = await waitForBackups(app.id, run.items, apiOptions, json);
677
+ if (json)
678
+ printLine(JSON.stringify({ items, statusUrl: run.statusUrl }, null, 2));
679
+ else
680
+ renderBackupTable(items);
681
+ // A snapshot that never became READY is a failed command, not a note.
682
+ if (items.some((b) => b.status !== 'READY'))
683
+ process.exitCode = ExitCode.ERROR;
684
+ return;
685
+ }
686
+ if (verb === 'restore') {
687
+ if (!backupId) {
688
+ throw usageError('Missing backup id', `inquir apps backups ${identifier} restore <backupId>`);
689
+ }
690
+ // Resolve the backup BEFORE asking for confirmation: a warning that cannot
691
+ // name the volume, the moment, and the size is a warning nobody reads.
692
+ const match = await resolveBackup(app.id, backupId, identifier, apiOptions);
693
+ if (match.status !== 'READY') {
694
+ throw usageError(`Backup ${match.id.slice(0, 8)} is ${match.status} and cannot be restored`, `inquir apps backups ${identifier} list`);
695
+ }
696
+ const when = match.createdAt.replace('T', ' ').slice(0, 16);
697
+ const summary = `${app.name} volume ${match.volumeName} from the ${when} backup (${formatBytes(match.sizeBytes ?? match.artifactSizeBytes)})`;
698
+ if (!runtime().yes) {
699
+ const approved = await confirm(`Overwrite ${summary}? Everything written since ${when} is lost, and ${app.name} stops during the restore.`, false);
700
+ if (!approved) {
701
+ throw usageError(`Refusing to restore ${app.name} without confirmation`, `inquir apps backups ${identifier} restore ${match.id.slice(0, 8)} --yes`);
702
+ }
703
+ }
704
+ await restoreAppBackup(app.id, match.id, apiOptions);
705
+ if (json) {
706
+ return printLine(JSON.stringify({ restored: true, application: app.name, backupId: match.id, volumeName: match.volumeName, createdAt: match.createdAt }, null, 2));
707
+ }
708
+ printSuccess(`Restored ${app.name} volume ${match.volumeName} from backup ${match.id.slice(0, 8)}`);
709
+ return;
710
+ }
711
+ if (verb === 'delete') {
712
+ if (!backupId) {
713
+ throw usageError('Missing backup id', `inquir apps backups ${identifier} delete <backupId>`);
714
+ }
715
+ const match = await resolveBackup(app.id, backupId, identifier, apiOptions);
716
+ const when = match.createdAt.replace('T', ' ').slice(0, 16);
717
+ if (!runtime().yes) {
718
+ const approved = await confirm(`Delete the ${when} backup of ${app.name} volume ${match.volumeName}? This restore point is gone for good.`, false);
719
+ if (!approved) {
720
+ throw usageError(`Refusing to delete a backup of ${app.name} without confirmation`, `inquir apps backups ${identifier} delete ${match.id.slice(0, 8)} --yes`);
721
+ }
722
+ }
723
+ await deleteAppBackup(app.id, match.id, apiOptions);
724
+ if (json) {
725
+ return printLine(JSON.stringify({ deleted: true, application: app.name, backupId: match.id, volumeName: match.volumeName, createdAt: match.createdAt }, null, 2));
726
+ }
727
+ printSuccess(`Deleted backup ${match.id.slice(0, 8)} of ${app.name} volume ${match.volumeName}`);
728
+ return;
729
+ }
730
+ throw usageError(`Unknown backups action '${verb}'`, BACKUPS_USAGE);
731
+ }
732
+ async function connectSubcommand(args, positionals, apiOptions) {
733
+ const [dbIdentifier, targetIdentifier] = positionals;
734
+ if (!dbIdentifier || !targetIdentifier) {
735
+ throw usageError('Usage: apps connect <database-app> <target-app>', 'inquir apps connect mydb web');
736
+ }
737
+ await requireAuth(apiOptions);
738
+ const db = await resolveApplication(dbIdentifier, apiOptions);
739
+ // The server refuses to replace a key the target already carries; the flag
740
+ // is how an app is repointed at the database's current private address
741
+ // (the composed URL never passes through this terminal).
742
+ const overwrite = args.includes('--overwrite');
743
+ const result = await connectApps(db.id, targetIdentifier, { ...apiOptions, overwrite });
744
+ if (readJsonFlag(args)) {
745
+ return printLine(JSON.stringify(result, null, 2));
746
+ }
747
+ printSuccess(`${result.envKey} set on ${result.target}`);
748
+ printInfo(dim(` Applies on the next release — deploy it: inquir apps deploy ${result.target}`));
749
+ }
750
+ async function connectionUrlSubcommand(args, positionals, apiOptions) {
751
+ const [identifier] = positionals;
752
+ if (!identifier)
753
+ throw usageError('Missing application', 'inquir apps connection-url mydb');
754
+ await requireAuth(apiOptions);
755
+ const app = await resolveApplication(identifier, apiOptions);
756
+ const result = await getAppConnectionUrl(app.id, apiOptions);
757
+ const requirePrivate = args.includes('--private');
758
+ const requirePublic = args.includes('--public');
759
+ if (requirePrivate && requirePublic) {
760
+ throw usageError('Use either --private or --public, not both', 'inquir apps connection-url mydb --private');
761
+ }
762
+ // Print BEFORE registering: this command exists to hand the credential over,
763
+ // and the redaction filter would otherwise mask its own output. Registering
764
+ // afterwards keeps it out of anything that prints later in this process.
765
+ if (readJsonFlag(args)) {
766
+ printLine(JSON.stringify(result, null, 2));
767
+ if (result.url)
768
+ registerSecret(result.url);
769
+ if (result.publicUrl)
770
+ registerSecret(result.publicUrl);
771
+ return;
772
+ }
773
+ const selected = selectConnectionUrl(result, requirePrivate ? 'private' : requirePublic ? 'public' : 'auto');
774
+ printLine(selected.url);
775
+ registerSecret(selected.url);
776
+ if (process.stderr.isTTY) {
777
+ if (selected.visibility === 'public') {
778
+ process.stderr.write(dim('Public TCP exposure does not add database TLS; require and verify TLS in the database itself.\n'));
779
+ }
780
+ process.stderr.write(dim(`This is a live credential and it is now in your shell history. To grant another app access without copying it: inquir apps connect ${app.name} <target-app>\n`));
781
+ }
782
+ }
783
+ export function selectConnectionUrl(result, preference = 'auto') {
784
+ if (preference !== 'public' && result.url) {
785
+ return { url: result.url, visibility: 'private' };
786
+ }
787
+ if (preference !== 'private' && result.publicUrl) {
788
+ return { url: result.publicUrl, visibility: 'public' };
789
+ }
790
+ const requested = preference === 'auto' ? 'private or public' : preference;
791
+ throw usageError(`No ${requested} database connection URL is available`, preference === 'public'
792
+ ? 'Enable Public Access in the application Networking settings.'
793
+ : 'Configure private networking, or use --public after explicitly enabling Public Access.');
794
+ }
244
795
  async function deploySubcommand(args, positionals, apiOptions) {
245
796
  const [identifier] = positionals;
246
797
  const image = readFlagValue(args, '--image');
@@ -253,8 +804,9 @@ async function deploySubcommand(args, positionals, apiOptions) {
253
804
  }
254
805
  await requireAuth(apiOptions);
255
806
  const app = await resolveApplication(identifier, apiOptions);
256
- const runtimeSpec = buildRuntimeFromFlags(args);
807
+ const runtimeSpec = buildRuntimeFromFlags(args, app.runtime);
257
808
  const json = readJsonFlag(args);
809
+ const promote = hasFlag(args, '--promote');
258
810
  // No image and no build id: build the source directory first, then release
259
811
  // the produced image. That is the Railway-shaped default — a Dockerfile in
260
812
  // the current directory is all a project needs.
@@ -270,12 +822,23 @@ async function deploySubcommand(args, positionals, apiOptions) {
270
822
  }
271
823
  buildId = build.id;
272
824
  }
273
- const queued = await createRelease(app.id, {
274
- ...(buildId ? { buildId } : { imageRef: image }),
275
- ...(runtimeSpec ? { runtime: runtimeSpec } : {}),
276
- }, apiOptions);
825
+ let queued;
826
+ try {
827
+ queued = await createRelease(app.id, {
828
+ ...(buildId ? { buildId } : { imageRef: image }),
829
+ ...(runtimeSpec ? { runtime: runtimeSpec } : {}),
830
+ ...(promote ? { promote: true } : {}),
831
+ }, apiOptions);
832
+ }
833
+ catch (err) {
834
+ if (err instanceof Error)
835
+ err.message = humanizeApiError(err.message);
836
+ throw err;
837
+ }
277
838
  if (!json) {
278
839
  printInfo(`Release ${queued.releaseId.slice(0, 8)} queued for ${app.name} (${image ?? `build ${buildId.slice(0, 8)}`})`);
840
+ if (promote)
841
+ printInfo(dim(' it takes production by itself once healthy'));
279
842
  }
280
843
  if (hasFlag(args, '--no-wait')) {
281
844
  if (json)
@@ -284,17 +847,109 @@ async function deploySubcommand(args, positionals, apiOptions) {
284
847
  printInfo(dim(` status: inquir apps status ${app.name}`));
285
848
  return;
286
849
  }
287
- const release = await waitForRelease(queued.releaseId, apiOptions);
850
+ await followQueuedRelease(app, queued, promote, json, apiOptions);
851
+ }
852
+ /**
853
+ * Wait for a queued release to settle and say where it ended up; with
854
+ * promote-on-ready, also wait for production to point at it.
855
+ */
856
+ async function followQueuedRelease(app, queued, promote, json, apiOptions) {
857
+ let release = await waitForRelease(queued.releaseId, apiOptions);
858
+ const production = promote && isHealthyRelease(release)
859
+ ? await waitForProduction(app.id, queued.releaseId, apiOptions)
860
+ : undefined;
861
+ if (production)
862
+ release = production.release;
863
+ const served = production?.servingProduction ?? false;
288
864
  if (json) {
289
- printLine(JSON.stringify(release, null, 2));
865
+ printLine(JSON.stringify(promote ? {
866
+ ...release,
867
+ servingProduction: served,
868
+ ...(production?.promotionError ? { promotionError: production.promotionError } : {}),
869
+ } : release, null, 2));
290
870
  }
291
871
  else {
292
872
  renderRelease(release, queued.previewUrl);
873
+ if (production) {
874
+ if (served)
875
+ printSuccess(`Production now serves ${queued.releaseId.slice(0, 8)}`);
876
+ else
877
+ printError(`${production.promotionError.message} — see: inquir apps status ${app.name}`);
878
+ }
293
879
  }
294
- // A release that never became healthy is a failed command, not a note.
295
- if (release.status === 'FAILED')
880
+ // A settled release can be stopped, and a healthy preview is not the
881
+ // requested result when the caller explicitly asked to serve production.
882
+ if (!isHealthyRelease(release) || (promote && !served))
296
883
  process.exitCode = ExitCode.ERROR;
297
884
  }
885
+ function isHealthyRelease(release) {
886
+ return release.status === 'READY' || release.status === 'ACTIVE';
887
+ }
888
+ /**
889
+ * Observe both the production pointer and current release health. Return the
890
+ * latest row so a late failure cannot be hidden by the earlier READY snapshot.
891
+ * Transport/auth errors propagate; refused promotion and timeout are outcomes.
892
+ */
893
+ async function waitForProduction(applicationId, releaseId, apiOptions, timeoutMs = 120_000) {
894
+ const deadline = Date.now() + timeoutMs;
895
+ for (;;) {
896
+ const app = await getApplication(applicationId, apiOptions);
897
+ const release = await getRelease(releaseId, apiOptions);
898
+ if (app.productionReleaseId === releaseId && isHealthyRelease(release)) {
899
+ return { release, servingProduction: true };
900
+ }
901
+ if (release.status === 'FAILED' || release.status === 'STOPPED' || release.failureReason?.startsWith('Auto-promote failed')) {
902
+ return {
903
+ release, servingProduction: false,
904
+ promotionError: {
905
+ code: 'PROMOTION_FAILED',
906
+ message: release.failureReason ?? `Release ${releaseId} is ${release.status.toLowerCase()} and is not serving production`,
907
+ },
908
+ };
909
+ }
910
+ if (Date.now() >= deadline) {
911
+ return {
912
+ release, servingProduction: false,
913
+ promotionError: {
914
+ code: 'PROMOTION_TIMEOUT',
915
+ message: `Release ${releaseId} did not begin serving production within ${Math.round(timeoutMs / 1000)}s. The CLI stopped waiting; check its status before retrying`,
916
+ },
917
+ };
918
+ }
919
+ await new Promise((resolve) => setTimeout(resolve, 2000));
920
+ }
921
+ }
922
+ async function redeploySubcommand(args, positionals, apiOptions) {
923
+ const [identifier] = positionals;
924
+ if (!identifier)
925
+ throw usageError('Missing application', 'inquir apps redeploy <app>');
926
+ await requireAuth(apiOptions);
927
+ const app = await resolveApplication(identifier, apiOptions);
928
+ const json = readJsonFlag(args);
929
+ const promote = !hasFlag(args, '--no-promote');
930
+ let queued;
931
+ try {
932
+ queued = await redeployApplication(app.id, { promote }, apiOptions);
933
+ }
934
+ catch (err) {
935
+ if (err instanceof Error)
936
+ err.message = humanizeApiError(err.message);
937
+ throw err;
938
+ }
939
+ if (!json) {
940
+ printInfo(`Release ${queued.releaseId.slice(0, 8)} queued for ${app.name} (redeploy of ${queued.redeployOf.slice(0, 8)})`);
941
+ if (promote)
942
+ printInfo(dim(' it takes production by itself once healthy'));
943
+ }
944
+ if (hasFlag(args, '--no-wait')) {
945
+ if (json)
946
+ printLine(JSON.stringify(queued, null, 2));
947
+ else
948
+ printInfo(dim(` status: inquir apps status ${app.name}`));
949
+ return;
950
+ }
951
+ await followQueuedRelease(app, queued, promote, json, apiOptions);
952
+ }
298
953
  /**
299
954
  * Zip a source directory, queue a build for it and follow the build log until
300
955
  * it settles. Returns the final build row.
@@ -393,8 +1048,8 @@ async function waitForRelease(releaseId, apiOptions, timeoutMs = 10 * 60_000) {
393
1048
  }
394
1049
  function renderRelease(release, previewUrl) {
395
1050
  const shown = presentRelease(release);
396
- if (release.status === 'FAILED') {
397
- printLine(`✗ Release ${release.id.slice(0, 8)} failed: ${release.failureReason ?? 'unknown reason'}`);
1051
+ if (!isHealthyRelease(release)) {
1052
+ printError(`Release ${release.id.slice(0, 8)} is ${shown.label}${release.failureReason ? `: ${release.failureReason}` : ''}`);
398
1053
  if (shown.hint)
399
1054
  printInfo(dim(` ${shown.hint}`));
400
1055
  printInfo(dim(` logs: inquir apps logs ${release.id}`));
@@ -443,6 +1098,51 @@ export function releaseEndpointLines(release, previewUrl, options = {}) {
443
1098
  }
444
1099
  return lines;
445
1100
  }
1101
+ /**
1102
+ * Where same-workspace services reach this application. The server projects
1103
+ * `privateDomain` only once the private route is verified end to end, so a
1104
+ * missing domain on an installation with private networking means "still
1105
+ * being reconciled", not "no such thing" — and that is worth a line too.
1106
+ */
1107
+ export function privateEndpointLines(detail, active, options = {}) {
1108
+ if (!detail.privateDomain) {
1109
+ return options.privateNetworking
1110
+ ? ['(provisioning — private DNS and routing are being verified)']
1111
+ : [];
1112
+ }
1113
+ const lines = [];
1114
+ if (detail.privateBaseUrl)
1115
+ lines.push(detail.privateBaseUrl);
1116
+ for (const port of active?.ports ?? []) {
1117
+ if (port.protocol !== 'tcp' || !port.containerPort)
1118
+ continue;
1119
+ lines.push(`${detail.privateDomain}:${port.containerPort} (${port.name})`);
1120
+ }
1121
+ return lines;
1122
+ }
1123
+ /**
1124
+ * The networkPolicy patch for `--egress` / `--ingress`. The API requires
1125
+ * egress inside networkPolicy, so a lone --ingress change carries the
1126
+ * application's current egress along unchanged, and vice versa; with neither
1127
+ * flag the policy is left alone (the server's defaults apply on create).
1128
+ */
1129
+ export function buildNetworkPolicyPatch(input) {
1130
+ const { egress, ingress, current } = input;
1131
+ if (egress && egress !== 'none' && egress !== 'full') {
1132
+ throw usageError(`Invalid --egress '${egress}'`, 'inquir apps update <app> --egress none');
1133
+ }
1134
+ if (ingress && ingress !== 'private' && ingress !== 'public') {
1135
+ throw usageError(`Invalid --ingress '${ingress}'`, 'inquir apps update <app> --ingress public');
1136
+ }
1137
+ if (!egress && !ingress)
1138
+ return undefined;
1139
+ const resolvedIngress = (ingress ?? current?.ingress);
1140
+ return {
1141
+ egress: (egress ?? current?.egress ?? 'full'),
1142
+ allow: [],
1143
+ ...(resolvedIngress ? { ingress: resolvedIngress } : {}),
1144
+ };
1145
+ }
446
1146
  /** A build-sourced release is best named by its build; a pulled one by its ref. */
447
1147
  export function describeImage(release) {
448
1148
  if (release.imageSource === 'build' && release.buildId)
@@ -462,18 +1162,69 @@ function describeRuntimePorts(spec) {
462
1162
  }
463
1163
  return parts.join(' ');
464
1164
  }
1165
+ function formatDurationSeconds(seconds) {
1166
+ const units = [
1167
+ [86_400, 'd'],
1168
+ [3600, 'h'],
1169
+ [60, 'm'],
1170
+ [1, 's'],
1171
+ ];
1172
+ let remaining = seconds;
1173
+ const parts = [];
1174
+ for (const [size, suffix] of units) {
1175
+ const count = Math.floor(remaining / size);
1176
+ if (count > 0 || (size === 1 && parts.length === 0)) {
1177
+ parts.push(`${count}${suffix}`);
1178
+ remaining -= count * size;
1179
+ }
1180
+ }
1181
+ return parts.join(' ');
1182
+ }
1183
+ export function describeScalingPolicy(policy) {
1184
+ const range = `${policy.minReplicas}–${policy.maxReplicas} replicas`;
1185
+ if (policy.activation === 'http') {
1186
+ return `http (sleep after ${formatDurationSeconds(policy.idleTimeoutSeconds)} idle), ${range}`;
1187
+ }
1188
+ return `${policy.activation}, ${range}`;
1189
+ }
465
1190
  async function statusSubcommand(args, positionals, apiOptions) {
466
1191
  const [identifier] = positionals;
467
1192
  if (!identifier)
468
1193
  throw usageError('Missing application', 'inquir apps status <app>');
469
1194
  await requireAuth(apiOptions);
470
1195
  const app = await resolveApplication(identifier, apiOptions);
471
- const detail = await getApplication(app.id, apiOptions);
472
- if (readJsonFlag(args)) {
1196
+ const json = readJsonFlag(args);
1197
+ // The apps suffix lives on the platform limits, never on the application, so
1198
+ // the permanent address needs both. Fetched alongside the detail and only
1199
+ // for the human output; losing it costs the address line, nothing else.
1200
+ const [detail, limits] = await Promise.all([
1201
+ getApplication(app.id, apiOptions),
1202
+ json ? Promise.resolve(null) : getAppLimits(apiOptions).catch(() => null),
1203
+ ]);
1204
+ if (json) {
473
1205
  printLine(JSON.stringify(detail, null, 2));
474
1206
  return;
475
1207
  }
476
1208
  printLine(`${detail.name}${detail.description ? dim(` — ${detail.description}`) : ''}`);
1209
+ if (detail.scaling)
1210
+ printLine(` scaling: ${describeScalingPolicy(detail.scaling)}`);
1211
+ if (detail.desiredReplicas !== undefined) {
1212
+ const cap = detail.scaling ? ` (maximum ${detail.scaling.maxReplicas})` : '';
1213
+ printLine(` replicas: ${detail.desiredReplicas} desired${cap}`);
1214
+ }
1215
+ const pinnedRunner = detail.volumeRunnerId;
1216
+ if (pinnedRunner)
1217
+ printLine(` volume pin: runner ${pinnedRunner}`);
1218
+ if (detail.lastActivityAt)
1219
+ printLine(` activity: ${detail.lastActivityAt}`);
1220
+ if (detail.lastScaledAt)
1221
+ printLine(` last scale: ${detail.lastScaledAt}`);
1222
+ // Where this app answers forever, as opposed to where it answers now: the
1223
+ // production lines below follow the routes, this one is the identity a
1224
+ // custom domain should CNAME to.
1225
+ const address = permanentAddress(detail.slug, limits?.appsPublicSuffix);
1226
+ if (address)
1227
+ printLine(` address: ${address} ${dim('(permanent — a rename never moves it)')}`);
477
1228
  for (const url of detail.production?.urls ?? [])
478
1229
  printLine(` production: ${url}`);
479
1230
  if (detail.production && (detail.production.urls ?? []).length === 0) {
@@ -486,6 +1237,29 @@ async function statusSubcommand(args, positionals, apiOptions) {
486
1237
  const endpoints = active ? releaseEndpointLines(active, undefined, { includeHostLocal: !hasProductionUrl }) : [];
487
1238
  for (const endpoint of endpoints)
488
1239
  printLine(` endpoint: ${endpoint}`);
1240
+ // The private address is what another app in the workspace dials; it is
1241
+ // shown only once the platform has verified the route, never guessed.
1242
+ const privateLines = privateEndpointLines(detail, active, {
1243
+ privateNetworking: limits?.features?.privateNetworking,
1244
+ });
1245
+ for (const line of privateLines)
1246
+ printLine(` private: ${line}`);
1247
+ // The serving release's frozen runtime is the truth about what is mounted
1248
+ // NOW. An app-level volume added after the deploy applies only to the next
1249
+ // release — showing it as mounted would tell the operator their database is
1250
+ // persistent when it is not.
1251
+ const mountedVolumes = active ? (active.runtime?.volumes ?? []) : (detail.runtime?.volumes ?? []);
1252
+ for (const volume of mountedVolumes) {
1253
+ printLine(` volume: ${volume.name} → ${volume.mountPath}${volume.readOnly ? ' (read-only)' : ''}`);
1254
+ }
1255
+ if (active) {
1256
+ const mountedNames = new Set(mountedVolumes.map((volume) => volume.name));
1257
+ for (const volume of detail.runtime?.volumes ?? []) {
1258
+ if (!mountedNames.has(volume.name)) {
1259
+ printLine(` volume: ${volume.name} → ${volume.mountPath} ${dim('(next release — not mounted yet)')}`);
1260
+ }
1261
+ }
1262
+ }
489
1263
  for (const domain of detail.domains ?? []) {
490
1264
  if (domain.custom && !domain.verified) {
491
1265
  printLine(` domain: ${domain.hostname} ${dim('(unverified — inquir apps domain ' + detail.name + ' ' + domain.hostname + ' --verify)')}`);
@@ -737,6 +1511,59 @@ async function buildsSubcommand(args, positionals, apiOptions) {
737
1511
  created: b.createdAt,
738
1512
  })), ['build', 'status', 'dockerfile', 'image', 'cached', 'created']));
739
1513
  }
1514
+ /** `apps scale <app> [always|http|manual]` — update only the scaling policy. */
1515
+ async function scaleSubcommand(args, positionals, apiOptions) {
1516
+ const [identifier, positionalActivation, ...extra] = positionals;
1517
+ if (!identifier) {
1518
+ throw usageError('Missing application', 'inquir apps scale <app> <always|http|manual>');
1519
+ }
1520
+ if (extra.length > 0) {
1521
+ throw usageError(`Unexpected argument '${extra[0]}'`, 'inquir apps scale <app> <always|http|manual>');
1522
+ }
1523
+ const scaling = buildScalingPatchFromFlags(args, positionalActivation);
1524
+ if (Object.keys(scaling).length === 0) {
1525
+ throw usageError('Nothing to scale', 'inquir apps scale <app> <always|http|manual> [--idle-timeout 15m]');
1526
+ }
1527
+ await requireAuth(apiOptions);
1528
+ const app = await resolveApplication(identifier, apiOptions);
1529
+ const updated = await updateApplication(app.id, { scaling }, apiOptions);
1530
+ if (readJsonFlag(args)) {
1531
+ printLine(JSON.stringify(updated, null, 2));
1532
+ return;
1533
+ }
1534
+ printSuccess(`Updated scaling for ${updated.name}`);
1535
+ printInfo(dim(` ${describeScalingPolicy(updated.scaling)}`));
1536
+ printInfo(dim(` desired replicas: ${updated.desiredReplicas}`));
1537
+ }
1538
+ async function runScaleActionSubcommand(action, args, positionals, apiOptions) {
1539
+ const [identifier, ...extra] = positionals;
1540
+ if (!identifier)
1541
+ throw usageError('Missing application', `inquir apps ${action} <app>`);
1542
+ if (extra.length > 0) {
1543
+ throw usageError(`Unexpected argument '${extra[0]}'`, `inquir apps ${action} <app>`);
1544
+ }
1545
+ await requireAuth(apiOptions);
1546
+ const app = await resolveApplication(identifier, apiOptions);
1547
+ const result = action === 'wake'
1548
+ ? await wakeApplication(app.id, apiOptions)
1549
+ : await pauseApplication(app.id, apiOptions);
1550
+ if (readJsonFlag(args)) {
1551
+ printLine(JSON.stringify(result, null, 2));
1552
+ return;
1553
+ }
1554
+ const verb = action === 'wake' ? 'Wake' : 'Pause';
1555
+ if (result.accepted)
1556
+ printSuccess(`${verb} requested for ${app.name}`);
1557
+ else
1558
+ printInfo(`${verb} was not accepted for ${app.name}`);
1559
+ printInfo(dim(` desired replicas: ${result.desiredReplicas}${result.state ? ` (${result.state})` : ''}`));
1560
+ }
1561
+ async function wakeSubcommand(args, positionals, apiOptions) {
1562
+ return runScaleActionSubcommand('wake', args, positionals, apiOptions);
1563
+ }
1564
+ async function pauseSubcommand(args, positionals, apiOptions) {
1565
+ return runScaleActionSubcommand('pause', args, positionals, apiOptions);
1566
+ }
740
1567
  /** `apps update <app>` — env vars, ports, resources, health, egress. */
741
1568
  async function updateSubcommand(args, positionals, apiOptions) {
742
1569
  const [identifier] = positionals;
@@ -745,19 +1572,22 @@ async function updateSubcommand(args, positionals, apiOptions) {
745
1572
  await requireAuth(apiOptions);
746
1573
  const app = await resolveApplication(identifier, apiOptions);
747
1574
  const envVars = parseSetFlags(readMultiFlag(args, '--set'));
748
- const runtimeSpec = buildRuntimeFromFlags(args);
749
- const egress = readFlagValue(args, '--egress');
1575
+ const runtimeSpec = buildRuntimeFromFlags(args, app.runtime);
750
1576
  const description = readFlagValue(args, '--description');
751
- if (egress && egress !== 'none' && egress !== 'full') {
752
- throw usageError(`Invalid --egress '${egress}'`, 'inquir apps update <app> --egress none');
753
- }
1577
+ const networkPolicy = buildNetworkPolicyPatch({
1578
+ egress: readFlagValue(args, '--egress'),
1579
+ ingress: readFlagValue(args, '--ingress'),
1580
+ current: app.networkPolicy,
1581
+ });
1582
+ const volumeRunner = readFlagValue(args, '--volume-runner');
754
1583
  const patch = {
755
1584
  ...(description !== undefined ? { description } : {}),
1585
+ ...(volumeRunner ? { volumeRunner } : {}),
756
1586
  ...(runtimeSpec ? { runtime: runtimeSpec } : {}),
757
1587
  // Only the keys given here change; the server merges masked values, so
758
1588
  // untouched secrets survive.
759
1589
  ...(Object.keys(envVars).length > 0 ? { envVars: { ...(app.envVars ?? {}), ...envVars } } : {}),
760
- ...(egress ? { networkPolicy: { egress: egress, allow: [] } } : {}),
1590
+ ...(networkPolicy ? { networkPolicy } : {}),
761
1591
  };
762
1592
  if (Object.keys(patch).length === 0) {
763
1593
  throw usageError('Nothing to update', 'inquir apps update <app> --set KEY=VALUE');
@@ -773,16 +1603,19 @@ async function updateSubcommand(args, positionals, apiOptions) {
773
1603
  async function execSubcommand(args, positionals, apiOptions) {
774
1604
  const [identifier, ...commandParts] = positionals;
775
1605
  if (!identifier)
776
- throw usageError('Missing release', 'inquir apps exec <app> -- <command>');
1606
+ throw usageError('Missing application', 'inquir apps exec <app> -- <command>');
777
1607
  const command = commandParts.join(' ').trim();
778
1608
  if (!command)
779
1609
  throw usageError('Missing command', 'inquir apps exec <app> -- ls -la');
780
1610
  await requireAuth(apiOptions);
781
- const releaseId = await resolveReleaseId(identifier, apiOptions);
1611
+ // `apps exec` is intentionally application-scoped. Accepting a stale or
1612
+ // preview release id here and then calling the production-only app endpoint
1613
+ // would silently change the operator's target to production.
1614
+ const application = await resolveApplication(identifier, apiOptions);
782
1615
  const timeoutRaw = readFlagValue(args, '--timeout');
783
1616
  let result;
784
1617
  try {
785
- result = await execInRelease(releaseId, command, timeoutRaw ? Number(timeoutRaw) : undefined, apiOptions);
1618
+ result = await execInApplication(application.id, command, timeoutRaw ? Number(timeoutRaw) : undefined, apiOptions);
786
1619
  }
787
1620
  catch (err) {
788
1621
  if (err instanceof Error)
@@ -889,6 +1722,9 @@ async function deleteSubcommand(args, positionals, apiOptions) {
889
1722
  });
890
1723
  }
891
1724
  await archiveApplication(app.id, apiOptions);
1725
+ if (readJsonFlag(args)) {
1726
+ return printLine(JSON.stringify({ ok: true, archived: { id: app.id, name: app.name } }, null, 2));
1727
+ }
892
1728
  printSuccess(`Archived ${app.name}`);
893
1729
  printInfo(dim(' every release was stopped and every hostname unbound'));
894
1730
  }