@inquir/compute-cli 0.3.0 → 0.4.1

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 (106) hide show
  1. package/README.md +528 -406
  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 +125 -0
  13. package/dist/commands/apps-describe.js.map +1 -0
  14. package/dist/commands/apps-instances.d.ts +18 -0
  15. package/dist/commands/apps-instances.d.ts.map +1 -0
  16. package/dist/commands/apps-instances.js +87 -0
  17. package/dist/commands/apps-instances.js.map +1 -0
  18. package/dist/commands/apps-observability.d.ts +15 -0
  19. package/dist/commands/apps-observability.d.ts.map +1 -0
  20. package/dist/commands/apps-observability.js +310 -0
  21. package/dist/commands/apps-observability.js.map +1 -0
  22. package/dist/commands/apps-promote.d.ts +31 -0
  23. package/dist/commands/apps-promote.d.ts.map +1 -0
  24. package/dist/commands/apps-promote.js +115 -0
  25. package/dist/commands/apps-promote.js.map +1 -0
  26. package/dist/commands/apps-resources.d.ts +6 -0
  27. package/dist/commands/apps-resources.d.ts.map +1 -0
  28. package/dist/commands/apps-resources.js +107 -0
  29. package/dist/commands/apps-resources.js.map +1 -0
  30. package/dist/commands/apps.d.ts +118 -2
  31. package/dist/commands/apps.d.ts.map +1 -1
  32. package/dist/commands/apps.js +999 -110
  33. package/dist/commands/apps.js.map +1 -1
  34. package/dist/commands/config.d.ts.map +1 -1
  35. package/dist/commands/config.js +7 -3
  36. package/dist/commands/config.js.map +1 -1
  37. package/dist/commands/delete.d.ts.map +1 -1
  38. package/dist/commands/delete.js +10 -2
  39. package/dist/commands/delete.js.map +1 -1
  40. package/dist/commands/deploy.js +3 -3
  41. package/dist/commands/deploy.js.map +1 -1
  42. package/dist/commands/init.js +15 -15
  43. package/dist/commands/login.d.ts.map +1 -1
  44. package/dist/commands/login.js +78 -33
  45. package/dist/commands/login.js.map +1 -1
  46. package/dist/commands/logout.d.ts.map +1 -1
  47. package/dist/commands/logout.js +37 -5
  48. package/dist/commands/logout.js.map +1 -1
  49. package/dist/commands/mcp.d.ts +13 -0
  50. package/dist/commands/mcp.d.ts.map +1 -1
  51. package/dist/commands/mcp.js +184 -77
  52. package/dist/commands/mcp.js.map +1 -1
  53. package/dist/commands/projects.d.ts.map +1 -1
  54. package/dist/commands/projects.js +107 -11
  55. package/dist/commands/projects.js.map +1 -1
  56. package/dist/index.js +9 -1
  57. package/dist/index.js.map +1 -1
  58. package/dist/lib/finite-source-command-client.d.ts +3 -0
  59. package/dist/lib/finite-source-command-client.d.ts.map +1 -0
  60. package/dist/lib/finite-source-command-client.js +3 -0
  61. package/dist/lib/finite-source-command-client.js.map +1 -0
  62. package/dist/lib/mcp-io.d.ts +23 -0
  63. package/dist/lib/mcp-io.d.ts.map +1 -0
  64. package/dist/lib/mcp-io.js +185 -0
  65. package/dist/lib/mcp-io.js.map +1 -0
  66. package/dist/lib/mcp-source-commands.d.ts +17 -0
  67. package/dist/lib/mcp-source-commands.d.ts.map +1 -0
  68. package/dist/lib/mcp-source-commands.js +345 -0
  69. package/dist/lib/mcp-source-commands.js.map +1 -0
  70. package/dist/utils/api.d.ts +39 -14
  71. package/dist/utils/api.d.ts.map +1 -1
  72. package/dist/utils/api.js +43 -6
  73. package/dist/utils/api.js.map +1 -1
  74. package/dist/utils/apps-presentation.d.ts +8 -1
  75. package/dist/utils/apps-presentation.d.ts.map +1 -1
  76. package/dist/utils/apps-presentation.js +35 -1
  77. package/dist/utils/apps-presentation.js.map +1 -1
  78. package/dist/utils/auth.d.ts +21 -1
  79. package/dist/utils/auth.d.ts.map +1 -1
  80. package/dist/utils/auth.js +119 -22
  81. package/dist/utils/auth.js.map +1 -1
  82. package/dist/utils/browser.d.ts +12 -0
  83. package/dist/utils/browser.d.ts.map +1 -1
  84. package/dist/utils/browser.js +37 -16
  85. package/dist/utils/browser.js.map +1 -1
  86. package/dist/utils/errors.d.ts.map +1 -1
  87. package/dist/utils/errors.js +41 -0
  88. package/dist/utils/errors.js.map +1 -1
  89. package/dist/utils/msys-path.d.ts +14 -0
  90. package/dist/utils/msys-path.d.ts.map +1 -0
  91. package/dist/utils/msys-path.js +55 -0
  92. package/dist/utils/msys-path.js.map +1 -0
  93. package/dist/utils/package-metadata.js +1 -1
  94. package/dist/utils/project-context.d.ts.map +1 -1
  95. package/dist/utils/project-context.js +7 -5
  96. package/dist/utils/project-context.js.map +1 -1
  97. package/dist/utils/project-lookup.d.ts +10 -0
  98. package/dist/utils/project-lookup.d.ts.map +1 -0
  99. package/dist/utils/project-lookup.js +52 -0
  100. package/dist/utils/project-lookup.js.map +1 -0
  101. package/dist/utils/source-pack.d.ts +24 -0
  102. package/dist/utils/source-pack.d.ts.map +1 -0
  103. package/dist/utils/source-pack.js +63 -0
  104. package/dist/utils/source-pack.js.map +1 -0
  105. package/package.json +26 -26
  106. package/skill/SKILL.md +73 -67
@@ -1,16 +1,25 @@
1
1
  import path from 'node:path';
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 { INSTANCES_HELP, instancesSubcommand, resolveReplicaAllocationId } from './apps-instances.js';
6
+ import { followPromoteRollout } from './apps-promote.js';
2
7
  import { hasFlag, readFlagValue, readJsonFlag, readLimitFlag, readMultiFlag, readPositionalArgs, } from '../utils/args.js';
3
- import { listAppBackups, getAppBackup, createAppBackup, deleteAppBackup, restoreAppBackup, getAppConnectionUrl, connectApps, addApplicationDomain, archiveApplication, cancelBuild, createApplication, getBuild, listApplicationDomains, listBuilds, queueBuild, removeApplicationDomain, createRelease, redeployApplication, execInApplication, getAppLimits, getApplication, getRelease, listApplications, promoteRelease, requireAuth, resolveApplication, rollbackApplication, stopRelease, streamBuildLogs, streamReleaseLogs, updateApplication, wakeApplication, pauseApplication, verifyApplicationDomain, } from '../utils/api.js';
4
- import { createZipBase64, isSettledReleaseStatus, isTerminalBuildStatus } from '../sdk.js';
5
- import { formatBytes, formatElapsed, humanizeApiError, permanentAddress, presentRelease, } from '../utils/apps-presentation.js';
8
+ import { listAppBackups, getAppBackup, createAppBackup, deleteAppBackup, restoreAppBackup, getAppConnectionUrl, connectApps, createApiClient, addApplicationDomain, archiveApplication, cancelBuild, createApplication, getBuild, listApplicationDomains, listBuilds, queueBuild, removeApplicationDomain, createRelease, redeployApplication, execInApplication, getAppLimits, getAppLocation, getApplication, getProject, getProductionReplacement, getRelease, listApplications, promoteRelease, isPromoteAccepted, requireAuth, resolveApplication, rollbackApplication, stopRelease, streamBuildLogs, streamReleaseLogs, updateApplication, wakeApplication, pauseApplication, verifyApplicationDomain, } from '../utils/api.js';
9
+ import { ApiError, DEFAULT_BUILD_TIMEOUT_POLICY, getAppLogHistory, isSettledReleaseStatus, isTerminalBuildStatus } from '../sdk.js';
10
+ import { formatBytes, formatElapsed, humanizeApiError, permanentAddress, presentRelease, replacementPhaseMeaning, } from '../utils/apps-presentation.js';
6
11
  import { buildApiOptions, runtime } from '../utils/context.js';
7
12
  import { confirm } from '../utils/prompt.js';
8
13
  import { resolveCreateProject } from '../utils/project-context.js';
9
- import { CliError, ExitCode, usageError } from '../utils/errors.js';
14
+ import { CliError, ExitCode, toCliError, usageError } from '../utils/errors.js';
10
15
  import { redact, registerSecret } from '../utils/redact.js';
11
- import { dim, formatTable, printCommandHelp, printError, printInfo, printLine, printSuccess } from '../utils/output.js';
16
+ import { dim, formatTable, printCommandHelp, printError, printInfo, printLine, printSuccess, printWarning } from '../utils/output.js';
17
+ import { containerPathArgument, MSYS_PATH_HINT } from '../utils/msys-path.js';
18
+ import { packSourceForUpload } from '../utils/source-pack.js';
12
19
  /** Value flags across every subcommand — readPositionalArgs must know them all. */
