@inquir/compute-cli 0.2.2 → 0.4.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 (119) hide show
  1. package/README.md +228 -21
  2. package/dist/commands/admin-tenants.d.ts +11 -0
  3. package/dist/commands/admin-tenants.d.ts.map +1 -0
  4. package/dist/commands/admin-tenants.js +318 -0
  5. package/dist/commands/admin-tenants.js.map +1 -0
  6. package/dist/commands/admin.d.ts +2 -0
  7. package/dist/commands/admin.d.ts.map +1 -0
  8. package/dist/commands/admin.js +445 -0
  9. package/dist/commands/admin.js.map +1 -0
  10. package/dist/commands/apps-describe.d.ts +9 -0
  11. package/dist/commands/apps-describe.d.ts.map +1 -0
  12. package/dist/commands/apps-describe.js +118 -0
  13. package/dist/commands/apps-describe.js.map +1 -0
  14. package/dist/commands/apps-observability.d.ts +9 -0
  15. package/dist/commands/apps-observability.d.ts.map +1 -0
  16. package/dist/commands/apps-observability.js +281 -0
  17. package/dist/commands/apps-observability.js.map +1 -0
  18. package/dist/commands/apps-promote.d.ts +31 -0
  19. package/dist/commands/apps-promote.d.ts.map +1 -0
  20. package/dist/commands/apps-promote.js +115 -0
  21. package/dist/commands/apps-promote.js.map +1 -0
  22. package/dist/commands/apps-resources.d.ts +6 -0
  23. package/dist/commands/apps-resources.d.ts.map +1 -0
  24. package/dist/commands/apps-resources.js +107 -0
  25. package/dist/commands/apps-resources.js.map +1 -0
  26. package/dist/commands/apps.d.ts +65 -5
  27. package/dist/commands/apps.d.ts.map +1 -1
  28. package/dist/commands/apps.js +1293 -89
  29. package/dist/commands/apps.js.map +1 -1
  30. package/dist/commands/config.d.ts.map +1 -1
  31. package/dist/commands/config.js +7 -3
  32. package/dist/commands/config.js.map +1 -1
  33. package/dist/commands/delete.d.ts.map +1 -1
  34. package/dist/commands/delete.js +10 -2
  35. package/dist/commands/delete.js.map +1 -1
  36. package/dist/commands/deploy.d.ts +38 -0
  37. package/dist/commands/deploy.d.ts.map +1 -1
  38. package/dist/commands/deploy.js +165 -12
  39. package/dist/commands/deploy.js.map +1 -1
  40. package/dist/commands/doctor.js +2 -2
  41. package/dist/commands/init.d.ts.map +1 -1
  42. package/dist/commands/init.js +3 -0
  43. package/dist/commands/init.js.map +1 -1
  44. package/dist/commands/login.d.ts.map +1 -1
  45. package/dist/commands/login.js +96 -20
  46. package/dist/commands/login.js.map +1 -1
  47. package/dist/commands/logout.d.ts.map +1 -1
  48. package/dist/commands/logout.js +37 -5
  49. package/dist/commands/logout.js.map +1 -1
  50. package/dist/commands/mcp.d.ts +13 -0
  51. package/dist/commands/mcp.d.ts.map +1 -1
  52. package/dist/commands/mcp.js +184 -77
  53. package/dist/commands/mcp.js.map +1 -1
  54. package/dist/commands/projects.d.ts +2 -0
  55. package/dist/commands/projects.d.ts.map +1 -0
  56. package/dist/commands/projects.js +194 -0
  57. package/dist/commands/projects.js.map +1 -0
  58. package/dist/index.js +11 -0
  59. package/dist/index.js.map +1 -1
  60. package/dist/lib/finite-source-command-client.d.ts +3 -0
  61. package/dist/lib/finite-source-command-client.d.ts.map +1 -0
  62. package/dist/lib/finite-source-command-client.js +3 -0
  63. package/dist/lib/finite-source-command-client.js.map +1 -0
  64. package/dist/lib/mcp-io.d.ts +23 -0
  65. package/dist/lib/mcp-io.d.ts.map +1 -0
  66. package/dist/lib/mcp-io.js +185 -0
  67. package/dist/lib/mcp-io.js.map +1 -0
  68. package/dist/lib/mcp-source-commands.d.ts +17 -0
  69. package/dist/lib/mcp-source-commands.d.ts.map +1 -0
  70. package/dist/lib/mcp-source-commands.js +345 -0
  71. package/dist/lib/mcp-source-commands.js.map +1 -0
  72. package/dist/utils/api.d.ts +58 -8
  73. package/dist/utils/api.d.ts.map +1 -1
  74. package/dist/utils/api.js +86 -7
  75. package/dist/utils/api.js.map +1 -1
  76. package/dist/utils/apps-presentation.d.ts +11 -1
  77. package/dist/utils/apps-presentation.d.ts.map +1 -1
  78. package/dist/utils/apps-presentation.js +37 -0
  79. package/dist/utils/apps-presentation.js.map +1 -1
  80. package/dist/utils/args.d.ts.map +1 -1
  81. package/dist/utils/args.js +8 -1
  82. package/dist/utils/args.js.map +1 -1
  83. package/dist/utils/assets.d.ts +48 -1
  84. package/dist/utils/assets.d.ts.map +1 -1
  85. package/dist/utils/assets.js +185 -7
  86. package/dist/utils/assets.js.map +1 -1
  87. package/dist/utils/auth.d.ts +29 -2
  88. package/dist/utils/auth.d.ts.map +1 -1
  89. package/dist/utils/auth.js +164 -27
  90. package/dist/utils/auth.js.map +1 -1
  91. package/dist/utils/browser.d.ts +12 -0
  92. package/dist/utils/browser.d.ts.map +1 -1
  93. package/dist/utils/browser.js +37 -16
  94. package/dist/utils/browser.js.map +1 -1
  95. package/dist/utils/context.d.ts.map +1 -1
  96. package/dist/utils/context.js +2 -0
  97. package/dist/utils/context.js.map +1 -1
  98. package/dist/utils/deploy-follow.d.ts +4 -3
  99. package/dist/utils/deploy-follow.d.ts.map +1 -1
  100. package/dist/utils/deploy-follow.js +76 -17
  101. package/dist/utils/deploy-follow.js.map +1 -1
  102. package/dist/utils/detect.d.ts +49 -1
  103. package/dist/utils/detect.d.ts.map +1 -1
  104. package/dist/utils/detect.js +153 -12
  105. package/dist/utils/detect.js.map +1 -1
  106. package/dist/utils/ensure-auth.d.ts.map +1 -1
  107. package/dist/utils/ensure-auth.js +6 -2
  108. package/dist/utils/ensure-auth.js.map +1 -1
  109. package/dist/utils/package-metadata.js +1 -1
  110. package/dist/utils/project-context.d.ts +17 -0
  111. package/dist/utils/project-context.d.ts.map +1 -0
  112. package/dist/utils/project-context.js +96 -0
  113. package/dist/utils/project-context.js.map +1 -0
  114. package/dist/utils/project-lookup.d.ts +10 -0
  115. package/dist/utils/project-lookup.d.ts.map +1 -0
  116. package/dist/utils/project-lookup.js +52 -0
  117. package/dist/utils/project-lookup.js.map +1 -0
  118. package/package.json +7 -4
  119. package/skill/SKILL.md +15 -2
@@ -1,27 +1,41 @@
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';
4
- import { createZipBase64, isSettledReleaseStatus, isTerminalBuildStatus } from '../sdk.js';
5
- import { formatElapsed, humanizeApiError, presentRelease } from '../utils/apps-presentation.js';
2
+ import { alertsSubcommand, logHistorySubcommand, metricsSubcommand, OBSERVATION_HELP, OBSERVATION_VALUE_FLAGS } from './apps-observability.js';
3
+ import { RESOURCES_HELP, RESOURCES_VALUE_FLAGS, resourcesSubcommand } from './apps-resources.js';
4
+ import { DESCRIBE_HELP, describeSubcommand } from './apps-describe.js';
5
+ import { followPromoteRollout } from './apps-promote.js';
6
+ import { hasFlag, readFlagValue, readJsonFlag, readLimitFlag, readMultiFlag, readPositionalArgs, } from '../utils/args.js';
7
+ import { listAppBackups, getAppBackup, createAppBackup, deleteAppBackup, restoreAppBackup, getAppConnectionUrl, connectApps, addApplicationDomain, archiveApplication, cancelBuild, createApplication, getBuild, listApplicationDomains, listBuilds, queueBuild, removeApplicationDomain, createRelease, redeployApplication, execInApplication, getAppLimits, getApplication, getProject, getRelease, listApplications, promoteRelease, isPromoteAccepted, requireAuth, resolveApplication, rollbackApplication, stopRelease, streamBuildLogs, streamReleaseLogs, updateApplication, wakeApplication, pauseApplication, verifyApplicationDomain, } from '../utils/api.js';
8
+ import { ApiError, DEFAULT_BUILD_TIMEOUT_POLICY, createZipBase64, isSettledReleaseStatus, isTerminalBuildStatus } from '../sdk.js';
9
+ import { formatBytes, formatElapsed, humanizeApiError, permanentAddress, presentRelease, } from '../utils/apps-presentation.js';
6
10
  import { buildApiOptions, runtime } from '../utils/context.js';
7
- 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';
11
+ import { confirm } from '../utils/prompt.js';
12
+ import { resolveCreateProject } from '../utils/project-context.js';
13
+ import { CliError, ExitCode, toCliError, usageError } from '../utils/errors.js';
14
+ import { redact, registerSecret } from '../utils/redact.js';
15
+ import { dim, formatTable, printCommandHelp, printError, printInfo, printLine, printSuccess } from '../utils/output.js';
10
16
  /** Value flags across every subcommand — readPositionalArgs must know them all. */