13
20
  const VALUE_FLAGS = [
21
+ ...OBSERVATION_VALUE_FLAGS,
22
+ ...RESOURCES_VALUE_FLAGS,
14
23
  '--project',
15
24
  '--template',
16
25
  '--image',
@@ -22,6 +31,7 @@ const VALUE_FLAGS = [
22
31
  '--set',
23
32
  '--memory',
24
33
  '--cpu',
34
+ '--drain-grace',
25
35
  '--health-path',
26
36
  '--health-command',
27
37
  '--description',
@@ -45,6 +55,8 @@ const VALUE_FLAGS = [
45
55
  '--stabilization',
46
56
  '--scale-down-stabilization',
47
57
  '--replicas',
58
+ '--node-selector',
59
+ '--location',
48
60
  ];
49
61
  export async function appsCommand(args) {
50
62
  const [subcommand, ...rest] = readPositionalArgs(args, { valueFlags: VALUE_FLAGS });
@@ -76,7 +88,21 @@ export async function appsCommand(args) {
76
88
  case 'status':
77
89
  return statusSubcommand(args, rest, apiOptions);
78
90
  case 'logs':
91
+ if (hasFlag(args, '--history'))
92
+ return logHistorySubcommand(args, rest, apiOptions);
79
93
  return logsSubcommand(args, rest, apiOptions);
94
+ case 'metrics':
95
+ return metricsSubcommand(args, rest, apiOptions);
96
+ case 'log-history':
97
+ return logHistorySubcommand(args, rest, apiOptions);
98
+ case 'alerts':
99
+ return alertsSubcommand(args, rest, apiOptions);
100
+ case 'resources':
101
+ return resourcesSubcommand(args, rest, apiOptions);
102
+ case 'describe':
103
+ return describeSubcommand(args, rest, apiOptions);
104
+ case 'instances':
105
+ return instancesSubcommand(args, rest, apiOptions);
80
106
  case 'promote':
81
107
  return promoteSubcommand(args, rest, apiOptions);
82
108
  case 'rollback':
@@ -124,12 +150,18 @@ function printAppsHelp() {
124
150
  'build <app> [--dir <path>] Build an image from source (Dockerfile) without releasing',
125
151
  'builds <app> List image builds',
126
152
  'status <app> Endpoints and recent releases',
127
- 'logs <app|release|build> Stream container (or build) logs',
153
+ 'describe <app> Why it is (not) running: conditions, placement, events',
154
+ 'instances <app> Replicas (like pods): name, state, runner, CPU/RAM now',
155
+ 'logs <app|release|build> Recent container (or build) logs, then follow (--no-follow exits)',
156
+ 'log-history <app> Persisted runtime or HTTP logs with time/replica filters',
157
+ 'metrics <app> CPU/RAM, network, disk I/O or HTTP history (--json)',
158
+ 'alerts <app> [action] Configure resource/deployment alerts and read incidents',
159
+ 'resources recommend <app> CPU/RAM right-sizing from observed usage (read-only)',
128
160
  'promote <release> Send production traffic to a release',
129
161
  'rollback <app> Return production to the previous release',
130
162
  'stop <release> Stop a release that is not serving production',
131
- 'exec <release> -- <cmd> Run a command inside the container',
132
- 'update <app> Change env vars, ports, resources, egress',
163
+ 'exec <app> [--replica <r>] -- <cmd> Run a command in production (one replica: name or index)',
164
+ 'update <app> Change env vars, ports, resources, egress, node selector',
133
165
  'scale <app> [mode] Set always/http/manual scaling (--replicas for always)',
134
166
  'wake <app> Start a sleeping application',
135
167
  'pause <app> Stop an http/manual app (HTTP wakes only http mode)',
@@ -137,10 +169,10 @@ function printAppsHelp() {
137
169
  'delete <app> Archive an application (stops every release)',
138
170
  '',
139
171
  'Create/deploy flags:',
140
- '--project <id|slug> create: use this project (overrides projects use)',
172
+ '--project <project> create: this project by id, slug or unique name (overrides projects use)',
141
173
  '--image <ref> Container image, e.g. nginx:alpine (omit to build from source)',
142
174
  '--promote deploy: take production by itself once healthy (needs apps:promote)',
143
- '--template <name> postgres | redis: volume, stable TCP endpoint, generated password, first release',
175
+ '--template <name> postgres | redis: volume, private TCP endpoint, generated password, first release',
144
176
  '--dir <path> Source directory to build (default: current directory)',
145
177
  '--dockerfile <path> Dockerfile relative to the context (default: Dockerfile)',
146
178
  '--context <path> Build context inside the source tree (default: .)',
@@ -150,9 +182,14 @@ function printAppsHelp() {
150
182
  '--tcp <name:port[:public]> Extra TCP port; endpoint port is platform-managed (repeatable)',
151
183
  '--volume <name:/path[:ro]> Persistent volume mount (repeatable)',
152
184
  '--volume-runner <id> Pin volumes (and the container) to a worker runner',
185
+ '--node-selector KEY=VALUE update: run only on servers with this node label (repeatable),',
186
+ ' e.g. inquir.io/region=eu; --clear-node-selector removes it',
187
+ '--location <id|auto> update (experimental): run in this location, e.g. nl or ru; auto',
188
+ ' runs wherever there is capacity. Applies from the next deploy',
153
189
  '--set KEY=VALUE Environment variable (repeatable)',
154
190
  '--memory <mb> Memory ceiling (default 512)',
155
191
  '--cpu <vcpus> CPU ceiling (default 0.5)',
192
+ '--drain-grace <seconds> Time the old container gets after SIGTERM before it is killed (0-3600)',
156
193
  '--egress <none|full> Outbound network policy',
157
194
  '--ingress <private|public> Public Networking: public creates the generated domain (new apps are private)',
158
195
  '--health-path </path> HTTP health check path',
@@ -210,6 +247,77 @@ export function parseTcpPortFlag(value) {
210
247
  }
211
248
  return spec;
212
249
  }
250
+ /**
251
+ * `--location <id|auto>`: the service's location, verbatim; the server says
252
+ * which ids it offers. Undefined when the flag is absent.
253
+ */
254
+ export function parseLocationFlag(value) {
255
+ if (value === undefined)
256
+ return undefined;
257
+ const location = value.trim().toLowerCase();
258
+ if (!/^[a-z0-9](?:[a-z0-9-]{0,30}[a-z0-9])?$/.test(location)) {
259
+ throw usageError(`Invalid --location value '${value}'`, 'inquir apps update <app> --location nl (or --location auto)');
260
+ }
261
+ return location;
262
+ }
263
+ /**
264
+ * The status line of a service's location: its setting by name, what
265
+ * production runs with while a change waits for the next deploy, and where
266
+ * the replicas run. Undefined while the platform offers no location choice.
267
+ */
268
+ export function describeServiceLocation(status) {
269
+ if (!status.enabled)
270
+ return undefined;
271
+ const name = (id) => (id === 'auto' ? 'Auto' : status.locations.find((entry) => entry.id === id)?.name ?? id);
272
+ const parts = [status.setting === 'auto' ? 'Auto (wherever there is capacity)' : name(status.setting)];
273
+ if (status.pending && status.applied !== null) {
274
+ parts.push(`applies on the next deploy (production runs ${status.applied === 'auto' ? 'without a location' : `in ${name(status.applied)}`})`);
275
+ }
276
+ const running = [...new Set(status.replicas.map((replica) => (replica.location ? name(replica.location) : 'a server without a location')))];
277
+ if (running.length > 0)
278
+ parts.push(`running in ${running.join(', ')}`);
279
+ return parts.join(' — ');
280
+ }
281
+ /**
282
+ * `--node-selector KEY=VALUE` (repeatable): the node labels a server must
283
+ * carry, verbatim (`inquir.io/region=eu`, `tier=fast`). The server validates
284
+ * keys and values and says which keys it exposes.
285
+ */
286
+ export function parseNodeSelectorFlags(values) {
287
+ const selector = {};
288
+ for (const entry of values) {
289
+ const index = entry.indexOf('=');
290
+ const key = index > 0 ? entry.slice(0, index).trim() : '';
291
+ if (!key) {
292
+ throw usageError(`Invalid --node-selector value '${entry}'`, 'inquir apps update <app> --node-selector inquir.io/region=eu');
293
+ }
294
+ if (Object.hasOwn(selector, key)) {
295
+ throw usageError(`--node-selector repeats the key '${key}'`, 'inquir apps update <app> --node-selector inquir.io/region=eu');
296
+ }
297
+ selector[key] = entry.slice(index + 1).trim();
298
+ }
299
+ return selector;
300
+ }
301
+ /**
302
+ * The runtime defaults with `--node-selector` / `--clear-node-selector`
303
+ * applied over `base` (the flags' runtime, else the stored one); undefined
304
+ * when neither flag is given.
305
+ */
306
+ export function applyNodeSelectorFlags(args, base) {
307
+ const values = readMultiFlag(args, '--node-selector');
308
+ const clear = hasFlag(args, '--clear-node-selector');
309
+ if (values.length === 0 && !clear)
310
+ return undefined;
311
+ if (values.length > 0 && clear) {
312
+ throw usageError('Use either --node-selector or --clear-node-selector', 'inquir apps update <app> --clear-node-selector');
313
+ }
314
+ if (!base) {
315
+ throw usageError('This application has no runtime defaults to place; set its port first', 'inquir apps update <app> --port 3000 --node-selector inquir.io/region=eu');
316
+ }
317
+ const rest = { ...base };
318
+ delete rest.nodeSelector;
319
+ return clear ? rest : { ...rest, nodeSelector: parseNodeSelectorFlags(values) };
320
+ }
213
321
  export function parseSetFlags(values) {
214
322
  const env = {};
215
323
  for (const entry of values) {
@@ -306,6 +414,7 @@ export function buildScalingPatchFromFlags(args, positionalActivation) {
306
414
  }
307
415
  const VOLUME_NAME_RE = /^[a-z0-9]([a-z0-9-]{0,30}[a-z0-9])?$/;
308
416
  const MAX_APP_VOLUMES = 16;
417
+ // eslint-disable-next-line no-control-regex -- Container mount paths must reject control characters.
309
418
  const CONTROL_CHAR_RE = /[\u0000-\u001f\u007f]/;
310
419
  /** `--volume name:/absolute/container/path[:ro]`; host paths are never accepted. */
311
420
  export function parseVolumeFlag(value) {
@@ -314,7 +423,7 @@ export function parseVolumeFlag(value) {
314
423
  let mountPath = separator > 0 ? value.slice(separator + 1) : '';
315
424
  let readOnly = false;
316
425
  if (/^[A-Za-z]:[\\/]/.test(mountPath)) {
317
- throw usageError(`Invalid --volume mount path '${mountPath}'`, 'Volume targets are absolute paths inside the Linux container, for example data:/var/lib/data');
426
+ throw usageError(`Invalid --volume mount path '${mountPath}'`, `Volume targets are absolute paths inside the Linux container, for example data:/var/lib/data. ${MSYS_PATH_HINT}`);
318
427
  }
319
428
  const modifierAt = mountPath.lastIndexOf(':');
320
429
  if (modifierAt >= 0) {
@@ -375,6 +484,16 @@ function mergeVolumes(base, overrides) {
375
484
  assertVolumeSet(merged);
376
485
  return merged;
377
486
  }
487
+ /** Largest `runtimeSpec.drainGraceSeconds` the API accepts. */
488
+ const MAX_DRAIN_GRACE_SECONDS = 3600;
489
+ /** `--drain-grace <seconds>`: whole seconds, 0..3600, as the API's runtime schema. */
490
+ export function parseDrainGraceFlag(value) {
491
+ const seconds = /^\d{1,4}$/.test(value) ? Number(value) : Number.NaN;
492
+ if (!(seconds <= MAX_DRAIN_GRACE_SECONDS)) {
493
+ throw usageError(`Invalid --drain-grace value '${value}' (whole seconds, 0-${MAX_DRAIN_GRACE_SECONDS})`, 'inquir apps deploy <app> --drain-grace 30');
494
+ }
495
+ return seconds;
496
+ }
378
497
  /** Runtime flags patch the saved application runtime rather than replacing unrelated fields. */
379
498
  export function buildRuntimeFromFlags(args, baseRuntime) {
380
499
  const portRaw = readFlagValue(args, '--port');
@@ -382,7 +501,13 @@ export function buildRuntimeFromFlags(args, baseRuntime) {
382
501
  const volumeValues = readMultiFlag(args, '--volume');
383
502
  const memory = readFlagValue(args, '--memory');
384
503
  const cpu = readFlagValue(args, '--cpu');
385
- const healthPath = readFlagValue(args, '--health-path');
504
+ const drainGraceRaw = readFlagValue(args, '--drain-grace');
505
+ const healthPathRaw = readFlagValue(args, '--health-path');
506
+ const healthPathArgument = healthPathRaw !== undefined ? containerPathArgument(healthPathRaw, '--health-path') : undefined;
507
+ if (healthPathArgument?.recoveredFrom) {
508
+ printWarning(`--health-path: read '${healthPathArgument.recoveredFrom}' as ${healthPathArgument.value} (Git Bash path conversion). ${MSYS_PATH_HINT}`);
509
+ }
510
+ const healthPath = healthPathArgument?.value;
386
511
  const healthCommand = readFlagValue(args, '--health-command');
387
512
  const ports = tcpValues.map(parseTcpPortFlag);
388
513
  const volumes = volumeValues.map(parseVolumeFlag);
@@ -390,7 +515,13 @@ export function buildRuntimeFromFlags(args, baseRuntime) {
390
515
  if (portRaw && (!Number.isInteger(port) || port < 1 || port > 65535)) {
391
516
  throw usageError(`Invalid --port value '${portRaw}'`, 'inquir apps create <name> --port 3000');
392
517
  }
393
- if (!portRaw && tcpValues.length === 0 && volumeValues.length === 0 && !memory && !cpu && !healthPath && !healthCommand) {
518
+ // `--drain-grace` without a value reads as undefined; refuse it rather than
519
+ // silently deploying without the setting the caller asked for.
520
+ if (drainGraceRaw === undefined && hasFlag(args, '--drain-grace'))
521
+ parseDrainGraceFlag('');
522
+ const drainGraceSeconds = drainGraceRaw !== undefined ? parseDrainGraceFlag(drainGraceRaw) : undefined;
523
+ if (!portRaw && tcpValues.length === 0 && volumeValues.length === 0 && !memory && !cpu &&
524
+ drainGraceSeconds === undefined && !healthPath && !healthCommand) {
394
525
  return undefined;
395
526
  }
396
527
  const spec = {
@@ -398,6 +529,7 @@ export function buildRuntimeFromFlags(args, baseRuntime) {
398
529
  ...(portRaw ? { port } : {}),
399
530
  ...(tcpValues.length > 0 ? { ports } : {}),
400
531
  ...(volumeValues.length > 0 ? { volumes: mergeVolumes(baseRuntime?.volumes, volumes) } : {}),
532
+ ...(drainGraceSeconds !== undefined ? { drainGraceSeconds } : {}),
401
533
  };
402
534
  if (memory || cpu) {
403
535
  const parsedMemory = memory ? Number(memory) : (baseRuntime?.resources?.memoryMb ?? 512);
@@ -453,7 +585,7 @@ async function createAppSubcommand(args, positionals, apiOptions) {
453
585
  if (!name)
454
586
  throw usageError('Missing application name', 'inquir apps create <name> --port 3000');
455
587
  if (positionals.length > 1)
456
- throw usageError('Expected one application name', 'inquir apps create <name> --project <id|slug>');
588
+ throw usageError('Expected one application name', 'inquir apps create <name> --project <project>');
457
589
  await requireAuth(apiOptions);
458
590
  const template = readFlagValue(args, '--template');
459
591
  if (template && !TEMPLATE_NAMES.includes(template)) {
@@ -477,7 +609,22 @@ async function createAppSubcommand(args, positionals, apiOptions) {
477
609
  runtime: buildRuntimeFromFlags(args),
478
610
  ...(Object.keys(envVars).length > 0 ? { envVars } : {}),
479
611
  ...(networkPolicy ? { networkPolicy } : {}),
480
- }, project.apiOptions);
612
+ }, project.apiOptions).catch((error) => {
613
+ // A worker runner pulls only a digest-pinned registry image, and this
614
+ // installation has no mirror of the template's image yet.
615
+ if (error instanceof ApiError && error.code === 'APP_TEMPLATE_IMAGE_NOT_MIRRORED') {
616
+ throw new CliError(`The ${template} template cannot run on a worker runner on this installation yet`, {
617
+ code: error.code,
618
+ exitCode: ExitCode.ERROR,
619
+ requestId: error.requestId,
620
+ details: error.payload,
621
+ explanation: error.message,
622
+ 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.',
623
+ command: `inquir apps create ${name} --template ${template}`,
624
+ });
625
+ }
626
+ throw error;
627
+ });
481
628
  const firstRelease = app.firstRelease;
482
629
  if (readJsonFlag(args)) {
483
630
  // A scripted create must answer the same question the human one does:
@@ -490,8 +637,9 @@ async function createAppSubcommand(args, positionals, apiOptions) {
490
637
  }
491
638
  printSuccess(`Created application ${app.name}`);
492
639
  printInfo(dim(` id: ${app.id}`));
493
- if (app.projectIds?.length)
494
- printInfo(dim(` project: ${app.projectIds.join(', ')}`));
640
+ for (const line of await landingProjectLines(app, project.projectId, project.apiOptions)) {
641
+ printInfo(dim(` ${line}`));
642
+ }
495
643
  if (template && firstRelease) {
496
644
  printInfo(dim(` template: ${template} — first release ${firstRelease.releaseId.slice(0, 8)} is starting`));
497
645
  const release = await waitForRelease(firstRelease.releaseId, project.apiOptions);
@@ -507,9 +655,148 @@ async function createAppSubcommand(args, positionals, apiOptions) {
507
655
  printInfo(dim(` deploy it: inquir apps deploy ${app.name} (builds the Dockerfile in the current directory)`));
508
656
  printInfo(dim(` or: inquir apps deploy ${app.name} --image <ref>`));
509
657
  }
658
+ /**
659
+ * Which project a new application landed in, by name. With neither --project
660
+ * nor a selection the server creates one named after the application, so a
661
+ * database and the app using it land in two projects: say so, and how to
662
+ * group them.
663
+ */
664
+ async function landingProjectLines(app, requestedProject, apiOptions) {
665
+ const projectId = app.projectIds?.[0];
666
+ if (!projectId)
667
+ return [];
668
+ const project = await getProject(projectId, apiOptions).catch(() => null);
669
+ const label = project ? `${project.name} (${project.slug})` : projectId;
670
+ if (requestedProject)
671
+ return [`project: ${label}`];
672
+ return [
673
+ `project: ${label}, created for it since no project is selected here`,
674
+ ` group it with another application: inquir projects move <project> ${app.name}`,
675
+ ' put new applications in one project: inquir projects use <project>',
676
+ ];
677
+ }
510
678
  const TEMPLATE_NAMES = ['postgres', 'redis'];
511
679
  /** `apps <sub> --help` answered per subcommand; the global page is the fallback. */
512
- const SUBCOMMAND_HELP = {
680
+ export const SUBCOMMAND_HELP = {
681
+ ...OBSERVATION_HELP,
682
+ ...RESOURCES_HELP,
683
+ ...DESCRIBE_HELP,
684
+ ...INSTANCES_HELP,
685
+ logs: [
686
+ 'Usage: inquir apps logs <app|release|build> [--tail <n>] [--no-follow] [--replica <name|index|allocation>]',
687
+ ' Prints the last --tail lines (default 200, at most 2000), then follows new',
688
+ ' ones until Ctrl-C.',
689
+ ' --no-follow Print the last --tail lines and exit (for scripts and agents).',
690
+ ' --replica Only one replica\'s lines (see apps instances <app>).',
691
+ ' --history selects persisted history instead; see apps log-history --help.',
692
+ ],
693
+ exec: [
694
+ 'Usage: inquir apps exec <app> [--replica <name|index|allocation>] [--timeout <ms>] -- <command>',
695
+ ' Runs one audited command in the production container. <app> is the',
696
+ ' application name or id; the id of its production release works too.',
697
+ ' --replica One serving replica (see apps instances <app>); default: the projected one.',
698
+ ],
699
+ list: [
700
+ 'Usage: inquir apps list [--json]',
701
+ '',
702
+ ' The workspace\'s applications with their production status.',
703
+ ],
704
+ deploy: [
705
+ 'Usage: inquir apps deploy <app> [--image <ref> | --build <buildId> | --dir <path>] [--promote] [--no-wait]',
706
+ '',
707
+ ' Without --image or --build, packs the source directory (default: the',
708
+ ' current one), builds its Dockerfile and releases the image as a preview.',
709
+ ' The upload honours .dockerignore and .inquirignore and never contains',
710
+ ' .env files, private keys or literal registry tokens.',
711
+ ' --image <ref> Release a container image instead of building',
712
+ ' --build <buildId> Release an existing successful build',
713
+ ' --dir <path> Source directory (default: current directory)',
714
+ ' --dockerfile, --context, --target, --build-arg KEY=VALUE Build options',
715
+ ' --promote Take production by itself once healthy (needs apps:promote)',
716
+ ' --no-wait Return as soon as the release is queued',
717
+ ' Runtime flags (--port, --tcp, --volume, --set, --memory, --cpu, ...) as in apps --help.',
718
+ ],
719
+ status: [
720
+ 'Usage: inquir apps status <app> [--json]',
721
+ '',
722
+ ' Production, endpoints (public URL, private address, TCP ports), volumes',
723
+ ' and recent releases.',
724
+ ],
725
+ rollback: [
726
+ 'Usage: inquir apps rollback <app> [--json]',
727
+ '',
728
+ ' Returns production to the previous release: instantly while its container',
729
+ ' still runs, otherwise after it restarts and passes its health check.',
730
+ ],
731
+ stop: [
732
+ 'Usage: inquir apps stop <release>',
733
+ '',
734
+ ' Stops a release that is not serving production; its container and preview',
735
+ ' URL go away.',
736
+ ],
737
+ build: [
738
+ 'Usage: inquir apps build <app> [--dir <path>] [--dockerfile <path>] [--context <path>] [--target <stage>] [--build-arg KEY=VALUE]',
739
+ '',
740
+ ' Builds an image from source without releasing it (release it later with',
741
+ ' apps deploy <app> --build <buildId>). Uploads like apps deploy.',
742
+ ],
743
+ builds: [
744
+ 'Usage: inquir apps builds <app> [--cancel <buildId>] [--json]',
745
+ '',
746
+ ' Lists the application\'s image builds; --cancel stops a queued or running one.',
747
+ ],
748
+ update: [
749
+ 'Usage: inquir apps update <app> [--set KEY=VALUE] [--port <n>] [--memory <mb>] [--cpu <vcpus>] [--ingress private|public] [flags]',
750
+ '',
751
+ ' Applied now: --ingress (the generated public domain is created or removed',
752
+ ' at once) and --description.',
753
+ ' Applied by the next release (inquir apps redeploy <app>): --set, --port,',
754
+ ' --tcp, --volume, --memory, --cpu, --drain-grace, --health-path,',
755
+ ' --health-command, --egress, --location, --node-selector / --clear-node-selector.',
756
+ ],
757
+ scale: [
758
+ 'Usage: inquir apps scale <app> <always|http|manual> [--replicas <n>] [--idle-timeout <d>] [--activation-timeout <d>] [--stabilization <d>]',
759
+ '',
760
+ ' always Keep --replicas replicas running (1..8)',
761
+ ' http Sleep after --idle-timeout without requests (minimum 5m); a request wakes it',
762
+ ' manual Run until paused; only wake starts it again',
763
+ ' Durations: 90s, 15m, 1h30m. Takes effect now.',
764
+ '',
765
+ 'Examples:',
766
+ ' inquir apps scale web always --replicas 2',
767
+ ' inquir apps scale web http --idle-timeout 15m',
768
+ ],
769
+ wake: [
770
+ 'Usage: inquir apps wake <app> [--json]',
771
+ '',
772
+ ' Starts a sleeping (http) or paused (manual) application.',
773
+ ],
774
+ pause: [
775
+ 'Usage: inquir apps pause <app> [--json]',
776
+ '',
777
+ ' Stops an http or manual application; an http one wakes on the next request.',
778
+ ],
779
+ domain: [
780
+ 'Usage: inquir apps domain <app> [hostname] [--verify | --remove] [--json]',
781
+ '',
782
+ ' Without a hostname, lists the bound domains. With one, binds it and prints',
783
+ ' the DNS records to create; --verify checks them, --remove unbinds it.',
784
+ ],
785
+ delete: [
786
+ 'Usage: inquir apps delete <app>',
787
+ '',
788
+ ' Archives the application and stops every release. Refused while another',
789
+ ' application depends on it (apps connect).',
790
+ ],
791
+ promote: [
792
+ 'Usage: inquir apps promote <release> [--no-wait] [--json]',
793
+ '',
794
+ ' Sends production traffic to a healthy release. A multi-replica service',
795
+ ' may be promoted as a rolling rollout: the server starts the new replicas a',
796
+ ' few at a time and switches production once enough of them serve, and the',
797
+ ' CLI follows it until then.',
798
+ ' --no-wait Return as soon as the rollout has started',
799
+ ],
513
800
  redeploy: [
514
801
  'Usage: inquir apps redeploy <app> [--no-promote] [--no-wait]',
515
802
  '',
@@ -525,14 +812,18 @@ const SUBCOMMAND_HELP = {
525
812
  create: [
526
813
  'Usage: inquir apps create <name> [--template postgres|redis] [flags]',
527
814
  '',
528
- ' --project <id|slug> Add to this project. Otherwise uses projects use;',
529
- ' without a selection, creates a project named after the app.',
530
- ' --template <name> postgres | redis: volume, pinned public TCP port,',
531
- ' generated password, and the first release — one command',
532
- ' from nothing to a running database.',
815
+ ' --project <project> Add to this project (id, slug or unique name). Otherwise',
816
+ ' uses projects use; without a selection, creates a project',
817
+ ' named after the app (group it later: inquir projects move).',
818
+ ' --template <name> postgres | redis: a volume, a private TCP port',
819
+ ' (<slug>.apps.internal, reachable from your other',
820
+ ' services), a generated password and the first release —',
821
+ ' one command from nothing to a running database.',
533
822
  ' --volume-runner <id> Pin volumes + container to a worker runner (id or',
534
- ' unique prefix); the data lives on that machine.',
535
- ' --port, --tcp, --volume, --memory, --cpu, --health-command, --set KEY=VALUE',
823
+ ' unique prefix); the data lives on that machine. With',
824
+ ' --template the worker runs the operator\'s digest-pinned',
825
+ ' mirror of the template image (APPS_TEMPLATE_IMAGES).',
826
+ ' --port, --tcp, --volume, --memory, --cpu, --drain-grace, --health-command, --set KEY=VALUE',
536
827
  '',
537
828
  'Examples:',
538
829
  ' inquir apps create mydb --template postgres',
@@ -559,6 +850,9 @@ const SUBCOMMAND_HELP = {
559
850
  'Writes the database connection URL into the target app\'s environment',
560
851
  '(DATABASE_URL for postgres, REDIS_URL for redis). The password never',
561
852
  'passes through your terminal. Takes effect on the target\'s next release.',
853
+ 'The URL names the database\'s private address, <slug>.apps.internal:<port>,',
854
+ 'on the control-plane runner and on a worker alike, once the platform has',
855
+ 'verified it (`inquir apps status <db>` lists it).',
562
856
  '',
563
857
  ' --overwrite Replace a DATABASE_URL/REDIS_URL the target already has —',
564
858
  ' e.g. to repoint it at the database\'s private address',
@@ -574,8 +868,55 @@ const SUBCOMMAND_HELP = {
574
868
  ],
575
869
  };
576
870
  const BACKUPS_USAGE = 'inquir apps backups <app> [list|create|restore <backupId>|delete <backupId>]';
871
+ /**
872
+ * "2/3 ~10-08 14:00": the row's place among its volume's backups of the same
873
+ * kind, and roughly when it goes; "next cleanup" once past the limit.
874
+ */
875
+ export function formatBackupKeeps(backup) {
876
+ const retention = backup.retention;
877
+ if (!retention)
878
+ return '';
879
+ const place = `${retention.position}/${retention.limit}`;
880
+ if (!retention.kept)
881
+ return `${place} next cleanup`;
882
+ if (!retention.expiresAt)
883
+ return place;
884
+ return `${place} ~${retention.expiresAt.slice(5, 16).replace('T', ' ')}`;
885
+ }
886
+ /** One line on what the workspace's backup policy keeps. */
887
+ export function formatBackupPolicy(policy) {
888
+ const owner = policy.source === 'override' ? 'This workspace' : `Plan ${policy.planCode}`;
889
+ const every = policy.frequencyHours === 24 ? 'daily' : `every ${policy.frequencyHours}h`;
890
+ const automatic = policy.automatic
891
+ ? `the last ${policy.retainCount} automatic backup(s) (${every})`
892
+ : 'no automatic backups';
893
+ let line = `${owner} keeps ${automatic} and ${policy.maxManualBackups} manual backup(s) per volume`;
894
+ if (policy.grace)
895
+ line += `; after a plan change, up to ${policy.grace.retainCount} are kept until ${policy.grace.until.slice(0, 10)}`;
896
+ if (!policy.automaticAvailable && policy.automaticUnavailableReason === 'volume_on_worker') {
897
+ line += '; this app\'s volumes live on a worker, where the platform cannot back them up yet';
898
+ }
899
+ return line;
900
+ }
901
+ /**
902
+ * An empty backup list. A volume on a worker runner cannot be snapshotted by
903
+ * the platform yet, and `create` is refused there too: say so instead of
904
+ * suggesting it.
905
+ */
906
+ export function emptyBackupsLines(appName, policy) {
907
+ if (policy && !policy.automaticAvailable && policy.automaticUnavailableReason === 'volume_on_worker') {
908
+ return [
909
+ `No backups for ${appName}: its volume lives on a worker runner, where the platform cannot back up volumes yet`,
910
+ ' (automatic or on demand; `backups create` is refused there too). Runner-side backups are in progress.',
911
+ ' Until then, see "Backups before risky changes" in the Node.js + Postgres tutorial:',
912
+ ' https://inquir.org/docs/node-postgres',
913
+ ];
914
+ }
915
+ return [`No backups for ${appName} yet — create one: inquir apps backups ${appName} create`];
916
+ }
577
917
  /** The one backup table: `create` prints it too, once the rows settle. */
578
918
  function renderBackupTable(items) {
919
+ const keeps = items.some((b) => b.retention);
579
920
  printLine(formatTable(items.map((b) => ({
580
921
  id: b.id.slice(0, 8),
581
922
  volume: b.volumeName,
@@ -583,7 +924,10 @@ function renderBackupTable(items) {
583
924
  trigger: b.trigger,
584
925
  size: formatBytes(b.sizeBytes ?? b.artifactSizeBytes),
585
926
  created: b.createdAt.replace('T', ' ').slice(0, 19),
586
- })), ['id', 'volume', 'status', 'trigger', 'size', 'created']));
927
+ keeps: formatBackupKeeps(b),
928
+ })), keeps
929
+ ? ['id', 'volume', 'status', 'trigger', 'size', 'created', 'keeps']
930
+ : ['id', 'volume', 'status', 'trigger', 'size', 'created']));
587
931
  }
588
932
  /** Accept a short id, the way the release commands do. */
589
933
  async function resolveBackup(applicationId, needle, identifier, apiOptions) {
@@ -655,9 +999,12 @@ async function backupsSubcommand(args, positionals, apiOptions) {
655
999
  if (json)
656
1000
  return printLine(JSON.stringify(page, null, 2));
657
1001
  if (page.items.length === 0) {
658
- printInfo(`No backups for ${app.name} yet — create one: inquir apps backups ${app.name} create`);
1002
+ for (const line of emptyBackupsLines(app.name, page.policy))
1003
+ printInfo(line);
659
1004
  return;
660
1005
  }
1006
+ if (page.policy)
1007
+ printInfo(dim(formatBackupPolicy(page.policy)));
661
1008
  renderBackupTable(page.items);
662
1009
  if (page.nextCursor) {
663
1010
  printInfo(dim(` older: inquir apps backups ${app.name} list --cursor ${page.nextCursor}`));
@@ -729,6 +1076,90 @@ async function backupsSubcommand(args, positionals, apiOptions) {
729
1076
  }
730
1077
  throw usageError(`Unknown backups action '${verb}'`, BACKUPS_USAGE);
731
1078
  }
1079
+ /**
1080
+ * The refusals of `apps connect` and `apps connection-url`, told as what to do
1081
+ * next. The server's code and message are kept; the cause, the fix and the
1082
+ * command to try are the CLI's. Anything else passes through unchanged to the
1083
+ * generic rendering.
1084
+ */
1085
+ export function explainDatabaseConnectionError(error, context) {
1086
+ if (!(error instanceof ApiError) || !error.code)
1087
+ return error;
1088
+ const { database, target } = context;
1089
+ const base = {
1090
+ code: error.code,
1091
+ exitCode: ExitCode.ERROR,
1092
+ requestId: error.requestId,
1093
+ details: error.payload,
1094
+ };
1095
+ const status = `inquir apps status ${database}`;
1096
+ const retry = target ? `inquir apps connect ${database} ${target}` : `inquir apps connection-url ${database}`;
1097
+ switch (error.code) {
1098
+ case 'APP_NOT_A_TEMPLATE':
1099
+ return new CliError(`${database} was not created from a database template`, {
1100
+ ...base,
1101
+ 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.',
1102
+ fix: target
1103
+ ? `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.`
1104
+ : '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.',
1105
+ command: target ? `inquir apps update ${target} --set DATABASE_URL=<url>` : status,
1106
+ });
1107
+ case 'APP_PRIVATE_ENDPOINT_NOT_READY':
1108
+ return new CliError(`${database} has no verified private address yet`, {
1109
+ ...base,
1110
+ explanation: error.message,
1111
+ 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.',
1112
+ fix: `Retry in a minute. If it lasts, check that the database is serving: ${status}`,
1113
+ command: retry,
1114
+ });
1115
+ case 'APP_NO_TCP_ENDPOINT':
1116
+ return new CliError(error.message, {
1117
+ ...base,
1118
+ // An older control plane also answers this for a database a worker
1119
+ // serves: it looked for the port only in the release's port mappings,
1120
+ // which such a release never has (src/apps/app-connection-endpoint.ts).
1121
+ 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.',
1122
+ 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.`,
1123
+ command: status,
1124
+ });
1125
+ case 'PRIVATE_NETWORK_NOT_CONFIGURED':
1126
+ return new CliError(error.message, {
1127
+ ...base,
1128
+ cause: 'The control plane offers no private address for this database.',
1129
+ fix: target
1130
+ ? '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.'
1131
+ : '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.',
1132
+ command: `inquir apps connection-url ${database} --public`,
1133
+ });
1134
+ case 'APP_NO_PRODUCTION_RELEASE':
1135
+ return new CliError(error.message, {
1136
+ ...base,
1137
+ fix: 'Deploy the database first; a template database ships its first release when it is created.',
1138
+ command: status,
1139
+ });
1140
+ case 'APP_PRODUCTION_RELEASE_NOT_ACTIVE':
1141
+ return new CliError(error.message, {
1142
+ ...base,
1143
+ fix: 'Wait until its release serves: on a worker runner the replica must start and pass its health check first.',
1144
+ command: status,
1145
+ });
1146
+ case 'APP_CREDENTIALS_MISSING':
1147
+ return new CliError(error.message, {
1148
+ ...base,
1149
+ cause: 'The running release\'s env has no password under the template\'s key (POSTGRES_PASSWORD, REDIS_PASSWORD).',
1150
+ 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.',
1151
+ command: `inquir apps update ${database} --set <POSTGRES_PASSWORD|REDIS_PASSWORD>=<password>`,
1152
+ });
1153
+ case 'ENV_KEY_ALREADY_SET':
1154
+ return new CliError(error.message, {
1155
+ ...base,
1156
+ fix: 'Replace it on purpose: the new URL applies on the target\'s next release.',
1157
+ command: `${retry} --overwrite`,
1158
+ });
1159
+ default:
1160
+ return error;
1161
+ }
1162
+ }
732
1163
  async function connectSubcommand(args, positionals, apiOptions) {
733
1164
  const [dbIdentifier, targetIdentifier] = positionals;
734
1165
  if (!dbIdentifier || !targetIdentifier) {
@@ -740,7 +1171,9 @@ async function connectSubcommand(args, positionals, apiOptions) {
740
1171
  // is how an app is repointed at the database's current private address
741
1172
  // (the composed URL never passes through this terminal).
742
1173
  const overwrite = args.includes('--overwrite');
743
- const result = await connectApps(db.id, targetIdentifier, { ...apiOptions, overwrite });
1174
+ const result = await connectApps(db.id, targetIdentifier, { ...apiOptions, overwrite }).catch((error) => {
1175
+ throw explainDatabaseConnectionError(error, { database: db.name, target: targetIdentifier });
1176
+ });
744
1177
  if (readJsonFlag(args)) {
745
1178
  return printLine(JSON.stringify(result, null, 2));
746
1179
  }
@@ -753,7 +1186,9 @@ async function connectionUrlSubcommand(args, positionals, apiOptions) {
753
1186
  throw usageError('Missing application', 'inquir apps connection-url mydb');
754
1187
  await requireAuth(apiOptions);
755
1188
  const app = await resolveApplication(identifier, apiOptions);
756
- const result = await getAppConnectionUrl(app.id, apiOptions);
1189
+ const result = await getAppConnectionUrl(app.id, apiOptions).catch((error) => {
1190
+ throw explainDatabaseConnectionError(error, { database: app.name });
1191
+ });
757
1192
  const requirePrivate = args.includes('--private');
758
1193
  const requirePublic = args.includes('--public');
759
1194
  if (requirePrivate && requirePublic) {
@@ -788,9 +1223,12 @@ export function selectConnectionUrl(result, preference = 'auto') {
788
1223
  return { url: result.publicUrl, visibility: 'public' };
789
1224
  }
790
1225
  const requested = preference === 'auto' ? 'private or public' : preference;
791
- throw usageError(`No ${requested} database connection URL is available`, preference === 'public'
1226
+ const reason = preference !== 'public' ? result.privateUnavailable : undefined;
1227
+ throw usageError(`No ${requested} database connection URL is available${reason ? `: ${reason.message}` : ''}`, preference === 'public'
792
1228
  ? 'Enable Public Access in the application Networking settings.'
793
- : 'Configure private networking, or use --public after explicitly enabling Public Access.');
1229
+ : reason?.code === 'APP_PRIVATE_ENDPOINT_NOT_READY'
1230
+ ? 'Retry in a minute; the private address is verified once the database serves.'
1231
+ : 'Configure private networking, or use --public after explicitly enabling Public Access.');
794
1232
  }
795
1233
  async function deploySubcommand(args, positionals, apiOptions) {
796
1234
  const [identifier] = positionals;
@@ -960,7 +1398,11 @@ async function runSourceBuild(applicationId, appName, args, apiOptions) {
960
1398
  const buildArgs = parseSetFlags(readMultiFlag(args, '--build-arg'));
961
1399
  if (!json)
962
1400
  printInfo(dim(`— packing ${dir} —`));
963
- const sourceZipBase64 = await createZipBase64(dir);
1401
+ const sourceZipBase64 = await packSourceForUpload(dir, {
1402
+ json,
1403
+ ...(readFlagValue(args, '--context') ? { context: readFlagValue(args, '--context') } : {}),
1404
+ ...(readFlagValue(args, '--dockerfile') ? { dockerfile: readFlagValue(args, '--dockerfile') } : {}),
1405
+ });
964
1406
  const queued = await queueBuild(applicationId, {
965
1407
  sourceZipBase64,
966
1408
  source: 'cli',
@@ -977,65 +1419,242 @@ async function runSourceBuild(applicationId, appName, args, apiOptions) {
977
1419
  ? `Build ${queued.buildId.slice(0, 8)} reused (identical source already built)`
978
1420
  : `Build ${queued.buildId.slice(0, 8)} queued for ${appName}`);
979
1421
  }
980
- if (queued.deduplicated || isTerminalBuildStatus(queued.status)) {
981
- return getBuild(queued.buildId, apiOptions);
1422
+ return waitForBuild(queued.buildId, apiOptions, {
1423
+ json, initial: queued, noLogs: queued.deduplicated || isTerminalBuildStatus(queued.status),
1424
+ });
1425
+ }
1426
+ const BUILD_WAIT_PHASE = { QUEUED: 'queued', EXECUTING: 'building' };
1427
+ const BUILD_WAIT_ERROR = { QUEUE: 'BUILD_QUEUE_TIMEOUT', EXECUTION: 'BUILD_TIMEOUT', STATUS_REQUEST: 'BUILD_STATUS_TIMEOUT' };
1428
+ const BUILD_STATUS_REQUEST_TIMEOUT_MS = 15_000;
1429
+ const BUILD_STATUS_POLL_INTERVAL_MS = 1_000;
1430
+ const BUILD_STATUS_RETRY_BACKOFF_MS = 2_000;
1431
+ const BUILD_LOG_DRAIN_TIMEOUT_MS = 250;
1432
+ const MAX_TIMER_DELAY_MS = 2_147_483_647;
1433
+ const HTTP_TOO_MANY_REQUESTS = 429;
1434
+ const HTTP_SERVER_ERROR_MIN = 500;
1435
+ function buildWaitPolicy(value) {
1436
+ if (!value || !Number.isSafeInteger(value.executionTimeoutMs) || value.executionTimeoutMs < 1 ||
1437
+ !Number.isSafeInteger(value.queueTimeoutMs) || value.queueTimeoutMs < 1 ||
1438
+ !Number.isSafeInteger(value.completionGraceMs) || value.completionGraceMs < 0 ||
1439
+ value.executionTimeoutMs > MAX_TIMER_DELAY_MS || value.queueTimeoutMs > MAX_TIMER_DELAY_MS ||
1440
+ value.completionGraceMs > MAX_TIMER_DELAY_MS) {
1441
+ return DEFAULT_BUILD_TIMEOUT_POLICY;
982
1442
  }
983
- // Follow the build log to the terminal event; the SSE stream ends on completion.
984
- for await (const event of streamBuildLogs(queued.buildId, apiOptions)) {
985
- if (event.type === 'log' && event.line) {
986
- if (json)
987
- process.stderr.write(`${redact(event.line)}\n`);
988
- else
989
- printLine(dim(` ${event.line}`));
1443
+ return value;
1444
+ }
1445
+ function buildWaitSleep(ms, signal) {
1446
+ signal.throwIfAborted();
1447
+ return new Promise((resolve, reject) => {
1448
+ const onAbort = () => { clearTimeout(timer); signal.removeEventListener('abort', onAbort); reject(signal.reason); };
1449
+ const timer = setTimeout(() => { signal.removeEventListener('abort', onAbort); resolve(); }, ms);
1450
+ signal.addEventListener('abort', onAbort, { once: true });
1451
+ });
1452
+ }
1453
+ /** Authoritative phase observation, independent of an open or broken log stream. */
1454
+ export async function waitForBuild(buildId, apiOptions, options = {}) {
1455
+ const clock = () => globalThis.performance.now();
1456
+ const controller = new AbortController();
1457
+ const initialPolicy = buildWaitPolicy(options.initial?.timeoutPolicy);
1458
+ const overallDeadline = clock() + initialPolicy.queueTimeoutMs + initialPolicy.executionTimeoutMs + initialPolicy.completionGraceMs;
1459
+ let phase = options.initial?.status === BUILD_WAIT_PHASE.EXECUTING
1460
+ ? BUILD_WAIT_PHASE.EXECUTING : BUILD_WAIT_PHASE.QUEUED;
1461
+ let deadline = overallDeadline;
1462
+ let timer;
1463
+ let expire;
1464
+ const expired = new Promise((_, reject) => { expire = reject; });
1465
+ function observe(value) {
1466
+ const next = value.status === BUILD_WAIT_PHASE.EXECUTING ? BUILD_WAIT_PHASE.EXECUTING : BUILD_WAIT_PHASE.QUEUED;
1467
+ // A stale queued observation cannot restart an already-running budget.
1468
+ if (phase === BUILD_WAIT_PHASE.EXECUTING && next === BUILD_WAIT_PHASE.QUEUED)
1469
+ return;
1470
+ const policy = buildWaitPolicy(value.timeoutPolicy ?? initialPolicy);
1471
+ const cap = next === BUILD_WAIT_PHASE.QUEUED ? policy.queueTimeoutMs : policy.executionTimeoutMs + policy.completionGraceMs;
1472
+ const remaining = typeof value.timeoutRemainingMs === 'number' && Number.isFinite(value.timeoutRemainingMs) && value.timeoutRemainingMs >= 0
1473
+ ? Math.min(value.timeoutRemainingMs, cap) : cap;
1474
+ const candidate = Math.min(overallDeadline, clock() + remaining);
1475
+ deadline = next === phase ? Math.min(deadline, candidate) : candidate;
1476
+ phase = next;
1477
+ armDeadline();
1478
+ }
1479
+ function armDeadline() {
1480
+ clearTimeout(timer);
1481
+ timer = setTimeout(() => {
1482
+ // Node clamps oversized delays to 1ms. Keep long valid policies bounded
1483
+ // by scheduling slices, and tolerate a timer waking before its deadline.
1484
+ if (clock() < deadline) {
1485
+ armDeadline();
1486
+ return;
1487
+ }
1488
+ const error = new CliError(`Build ${buildId} exceeded the CLI ${phase === BUILD_WAIT_PHASE.QUEUED ? 'queue' : 'execution'} wait deadline`, {
1489
+ code: phase === BUILD_WAIT_PHASE.QUEUED ? BUILD_WAIT_ERROR.QUEUE : BUILD_WAIT_ERROR.EXECUTION, exitCode: ExitCode.ERROR,
1490
+ explanation: `The CLI stopped waiting for build ${buildId}; it has not cancelled the remote build. Check this build before retrying.`,
1491
+ fix: `Inspect its logs: inquir apps logs ${buildId}`,
1492
+ });
1493
+ expire(error);
1494
+ controller.abort(error);
1495
+ }, Math.min(MAX_TIMER_DELAY_MS, Math.max(0, deadline - clock())));
1496
+ }
1497
+ async function statusRequest() {
1498
+ controller.signal.throwIfAborted();
1499
+ const request = new AbortController();
1500
+ const onAbort = () => request.abort(controller.signal.reason);
1501
+ controller.signal.addEventListener('abort', onAbort, { once: true });
1502
+ let requestTimer;
1503
+ const timedOut = new Promise((_, reject) => {
1504
+ requestTimer = setTimeout(() => {
1505
+ const error = new CliError('Build status request timed out', { code: BUILD_WAIT_ERROR.STATUS_REQUEST, exitCode: ExitCode.NETWORK });
1506
+ reject(error);
1507
+ request.abort(error);
1508
+ }, Math.min(BUILD_STATUS_REQUEST_TIMEOUT_MS, Math.max(0, deadline - clock())));
1509
+ });
1510
+ try {
1511
+ return await Promise.race([getBuild(buildId, apiOptions, request.signal), timedOut]);
990
1512
  }
991
- else if (event.type === 'complete') {
992
- break;
1513
+ finally {
1514
+ clearTimeout(requestTimer);
1515
+ controller.signal.removeEventListener('abort', onAbort);
1516
+ request.abort();
993
1517
  }
994
- else if (event.type === 'error') {
995
- printLine(dim(`— ${event.message ?? 'stream error'} —`));
996
- break;
1518
+ }
1519
+ async function poll() {
1520
+ for (;;) {
1521
+ controller.signal.throwIfAborted();
1522
+ try {
1523
+ const build = await statusRequest();
1524
+ controller.signal.throwIfAborted();
1525
+ if (isTerminalBuildStatus(build.status))
1526
+ return build;
1527
+ observe(build);
1528
+ }
1529
+ catch (error) {
1530
+ controller.signal.throwIfAborted();
1531
+ const retryable = error instanceof ApiError ? error.status === HTTP_TOO_MANY_REQUESTS || error.status >= HTTP_SERVER_ERROR_MIN : toCliError(error).exitCode === ExitCode.NETWORK;
1532
+ if (!retryable)
1533
+ throw error;
1534
+ await buildWaitSleep(BUILD_STATUS_RETRY_BACKOFF_MS, controller.signal);
1535
+ }
1536
+ await buildWaitSleep(BUILD_STATUS_POLL_INTERVAL_MS, controller.signal);
997
1537
  }
998
1538
  }
999
- return waitForBuild(queued.buildId, apiOptions);
1000
- }
1001
- async function waitForBuild(buildId, apiOptions, timeoutMs = 20 * 60_000) {
1002
- const deadline = Date.now() + timeoutMs;
1003
- for (;;) {
1004
- const build = await getBuild(buildId, apiOptions);
1005
- if (isTerminalBuildStatus(build.status))
1006
- return build;
1007
- if (Date.now() > deadline) {
1008
- throw new CliError(`Build ${buildId} did not finish within ${Math.round(timeoutMs / 1000)}s`, {
1009
- code: 'BUILD_TIMEOUT',
1010
- exitCode: ExitCode.ERROR,
1011
- fix: `Check its logs: inquir apps logs ${buildId}`,
1012
- });
1539
+ async function consumeLogs() {
1540
+ if (options.noLogs)
1541
+ return;
1542
+ try {
1543
+ for await (const event of streamBuildLogs(buildId, apiOptions, controller.signal)) {
1544
+ if (controller.signal.aborted)
1545
+ break;
1546
+ if (event.type === 'log' && event.line) {
1547
+ if (options.json)
1548
+ process.stderr.write(`${redact(event.line)}\n`);
1549
+ else
1550
+ printLine(dim(` ${event.line}`));
1551
+ }
1552
+ else if (event.type === 'complete' || event.type === 'error')
1553
+ break;
1554
+ }
1555
+ }
1556
+ catch {
1557
+ // Logs are best effort. Only the build row decides the result.
1013
1558
  }
1014
- await new Promise((resolve) => setTimeout(resolve, 1000));
1015
1559
  }
1560
+ observe(options.initial ?? { status: BUILD_WAIT_PHASE.QUEUED });
1561
+ const logs = consumeLogs();
1562
+ try {
1563
+ const result = await Promise.race([poll(), expired]);
1564
+ let drainTimer;
1565
+ try {
1566
+ await Promise.race([logs, new Promise((resolve) => {
1567
+ drainTimer = setTimeout(resolve, Math.max(0, Math.min(BUILD_LOG_DRAIN_TIMEOUT_MS, deadline - clock())));
1568
+ })]);
1569
+ }
1570
+ finally {
1571
+ clearTimeout(drainTimer);
1572
+ }
1573
+ return result;
1574
+ }
1575
+ finally {
1576
+ clearTimeout(timer);
1577
+ controller.abort();
1578
+ }
1579
+ }
1580
+ /**
1581
+ * A release only the allocation plane serves (volumes pinned to a worker) stays
1582
+ * STARTING while that runner refuses its starts, and the plane retries them
1583
+ * forever with backoff. After this many failed attempts the wait reports the
1584
+ * reason instead of sitting out its whole timeout.
1585
+ */
1586
+ const FAILED_STARTS_BEFORE_REPORTING = 3;
1587
+ /** Every how many release polls a running replacement's phase is re-read. */
1588
+ const REPLACEMENT_PROBE_EVERY = 5;
1589
+ /**
1590
+ * The phase of the stop-first production replacement a PENDING release is
1591
+ * the target of. Bounded: the operation is read on the first PENDING poll
1592
+ * and then every REPLACEMENT_PROBE_EVERY polls, and never again once a read
1593
+ * fails or names no replacement for this release (a preview, an older
1594
+ * server), so an ordinary deploy costs one extra request.
1595
+ */
1596
+ export function createReplacementProbe(read, every = REPLACEMENT_PROBE_EVERY) {
1597
+ let polls = 0;
1598
+ let done = false;
1599
+ let phase = '';
1600
+ return {
1601
+ async phaseOf(release) {
1602
+ if (done)
1603
+ return '';
1604
+ polls += 1;
1605
+ if ((polls - 1) % every !== 0)
1606
+ return phase;
1607
+ const operation = await read(release.applicationId).catch(() => null);
1608
+ if (!operation || operation.targetReleaseId !== release.id) {
1609
+ done = true;
1610
+ phase = '';
1611
+ return '';
1612
+ }
1613
+ phase = operation.phase;
1614
+ return phase;
1615
+ },
1616
+ };
1016
1617
  }
1017
1618
  /** Poll until the release settles; the platform decides when it is healthy. */
1018
1619
  async function waitForRelease(releaseId, apiOptions, timeoutMs = 10 * 60_000) {
1019
1620
  const deadline = Date.now() + timeoutMs;
1020
1621
  const startedAt = Date.now();
1021
- let lastStatus = '';
1622
+ let lastProgress = '';
1022
1623
  const json = readJsonFlag(process.argv);
1624
+ let replacementClient;
1625
+ const replacement = createReplacementProbe(async (applicationId) => {
1626
+ replacementClient ??= await createApiClient(apiOptions);
1627
+ return getProductionReplacement(applicationId, apiOptions, replacementClient);
1628
+ });
1023
1629
  for (;;) {
1024
1630
  const release = await getRelease(releaseId, apiOptions);
1025
- if (release.status !== lastStatus) {
1026
- lastStatus = release.status;
1631
+ // A failed start on the pinned runner changes the reason, not the status.
1632
+ const reason = release.serving ? release.failureReason ?? '' : '';
1633
+ // PENDING is also the whole of a stop-first replacement of production,
1634
+ // which pulls nothing: its phase says what the wait is.
1635
+ const phase = release.status === 'PENDING' ? await replacement.phaseOf(release) : '';
1636
+ if (`${release.status}\n${reason}\n${phase}` !== lastProgress) {
1637
+ lastProgress = `${release.status}\n${reason}\n${phase}`;
1027
1638
  // Say what is happening in words, with elapsed time — a two-minute health
1028
1639
  // gate in silence reads as a hang. Under --json only stdout must stay
1029
1640
  // parseable; progress goes to stderr.
1030
1641
  const shown = presentRelease(release);
1031
- const line = `— ${shown.label} (${formatElapsed(Date.now() - startedAt)})${shown.meaning ? `: ${shown.meaning}` : ''} —`;
1032
- if (json)
1033
- process.stderr.write(`${line}\n`);
1034
- else
1035
- printInfo(dim(line));
1642
+ const meaning = (phase && replacementPhaseMeaning(phase)) || shown.meaning;
1643
+ const lines = [
1644
+ `— ${shown.label} (${formatElapsed(Date.now() - startedAt)})${meaning ? `: ${meaning}` : ''} —`,
1645
+ ...(reason ? [` ${reason}`] : []),
1646
+ ];
1647
+ for (const line of lines) {
1648
+ if (json)
1649
+ process.stderr.write(`${line}\n`);
1650
+ else
1651
+ printInfo(dim(line));
1652
+ }
1036
1653
  }
1037
1654
  if (isSettledReleaseStatus(release.status))
1038
1655
  return release;
1656
+ if ((release.serving?.failedStarts ?? 0) >= FAILED_STARTS_BEFORE_REPORTING)
1657
+ return release;
1039
1658
  if (Date.now() > deadline) {
1040
1659
  throw new CliError(`Release ${releaseId} did not settle within ${Math.round(timeoutMs / 1000)}s`, {
1041
1660
  code: 'RELEASE_TIMEOUT',
@@ -1106,17 +1725,30 @@ export function releaseEndpointLines(release, previewUrl, options = {}) {
1106
1725
  */
1107
1726
  export function privateEndpointLines(detail, active, options = {}) {
1108
1727
  if (!detail.privateDomain) {
1109
- return options.privateNetworking
1110
- ? ['(provisioning — private DNS and routing are being verified)']
1111
- : [];
1728
+ if (!options.privateNetworking)
1729
+ return [];
1730
+ // The address routes to production, so without one there is nothing to
1731
+ // verify yet, and with no serving replica it is down, not provisioning.
1732
+ if (!active)
1733
+ return ['(none yet — production gets one once a release is promoted)'];
1734
+ if (active.serving && active.serving.ready === 0) {
1735
+ return ['(unavailable — no production replica is serving; see inquir apps describe <app>)'];
1736
+ }
1737
+ return ['(provisioning — private DNS and routing are being verified)'];
1112
1738
  }
1113
1739
  const lines = [];
1114
1740
  if (detail.privateBaseUrl)
1115
1741
  lines.push(detail.privateBaseUrl);
1116
- for (const port of active?.ports ?? []) {
1117
- if (port.protocol !== 'tcp' || !port.containerPort)
1118
- continue;
1119
- lines.push(`${detail.privateDomain}:${port.containerPort} (${port.name})`);
1742
+ // The private proxy listens on a TCP port's container port. A container the
1743
+ // control plane started reports its ports on the release; a release the
1744
+ // allocation plane serves on a worker reports none (its endpoints live on
1745
+ // its allocations), so its declared TCP ports stand in.
1746
+ const reported = (active?.ports ?? []).filter((port) => port.protocol === 'tcp' && port.containerPort);
1747
+ const tcpPorts = reported.length > 0
1748
+ ? reported.map((port) => ({ name: port.name, port: port.containerPort }))
1749
+ : (active?.runtime?.ports ?? []).filter((port) => port.protocol === 'tcp');
1750
+ for (const port of tcpPorts) {
1751
+ lines.push(`${detail.privateDomain}:${port.port} (${port.name})`);
1120
1752
  }
1121
1753
  return lines;
1122
1754
  }
@@ -1180,6 +1812,18 @@ function formatDurationSeconds(seconds) {
1180
1812
  }
1181
1813
  return parts.join(' ');
1182
1814
  }
1815
+ /** One line for a startup probe; defaults as the server applies them (src/apps/startup-probe.ts). */
1816
+ export function describeStartupProbe(probe) {
1817
+ const period = probe.periodSeconds ?? 10;
1818
+ const threshold = probe.failureThreshold ?? 3;
1819
+ const delay = probe.initialDelaySeconds ?? 0;
1820
+ const mode = probe.type ?? (probe.command ? 'command' : undefined);
1821
+ const target = mode === 'command'
1822
+ ? `command ${JSON.stringify(probe.command ?? '')}`
1823
+ : `${mode ?? 'probe'}${probe.port ? ` port ${probe.port}` : ''}${mode !== 'tcp' ? ` ${probe.path ?? '/'}` : ''}`;
1824
+ return `${target}, every ${period}s, ${threshold} failure${threshold === 1 ? '' : 's'} allowed` +
1825
+ `${delay ? ` after ${delay}s` : ''} (up to ${delay + period * threshold}s before the healthcheck)`;
1826
+ }
1183
1827
  export function describeScalingPolicy(policy) {
1184
1828
  const range = `${policy.minReplicas}–${policy.maxReplicas} replicas`;
1185
1829
  if (policy.activation === 'http') {
@@ -1215,6 +1859,20 @@ async function statusSubcommand(args, positionals, apiOptions) {
1215
1859
  const pinnedRunner = detail.volumeRunnerId;
1216
1860
  if (pinnedRunner)
1217
1861
  printLine(` volume pin: runner ${pinnedRunner}`);
1862
+ // Experimental service location: shown only where the platform offers it.
1863
+ if (limits?.features?.serviceLocations) {
1864
+ const location = await getAppLocation(app.id, apiOptions).catch(() => null);
1865
+ const line = location ? describeServiceLocation(location) : undefined;
1866
+ if (line)
1867
+ printLine(` location: ${line}`);
1868
+ }
1869
+ if (detail.runtime?.startupProbe)
1870
+ printLine(` startup: ${describeStartupProbe(detail.runtime.startupProbe)}`);
1871
+ const servingRelease = detail.releases.find((r) => r.id === detail.production?.activeReleaseId);
1872
+ if (servingRelease?.serving && servingRelease.serving.ready < servingRelease.serving.desired) {
1873
+ // Production points at it, but its replica does not answer on the runner yet.
1874
+ printLine(` serving: not yet — ${servingRelease.failureReason ?? 'its replica is starting on the pinned runner'}`);
1875
+ }
1218
1876
  if (detail.lastActivityAt)
1219
1877
  printLine(` activity: ${detail.lastActivityAt}`);
1220
1878
  if (detail.lastScaledAt)
@@ -1350,29 +2008,74 @@ async function logsSubcommand(args, positionals, apiOptions) {
1350
2008
  const build = await getBuild(identifier, apiOptions).catch(() => null);
1351
2009
  if (build) {
1352
2010
  printInfo(dim(`— build ${build.id.slice(0, 8)} (${build.status}) —`));
1353
- for await (const event of streamBuildLogs(build.id, apiOptions)) {
1354
- if (event.type === 'log' && event.line) {
1355
- if (json)
1356
- process.stderr.write(`${redact(event.line)}\n`);
1357
- else
1358
- printLine(event.line);
1359
- }
1360
- else if (event.type === 'complete') {
1361
- printInfo(dim(`— build ${event.status ?? 'finished'} —`));
1362
- break;
1363
- }
1364
- else if (event.type === 'error') {
1365
- printLine(dim(`— ${event.message ?? 'stream error'} —`));
1366
- break;
2011
+ // A finished build replays its log and the stream ends by itself. A
2012
+ // running one follows; with --no-follow, stop once the replay goes quiet.
2013
+ const stop = new AbortController();
2014
+ const quiet = hasFlag(args, '--no-follow') && !isTerminalBuildStatus(build.status)
2015
+ ? createQuietStop(() => stop.abort())
2016
+ : null;
2017
+ try {
2018
+ for await (const event of streamBuildLogs(build.id, apiOptions, stop.signal)) {
2019
+ quiet?.heard();
2020
+ if (event.type === 'log' && event.line) {
2021
+ if (json)
2022
+ process.stderr.write(`${redact(event.line)}\n`);
2023
+ else
2024
+ printLine(event.line);
2025
+ }
2026
+ else if (event.type === 'complete') {
2027
+ printInfo(dim(`— build ${event.status ?? 'finished'} —`));
2028
+ break;
2029
+ }
2030
+ else if (event.type === 'error') {
2031
+ printLine(dim(`— ${event.message ?? 'stream error'} —`));
2032
+ break;
2033
+ }
1367
2034
  }
1368
2035
  }
2036
+ catch (error) {
2037
+ if (!stop.signal.aborted)
2038
+ throw error;
2039
+ }
2040
+ finally {
2041
+ quiet?.cancel();
2042
+ }
2043
+ if (stop.signal.aborted) {
2044
+ printInfo(dim(`— build ${build.id.slice(0, 8)} is still running; follow it: inquir apps logs ${build.id} —`));
2045
+ }
1369
2046
  return;
1370
2047
  }
1371
2048
  const releaseId = await resolveReleaseId(identifier, apiOptions);
1372
2049
  const tailRaw = readFlagValue(args, '--tail');
1373
2050
  const tail = tailRaw ? Number(tailRaw) : 200;
1374
- printInfo(dim(`— streaming logs for release ${releaseId.slice(0, 8)} (ctrl-c to stop) —`));
1375
- for await (const event of streamReleaseLogs(releaseId, { tail }, apiOptions)) {
2051
+ if (!Number.isInteger(tail) || tail < 1 || tail > MAX_LOG_TAIL) {
2052
+ throw usageError(`--tail must be an integer between 1 and ${MAX_LOG_TAIL}`, 'inquir apps logs <app> --tail 100');
2053
+ }
2054
+ const replica = readFlagValue(args, '--replica');
2055
+ if (hasFlag(args, '--replica') && !replica?.trim())
2056
+ throw usageError('Missing --replica value', 'inquir apps instances <app>');
2057
+ let allocationId;
2058
+ if (replica?.trim()) {
2059
+ const release = await getRelease(releaseId, apiOptions);
2060
+ // Only this release's replicas: an index or name of production would be another container.
2061
+ allocationId = await resolveReplicaAllocationId(release.applicationId, replica, apiOptions, { releaseId: release.id });
2062
+ }
2063
+ if (hasFlag(args, '--no-follow')) {
2064
+ const release = await getRelease(releaseId, apiOptions);
2065
+ const client = await createApiClient(apiOptions);
2066
+ const entries = await readRecentRuntimeLogs((query) => getAppLogHistory(client, release.applicationId, query), { releaseId, tail, ...(allocationId ? { allocationId } : {}) });
2067
+ if (json) {
2068
+ printLine(redact(JSON.stringify(entries, null, 2)));
2069
+ return;
2070
+ }
2071
+ for (const entry of entries)
2072
+ printLine(entry.message);
2073
+ if (entries.length === 0)
2074
+ printInfo(dim(`— no stored logs for release ${releaseId.slice(0, 8)} —`));
2075
+ return;
2076
+ }
2077
+ printInfo(dim(`— streaming logs for release ${releaseId.slice(0, 8)}${allocationId ? `, replica ${allocationId.slice(0, 8)}` : ''} (ctrl-c to stop) —`));
2078
+ for await (const event of streamReleaseLogs(releaseId, { tail, ...(allocationId ? { allocationId } : {}) }, apiOptions)) {
1376
2079
  if (event.type === 'log' && event.line) {
1377
2080
  // Under --json the log tail goes to stderr so stdout stays parseable.
1378
2081
  if (json)
@@ -1389,6 +2092,63 @@ async function logsSubcommand(args, positionals, apiOptions) {
1389
2092
  }
1390
2093
  }
1391
2094
  }
2095
+ /** The most lines `apps logs --tail` asks for (the API's own ceiling). */
2096
+ const MAX_LOG_TAIL = 2000;
2097
+ /**
2098
+ * How far back `--no-follow` reads: inside the 7-day runtime retention with
2099
+ * an hour to spare, so a client clock up to an hour off still names a window
2100
+ * the API accepts. `to` is left to the server's clock.
2101
+ */
2102
+ const RUNTIME_LOG_WINDOW_MS = 7 * 86_400_000 - 3_600_000;
2103
+ /** `logs --no-follow` on a running build: stop once its replay is this quiet... */
2104
+ const BUILD_LOG_QUIET_MS = 2_000;
2105
+ /** ...or when nothing at all arrived this long after the request (a slow connection). */
2106
+ const BUILD_LOG_FIRST_EVENT_MS = 15_000;
2107
+ /**
2108
+ * Calls `stop` once a stream goes quiet: BUILD_LOG_FIRST_EVENT_MS before the
2109
+ * first event (connecting may be slow), BUILD_LOG_QUIET_MS after the latest.
2110
+ */
2111
+ export function createQuietStop(stop, timings = { firstEventMs: BUILD_LOG_FIRST_EVENT_MS, quietMs: BUILD_LOG_QUIET_MS }) {
2112
+ let timer = setTimeout(stop, timings.firstEventMs);
2113
+ return {
2114
+ heard() {
2115
+ clearTimeout(timer);
2116
+ timer = setTimeout(stop, timings.quietMs);
2117
+ },
2118
+ cancel() {
2119
+ clearTimeout(timer);
2120
+ },
2121
+ };
2122
+ }
2123
+ const LOG_HISTORY_PAGE = 200;
2124
+ /**
2125
+ * `apps logs --no-follow`: the last `tail` stored lines of one release (one
2126
+ * replica with `allocationId`), oldest first — what the live stream prints
2127
+ * before it starts following, without the following.
2128
+ */
2129
+ export async function readRecentRuntimeLogs(fetchPage, options) {
2130
+ const now = options.now ?? Date.now();
2131
+ // The first page fixes the window; later pages repeat it with the cursor.
2132
+ let window = { from: new Date(now - RUNTIME_LOG_WINDOW_MS).toISOString() };
2133
+ const newestFirst = [];
2134
+ let cursor;
2135
+ while (newestFirst.length < options.tail) {
2136
+ const page = await fetchPage({
2137
+ ...window,
2138
+ releaseId: options.releaseId,
2139
+ ...(options.allocationId ? { allocationId: options.allocationId } : {}),
2140
+ limit: Math.min(LOG_HISTORY_PAGE, options.tail - newestFirst.length),
2141
+ ...(cursor ? { cursor } : {}),
2142
+ });
2143
+ newestFirst.push(...page.entries);
2144
+ if (!page.nextCursor || page.entries.length === 0)
2145
+ break;
2146
+ cursor = page.nextCursor;
2147
+ if (page.from && page.to)
2148
+ window = { from: page.from, to: page.to };
2149
+ }
2150
+ return newestFirst.slice(0, options.tail).reverse();
2151
+ }
1392
2152
  async function promoteSubcommand(args, positionals, apiOptions) {
1393
2153
  const [identifier] = positionals;
1394
2154
  if (!identifier)
@@ -1399,9 +2159,11 @@ async function promoteSubcommand(args, positionals, apiOptions) {
1399
2159
  // Say up front what promotion does to this release: a preview-profile
1400
2160
  // release with production-only variables is restarted with them before
1401
2161
  // traffic moves, and its preview URL retires.
2162
+ let release = null;
2163
+ let app = null;
1402
2164
  if (!json) {
1403
- const release = await getRelease(releaseId, apiOptions).catch(() => null);
1404
- const app = release ? await getApplication(release.applicationId, apiOptions).catch(() => null) : null;
2165
+ release = await getRelease(releaseId, apiOptions).catch(() => null);
2166
+ app = release ? await getApplication(release.applicationId, apiOptions).catch(() => null) : null;
1405
2167
  if (release && release.envProfile !== 'production' && (app?.productionOnlyEnvKeys?.length ?? 0) > 0) {
1406
2168
  printInfo(dim(' the container restarts with production-only variables before traffic moves'));
1407
2169
  }
@@ -1418,6 +2180,12 @@ async function promoteSubcommand(args, positionals, apiOptions) {
1418
2180
  err.message = humanizeApiError(err.message);
1419
2181
  throw err;
1420
2182
  }
2183
+ // 202: the server runs it as a rolling rollout and goes on in the
2184
+ // background. An older server always answers with the outcome below.
2185
+ if (isPromoteAccepted(result)) {
2186
+ await followPromoteRollout(releaseId, result, { json, wait: !hasFlag(args, '--no-wait') }, apiOptions);
2187
+ return;
2188
+ }
1421
2189
  if (json) {
1422
2190
  printLine(JSON.stringify(result, null, 2));
1423
2191
  return;
@@ -1427,12 +2195,33 @@ async function promoteSubcommand(args, positionals, apiOptions) {
1427
2195
  printLine(` ${url}`);
1428
2196
  }
1429
2197
  if ((result.productionUrls ?? []).length === 0 && !result.productionUrl) {
1430
- printInfo(dim(' no hostname — reach it at its TCP endpoints (inquir apps status <app>)'));
2198
+ for (const line of promotedWithoutHostnameLines(release, app))
2199
+ printInfo(dim(line));
1431
2200
  }
1432
2201
  if (result.previousReleaseId) {
1433
2202
  printInfo(dim(` previous release ${result.previousReleaseId.slice(0, 8)} kept for rollback`));
1434
2203
  }
1435
2204
  }
2205
+ /**
2206
+ * What to say after a promotion that produced no public URL. An HTTP service
2207
+ * is private, not unreachable: same-workspace services reach it at its
2208
+ * private address. Only a TCP-only service is reached at its TCP endpoints.
2209
+ */
2210
+ export function promotedWithoutHostnameLines(release, app) {
2211
+ const name = app?.name ?? '<app>';
2212
+ // The server reads a port without a protocol as HTTP (src/apps/app-ports.ts).
2213
+ const http = typeof release?.runtime?.port === 'number'
2214
+ || (release?.runtime?.ports ?? []).some((port) => (port.protocol ?? 'http') === 'http');
2215
+ if (http) {
2216
+ return [
2217
+ app?.privateBaseUrl
2218
+ ? ` private — your other services reach it at ${app.privateBaseUrl}`
2219
+ : ` private — your other services reach it at http://<slug>.apps.internal, listed by inquir apps status ${name} once routing is verified`,
2220
+ ` public URL: inquir apps update ${name} --ingress public`,
2221
+ ];
2222
+ }
2223
+ return [` no hostname — reach it at its TCP endpoints (inquir apps status ${name})`];
2224
+ }
1436
2225
  async function rollbackSubcommand(args, positionals, apiOptions) {
1437
2226
  const [identifier] = positionals;
1438
2227
  if (!identifier)
@@ -1572,7 +2361,8 @@ async function updateSubcommand(args, positionals, apiOptions) {
1572
2361
  await requireAuth(apiOptions);
1573
2362
  const app = await resolveApplication(identifier, apiOptions);
1574
2363
  const envVars = parseSetFlags(readMultiFlag(args, '--set'));
1575
- const runtimeSpec = buildRuntimeFromFlags(args, app.runtime);
2364
+ const flagsRuntime = buildRuntimeFromFlags(args, app.runtime);
2365
+ const runtimeSpec = applyNodeSelectorFlags(args, flagsRuntime ?? app.runtime) ?? flagsRuntime;
1576
2366
  const description = readFlagValue(args, '--description');
1577
2367
  const networkPolicy = buildNetworkPolicyPatch({
1578
2368
  egress: readFlagValue(args, '--egress'),
@@ -1580,9 +2370,11 @@ async function updateSubcommand(args, positionals, apiOptions) {
1580
2370
  current: app.networkPolicy,
1581
2371
  });
1582
2372
  const volumeRunner = readFlagValue(args, '--volume-runner');
2373
+ const location = parseLocationFlag(readFlagValue(args, '--location'));
1583
2374
  const patch = {
1584
2375
  ...(description !== undefined ? { description } : {}),
1585
2376
  ...(volumeRunner ? { volumeRunner } : {}),
2377
+ ...(location !== undefined ? { location } : {}),
1586
2378
  ...(runtimeSpec ? { runtime: runtimeSpec } : {}),
1587
2379
  // Only the keys given here change; the server merges masked values, so
1588
2380
  // untouched secrets survive.
@@ -1598,7 +2390,43 @@ async function updateSubcommand(args, positionals, apiOptions) {
1598
2390
  return;
1599
2391
  }
1600
2392
  printSuccess(`Updated ${updated.name}`);
1601
- printInfo(dim(' changes apply to the next release: inquir apps deploy ' + updated.name));
2393
+ // Advisory: e.g. a node selector no server matches right now.
2394
+ for (const warning of updated.warnings ?? [])
2395
+ printInfo(` warning: ${warning}`);
2396
+ const ingress = readFlagValue(args, '--ingress');
2397
+ for (const line of updateTimingLines(updated.name, {
2398
+ hasProduction: Boolean(updated.productionReleaseId ?? app.productionReleaseId),
2399
+ ingress: ingress === 'public' || ingress === 'private' ? ingress : undefined,
2400
+ nextRelease: Object.keys(envVars).length > 0 || runtimeSpec !== undefined
2401
+ || readFlagValue(args, '--egress') !== undefined || volumeRunner !== undefined
2402
+ || location !== undefined,
2403
+ })) {
2404
+ printInfo(dim(line));
2405
+ }
2406
+ }
2407
+ /**
2408
+ * When an `apps update` takes effect. Public Networking is applied by the
2409
+ * server at once (the generated route is created or removed in the same
2410
+ * request); env, runtime, egress, placement and location are what the next
2411
+ * release is built from.
2412
+ */
2413
+ export function updateTimingLines(appName, changed) {
2414
+ const lines = [];
2415
+ const hasProduction = changed.hasProduction !== false;
2416
+ if (changed.ingress === 'public') {
2417
+ lines.push(hasProduction
2418
+ ? ` public networking: applied now — the generated domain serves production (inquir apps status ${appName})`
2419
+ : ' public networking: on — the generated domain serves production once a release is promoted');
2420
+ }
2421
+ else if (changed.ingress === 'private') {
2422
+ lines.push(' public networking: removed now — the generated domain no longer serves it; the private address stays');
2423
+ }
2424
+ if (changed.nextRelease) {
2425
+ // redeploy re-releases production and refuses without one.
2426
+ const next = hasProduction ? `inquir apps redeploy ${appName}` : `inquir apps deploy ${appName}`;
2427
+ lines.push(` ${changed.ingress ? 'the other changes apply' : 'changes apply'} to the next release: ${next}`);
2428
+ }
2429
+ return lines;
1602
2430
  }
1603
2431
  async function execSubcommand(args, positionals, apiOptions) {
1604
2432
  const [identifier, ...commandParts] = positionals;
@@ -1608,14 +2436,15 @@ async function execSubcommand(args, positionals, apiOptions) {
1608
2436
  if (!command)
1609
2437
  throw usageError('Missing command', 'inquir apps exec <app> -- ls -la');
1610
2438
  await requireAuth(apiOptions);
1611
- // `apps exec` is intentionally application-scoped. Accepting a stale or
1612
- // preview release id here and then calling the production-only app endpoint
1613
- // would silently change the operator's target to production.
1614
- const application = await resolveApplication(identifier, apiOptions);
2439
+ const application = await resolveExecApplication(identifier, apiOptions, readJsonFlag(args));
1615
2440
  const timeoutRaw = readFlagValue(args, '--timeout');
2441
+ const replica = readFlagValue(args.slice(0, args.includes('--') ? args.indexOf('--') : args.length), '--replica');
2442
+ if (hasFlag(args.slice(0, args.includes('--') ? args.indexOf('--') : args.length), '--replica') && !replica?.trim()) {
2443
+ throw usageError('Missing --replica value', 'inquir apps exec <app> --replica <name|index> -- <command>');
2444
+ }
1616
2445
  let result;
1617
2446
  try {
1618
- result = await execInApplication(application.id, command, timeoutRaw ? Number(timeoutRaw) : undefined, apiOptions);
2447
+ result = await execInApplication(application.id, command, timeoutRaw ? Number(timeoutRaw) : undefined, apiOptions, replica?.trim() ? { replica: replica.trim() } : {});
1619
2448
  }
1620
2449
  catch (err) {
1621
2450
  if (err instanceof Error)
@@ -1626,6 +2455,9 @@ async function execSubcommand(args, positionals, apiOptions) {
1626
2455
  printLine(JSON.stringify(result, null, 2));
1627
2456
  }
1628
2457
  else {
2458
+ if (replica && result.replicaIndex !== undefined) {
2459
+ printInfo(dim(`— replica ${result.replicaIndex}${result.allocationId ? ` (${result.allocationId.slice(0, 8)})` : ''} —`));
2460
+ }
1629
2461
  if (result.stdout)
1630
2462
  process.stdout.write(result.stdout);
1631
2463
  if (result.stderr)
@@ -1636,18 +2468,68 @@ async function execSubcommand(args, positionals, apiOptions) {
1636
2468
  if (result.exitCode !== 0)
1637
2469
  process.exitCode = ExitCode.ERROR;
1638
2470
  }
2471
+ /**
2472
+ * `apps exec` runs in production through the application-scoped endpoint. An
2473
+ * application name or id is the target; a release id is accepted only when it
2474
+ * is that application's production release. A stale or preview release id
2475
+ * would otherwise silently change the operator's target to production, so it
2476
+ * is refused with the command that does run.
2477
+ */
2478
+ async function resolveExecApplication(identifier, apiOptions, json) {
2479
+ let appError;
2480
+ try {
2481
+ return await resolveApplication(identifier, apiOptions);
2482
+ }
2483
+ catch (error) {
2484
+ appError = error;
2485
+ }
2486
+ const release = await getRelease(identifier, apiOptions).catch(() => null);
2487
+ if (!release)
2488
+ throw appError;
2489
+ const detail = await getApplication(release.applicationId, apiOptions);
2490
+ const production = detail.production?.activeReleaseId;
2491
+ if (production !== release.id) {
2492
+ throw new CliError(`Release ${release.id.slice(0, 8)} is not ${detail.name}'s production release`, {
2493
+ code: 'EXEC_NOT_PRODUCTION',
2494
+ exitCode: ExitCode.USAGE,
2495
+ explanation: 'apps exec runs a command in the production container only.',
2496
+ fix: production
2497
+ ? `Run it in production (release ${production.slice(0, 8)}): inquir apps exec ${detail.name} -- <command>`
2498
+ : `${detail.name} has no production release yet; promote one first: inquir apps promote ${release.id.slice(0, 8)}`,
2499
+ });
2500
+ }
2501
+ const note = `— ${detail.name}, production release ${release.id.slice(0, 8)} —`;
2502
+ if (json)
2503
+ process.stderr.write(`${note}\n`);
2504
+ else
2505
+ printInfo(dim(note));
2506
+ return detail;
2507
+ }
1639
2508
  function printVerificationInstructions(domain) {
1640
2509
  const v = domain.verification;
1641
2510
  if (!v)
1642
2511
  return;
1643
- printInfo(dim(' prove ownership, then verify:'));
1644
- printLine(` TXT ${v.txtName} → ${v.txtValue}`);
1645
- for (const target of v.cnameTargets)
1646
- printLine(` CNAME ${domain.hostname} → ${target}`);
2512
+ // A verified hostname keeps its records on display: they are what gets compared with the
2513
+ // registrar when the name stops resolving.
2514
+ printInfo(dim(domain.verified
2515
+ ? ` ${domain.hostname} — verified with these records (the routing record must stay in place):`
2516
+ : ` ${domain.hostname} — prove ownership, then verify:`));
2517
+ const row = (label, name, value) => printLine(` ${label.padEnd(8)} ${name} → ${value}`);
2518
+ row('TXT', v.txtName, v.txtValue);
2519
+ // One target, not a menu: every entry is accepted by verify and leads to the same ingress. The first is the
2520
+ // app's own hostname when an apps suffix is configured, otherwise the platform target.
2521
+ if (v.cnameTargets[0])
2522
+ row('CNAME', domain.hostname, v.cnameTargets[0]);
1647
2523
  for (const ip of v.ingressIps)
1648
- printLine(` A ${domain.hostname} → ${ip}`);
2524
+ row('A', domain.hostname, ip);
1649
2525
  if (v.ingressAliasTarget)
1650
- printLine(` ALIAS ${domain.hostname} → ${v.ingressAliasTarget}`);
2526
+ row('ALIAS', domain.hostname, v.ingressAliasTarget);
2527
+ if (v.apexCnameTargets?.length) {
2528
+ printInfo(dim(domain.verified
2529
+ ? ' verified by the TXT record alone — where the apex points was never checked; traffic reaches the app only through ALIAS/ANAME/CNAME flattening onto:'
2530
+ : ' an apex domain is verified by the TXT record alone; point it with ALIAS/ANAME/CNAME flattening so traffic reaches the app:'));
2531
+ row('ALIAS', domain.hostname, v.apexCnameTargets[0]);
2532
+ }
1651
2533
  }
1652
2534
  /**
1653
2535
  * `apps domain <app> [hostname] [--verify|--remove]` — bind a custom hostname
@@ -1678,8 +2560,7 @@ async function domainSubcommand(args, positionals, apiOptions) {
1678
2560
  verified: d.verified ? 'yes' : 'no',
1679
2561
  })), ['hostname', 'kind', 'source', 'verified']));
1680
2562
  for (const d of items)
1681
- if (!d.verified)
1682
- printVerificationInstructions(d);
2563
+ printVerificationInstructions(d);
1683
2564
  return;
1684
2565
  }
1685
2566
  if (hasFlag(args, '--remove')) {
@@ -1704,6 +2585,14 @@ async function domainSubcommand(args, positionals, apiOptions) {
1704
2585
  printLine(JSON.stringify(domain, null, 2));
1705
2586
  return;
1706
2587
  }
2588
+ if (domain.verified) {
2589
+ // An operator-granted platform hostname verifies on bind: no records to publish.
2590
+ printSuccess(`${domain.hostname} bound to ${app.name} and verified — it now serves ${app.name}`);
2591
+ if (!domain.activeReleaseId) {
2592
+ printInfo(dim(' no release is promoted yet: inquir apps promote <release>'));
2593
+ }
2594
+ return;
2595
+ }
1707
2596
  printSuccess(`${domain.hostname} bound to ${app.name} (unverified)`);
1708
2597
  printVerificationInstructions(domain);
1709
2598
  printInfo(dim(` then: inquir apps domain ${app.name} ${domain.hostname} --verify`));