11
17
  const VALUE_FLAGS = [
18
+ ...OBSERVATION_VALUE_FLAGS,
19
+ ...RESOURCES_VALUE_FLAGS,
20
+ '--project',
21
+ '--template',
12
22
  '--image',
13
23
  '--port',
14
24
  '--tcp',
25
+ '--volume',
26
+ '--volume-runner',
15
27
  '--env',
16
28
  '--set',
17
29
  '--memory',
18
30
  '--cpu',
31
+ '--drain-grace',
19
32
  '--health-path',
20
33
  '--health-command',
21
34
  '--description',
22
35
  '--tail',
23
36
  '--timeout',
24
37
  '--limit',
38
+ '--cursor',
25
39
  '--dir',
26
40
  '--dockerfile',
27
41
  '--context',
@@ -29,9 +43,22 @@ const VALUE_FLAGS = [
29
43
  '--build-arg',
30
44
  '--build',
31
45
  '--egress',
46
+ '--ingress',
47
+ '--activation',
48
+ '--mode',
49
+ '--idle',
50
+ '--idle-timeout',
51
+ '--activation-timeout',
52
+ '--stabilization',
53
+ '--scale-down-stabilization',
54
+ '--replicas',
32
55
  ];
33
56
  export async function appsCommand(args) {
34
57
  const [subcommand, ...rest] = readPositionalArgs(args, { valueFlags: VALUE_FLAGS });
58
+ if (subcommand && (hasFlag(args, '--help') || hasFlag(args, '-h')) && SUBCOMMAND_HELP[subcommand]) {
59
+ SUBCOMMAND_HELP[subcommand].forEach((line) => printLine(line));
60
+ return;
61
+ }
35
62
  if (!subcommand || hasFlag(args, '--help') || hasFlag(args, '-h')) {
36
63
  printAppsHelp();
37
64
  if (!subcommand && !hasFlag(args, '--help') && !hasFlag(args, '-h')) {
@@ -40,6 +67,10 @@ export async function appsCommand(args) {
40
67
  return;
41
68
  }
42
69
  const apiOptions = buildApiOptions(args);
70
+ const flagArgs = args.includes('--') ? args.slice(0, args.indexOf('--')) : args;
71
+ if (subcommand !== 'create' && hasFlag(flagArgs, '--project')) {
72
+ throw usageError('--project is only supported by apps create', 'inquir projects add <project> <app>');
73
+ }
43
74
  switch (subcommand) {
44
75
  case 'list':
45
76
  return listAppsSubcommand(args, apiOptions);
@@ -47,10 +78,24 @@ export async function appsCommand(args) {
47
78
  return createAppSubcommand(args, rest, apiOptions);
48
79
  case 'deploy':
49
80
  return deploySubcommand(args, rest, apiOptions);
81
+ case 'redeploy':
82
+ return redeploySubcommand(args, rest, apiOptions);
50
83
  case 'status':
51
84
  return statusSubcommand(args, rest, apiOptions);
52
85
  case 'logs':
86
+ if (hasFlag(args, '--history'))
87
+ return logHistorySubcommand(args, rest, apiOptions);
53
88
  return logsSubcommand(args, rest, apiOptions);
89
+ case 'metrics':
90
+ return metricsSubcommand(args, rest, apiOptions);
91
+ case 'log-history':
92
+ return logHistorySubcommand(args, rest, apiOptions);
93
+ case 'alerts':
94
+ return alertsSubcommand(args, rest, apiOptions);
95
+ case 'resources':
96
+ return resourcesSubcommand(args, rest, apiOptions);
97
+ case 'describe':
98
+ return describeSubcommand(args, rest, apiOptions);
54
99
  case 'promote':
55
100
  return promoteSubcommand(args, rest, apiOptions);
56
101
  case 'rollback':
@@ -65,9 +110,21 @@ export async function appsCommand(args) {
65
110
  return buildsSubcommand(args, rest, apiOptions);
66
111
  case 'update':
67
112
  return updateSubcommand(args, rest, apiOptions);
113
+ case 'scale':
114
+ return scaleSubcommand(args, rest, apiOptions);
115
+ case 'wake':
116
+ return wakeSubcommand(args, rest, apiOptions);
117
+ case 'pause':
118
+ return pauseSubcommand(args, rest, apiOptions);
68
119
  case 'domain':
69
120
  case 'domains':
70
121
  return domainSubcommand(args, rest, apiOptions);
122
+ case 'backups':
123
+ return backupsSubcommand(args, rest, apiOptions);
124
+ case 'connect':
125
+ return connectSubcommand(args, rest, apiOptions);
126
+ case 'connection-url':
127
+ return connectionUrlSubcommand(args, rest, apiOptions);
71
128
  case 'delete':
72
129
  return deleteSubcommand(args, rest, apiOptions);
73
130
  default:
@@ -79,56 +136,87 @@ function printAppsHelp() {
79
136
  'list List applications',
80
137
  'create <name> Create an application',
81
138
  'deploy <app> [--image <ref>] Release from an image, or build the current directory',
139
+ 'redeploy <app> Release what production runs again; promote when healthy',
140
+ 'backups <app> [list|create|restore <id>|delete <id>] Volume backups (databases)',
141
+ 'connect <db> <app> Put the database URL into another app\'s env',
142
+ 'connection-url <db> Print a private URL, or public fallback (--private/--public)',
82
143
  'build <app> [--dir <path>] Build an image from source (Dockerfile) without releasing',
83
144
  'builds <app> List image builds',
84
145
  'status <app> Endpoints and recent releases',
146
+ 'describe <app> Why it is (not) running: conditions, placement, events',
85
147
  'logs <app|release|build> Stream container (or build) logs',
148
+ 'log-history <app> Persisted runtime or HTTP logs with time/replica filters',
149
+ 'metrics <app> CPU/RAM, network, disk I/O or HTTP history (--json)',
150
+ 'alerts <app> [action] Configure resource/deployment alerts and read incidents',
151
+ 'resources recommend <app> CPU/RAM right-sizing from observed usage (read-only)',
86
152
  'promote <release> Send production traffic to a release',
87
153
  'rollback <app> Return production to the previous release',
88
154
  'stop <release> Stop a release that is not serving production',
89
155
  'exec <release> -- <cmd> Run a command inside the container',
90
156
  'update <app> Change env vars, ports, resources, egress',
157
+ 'scale <app> [mode] Set always/http/manual scaling (--replicas for always)',
158
+ 'wake <app> Start a sleeping application',
159
+ 'pause <app> Stop an http/manual app (HTTP wakes only http mode)',
91
160
  'domain <app> [hostname] Bind a hostname (or list them); --verify / --remove',
92
161
  'delete <app> Archive an application (stops every release)',
93
162
  '',
94
163
  'Create/deploy flags:',
164
+ '--project <project> create: this project by id, slug or unique name (overrides projects use)',
95
165
  '--image <ref> Container image, e.g. nginx:alpine (omit to build from source)',
166
+ '--promote deploy: take production by itself once healthy (needs apps:promote)',
167
+ '--template <name> postgres | redis: volume, stable TCP endpoint, generated password, first release',
96
168
  '--dir <path> Source directory to build (default: current directory)',
97
169
  '--dockerfile <path> Dockerfile relative to the context (default: Dockerfile)',
98
170
  '--context <path> Build context inside the source tree (default: .)',
99
171
  '--build-arg KEY=VALUE Docker build argument (repeatable)',
100
172
  '--build <buildId> Release an existing successful build',
101
173
  '--port <n> HTTP port (omit for a TCP-only service)',
102
- '--tcp <name:port[:public][:hostPort]> Extra TCP port (repeatable)',
174
+ '--tcp <name:port[:public]> Extra TCP port; endpoint port is platform-managed (repeatable)',
175
+ '--volume <name:/path[:ro]> Persistent volume mount (repeatable)',
176
+ '--volume-runner <id> Pin volumes (and the container) to a worker runner',
103
177
  '--set KEY=VALUE Environment variable (repeatable)',
104
178
  '--memory <mb> Memory ceiling (default 512)',
105
179
  '--cpu <vcpus> CPU ceiling (default 0.5)',
180
+ '--drain-grace <seconds> Time the old container gets after SIGTERM before it is killed (0-3600)',
106
181
  '--egress <none|full> Outbound network policy',
182
+ '--ingress <private|public> Public Networking: public creates the generated domain (new apps are private)',
107
183
  '--health-path </path> HTTP health check path',
108
184
  '--health-command <cmd> Health command inside the container (databases)',
109
185
  '--no-wait Return as soon as the release is queued',
110
186
  '--json Machine-readable JSON output',
187
+ '',
188
+ 'Scaling flags:',
189
+ '--activation <mode> always, http (sleep when idle), or manual',
190
+ '--replicas <n> Horizontal replicas for always mode (allocation plane; 1..8)',
191
+ '--idle-timeout <duration> HTTP idle time before sleep (minimum 5m)',
192
+ '--activation-timeout <duration> Maximum wake time (5s–5m)',
193
+ '--stabilization <duration> Quiet period before sleeping (minimum 0s)',
111
194
  ], [
112
195
  'inquir apps create web --port 3000',
113
196
  'inquir apps deploy web # builds ./Dockerfile, then releases it',
114
197
  'inquir apps deploy web --image nginx:alpine',
115
- 'inquir apps deploy cache --image redis:7-alpine --tcp redis:6379:public:26379 \\',
198
+ 'inquir apps deploy cache --image redis:7-alpine --tcp redis:6379:public \\',
116
199
  ' --health-command "redis-cli ping"',
200
+ 'inquir apps deploy db --image postgres:16 --volume data:/var/lib/postgresql/data',
201
+ 'inquir apps scale web http --idle-timeout 15m',
202
+ 'inquir apps wake web',
203
+ 'inquir apps pause web',
117
204
  'inquir apps logs web --tail 100',
118
205
  'inquir apps promote <releaseId>',
119
206
  'inquir apps domain web shop.example.com && inquir apps domain web shop.example.com --verify',
120
207
  ]);
121
208
  }
122
209
  /**
123
- * `--tcp name:port[:public][:hostPort]` — the shorthand people actually want
124
- * to type for a database or SSH endpoint.
210
+ * `--tcp name:port[:public][:hostPort]` — ordinary usage stops at `public` and
211
+ * lets the platform lease a stable endpoint port. The final numeric modifier
212
+ * remains an API/CLI compatibility escape hatch for an existing installation.
125
213
  */
126
214
  export function parseTcpPortFlag(value) {
127
215
  const parts = value.split(':');
128
216
  const [name, portRaw, ...flags] = parts;
129
217
  const port = Number(portRaw);
130
218
  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');
219
+ throw usageError(`Invalid --tcp value '${value}'`, 'inquir apps deploy <app> --tcp postgres:5432:public');
132
220
  }
133
221
  const spec = { name, port, protocol: 'tcp' };
134
222
  for (const flag of flags) {
@@ -143,7 +231,7 @@ export function parseTcpPortFlag(value) {
143
231
  spec.public = true;
144
232
  continue;
145
233
  }
146
- throw usageError(`Invalid --tcp modifier '${flag}' in '${value}'`, 'inquir apps deploy <app> --tcp postgres:5432:public:25432');
234
+ throw usageError(`Invalid --tcp modifier '${flag}' in '${value}'`, 'inquir apps deploy <app> --tcp postgres:5432:public');
147
235
  }
148
236
  return spec;
149
237
  }
@@ -158,27 +246,211 @@ export function parseSetFlags(values) {
158
246
  }
159
247
  return env;
160
248
  }
161
- /** Runtime spec assembled from flags; undefined when nothing was specified. */
162
- export function buildRuntimeFromFlags(args) {
249
+ const MAX_SCALING_DELAY_SECONDS = 30 * 24 * 60 * 60;
250
+ const SCALING_ACTIVATIONS = new Set(['always', 'http', 'manual']);
251
+ /** Parse a CLI duration such as `90`, `90s`, `15m`, `1h30m`, or `1.5m`. */
252
+ export function parseDurationSeconds(value) {
253
+ if (!value || value !== value.trim()) {
254
+ throw usageError(`Invalid duration '${value}'`, 'Use seconds or a duration such as 30s, 15m, or 1h30m');
255
+ }
256
+ if (/^\d+$/.test(value)) {
257
+ const seconds = Number(value);
258
+ if (Number.isSafeInteger(seconds))
259
+ return seconds;
260
+ throw usageError(`Invalid duration '${value}'`, 'Duration is too large');
261
+ }
262
+ const unitSeconds = { s: 1, m: 60, h: 3600, d: 86_400 };
263
+ const component = /(\d+(?:\.\d+)?)([smhd])/gy;
264
+ let position = 0;
265
+ let seconds = 0;
266
+ while (position < value.length) {
267
+ component.lastIndex = position;
268
+ const match = component.exec(value);
269
+ if (!match) {
270
+ throw usageError(`Invalid duration '${value}'`, 'Use seconds or a duration such as 30s, 15m, or 1h30m');
271
+ }
272
+ seconds += Number(match[1]) * unitSeconds[match[2]];
273
+ position = component.lastIndex;
274
+ }
275
+ if (!Number.isSafeInteger(seconds)) {
276
+ throw usageError(`Duration '${value}' must resolve to a whole number of seconds`, 'Use an exact duration such as 90s or 1.5m');
277
+ }
278
+ return seconds;
279
+ }
280
+ function readAliasedFlagValue(args, flags) {
281
+ const present = flags.filter((flag) => hasFlag(args, flag));
282
+ if (present.length > 1) {
283
+ throw usageError(`Use only one of ${flags.join(', ')}`, `inquir apps scale <app> ${flags[0]} <duration>`);
284
+ }
285
+ if (present.length === 0)
286
+ return undefined;
287
+ const value = readFlagValue(args, present[0]);
288
+ if (value === undefined) {
289
+ throw usageError(`Missing value for ${present[0]}`, `inquir apps scale <app> ${present[0]} <value>`);
290
+ }
291
+ return value;
292
+ }
293
+ function parseScalingDuration(raw, flag, min, max) {
294
+ if (raw === undefined)
295
+ return undefined;
296
+ const seconds = parseDurationSeconds(raw);
297
+ if (seconds < min || seconds > max) {
298
+ throw usageError(`Invalid ${flag} '${raw}' (${seconds}s); expected ${min}s–${max}s`, `inquir apps scale <app> ${flag} <duration>`);
299
+ }
300
+ return seconds;
301
+ }
302
+ /** Build the partial policy sent by `apps scale`; omitted values remain unchanged server-side. */
303
+ export function buildScalingPatchFromFlags(args, positionalActivation) {
304
+ const flagActivation = readAliasedFlagValue(args, ['--activation', '--mode']);
305
+ if (positionalActivation && flagActivation && positionalActivation !== flagActivation) {
306
+ throw usageError(`Conflicting scaling modes '${positionalActivation}' and '${flagActivation}'`, 'inquir apps scale <app> <always|http|manual>');
307
+ }
308
+ const activationRaw = positionalActivation ?? flagActivation;
309
+ if (activationRaw && !SCALING_ACTIVATIONS.has(activationRaw)) {
310
+ throw usageError(`Invalid scaling mode '${activationRaw}'`, 'inquir apps scale <app> <always|http|manual>');
311
+ }
312
+ const idleTimeoutSeconds = parseScalingDuration(readAliasedFlagValue(args, ['--idle-timeout', '--idle']), '--idle-timeout', 300, MAX_SCALING_DELAY_SECONDS);
313
+ const activationTimeoutSeconds = parseScalingDuration(readAliasedFlagValue(args, ['--activation-timeout']), '--activation-timeout', 5, 300);
314
+ const scaleDownStabilizationSeconds = parseScalingDuration(readAliasedFlagValue(args, ['--stabilization', '--scale-down-stabilization']), '--stabilization', 0, MAX_SCALING_DELAY_SECONDS);
315
+ const replicasRaw = readAliasedFlagValue(args, ['--replicas']);
316
+ let maxReplicas;
317
+ if (replicasRaw !== undefined) {
318
+ const parsed = Number.parseInt(replicasRaw, 10);
319
+ if (!Number.isSafeInteger(parsed) || parsed < 1 || parsed > 8) {
320
+ throw usageError(`Invalid --replicas '${replicasRaw}' (1..8; above 1 needs always mode and the allocation plane)`, 'inquir apps scale <app> always --replicas <n>');
321
+ }
322
+ maxReplicas = parsed;
323
+ }
324
+ return {
325
+ ...(activationRaw ? { activation: activationRaw } : {}),
326
+ ...(maxReplicas !== undefined ? { maxReplicas } : {}),
327
+ ...(idleTimeoutSeconds !== undefined ? { idleTimeoutSeconds } : {}),
328
+ ...(activationTimeoutSeconds !== undefined ? { activationTimeoutSeconds } : {}),
329
+ ...(scaleDownStabilizationSeconds !== undefined ? { scaleDownStabilizationSeconds } : {}),
330
+ };
331
+ }
332
+ const VOLUME_NAME_RE = /^[a-z0-9]([a-z0-9-]{0,30}[a-z0-9])?$/;
333
+ const MAX_APP_VOLUMES = 16;
334
+ // eslint-disable-next-line no-control-regex -- Container mount paths must reject control characters.
335
+ const CONTROL_CHAR_RE = /[\u0000-\u001f\u007f]/;
336
+ /** `--volume name:/absolute/container/path[:ro]`; host paths are never accepted. */
337
+ export function parseVolumeFlag(value) {
338
+ const separator = value.indexOf(':');
339
+ const name = separator > 0 ? value.slice(0, separator) : '';
340
+ let mountPath = separator > 0 ? value.slice(separator + 1) : '';
341
+ let readOnly = false;
342
+ if (/^[A-Za-z]:[\\/]/.test(mountPath)) {
343
+ throw usageError(`Invalid --volume mount path '${mountPath}'`, 'Volume targets are absolute paths inside the Linux container, for example data:/var/lib/data');
344
+ }
345
+ const modifierAt = mountPath.lastIndexOf(':');
346
+ if (modifierAt >= 0) {
347
+ const modifier = mountPath.slice(modifierAt + 1);
348
+ if (modifier !== 'ro') {
349
+ throw usageError(`Invalid --volume modifier '${modifier}' in '${value}'`, 'inquir apps deploy <app> --volume data:/var/lib/data[:ro]');
350
+ }
351
+ readOnly = true;
352
+ mountPath = mountPath.slice(0, modifierAt);
353
+ }
354
+ if (!VOLUME_NAME_RE.test(name)) {
355
+ throw usageError(`Invalid --volume name '${name || value}'`, 'inquir apps deploy <app> --volume data:/var/lib/data[:ro]');
356
+ }
357
+ const pathSegments = mountPath.split('/');
358
+ if (mountPath !== mountPath.trim() ||
359
+ mountPath.length < 2 ||
360
+ mountPath.length > 1024 ||
361
+ !mountPath.startsWith('/') ||
362
+ mountPath.includes('\\') ||
363
+ pathSegments.includes('..') ||
364
+ CONTROL_CHAR_RE.test(mountPath)) {
365
+ throw usageError(`Invalid --volume mount path '${mountPath || value}'`, 'inquir apps deploy <app> --volume data:/var/lib/data[:ro]');
366
+ }
367
+ const normalizedPath = path.posix.normalize(mountPath);
368
+ const normalized = normalizedPath.length > 1 ? normalizedPath.replace(/\/+$/, '') : normalizedPath;
369
+ if (normalized === '/' ||
370
+ ['/dev', '/proc', '/sys'].some((root) => normalized === root || normalized.startsWith(`${root}/`))) {
371
+ throw usageError(`Invalid --volume mount path '${mountPath}'`, 'Mount below an application data directory, for example data:/var/lib/data');
372
+ }
373
+ return { name, mountPath: normalized, ...(readOnly ? { readOnly: true } : {}) };
374
+ }
375
+ function assertVolumeSet(volumes) {
376
+ if (volumes.length > MAX_APP_VOLUMES) {
377
+ throw usageError(`At most ${MAX_APP_VOLUMES} --volume entries are supported`, 'inquir apps deploy --help');
378
+ }
379
+ const names = new Set();
380
+ const targets = new Set();
381
+ for (const volume of volumes) {
382
+ if (names.has(volume.name)) {
383
+ throw usageError(`Duplicate --volume name '${volume.name}'`, 'Use each volume name once');
384
+ }
385
+ if (targets.has(volume.mountPath)) {
386
+ throw usageError(`Duplicate --volume mount path '${volume.mountPath}'`, 'Use each mount path once');
387
+ }
388
+ names.add(volume.name);
389
+ targets.add(volume.mountPath);
390
+ }
391
+ }
392
+ function mergeVolumes(base, overrides) {
393
+ const overrideNames = new Set();
394
+ for (const volume of overrides) {
395
+ if (overrideNames.has(volume.name)) {
396
+ throw usageError(`Duplicate --volume name '${volume.name}'`, 'Use each volume name once');
397
+ }
398
+ overrideNames.add(volume.name);
399
+ }
400
+ const merged = [...(base ?? []).filter((volume) => !overrideNames.has(volume.name)), ...overrides];
401
+ assertVolumeSet(merged);
402
+ return merged;
403
+ }
404
+ /** Largest `runtimeSpec.drainGraceSeconds` the API accepts. */
405
+ const MAX_DRAIN_GRACE_SECONDS = 3600;
406
+ /** `--drain-grace <seconds>`: whole seconds, 0..3600, as the API's runtime schema. */
407
+ export function parseDrainGraceFlag(value) {
408
+ const seconds = /^\d{1,4}$/.test(value) ? Number(value) : Number.NaN;
409
+ if (!(seconds <= MAX_DRAIN_GRACE_SECONDS)) {
410
+ throw usageError(`Invalid --drain-grace value '${value}' (whole seconds, 0-${MAX_DRAIN_GRACE_SECONDS})`, 'inquir apps deploy <app> --drain-grace 30');
411
+ }
412
+ return seconds;
413
+ }
414
+ /** Runtime flags patch the saved application runtime rather than replacing unrelated fields. */
415
+ export function buildRuntimeFromFlags(args, baseRuntime) {
163
416
  const portRaw = readFlagValue(args, '--port');
164
417
  const tcpValues = readMultiFlag(args, '--tcp');
418
+ const volumeValues = readMultiFlag(args, '--volume');
165
419
  const memory = readFlagValue(args, '--memory');
166
420
  const cpu = readFlagValue(args, '--cpu');
421
+ const drainGraceRaw = readFlagValue(args, '--drain-grace');
167
422
  const healthPath = readFlagValue(args, '--health-path');
168
423
  const healthCommand = readFlagValue(args, '--health-command');
169
424
  const ports = tcpValues.map(parseTcpPortFlag);
425
+ const volumes = volumeValues.map(parseVolumeFlag);
170
426
  const port = portRaw ? Number(portRaw) : undefined;
171
427
  if (portRaw && (!Number.isInteger(port) || port < 1 || port > 65535)) {
172
428
  throw usageError(`Invalid --port value '${portRaw}'`, 'inquir apps create <name> --port 3000');
173
429
  }
174
- if (port === undefined && ports.length === 0 && !memory && !cpu && !healthPath && !healthCommand) {
430
+ // `--drain-grace` without a value reads as undefined; refuse it rather than
431
+ // silently deploying without the setting the caller asked for.
432
+ if (drainGraceRaw === undefined && hasFlag(args, '--drain-grace'))
433
+ parseDrainGraceFlag('');
434
+ const drainGraceSeconds = drainGraceRaw !== undefined ? parseDrainGraceFlag(drainGraceRaw) : undefined;
435
+ if (!portRaw && tcpValues.length === 0 && volumeValues.length === 0 && !memory && !cpu &&
436
+ drainGraceSeconds === undefined && !healthPath && !healthCommand) {
175
437
  return undefined;
176
438
  }
177
439
  const spec = {
178
- ...(port !== undefined ? { port } : {}),
179
- ...(ports.length > 0 ? { ports } : {}),
180
- resources: { cpuVCpus: cpu ? Number(cpu) : 0.5, memoryMb: memory ? Number(memory) : 512 },
440
+ ...(baseRuntime ?? {}),
441
+ ...(portRaw ? { port } : {}),
442
+ ...(tcpValues.length > 0 ? { ports } : {}),
443
+ ...(volumeValues.length > 0 ? { volumes: mergeVolumes(baseRuntime?.volumes, volumes) } : {}),
444
+ ...(drainGraceSeconds !== undefined ? { drainGraceSeconds } : {}),
181
445
  };
446
+ if (memory || cpu) {
447
+ const parsedMemory = memory ? Number(memory) : (baseRuntime?.resources?.memoryMb ?? 512);
448
+ const parsedCpu = cpu ? Number(cpu) : (baseRuntime?.resources?.cpuVCpus ?? 0.5);
449
+ if (!Number.isInteger(parsedMemory) || parsedMemory < 1 || !Number.isFinite(parsedCpu) || parsedCpu <= 0) {
450
+ throw usageError('Invalid --memory or --cpu value', 'inquir apps update <app> --memory 512 --cpu 0.5');
451
+ }
452
+ spec.resources = { cpuVCpus: parsedCpu, memoryMb: parsedMemory };
453
+ }
182
454
  if (healthCommand) {
183
455
  spec.healthcheck = {
184
456
  type: 'command',
@@ -187,7 +459,7 @@ export function buildRuntimeFromFlags(args) {
187
459
  timeoutSeconds: 5,
188
460
  };
189
461
  }
190
- else if (port !== undefined) {
462
+ else if (healthPath || (portRaw && !baseRuntime?.healthcheck)) {
191
463
  spec.healthcheck = {
192
464
  type: 'http',
193
465
  path: healthPath || '/',
@@ -224,23 +496,496 @@ async function createAppSubcommand(args, positionals, apiOptions) {
224
496
  const [name] = positionals;
225
497
  if (!name)
226
498
  throw usageError('Missing application name', 'inquir apps create <name> --port 3000');
499
+ if (positionals.length > 1)
500
+ throw usageError('Expected one application name', 'inquir apps create <name> --project <project>');
227
501
  await requireAuth(apiOptions);
502
+ const template = readFlagValue(args, '--template');
503
+ if (template && !TEMPLATE_NAMES.includes(template)) {
504
+ throw usageError(`Unknown template '${template}'. Available: ${TEMPLATE_NAMES.join(', ')}`, `inquir apps create ${name} --template postgres`);
505
+ }
228
506
  const envVars = parseSetFlags(readMultiFlag(args, '--set'));
507
+ const volumeRunner = readFlagValue(args, '--volume-runner');
508
+ // Omitted flags leave the server defaults in force: full egress, and a
509
+ // private-only service until a public domain is asked for.
510
+ const networkPolicy = buildNetworkPolicyPatch({
511
+ egress: readFlagValue(args, '--egress'),
512
+ ingress: readFlagValue(args, '--ingress'),
513
+ });
514
+ const project = await resolveCreateProject(args, apiOptions);
229
515
  const app = await createApplication({
230
516
  name,
517
+ ...(project.projectId ? { projectId: project.projectId } : {}),
231
518
  description: readFlagValue(args, '--description'),
519
+ ...(template ? { template } : {}),
520
+ ...(volumeRunner ? { volumeRunner } : {}),
232
521
  runtime: buildRuntimeFromFlags(args),
233
522
  ...(Object.keys(envVars).length > 0 ? { envVars } : {}),
234
- }, apiOptions);
523
+ ...(networkPolicy ? { networkPolicy } : {}),
524
+ }, project.apiOptions).catch((error) => {
525
+ // A worker runner pulls only a digest-pinned registry image, and this
526
+ // installation has no mirror of the template's image yet.
527
+ if (error instanceof ApiError && error.code === 'APP_TEMPLATE_IMAGE_NOT_MIRRORED') {
528
+ throw new CliError(`The ${template} template cannot run on a worker runner on this installation yet`, {
529
+ code: error.code,
530
+ exitCode: ExitCode.ERROR,
531
+ requestId: error.requestId,
532
+ details: error.payload,
533
+ explanation: error.message,
534
+ fix: 'An operator mirrors the template image into the platform registry and sets APPS_TEMPLATE_IMAGES (docs/operations/template-images.md). Until then, create the database without --volume-runner: it runs on the control-plane runner.',
535
+ command: `inquir apps create ${name} --template ${template}`,
536
+ });
537
+ }
538
+ throw error;
539
+ });
540
+ const firstRelease = app.firstRelease;
235
541
  if (readJsonFlag(args)) {
236
- printLine(JSON.stringify(app, null, 2));
542
+ // A scripted create must answer the same question the human one does:
543
+ // "is it up, and where do I reach it?" — so it waits for the release too.
544
+ const settled = template && firstRelease ? await waitForRelease(firstRelease.releaseId, project.apiOptions) : null;
545
+ printLine(JSON.stringify(settled ? { ...app, firstRelease: settled } : app, null, 2));
546
+ if (settled && !isHealthyRelease(settled))
547
+ process.exitCode = ExitCode.ERROR;
237
548
  return;
238
549
  }
239
550
  printSuccess(`Created application ${app.name}`);
240
551
  printInfo(dim(` id: ${app.id}`));
552
+ for (const line of await landingProjectLines(app, project.projectId, project.apiOptions)) {
553
+ printInfo(dim(` ${line}`));
554
+ }
555
+ if (template && firstRelease) {
556
+ printInfo(dim(` template: ${template} — first release ${firstRelease.releaseId.slice(0, 8)} is starting`));
557
+ const release = await waitForRelease(firstRelease.releaseId, project.apiOptions);
558
+ renderRelease(release);
559
+ if (!isHealthyRelease(release)) {
560
+ process.exitCode = ExitCode.ERROR;
561
+ return;
562
+ }
563
+ printInfo(dim(` connection string: inquir apps connection-url ${app.name}`));
564
+ printInfo(dim(` wire an app to it: inquir apps connect ${app.name} <target-app>`));
565
+ return;
566
+ }
241
567
  printInfo(dim(` deploy it: inquir apps deploy ${app.name} (builds the Dockerfile in the current directory)`));
242
568
  printInfo(dim(` or: inquir apps deploy ${app.name} --image <ref>`));
243
569
  }
570
+ /**
571
+ * Which project a new application landed in, by name. With neither --project
572
+ * nor a selection the server creates one named after the application, so a
573
+ * database and the app using it land in two projects: say so, and how to
574
+ * group them.
575
+ */
576
+ async function landingProjectLines(app, requestedProject, apiOptions) {
577
+ const projectId = app.projectIds?.[0];
578
+ if (!projectId)
579
+ return [];
580
+ const project = await getProject(projectId, apiOptions).catch(() => null);
581
+ const label = project ? `${project.name} (${project.slug})` : projectId;
582
+ if (requestedProject)
583
+ return [`project: ${label}`];
584
+ return [
585
+ `project: ${label}, created for it since no project is selected here`,
586
+ ` group it with another application: inquir projects move <project> ${app.name}`,
587
+ ' put new applications in one project: inquir projects use <project>',
588
+ ];
589
+ }
590
+ const TEMPLATE_NAMES = ['postgres', 'redis'];
591
+ /** `apps <sub> --help` answered per subcommand; the global page is the fallback. */
592
+ const SUBCOMMAND_HELP = {
593
+ ...OBSERVATION_HELP,
594
+ ...RESOURCES_HELP,
595
+ ...DESCRIBE_HELP,
596
+ logs: [
597
+ 'Usage: inquir apps logs <app|release|build> [--tail <n>]',
598
+ ' Streams container or build logs. Ctrl-C stops following.',
599
+ ' --history selects persisted history instead; see apps log-history --help.',
600
+ ],
601
+ promote: [
602
+ 'Usage: inquir apps promote <release> [--no-wait] [--json]',
603
+ '',
604
+ ' Sends production traffic to a healthy release. A multi-replica service',
605
+ ' may be promoted as a rolling rollout: the server starts the new replicas a',
606
+ ' few at a time and switches production once enough of them serve, and the',
607
+ ' CLI follows it until then.',
608
+ ' --no-wait Return as soon as the rollout has started',
609
+ ],
610
+ redeploy: [
611
+ 'Usage: inquir apps redeploy <app> [--no-promote] [--no-wait]',
612
+ '',
613
+ ' Releases what production runs now — the same build, or the same image',
614
+ " reference pulled again — under the application's current env, ports and",
615
+ ' resources, and promotes it once it is healthy. Use it after `apps update`',
616
+ ' to apply changed settings, or to start over from a clean container.',
617
+ ' --no-promote Only queue the release; promote it yourself later',
618
+ ' --no-wait Return as soon as the release is queued',
619
+ '',
620
+ 'Applications with volumes are recreated: the service is down for the swap.',
621
+ ],
622
+ create: [
623
+ 'Usage: inquir apps create <name> [--template postgres|redis] [flags]',
624
+ '',
625
+ ' --project <project> Add to this project (id, slug or unique name). Otherwise',
626
+ ' uses projects use; without a selection, creates a project',
627
+ ' named after the app (group it later: inquir projects move).',
628
+ ' --template <name> postgres | redis: volume, pinned public TCP port,',
629
+ ' generated password, and the first release — one command',
630
+ ' from nothing to a running database.',
631
+ ' --volume-runner <id> Pin volumes + container to a worker runner (id or',
632
+ ' unique prefix); the data lives on that machine. With',
633
+ ' --template the worker runs the operator\'s digest-pinned',
634
+ ' mirror of the template image (APPS_TEMPLATE_IMAGES).',
635
+ ' --port, --tcp, --volume, --memory, --cpu, --drain-grace, --health-command, --set KEY=VALUE',
636
+ '',
637
+ 'Examples:',
638
+ ' inquir apps create mydb --template postgres',
639
+ ' inquir apps create web --port 3000',
640
+ ],
641
+ backups: [
642
+ 'Usage: inquir apps backups <app> [list|create|restore <backupId>|delete <backupId>]',
643
+ '',
644
+ ' list (default) Snapshots of this app\'s volumes, newest first',
645
+ ' --volume <name> only that volume',
646
+ ' --limit <n>, --cursor <c> page through older ones',
647
+ ' create Snapshot every mounted volume now, then follow the',
648
+ ' rows until they settle (--no-wait returns at once)',
649
+ ' restore <backupId> OVERWRITE a volume from a snapshot — asks for',
650
+ ' confirmation, or pass --yes in a script',
651
+ ' delete <backupId> Drop a restore point for good — asks for confirmation',
652
+ '',
653
+ 'Backups are crash-consistent and only exist for applications with volumes.',
654
+ 'Restore stops the application while it runs.',
655
+ ],
656
+ connect: [
657
+ 'Usage: inquir apps connect <database-app> <target-app> [--overwrite]',
658
+ '',
659
+ 'Writes the database connection URL into the target app\'s environment',
660
+ '(DATABASE_URL for postgres, REDIS_URL for redis). The password never',
661
+ 'passes through your terminal. Takes effect on the target\'s next release.',
662
+ 'The URL names the database\'s private address, <slug>.apps.internal:<port>,',
663
+ 'on the control-plane runner and on a worker alike, once the platform has',
664
+ 'verified it (`inquir apps status <db>` lists it).',
665
+ '',
666
+ ' --overwrite Replace a DATABASE_URL/REDIS_URL the target already has —',
667
+ ' e.g. to repoint it at the database\'s private address',
668
+ ' (<slug>.apps.internal) after a hand-written hostname.',
669
+ ],
670
+ 'connection-url': [
671
+ 'Usage: inquir apps connection-url <database-app> [--private|--public]',
672
+ '',
673
+ 'Prints the private URL when available, otherwise the explicit Public',
674
+ 'Access URL. Use --private or --public to require one visibility.',
675
+ 'Public TCP exposure does not add database TLS. Prefer',
676
+ '`inquir apps connect` when the consumer is another app on the platform.',
677
+ ],
678
+ };
679
+ const BACKUPS_USAGE = 'inquir apps backups <app> [list|create|restore <backupId>|delete <backupId>]';
680
+ /** The one backup table: `create` prints it too, once the rows settle. */
681
+ function renderBackupTable(items) {
682
+ printLine(formatTable(items.map((b) => ({
683
+ id: b.id.slice(0, 8),
684
+ volume: b.volumeName,
685
+ status: b.status,
686
+ trigger: b.trigger,
687
+ size: formatBytes(b.sizeBytes ?? b.artifactSizeBytes),
688
+ created: b.createdAt.replace('T', ' ').slice(0, 19),
689
+ })), ['id', 'volume', 'status', 'trigger', 'size', 'created']));
690
+ }
691
+ /** Accept a short id, the way the release commands do. */
692
+ async function resolveBackup(applicationId, needle, identifier, apiOptions) {
693
+ const { items } = await listAppBackups(applicationId, { limit: 100 }, apiOptions);
694
+ const match = items.find((b) => b.id === needle || b.id.startsWith(needle));
695
+ if (!match)
696
+ throw usageError(`No backup matching '${needle}'`, `inquir apps backups ${identifier} list`);
697
+ return match;
698
+ }
699
+ /**
700
+ * Follow the rows a backup run created until none is PENDING. The copy outlives
701
+ * the request that started it (the API answers 202), so the rows — not the
702
+ * response — carry the outcome.
703
+ */
704
+ async function waitForBackups(applicationId, started, apiOptions, json, timeoutMs = 30 * 60_000) {
705
+ const deadline = Date.now() + timeoutMs;
706
+ const startedAt = Date.now();
707
+ const settled = new Map();
708
+ const announce = (line) => {
709
+ // Under --json only stdout must stay parseable; progress goes to stderr.
710
+ if (json)
711
+ process.stderr.write(`${line}\n`);
712
+ else
713
+ printInfo(dim(line));
714
+ };
715
+ announce(`— copying ${started.length} volume(s); a large volume takes minutes —`);
716
+ for (;;) {
717
+ for (const row of started) {
718
+ if (settled.has(row.id))
719
+ continue;
720
+ const current = await getAppBackup(applicationId, row.id, apiOptions);
721
+ if (current.status === 'PENDING')
722
+ continue;
723
+ settled.set(row.id, current);
724
+ const size = current.status === 'READY' ? ` ${formatBytes(current.sizeBytes ?? current.artifactSizeBytes)}` : '';
725
+ announce(`— ${current.volumeName} ${current.status}${size} (${formatElapsed(Date.now() - startedAt)}) —`);
726
+ }
727
+ if (settled.size === started.length)
728
+ return started.map((row) => settled.get(row.id));
729
+ if (Date.now() > deadline) {
730
+ throw new CliError(`Backup of ${applicationId} did not settle within ${Math.round(timeoutMs / 60_000)}m`, {
731
+ code: 'BACKUP_TIMEOUT',
732
+ exitCode: ExitCode.ERROR,
733
+ fix: 'The copy keeps running server-side; check the rows later.',
734
+ command: `inquir apps backups ${applicationId} list`,
735
+ });
736
+ }
737
+ await new Promise((resolve) => setTimeout(resolve, 2000));
738
+ }
739
+ }
740
+ async function backupsSubcommand(args, positionals, apiOptions) {
741
+ const [identifier, verb = 'list', backupId] = positionals;
742
+ if (!identifier) {
743
+ throw usageError('Missing application', BACKUPS_USAGE);
744
+ }
745
+ await requireAuth(apiOptions);
746
+ const app = await resolveApplication(identifier, apiOptions);
747
+ const json = readJsonFlag(args);
748
+ if (verb === 'list') {
749
+ const cursor = readFlagValue(args, '--cursor');
750
+ const volumeName = readFlagValue(args, '--volume');
751
+ const page = await listAppBackups(app.id, {
752
+ // Each flag is sent only when given, so an unset one keeps the API's
753
+ // own page size instead of pinning a second default here.
754
+ ...(readFlagValue(args, '--limit') ? { limit: readLimitFlag(args, 0) } : {}),
755
+ ...(cursor ? { cursor } : {}),
756
+ ...(volumeName ? { volumeName } : {}),
757
+ }, apiOptions);
758
+ if (json)
759
+ return printLine(JSON.stringify(page, null, 2));
760
+ if (page.items.length === 0) {
761
+ printInfo(`No backups for ${app.name} yet — create one: inquir apps backups ${app.name} create`);
762
+ return;
763
+ }
764
+ renderBackupTable(page.items);
765
+ if (page.nextCursor) {
766
+ printInfo(dim(` older: inquir apps backups ${app.name} list --cursor ${page.nextCursor}`));
767
+ }
768
+ return;
769
+ }
770
+ if (verb === 'create') {
771
+ const run = await createAppBackup(app.id, apiOptions);
772
+ if (hasFlag(args, '--no-wait')) {
773
+ if (json)
774
+ return printLine(JSON.stringify(run, null, 2));
775
+ renderBackupTable(run.items);
776
+ printInfo(dim(` follow them: inquir apps backups ${app.name} list`));
777
+ return;
778
+ }
779
+ const items = await waitForBackups(app.id, run.items, apiOptions, json);
780
+ if (json)
781
+ printLine(JSON.stringify({ items, statusUrl: run.statusUrl }, null, 2));
782
+ else
783
+ renderBackupTable(items);
784
+ // A snapshot that never became READY is a failed command, not a note.
785
+ if (items.some((b) => b.status !== 'READY'))
786
+ process.exitCode = ExitCode.ERROR;
787
+ return;
788
+ }
789
+ if (verb === 'restore') {
790
+ if (!backupId) {
791
+ throw usageError('Missing backup id', `inquir apps backups ${identifier} restore <backupId>`);
792
+ }
793
+ // Resolve the backup BEFORE asking for confirmation: a warning that cannot
794
+ // name the volume, the moment, and the size is a warning nobody reads.
795
+ const match = await resolveBackup(app.id, backupId, identifier, apiOptions);
796
+ if (match.status !== 'READY') {
797
+ throw usageError(`Backup ${match.id.slice(0, 8)} is ${match.status} and cannot be restored`, `inquir apps backups ${identifier} list`);
798
+ }
799
+ const when = match.createdAt.replace('T', ' ').slice(0, 16);
800
+ const summary = `${app.name} volume ${match.volumeName} from the ${when} backup (${formatBytes(match.sizeBytes ?? match.artifactSizeBytes)})`;
801
+ if (!runtime().yes) {
802
+ const approved = await confirm(`Overwrite ${summary}? Everything written since ${when} is lost, and ${app.name} stops during the restore.`, false);
803
+ if (!approved) {
804
+ throw usageError(`Refusing to restore ${app.name} without confirmation`, `inquir apps backups ${identifier} restore ${match.id.slice(0, 8)} --yes`);
805
+ }
806
+ }
807
+ await restoreAppBackup(app.id, match.id, apiOptions);
808
+ if (json) {
809
+ return printLine(JSON.stringify({ restored: true, application: app.name, backupId: match.id, volumeName: match.volumeName, createdAt: match.createdAt }, null, 2));
810
+ }
811
+ printSuccess(`Restored ${app.name} volume ${match.volumeName} from backup ${match.id.slice(0, 8)}`);
812
+ return;
813
+ }
814
+ if (verb === 'delete') {
815
+ if (!backupId) {
816
+ throw usageError('Missing backup id', `inquir apps backups ${identifier} delete <backupId>`);
817
+ }
818
+ const match = await resolveBackup(app.id, backupId, identifier, apiOptions);
819
+ const when = match.createdAt.replace('T', ' ').slice(0, 16);
820
+ if (!runtime().yes) {
821
+ const approved = await confirm(`Delete the ${when} backup of ${app.name} volume ${match.volumeName}? This restore point is gone for good.`, false);
822
+ if (!approved) {
823
+ throw usageError(`Refusing to delete a backup of ${app.name} without confirmation`, `inquir apps backups ${identifier} delete ${match.id.slice(0, 8)} --yes`);
824
+ }
825
+ }
826
+ await deleteAppBackup(app.id, match.id, apiOptions);
827
+ if (json) {
828
+ return printLine(JSON.stringify({ deleted: true, application: app.name, backupId: match.id, volumeName: match.volumeName, createdAt: match.createdAt }, null, 2));
829
+ }
830
+ printSuccess(`Deleted backup ${match.id.slice(0, 8)} of ${app.name} volume ${match.volumeName}`);
831
+ return;
832
+ }
833
+ throw usageError(`Unknown backups action '${verb}'`, BACKUPS_USAGE);
834
+ }
835
+ /**
836
+ * The refusals of `apps connect` and `apps connection-url`, told as what to do
837
+ * next. The server's code and message are kept; the cause, the fix and the
838
+ * command to try are the CLI's. Anything else passes through unchanged to the
839
+ * generic rendering.
840
+ */
841
+ export function explainDatabaseConnectionError(error, context) {
842
+ if (!(error instanceof ApiError) || !error.code)
843
+ return error;
844
+ const { database, target } = context;
845
+ const base = {
846
+ code: error.code,
847
+ exitCode: ExitCode.ERROR,
848
+ requestId: error.requestId,
849
+ details: error.payload,
850
+ };
851
+ const status = `inquir apps status ${database}`;
852
+ const retry = target ? `inquir apps connect ${database} ${target}` : `inquir apps connection-url ${database}`;
853
+ switch (error.code) {
854
+ case 'APP_NOT_A_TEMPLATE':
855
+ return new CliError(`${database} was not created from a database template`, {
856
+ ...base,
857
+ explanation: 'connect and connection-url compose URLs only for postgres and redis template databases: the template says which variables hold the user, the password and the database.',
858
+ fix: target
859
+ ? `Create the database with --template (on a worker too: --template postgres --volume-runner <runner>), or set the URL on ${target} yourself; a URL set by hand is not recorded as a dependency.`
860
+ : 'Create the database with --template (on a worker too: --template postgres --volume-runner <runner>), or compose the URL from its own credentials and its private address.',
861
+ command: target ? `inquir apps update ${target} --set DATABASE_URL=<url>` : status,
862
+ });
863
+ case 'APP_PRIVATE_ENDPOINT_NOT_READY':
864
+ return new CliError(`${database} has no verified private address yet`, {
865
+ ...base,
866
+ explanation: error.message,
867
+ cause: 'The platform verifies a database\'s private proxy once its replica serves, on a worker runner too, and re-checks it about every 20 seconds.',
868
+ fix: `Retry in a minute. If it lasts, check that the database is serving: ${status}`,
869
+ command: retry,
870
+ });
871
+ case 'APP_NO_TCP_ENDPOINT':
872
+ return new CliError(error.message, {
873
+ ...base,
874
+ // An older control plane also answers this for a database a worker
875
+ // serves: it looked for the port only in the release's port mappings,
876
+ // which such a release never has (src/apps/app-connection-endpoint.ts).
877
+ cause: 'The server found no private TCP port on the database\'s production release: it declares none, or it runs on a worker runner and the control plane predates reading those ports.',
878
+ fix: `Check the release's ports with ${status}. If it declares one (e.g. --tcp postgres:5432), the control plane needs the update that reads worker-served ports; otherwise declare the port and deploy the database again.`,
879
+ command: status,
880
+ });
881
+ case 'PRIVATE_NETWORK_NOT_CONFIGURED':
882
+ return new CliError(error.message, {
883
+ ...base,
884
+ cause: 'The control plane offers no private address for this database.',
885
+ fix: target
886
+ ? 'connect writes private URLs only. A client outside the platform can use the public URL once Public Access is enabled on the database. If other apps have private addresses here, the database\'s may just not be verified yet: retry in a minute.'
887
+ : 'Enable Public Access on the database and ask for its public URL. If other apps have private addresses here, the database\'s may just not be verified yet: retry in a minute.',
888
+ command: `inquir apps connection-url ${database} --public`,
889
+ });
890
+ case 'APP_NO_PRODUCTION_RELEASE':
891
+ return new CliError(error.message, {
892
+ ...base,
893
+ fix: 'Deploy the database first; a template database ships its first release when it is created.',
894
+ command: status,
895
+ });
896
+ case 'APP_PRODUCTION_RELEASE_NOT_ACTIVE':
897
+ return new CliError(error.message, {
898
+ ...base,
899
+ fix: 'Wait until its release serves: on a worker runner the replica must start and pass its health check first.',
900
+ command: status,
901
+ });
902
+ case 'APP_CREDENTIALS_MISSING':
903
+ return new CliError(error.message, {
904
+ ...base,
905
+ cause: 'The running release\'s env has no password under the template\'s key (POSTGRES_PASSWORD, REDIS_PASSWORD).',
906
+ fix: 'Set it on the database to the password its data actually uses, then deploy the database again; a new value does not change the password of existing data.',
907
+ command: `inquir apps update ${database} --set <POSTGRES_PASSWORD|REDIS_PASSWORD>=<password>`,
908
+ });
909
+ case 'ENV_KEY_ALREADY_SET':
910
+ return new CliError(error.message, {
911
+ ...base,
912
+ fix: 'Replace it on purpose: the new URL applies on the target\'s next release.',
913
+ command: `${retry} --overwrite`,
914
+ });
915
+ default:
916
+ return error;
917
+ }
918
+ }
919
+ async function connectSubcommand(args, positionals, apiOptions) {
920
+ const [dbIdentifier, targetIdentifier] = positionals;
921
+ if (!dbIdentifier || !targetIdentifier) {
922
+ throw usageError('Usage: apps connect <database-app> <target-app>', 'inquir apps connect mydb web');
923
+ }
924
+ await requireAuth(apiOptions);
925
+ const db = await resolveApplication(dbIdentifier, apiOptions);
926
+ // The server refuses to replace a key the target already carries; the flag
927
+ // is how an app is repointed at the database's current private address
928
+ // (the composed URL never passes through this terminal).
929
+ const overwrite = args.includes('--overwrite');
930
+ const result = await connectApps(db.id, targetIdentifier, { ...apiOptions, overwrite }).catch((error) => {
931
+ throw explainDatabaseConnectionError(error, { database: db.name, target: targetIdentifier });
932
+ });
933
+ if (readJsonFlag(args)) {
934
+ return printLine(JSON.stringify(result, null, 2));
935
+ }
936
+ printSuccess(`${result.envKey} set on ${result.target}`);
937
+ printInfo(dim(` Applies on the next release — deploy it: inquir apps deploy ${result.target}`));
938
+ }
939
+ async function connectionUrlSubcommand(args, positionals, apiOptions) {
940
+ const [identifier] = positionals;
941
+ if (!identifier)
942
+ throw usageError('Missing application', 'inquir apps connection-url mydb');
943
+ await requireAuth(apiOptions);
944
+ const app = await resolveApplication(identifier, apiOptions);
945
+ const result = await getAppConnectionUrl(app.id, apiOptions).catch((error) => {
946
+ throw explainDatabaseConnectionError(error, { database: app.name });
947
+ });
948
+ const requirePrivate = args.includes('--private');
949
+ const requirePublic = args.includes('--public');
950
+ if (requirePrivate && requirePublic) {
951
+ throw usageError('Use either --private or --public, not both', 'inquir apps connection-url mydb --private');
952
+ }
953
+ // Print BEFORE registering: this command exists to hand the credential over,
954
+ // and the redaction filter would otherwise mask its own output. Registering
955
+ // afterwards keeps it out of anything that prints later in this process.
956
+ if (readJsonFlag(args)) {
957
+ printLine(JSON.stringify(result, null, 2));
958
+ if (result.url)
959
+ registerSecret(result.url);
960
+ if (result.publicUrl)
961
+ registerSecret(result.publicUrl);
962
+ return;
963
+ }
964
+ const selected = selectConnectionUrl(result, requirePrivate ? 'private' : requirePublic ? 'public' : 'auto');
965
+ printLine(selected.url);
966
+ registerSecret(selected.url);
967
+ if (process.stderr.isTTY) {
968
+ if (selected.visibility === 'public') {
969
+ process.stderr.write(dim('Public TCP exposure does not add database TLS; require and verify TLS in the database itself.\n'));
970
+ }
971
+ 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`));
972
+ }
973
+ }
974
+ export function selectConnectionUrl(result, preference = 'auto') {
975
+ if (preference !== 'public' && result.url) {
976
+ return { url: result.url, visibility: 'private' };
977
+ }
978
+ if (preference !== 'private' && result.publicUrl) {
979
+ return { url: result.publicUrl, visibility: 'public' };
980
+ }
981
+ const requested = preference === 'auto' ? 'private or public' : preference;
982
+ const reason = preference !== 'public' ? result.privateUnavailable : undefined;
983
+ throw usageError(`No ${requested} database connection URL is available${reason ? `: ${reason.message}` : ''}`, preference === 'public'
984
+ ? 'Enable Public Access in the application Networking settings.'
985
+ : reason?.code === 'APP_PRIVATE_ENDPOINT_NOT_READY'
986
+ ? 'Retry in a minute; the private address is verified once the database serves.'
987
+ : 'Configure private networking, or use --public after explicitly enabling Public Access.');
988
+ }
244
989
  async function deploySubcommand(args, positionals, apiOptions) {
245
990
  const [identifier] = positionals;
246
991
  const image = readFlagValue(args, '--image');
@@ -253,8 +998,9 @@ async function deploySubcommand(args, positionals, apiOptions) {
253
998
  }
254
999
  await requireAuth(apiOptions);
255
1000
  const app = await resolveApplication(identifier, apiOptions);
256
- const runtimeSpec = buildRuntimeFromFlags(args);
1001
+ const runtimeSpec = buildRuntimeFromFlags(args, app.runtime);
257
1002
  const json = readJsonFlag(args);
1003
+ const promote = hasFlag(args, '--promote');
258
1004
  // No image and no build id: build the source directory first, then release
259
1005
  // the produced image. That is the Railway-shaped default — a Dockerfile in
260
1006
  // the current directory is all a project needs.
@@ -270,12 +1016,23 @@ async function deploySubcommand(args, positionals, apiOptions) {
270
1016
  }
271
1017
  buildId = build.id;
272
1018
  }
273
- const queued = await createRelease(app.id, {
274
- ...(buildId ? { buildId } : { imageRef: image }),
275
- ...(runtimeSpec ? { runtime: runtimeSpec } : {}),
276
- }, apiOptions);
1019
+ let queued;
1020
+ try {
1021
+ queued = await createRelease(app.id, {
1022
+ ...(buildId ? { buildId } : { imageRef: image }),
1023
+ ...(runtimeSpec ? { runtime: runtimeSpec } : {}),
1024
+ ...(promote ? { promote: true } : {}),
1025
+ }, apiOptions);
1026
+ }
1027
+ catch (err) {
1028
+ if (err instanceof Error)
1029
+ err.message = humanizeApiError(err.message);
1030
+ throw err;
1031
+ }
277
1032
  if (!json) {
278
1033
  printInfo(`Release ${queued.releaseId.slice(0, 8)} queued for ${app.name} (${image ?? `build ${buildId.slice(0, 8)}`})`);
1034
+ if (promote)
1035
+ printInfo(dim(' it takes production by itself once healthy'));
279
1036
  }
280
1037
  if (hasFlag(args, '--no-wait')) {
281
1038
  if (json)
@@ -284,17 +1041,109 @@ async function deploySubcommand(args, positionals, apiOptions) {
284
1041
  printInfo(dim(` status: inquir apps status ${app.name}`));
285
1042
  return;
286
1043
  }
287
- const release = await waitForRelease(queued.releaseId, apiOptions);
1044
+ await followQueuedRelease(app, queued, promote, json, apiOptions);
1045
+ }
1046
+ /**
1047
+ * Wait for a queued release to settle and say where it ended up; with
1048
+ * promote-on-ready, also wait for production to point at it.
1049
+ */
1050
+ async function followQueuedRelease(app, queued, promote, json, apiOptions) {
1051
+ let release = await waitForRelease(queued.releaseId, apiOptions);
1052
+ const production = promote && isHealthyRelease(release)
1053
+ ? await waitForProduction(app.id, queued.releaseId, apiOptions)
1054
+ : undefined;
1055
+ if (production)
1056
+ release = production.release;
1057
+ const served = production?.servingProduction ?? false;
288
1058
  if (json) {
289
- printLine(JSON.stringify(release, null, 2));
1059
+ printLine(JSON.stringify(promote ? {
1060
+ ...release,
1061
+ servingProduction: served,
1062
+ ...(production?.promotionError ? { promotionError: production.promotionError } : {}),
1063
+ } : release, null, 2));
290
1064
  }
291
1065
  else {
292
1066
  renderRelease(release, queued.previewUrl);
1067
+ if (production) {
1068
+ if (served)
1069
+ printSuccess(`Production now serves ${queued.releaseId.slice(0, 8)}`);
1070
+ else
1071
+ printError(`${production.promotionError.message} — see: inquir apps status ${app.name}`);
1072
+ }
293
1073
  }
294
- // A release that never became healthy is a failed command, not a note.
295
- if (release.status === 'FAILED')
1074
+ // A settled release can be stopped, and a healthy preview is not the
1075
+ // requested result when the caller explicitly asked to serve production.
1076
+ if (!isHealthyRelease(release) || (promote && !served))
296
1077
  process.exitCode = ExitCode.ERROR;
297
1078
  }
1079
+ function isHealthyRelease(release) {
1080
+ return release.status === 'READY' || release.status === 'ACTIVE';
1081
+ }
1082
+ /**
1083
+ * Observe both the production pointer and current release health. Return the
1084
+ * latest row so a late failure cannot be hidden by the earlier READY snapshot.
1085
+ * Transport/auth errors propagate; refused promotion and timeout are outcomes.
1086
+ */
1087
+ async function waitForProduction(applicationId, releaseId, apiOptions, timeoutMs = 120_000) {
1088
+ const deadline = Date.now() + timeoutMs;
1089
+ for (;;) {
1090
+ const app = await getApplication(applicationId, apiOptions);
1091
+ const release = await getRelease(releaseId, apiOptions);
1092
+ if (app.productionReleaseId === releaseId && isHealthyRelease(release)) {
1093
+ return { release, servingProduction: true };
1094
+ }
1095
+ if (release.status === 'FAILED' || release.status === 'STOPPED' || release.failureReason?.startsWith('Auto-promote failed')) {
1096
+ return {
1097
+ release, servingProduction: false,
1098
+ promotionError: {
1099
+ code: 'PROMOTION_FAILED',
1100
+ message: release.failureReason ?? `Release ${releaseId} is ${release.status.toLowerCase()} and is not serving production`,
1101
+ },
1102
+ };
1103
+ }
1104
+ if (Date.now() >= deadline) {
1105
+ return {
1106
+ release, servingProduction: false,
1107
+ promotionError: {
1108
+ code: 'PROMOTION_TIMEOUT',
1109
+ message: `Release ${releaseId} did not begin serving production within ${Math.round(timeoutMs / 1000)}s. The CLI stopped waiting; check its status before retrying`,
1110
+ },
1111
+ };
1112
+ }
1113
+ await new Promise((resolve) => setTimeout(resolve, 2000));
1114
+ }
1115
+ }
1116
+ async function redeploySubcommand(args, positionals, apiOptions) {
1117
+ const [identifier] = positionals;
1118
+ if (!identifier)
1119
+ throw usageError('Missing application', 'inquir apps redeploy <app>');
1120
+ await requireAuth(apiOptions);
1121
+ const app = await resolveApplication(identifier, apiOptions);
1122
+ const json = readJsonFlag(args);
1123
+ const promote = !hasFlag(args, '--no-promote');
1124
+ let queued;
1125
+ try {
1126
+ queued = await redeployApplication(app.id, { promote }, apiOptions);
1127
+ }
1128
+ catch (err) {
1129
+ if (err instanceof Error)
1130
+ err.message = humanizeApiError(err.message);
1131
+ throw err;
1132
+ }
1133
+ if (!json) {
1134
+ printInfo(`Release ${queued.releaseId.slice(0, 8)} queued for ${app.name} (redeploy of ${queued.redeployOf.slice(0, 8)})`);
1135
+ if (promote)
1136
+ printInfo(dim(' it takes production by itself once healthy'));
1137
+ }
1138
+ if (hasFlag(args, '--no-wait')) {
1139
+ if (json)
1140
+ printLine(JSON.stringify(queued, null, 2));
1141
+ else
1142
+ printInfo(dim(` status: inquir apps status ${app.name}`));
1143
+ return;
1144
+ }
1145
+ await followQueuedRelease(app, queued, promote, json, apiOptions);
1146
+ }
298
1147
  /**
299
1148
  * Zip a source directory, queue a build for it and follow the build log until
300
1149
  * it settles. Returns the final build row.
@@ -322,65 +1171,202 @@ async function runSourceBuild(applicationId, appName, args, apiOptions) {
322
1171
  ? `Build ${queued.buildId.slice(0, 8)} reused (identical source already built)`
323
1172
  : `Build ${queued.buildId.slice(0, 8)} queued for ${appName}`);
324
1173
  }
325
- if (queued.deduplicated || isTerminalBuildStatus(queued.status)) {
326
- return getBuild(queued.buildId, apiOptions);
1174
+ return waitForBuild(queued.buildId, apiOptions, {
1175
+ json, initial: queued, noLogs: queued.deduplicated || isTerminalBuildStatus(queued.status),
1176
+ });
1177
+ }
1178
+ const BUILD_WAIT_PHASE = { QUEUED: 'queued', EXECUTING: 'building' };
1179
+ const BUILD_WAIT_ERROR = { QUEUE: 'BUILD_QUEUE_TIMEOUT', EXECUTION: 'BUILD_TIMEOUT', STATUS_REQUEST: 'BUILD_STATUS_TIMEOUT' };
1180
+ const BUILD_STATUS_REQUEST_TIMEOUT_MS = 15_000;
1181
+ const BUILD_STATUS_POLL_INTERVAL_MS = 1_000;
1182
+ const BUILD_STATUS_RETRY_BACKOFF_MS = 2_000;
1183
+ const BUILD_LOG_DRAIN_TIMEOUT_MS = 250;
1184
+ const MAX_TIMER_DELAY_MS = 2_147_483_647;
1185
+ const HTTP_TOO_MANY_REQUESTS = 429;
1186
+ const HTTP_SERVER_ERROR_MIN = 500;
1187
+ function buildWaitPolicy(value) {
1188
+ if (!value || !Number.isSafeInteger(value.executionTimeoutMs) || value.executionTimeoutMs < 1 ||
1189
+ !Number.isSafeInteger(value.queueTimeoutMs) || value.queueTimeoutMs < 1 ||
1190
+ !Number.isSafeInteger(value.completionGraceMs) || value.completionGraceMs < 0 ||
1191
+ value.executionTimeoutMs > MAX_TIMER_DELAY_MS || value.queueTimeoutMs > MAX_TIMER_DELAY_MS ||
1192
+ value.completionGraceMs > MAX_TIMER_DELAY_MS) {
1193
+ return DEFAULT_BUILD_TIMEOUT_POLICY;
1194
+ }
1195
+ return value;
1196
+ }
1197
+ function buildWaitSleep(ms, signal) {
1198
+ signal.throwIfAborted();
1199
+ return new Promise((resolve, reject) => {
1200
+ const onAbort = () => { clearTimeout(timer); signal.removeEventListener('abort', onAbort); reject(signal.reason); };
1201
+ const timer = setTimeout(() => { signal.removeEventListener('abort', onAbort); resolve(); }, ms);
1202
+ signal.addEventListener('abort', onAbort, { once: true });
1203
+ });
1204
+ }
1205
+ /** Authoritative phase observation, independent of an open or broken log stream. */
1206
+ export async function waitForBuild(buildId, apiOptions, options = {}) {
1207
+ const clock = () => globalThis.performance.now();
1208
+ const controller = new AbortController();
1209
+ const initialPolicy = buildWaitPolicy(options.initial?.timeoutPolicy);
1210
+ const overallDeadline = clock() + initialPolicy.queueTimeoutMs + initialPolicy.executionTimeoutMs + initialPolicy.completionGraceMs;
1211
+ let phase = options.initial?.status === BUILD_WAIT_PHASE.EXECUTING
1212
+ ? BUILD_WAIT_PHASE.EXECUTING : BUILD_WAIT_PHASE.QUEUED;
1213
+ let deadline = overallDeadline;
1214
+ let timer;
1215
+ let expire;
1216
+ const expired = new Promise((_, reject) => { expire = reject; });
1217
+ function observe(value) {
1218
+ const next = value.status === BUILD_WAIT_PHASE.EXECUTING ? BUILD_WAIT_PHASE.EXECUTING : BUILD_WAIT_PHASE.QUEUED;
1219
+ // A stale queued observation cannot restart an already-running budget.
1220
+ if (phase === BUILD_WAIT_PHASE.EXECUTING && next === BUILD_WAIT_PHASE.QUEUED)
1221
+ return;
1222
+ const policy = buildWaitPolicy(value.timeoutPolicy ?? initialPolicy);
1223
+ const cap = next === BUILD_WAIT_PHASE.QUEUED ? policy.queueTimeoutMs : policy.executionTimeoutMs + policy.completionGraceMs;
1224
+ const remaining = typeof value.timeoutRemainingMs === 'number' && Number.isFinite(value.timeoutRemainingMs) && value.timeoutRemainingMs >= 0
1225
+ ? Math.min(value.timeoutRemainingMs, cap) : cap;
1226
+ const candidate = Math.min(overallDeadline, clock() + remaining);
1227
+ deadline = next === phase ? Math.min(deadline, candidate) : candidate;
1228
+ phase = next;
1229
+ armDeadline();
1230
+ }
1231
+ function armDeadline() {
1232
+ clearTimeout(timer);
1233
+ timer = setTimeout(() => {
1234
+ // Node clamps oversized delays to 1ms. Keep long valid policies bounded
1235
+ // by scheduling slices, and tolerate a timer waking before its deadline.
1236
+ if (clock() < deadline) {
1237
+ armDeadline();
1238
+ return;
1239
+ }
1240
+ const error = new CliError(`Build ${buildId} exceeded the CLI ${phase === BUILD_WAIT_PHASE.QUEUED ? 'queue' : 'execution'} wait deadline`, {
1241
+ code: phase === BUILD_WAIT_PHASE.QUEUED ? BUILD_WAIT_ERROR.QUEUE : BUILD_WAIT_ERROR.EXECUTION, exitCode: ExitCode.ERROR,
1242
+ explanation: `The CLI stopped waiting for build ${buildId}; it has not cancelled the remote build. Check this build before retrying.`,
1243
+ fix: `Inspect its logs: inquir apps logs ${buildId}`,
1244
+ });
1245
+ expire(error);
1246
+ controller.abort(error);
1247
+ }, Math.min(MAX_TIMER_DELAY_MS, Math.max(0, deadline - clock())));
1248
+ }
1249
+ async function statusRequest() {
1250
+ controller.signal.throwIfAborted();
1251
+ const request = new AbortController();
1252
+ const onAbort = () => request.abort(controller.signal.reason);
1253
+ controller.signal.addEventListener('abort', onAbort, { once: true });
1254
+ let requestTimer;
1255
+ const timedOut = new Promise((_, reject) => {
1256
+ requestTimer = setTimeout(() => {
1257
+ const error = new CliError('Build status request timed out', { code: BUILD_WAIT_ERROR.STATUS_REQUEST, exitCode: ExitCode.NETWORK });
1258
+ reject(error);
1259
+ request.abort(error);
1260
+ }, Math.min(BUILD_STATUS_REQUEST_TIMEOUT_MS, Math.max(0, deadline - clock())));
1261
+ });
1262
+ try {
1263
+ return await Promise.race([getBuild(buildId, apiOptions, request.signal), timedOut]);
1264
+ }
1265
+ finally {
1266
+ clearTimeout(requestTimer);
1267
+ controller.signal.removeEventListener('abort', onAbort);
1268
+ request.abort();
1269
+ }
327
1270
  }
328
- // Follow the build log to the terminal event; the SSE stream ends on completion.
329
- for await (const event of streamBuildLogs(queued.buildId, apiOptions)) {
330
- if (event.type === 'log' && event.line) {
331
- if (json)
332
- process.stderr.write(`${redact(event.line)}\n`);
333
- else
334
- printLine(dim(` ${event.line}`));
1271
+ async function poll() {
1272
+ for (;;) {
1273
+ controller.signal.throwIfAborted();
1274
+ try {
1275
+ const build = await statusRequest();
1276
+ controller.signal.throwIfAborted();
1277
+ if (isTerminalBuildStatus(build.status))
1278
+ return build;
1279
+ observe(build);
1280
+ }
1281
+ catch (error) {
1282
+ controller.signal.throwIfAborted();
1283
+ const retryable = error instanceof ApiError ? error.status === HTTP_TOO_MANY_REQUESTS || error.status >= HTTP_SERVER_ERROR_MIN : toCliError(error).exitCode === ExitCode.NETWORK;
1284
+ if (!retryable)
1285
+ throw error;
1286
+ await buildWaitSleep(BUILD_STATUS_RETRY_BACKOFF_MS, controller.signal);
1287
+ }
1288
+ await buildWaitSleep(BUILD_STATUS_POLL_INTERVAL_MS, controller.signal);
335
1289
  }
336
- else if (event.type === 'complete') {
337
- break;
1290
+ }
1291
+ async function consumeLogs() {
1292
+ if (options.noLogs)
1293
+ return;
1294
+ try {
1295
+ for await (const event of streamBuildLogs(buildId, apiOptions, controller.signal)) {
1296
+ if (controller.signal.aborted)
1297
+ break;
1298
+ if (event.type === 'log' && event.line) {
1299
+ if (options.json)
1300
+ process.stderr.write(`${redact(event.line)}\n`);
1301
+ else
1302
+ printLine(dim(` ${event.line}`));
1303
+ }
1304
+ else if (event.type === 'complete' || event.type === 'error')
1305
+ break;
1306
+ }
338
1307
  }
339
- else if (event.type === 'error') {
340
- printLine(dim(`— ${event.message ?? 'stream error'} —`));
341
- break;
1308
+ catch {
1309
+ // Logs are best effort. Only the build row decides the result.
342
1310
  }
343
1311
  }
344
- return waitForBuild(queued.buildId, apiOptions);
345
- }
346
- async function waitForBuild(buildId, apiOptions, timeoutMs = 20 * 60_000) {
347
- const deadline = Date.now() + timeoutMs;
348
- for (;;) {
349
- const build = await getBuild(buildId, apiOptions);
350
- if (isTerminalBuildStatus(build.status))
351
- return build;
352
- if (Date.now() > deadline) {
353
- throw new CliError(`Build ${buildId} did not finish within ${Math.round(timeoutMs / 1000)}s`, {
354
- code: 'BUILD_TIMEOUT',
355
- exitCode: ExitCode.ERROR,
356
- fix: `Check its logs: inquir apps logs ${buildId}`,
357
- });
1312
+ observe(options.initial ?? { status: BUILD_WAIT_PHASE.QUEUED });
1313
+ const logs = consumeLogs();
1314
+ try {
1315
+ const result = await Promise.race([poll(), expired]);
1316
+ let drainTimer;
1317
+ try {
1318
+ await Promise.race([logs, new Promise((resolve) => {
1319
+ drainTimer = setTimeout(resolve, Math.max(0, Math.min(BUILD_LOG_DRAIN_TIMEOUT_MS, deadline - clock())));
1320
+ })]);
358
1321
  }
359
- await new Promise((resolve) => setTimeout(resolve, 1000));
1322
+ finally {
1323
+ clearTimeout(drainTimer);
1324
+ }
1325
+ return result;
1326
+ }
1327
+ finally {
1328
+ clearTimeout(timer);
1329
+ controller.abort();
360
1330
  }
361
1331
  }
1332
+ /**
1333
+ * A release only the allocation plane serves (volumes pinned to a worker) stays
1334
+ * STARTING while that runner refuses its starts, and the plane retries them
1335
+ * forever with backoff. After this many failed attempts the wait reports the
1336
+ * reason instead of sitting out its whole timeout.
1337
+ */
1338
+ const FAILED_STARTS_BEFORE_REPORTING = 3;
362
1339
  /** Poll until the release settles; the platform decides when it is healthy. */
363
1340
  async function waitForRelease(releaseId, apiOptions, timeoutMs = 10 * 60_000) {
364
1341
  const deadline = Date.now() + timeoutMs;
365
1342
  const startedAt = Date.now();
366
- let lastStatus = '';
1343
+ let lastProgress = '';
367
1344
  const json = readJsonFlag(process.argv);
368
1345
  for (;;) {
369
1346
  const release = await getRelease(releaseId, apiOptions);
370
- if (release.status !== lastStatus) {
371
- lastStatus = release.status;
1347
+ // A failed start on the pinned runner changes the reason, not the status.
1348
+ const reason = release.serving ? release.failureReason ?? '' : '';
1349
+ if (`${release.status}\n${reason}` !== lastProgress) {
1350
+ lastProgress = `${release.status}\n${reason}`;
372
1351
  // Say what is happening in words, with elapsed time — a two-minute health
373
1352
  // gate in silence reads as a hang. Under --json only stdout must stay
374
1353
  // parseable; progress goes to stderr.
375
1354
  const shown = presentRelease(release);
376
- const line = `— ${shown.label} (${formatElapsed(Date.now() - startedAt)})${shown.meaning ? `: ${shown.meaning}` : ''} —`;
377
- if (json)
378
- process.stderr.write(`${line}\n`);
379
- else
380
- printInfo(dim(line));
1355
+ const lines = [
1356
+ `— ${shown.label} (${formatElapsed(Date.now() - startedAt)})${shown.meaning ? `: ${shown.meaning}` : ''} —`,
1357
+ ...(reason ? [` ${reason}`] : []),
1358
+ ];
1359
+ for (const line of lines) {
1360
+ if (json)
1361
+ process.stderr.write(`${line}\n`);
1362
+ else
1363
+ printInfo(dim(line));
1364
+ }
381
1365
  }
382
1366
  if (isSettledReleaseStatus(release.status))
383
1367
  return release;
1368
+ if ((release.serving?.failedStarts ?? 0) >= FAILED_STARTS_BEFORE_REPORTING)
1369
+ return release;
384
1370
  if (Date.now() > deadline) {
385
1371
  throw new CliError(`Release ${releaseId} did not settle within ${Math.round(timeoutMs / 1000)}s`, {
386
1372
  code: 'RELEASE_TIMEOUT',
@@ -393,8 +1379,8 @@ async function waitForRelease(releaseId, apiOptions, timeoutMs = 10 * 60_000) {
393
1379
  }
394
1380
  function renderRelease(release, previewUrl) {
395
1381
  const shown = presentRelease(release);
396
- if (release.status === 'FAILED') {
397
- printLine(`✗ Release ${release.id.slice(0, 8)} failed: ${release.failureReason ?? 'unknown reason'}`);
1382
+ if (!isHealthyRelease(release)) {
1383
+ printError(`Release ${release.id.slice(0, 8)} is ${shown.label}${release.failureReason ? `: ${release.failureReason}` : ''}`);
398
1384
  if (shown.hint)
399
1385
  printInfo(dim(` ${shown.hint}`));
400
1386
  printInfo(dim(` logs: inquir apps logs ${release.id}`));
@@ -443,6 +1429,57 @@ export function releaseEndpointLines(release, previewUrl, options = {}) {
443
1429
  }
444
1430
  return lines;
445
1431
  }
1432
+ /**
1433
+ * Where same-workspace services reach this application. The server projects
1434
+ * `privateDomain` only once the private route is verified end to end, so a
1435
+ * missing domain on an installation with private networking means "still
1436
+ * being reconciled", not "no such thing" — and that is worth a line too.
1437
+ */
1438
+ export function privateEndpointLines(detail, active, options = {}) {
1439
+ if (!detail.privateDomain) {
1440
+ return options.privateNetworking
1441
+ ? ['(provisioning — private DNS and routing are being verified)']
1442
+ : [];
1443
+ }
1444
+ const lines = [];
1445
+ if (detail.privateBaseUrl)
1446
+ lines.push(detail.privateBaseUrl);
1447
+ // The private proxy listens on a TCP port's container port. A container the
1448
+ // control plane started reports its ports on the release; a release the
1449
+ // allocation plane serves on a worker reports none (its endpoints live on
1450
+ // its allocations), so its declared TCP ports stand in.
1451
+ const reported = (active?.ports ?? []).filter((port) => port.protocol === 'tcp' && port.containerPort);
1452
+ const tcpPorts = reported.length > 0
1453
+ ? reported.map((port) => ({ name: port.name, port: port.containerPort }))
1454
+ : (active?.runtime?.ports ?? []).filter((port) => port.protocol === 'tcp');
1455
+ for (const port of tcpPorts) {
1456
+ lines.push(`${detail.privateDomain}:${port.port} (${port.name})`);
1457
+ }
1458
+ return lines;
1459
+ }
1460
+ /**
1461
+ * The networkPolicy patch for `--egress` / `--ingress`. The API requires
1462
+ * egress inside networkPolicy, so a lone --ingress change carries the
1463
+ * application's current egress along unchanged, and vice versa; with neither
1464
+ * flag the policy is left alone (the server's defaults apply on create).
1465
+ */
1466
+ export function buildNetworkPolicyPatch(input) {
1467
+ const { egress, ingress, current } = input;
1468
+ if (egress && egress !== 'none' && egress !== 'full') {
1469
+ throw usageError(`Invalid --egress '${egress}'`, 'inquir apps update <app> --egress none');
1470
+ }
1471
+ if (ingress && ingress !== 'private' && ingress !== 'public') {
1472
+ throw usageError(`Invalid --ingress '${ingress}'`, 'inquir apps update <app> --ingress public');
1473
+ }
1474
+ if (!egress && !ingress)
1475
+ return undefined;
1476
+ const resolvedIngress = (ingress ?? current?.ingress);
1477
+ return {
1478
+ egress: (egress ?? current?.egress ?? 'full'),
1479
+ allow: [],
1480
+ ...(resolvedIngress ? { ingress: resolvedIngress } : {}),
1481
+ };
1482
+ }
446
1483
  /** A build-sourced release is best named by its build; a pulled one by its ref. */
447
1484
  export function describeImage(release) {
448
1485
  if (release.imageSource === 'build' && release.buildId)
@@ -462,18 +1499,74 @@ function describeRuntimePorts(spec) {
462
1499
  }
463
1500
  return parts.join(' ');
464
1501
  }
1502
+ function formatDurationSeconds(seconds) {
1503
+ const units = [
1504
+ [86_400, 'd'],
1505
+ [3600, 'h'],
1506
+ [60, 'm'],
1507
+ [1, 's'],
1508
+ ];
1509
+ let remaining = seconds;
1510
+ const parts = [];
1511
+ for (const [size, suffix] of units) {
1512
+ const count = Math.floor(remaining / size);
1513
+ if (count > 0 || (size === 1 && parts.length === 0)) {
1514
+ parts.push(`${count}${suffix}`);
1515
+ remaining -= count * size;
1516
+ }
1517
+ }
1518
+ return parts.join(' ');
1519
+ }
1520
+ export function describeScalingPolicy(policy) {
1521
+ const range = `${policy.minReplicas}–${policy.maxReplicas} replicas`;
1522
+ if (policy.activation === 'http') {
1523
+ return `http (sleep after ${formatDurationSeconds(policy.idleTimeoutSeconds)} idle), ${range}`;
1524
+ }
1525
+ return `${policy.activation}, ${range}`;
1526
+ }
465
1527
  async function statusSubcommand(args, positionals, apiOptions) {
466
1528
  const [identifier] = positionals;
467
1529
  if (!identifier)
468
1530
  throw usageError('Missing application', 'inquir apps status <app>');
469
1531
  await requireAuth(apiOptions);
470
1532
  const app = await resolveApplication(identifier, apiOptions);
471
- const detail = await getApplication(app.id, apiOptions);
472
- if (readJsonFlag(args)) {
1533
+ const json = readJsonFlag(args);
1534
+ // The apps suffix lives on the platform limits, never on the application, so
1535
+ // the permanent address needs both. Fetched alongside the detail and only
1536
+ // for the human output; losing it costs the address line, nothing else.
1537
+ const [detail, limits] = await Promise.all([
1538
+ getApplication(app.id, apiOptions),
1539
+ json ? Promise.resolve(null) : getAppLimits(apiOptions).catch(() => null),
1540
+ ]);
1541
+ if (json) {
473
1542
  printLine(JSON.stringify(detail, null, 2));
474
1543
  return;
475
1544
  }
476
1545
  printLine(`${detail.name}${detail.description ? dim(` — ${detail.description}`) : ''}`);
1546
+ if (detail.scaling)
1547
+ printLine(` scaling: ${describeScalingPolicy(detail.scaling)}`);
1548
+ if (detail.desiredReplicas !== undefined) {
1549
+ const cap = detail.scaling ? ` (maximum ${detail.scaling.maxReplicas})` : '';
1550
+ printLine(` replicas: ${detail.desiredReplicas} desired${cap}`);
1551
+ }
1552
+ const pinnedRunner = detail.volumeRunnerId;
1553
+ if (pinnedRunner)
1554
+ printLine(` volume pin: runner ${pinnedRunner}`);
1555
+ const servingRelease = detail.releases.find((r) => r.id === detail.production?.activeReleaseId);
1556
+ if (servingRelease?.serving && servingRelease.serving.ready < servingRelease.serving.desired) {
1557
+ // Production points at it, but its replica does not answer on the runner yet.
1558
+ printLine(` serving: not yet — ${servingRelease.failureReason ?? 'its replica is starting on the pinned runner'}`);
1559
+ }
1560
+ if (detail.lastActivityAt)
1561
+ printLine(` activity: ${detail.lastActivityAt}`);
1562
+ if (detail.lastScaledAt)
1563
+ printLine(` last scale: ${detail.lastScaledAt}`);
1564
+ // Where this app answers forever, as opposed to where it answers now: the
1565
+ // production lines below follow the routes, this one is the identity a
1566
+ // custom domain should CNAME to.
1567
+ const address = permanentAddress(detail.slug, limits?.appsPublicSuffix);
1568
+ if (address)
1569
+ printLine(` address: ${address} ${dim('(permanent — a rename never moves it)')}`);
477
1570
  for (const url of detail.production?.urls ?? [])
478
1571
  printLine(` production: ${url}`);
479
1572
  if (detail.production && (detail.production.urls ?? []).length === 0) {
@@ -486,6 +1579,29 @@ async function statusSubcommand(args, positionals, apiOptions) {
486
1579
  const endpoints = active ? releaseEndpointLines(active, undefined, { includeHostLocal: !hasProductionUrl }) : [];
487
1580
  for (const endpoint of endpoints)
488
1581
  printLine(` endpoint: ${endpoint}`);
1582
+ // The private address is what another app in the workspace dials; it is
1583
+ // shown only once the platform has verified the route, never guessed.
1584
+ const privateLines = privateEndpointLines(detail, active, {
1585
+ privateNetworking: limits?.features?.privateNetworking,
1586
+ });
1587
+ for (const line of privateLines)
1588
+ printLine(` private: ${line}`);
1589
+ // The serving release's frozen runtime is the truth about what is mounted
1590
+ // NOW. An app-level volume added after the deploy applies only to the next
1591
+ // release — showing it as mounted would tell the operator their database is
1592
+ // persistent when it is not.
1593
+ const mountedVolumes = active ? (active.runtime?.volumes ?? []) : (detail.runtime?.volumes ?? []);
1594
+ for (const volume of mountedVolumes) {
1595
+ printLine(` volume: ${volume.name} → ${volume.mountPath}${volume.readOnly ? ' (read-only)' : ''}`);
1596
+ }
1597
+ if (active) {
1598
+ const mountedNames = new Set(mountedVolumes.map((volume) => volume.name));
1599
+ for (const volume of detail.runtime?.volumes ?? []) {
1600
+ if (!mountedNames.has(volume.name)) {
1601
+ printLine(` volume: ${volume.name} → ${volume.mountPath} ${dim('(next release — not mounted yet)')}`);
1602
+ }
1603
+ }
1604
+ }
489
1605
  for (const domain of detail.domains ?? []) {
490
1606
  if (domain.custom && !domain.verified) {
491
1607
  printLine(` domain: ${domain.hostname} ${dim('(unverified — inquir apps domain ' + detail.name + ' ' + domain.hostname + ' --verify)')}`);
@@ -644,6 +1760,12 @@ async function promoteSubcommand(args, positionals, apiOptions) {
644
1760
  err.message = humanizeApiError(err.message);
645
1761
  throw err;
646
1762
  }
1763
+ // 202: the server runs it as a rolling rollout and goes on in the
1764
+ // background. An older server always answers with the outcome below.
1765
+ if (isPromoteAccepted(result)) {
1766
+ await followPromoteRollout(releaseId, result, { json, wait: !hasFlag(args, '--no-wait') }, apiOptions);
1767
+ return;
1768
+ }
647
1769
  if (json) {
648
1770
  printLine(JSON.stringify(result, null, 2));
649
1771
  return;
@@ -737,6 +1859,59 @@ async function buildsSubcommand(args, positionals, apiOptions) {
737
1859
  created: b.createdAt,
738
1860
  })), ['build', 'status', 'dockerfile', 'image', 'cached', 'created']));
739
1861
  }
1862
+ /** `apps scale <app> [always|http|manual]` — update only the scaling policy. */
1863
+ async function scaleSubcommand(args, positionals, apiOptions) {
1864
+ const [identifier, positionalActivation, ...extra] = positionals;
1865
+ if (!identifier) {
1866
+ throw usageError('Missing application', 'inquir apps scale <app> <always|http|manual>');
1867
+ }
1868
+ if (extra.length > 0) {
1869
+ throw usageError(`Unexpected argument '${extra[0]}'`, 'inquir apps scale <app> <always|http|manual>');
1870
+ }
1871
+ const scaling = buildScalingPatchFromFlags(args, positionalActivation);
1872
+ if (Object.keys(scaling).length === 0) {
1873
+ throw usageError('Nothing to scale', 'inquir apps scale <app> <always|http|manual> [--idle-timeout 15m]');
1874
+ }
1875
+ await requireAuth(apiOptions);
1876
+ const app = await resolveApplication(identifier, apiOptions);
1877
+ const updated = await updateApplication(app.id, { scaling }, apiOptions);
1878
+ if (readJsonFlag(args)) {
1879
+ printLine(JSON.stringify(updated, null, 2));
1880
+ return;
1881
+ }
1882
+ printSuccess(`Updated scaling for ${updated.name}`);
1883
+ printInfo(dim(` ${describeScalingPolicy(updated.scaling)}`));
1884
+ printInfo(dim(` desired replicas: ${updated.desiredReplicas}`));
1885
+ }
1886
+ async function runScaleActionSubcommand(action, args, positionals, apiOptions) {
1887
+ const [identifier, ...extra] = positionals;
1888
+ if (!identifier)
1889
+ throw usageError('Missing application', `inquir apps ${action} <app>`);
1890
+ if (extra.length > 0) {
1891
+ throw usageError(`Unexpected argument '${extra[0]}'`, `inquir apps ${action} <app>`);
1892
+ }
1893
+ await requireAuth(apiOptions);
1894
+ const app = await resolveApplication(identifier, apiOptions);
1895
+ const result = action === 'wake'
1896
+ ? await wakeApplication(app.id, apiOptions)
1897
+ : await pauseApplication(app.id, apiOptions);
1898
+ if (readJsonFlag(args)) {
1899
+ printLine(JSON.stringify(result, null, 2));
1900
+ return;
1901
+ }
1902
+ const verb = action === 'wake' ? 'Wake' : 'Pause';
1903
+ if (result.accepted)
1904
+ printSuccess(`${verb} requested for ${app.name}`);
1905
+ else
1906
+ printInfo(`${verb} was not accepted for ${app.name}`);
1907
+ printInfo(dim(` desired replicas: ${result.desiredReplicas}${result.state ? ` (${result.state})` : ''}`));
1908
+ }
1909
+ async function wakeSubcommand(args, positionals, apiOptions) {
1910
+ return runScaleActionSubcommand('wake', args, positionals, apiOptions);
1911
+ }
1912
+ async function pauseSubcommand(args, positionals, apiOptions) {
1913
+ return runScaleActionSubcommand('pause', args, positionals, apiOptions);
1914
+ }
740
1915
  /** `apps update <app>` — env vars, ports, resources, health, egress. */
741
1916
  async function updateSubcommand(args, positionals, apiOptions) {
742
1917
  const [identifier] = positionals;
@@ -745,19 +1920,22 @@ async function updateSubcommand(args, positionals, apiOptions) {
745
1920
  await requireAuth(apiOptions);
746
1921
  const app = await resolveApplication(identifier, apiOptions);
747
1922
  const envVars = parseSetFlags(readMultiFlag(args, '--set'));
748
- const runtimeSpec = buildRuntimeFromFlags(args);
749
- const egress = readFlagValue(args, '--egress');
1923
+ const runtimeSpec = buildRuntimeFromFlags(args, app.runtime);
750
1924
  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
- }
1925
+ const networkPolicy = buildNetworkPolicyPatch({
1926
+ egress: readFlagValue(args, '--egress'),
1927
+ ingress: readFlagValue(args, '--ingress'),
1928
+ current: app.networkPolicy,
1929
+ });
1930
+ const volumeRunner = readFlagValue(args, '--volume-runner');
754
1931
  const patch = {
755
1932
  ...(description !== undefined ? { description } : {}),
1933
+ ...(volumeRunner ? { volumeRunner } : {}),
756
1934
  ...(runtimeSpec ? { runtime: runtimeSpec } : {}),
757
1935
  // Only the keys given here change; the server merges masked values, so
758
1936
  // untouched secrets survive.
759
1937
  ...(Object.keys(envVars).length > 0 ? { envVars: { ...(app.envVars ?? {}), ...envVars } } : {}),
760
- ...(egress ? { networkPolicy: { egress: egress, allow: [] } } : {}),
1938
+ ...(networkPolicy ? { networkPolicy } : {}),
761
1939
  };
762
1940
  if (Object.keys(patch).length === 0) {
763
1941
  throw usageError('Nothing to update', 'inquir apps update <app> --set KEY=VALUE');
@@ -773,16 +1951,19 @@ async function updateSubcommand(args, positionals, apiOptions) {
773
1951
  async function execSubcommand(args, positionals, apiOptions) {
774
1952
  const [identifier, ...commandParts] = positionals;
775
1953
  if (!identifier)
776
- throw usageError('Missing release', 'inquir apps exec <app> -- <command>');
1954
+ throw usageError('Missing application', 'inquir apps exec <app> -- <command>');
777
1955
  const command = commandParts.join(' ').trim();
778
1956
  if (!command)
779
1957
  throw usageError('Missing command', 'inquir apps exec <app> -- ls -la');
780
1958
  await requireAuth(apiOptions);
781
- const releaseId = await resolveReleaseId(identifier, apiOptions);
1959
+ // `apps exec` is intentionally application-scoped. Accepting a stale or
1960
+ // preview release id here and then calling the production-only app endpoint
1961
+ // would silently change the operator's target to production.
1962
+ const application = await resolveApplication(identifier, apiOptions);
782
1963
  const timeoutRaw = readFlagValue(args, '--timeout');
783
1964
  let result;
784
1965
  try {
785
- result = await execInRelease(releaseId, command, timeoutRaw ? Number(timeoutRaw) : undefined, apiOptions);
1966
+ result = await execInApplication(application.id, command, timeoutRaw ? Number(timeoutRaw) : undefined, apiOptions);
786
1967
  }
787
1968
  catch (err) {
788
1969
  if (err instanceof Error)
@@ -807,14 +1988,27 @@ function printVerificationInstructions(domain) {
807
1988
  const v = domain.verification;
808
1989
  if (!v)
809
1990
  return;
810
- printInfo(dim(' prove ownership, then verify:'));
811
- printLine(` TXT ${v.txtName} → ${v.txtValue}`);
812
- for (const target of v.cnameTargets)
813
- printLine(` CNAME ${domain.hostname} → ${target}`);
1991
+ // A verified hostname keeps its records on display: they are what gets compared with the
1992
+ // registrar when the name stops resolving.
1993
+ printInfo(dim(domain.verified
1994
+ ? ` ${domain.hostname} — verified with these records (the routing record must stay in place):`
1995
+ : ` ${domain.hostname} — prove ownership, then verify:`));
1996
+ const row = (label, name, value) => printLine(` ${label.padEnd(8)} ${name} → ${value}`);
1997
+ row('TXT', v.txtName, v.txtValue);
1998
+ // One target, not a menu: every entry is accepted by verify and leads to the same ingress. The first is the
1999
+ // app's own hostname when an apps suffix is configured, otherwise the platform target.
2000
+ if (v.cnameTargets[0])
2001
+ row('CNAME', domain.hostname, v.cnameTargets[0]);
814
2002
  for (const ip of v.ingressIps)
815
- printLine(` A ${domain.hostname} → ${ip}`);
2003
+ row('A', domain.hostname, ip);
816
2004
  if (v.ingressAliasTarget)
817
- printLine(` ALIAS ${domain.hostname} → ${v.ingressAliasTarget}`);
2005
+ row('ALIAS', domain.hostname, v.ingressAliasTarget);
2006
+ if (v.apexCnameTargets?.length) {
2007
+ printInfo(dim(domain.verified
2008
+ ? ' verified by the TXT record alone — where the apex points was never checked; traffic reaches the app only through ALIAS/ANAME/CNAME flattening onto:'
2009
+ : ' an apex domain is verified by the TXT record alone; point it with ALIAS/ANAME/CNAME flattening so traffic reaches the app:'));
2010
+ row('ALIAS', domain.hostname, v.apexCnameTargets[0]);
2011
+ }
818
2012
  }
819
2013
  /**
820
2014
  * `apps domain <app> [hostname] [--verify|--remove]` — bind a custom hostname
@@ -845,8 +2039,7 @@ async function domainSubcommand(args, positionals, apiOptions) {
845
2039
  verified: d.verified ? 'yes' : 'no',
846
2040
  })), ['hostname', 'kind', 'source', 'verified']));
847
2041
  for (const d of items)
848
- if (!d.verified)
849
- printVerificationInstructions(d);
2042
+ printVerificationInstructions(d);
850
2043
  return;
851
2044
  }
852
2045
  if (hasFlag(args, '--remove')) {
@@ -871,6 +2064,14 @@ async function domainSubcommand(args, positionals, apiOptions) {
871
2064
  printLine(JSON.stringify(domain, null, 2));
872
2065
  return;
873
2066
  }
2067
+ if (domain.verified) {
2068
+ // An operator-granted platform hostname verifies on bind: no records to publish.
2069
+ printSuccess(`${domain.hostname} bound to ${app.name} and verified — it now serves ${app.name}`);
2070
+ if (!domain.activeReleaseId) {
2071
+ printInfo(dim(' no release is promoted yet: inquir apps promote <release>'));
2072
+ }
2073
+ return;
2074
+ }
874
2075
  printSuccess(`${domain.hostname} bound to ${app.name} (unverified)`);
875
2076
  printVerificationInstructions(domain);
876
2077
  printInfo(dim(` then: inquir apps domain ${app.name} ${domain.hostname} --verify`));
@@ -889,6 +2090,9 @@ async function deleteSubcommand(args, positionals, apiOptions) {
889
2090
  });
890
2091
  }
891
2092
  await archiveApplication(app.id, apiOptions);
2093
+ if (readJsonFlag(args)) {
2094
+ return printLine(JSON.stringify({ ok: true, archived: { id: app.id, name: app.name } }, null, 2));
2095
+ }
892
2096
  printSuccess(`Archived ${app.name}`);
893
2097
  printInfo(dim(' every release was stopped and every hostname unbound'));
894
2098
  }