@inquir/compute-cli 0.4.0 → 0.4.2

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 (61) hide show
  1. package/README.md +667 -619
  2. package/dist/commands/admin.d.ts.map +1 -1
  3. package/dist/commands/admin.js +7 -5
  4. package/dist/commands/admin.js.map +1 -1
  5. package/dist/commands/apps-describe.d.ts.map +1 -1
  6. package/dist/commands/apps-describe.js +8 -1
  7. package/dist/commands/apps-describe.js.map +1 -1
  8. package/dist/commands/apps-instances.d.ts +18 -0
  9. package/dist/commands/apps-instances.d.ts.map +1 -0
  10. package/dist/commands/apps-instances.js +87 -0
  11. package/dist/commands/apps-instances.js.map +1 -0
  12. package/dist/commands/apps-observability.d.ts +6 -0
  13. package/dist/commands/apps-observability.d.ts.map +1 -1
  14. package/dist/commands/apps-observability.js +38 -9
  15. package/dist/commands/apps-observability.js.map +1 -1
  16. package/dist/commands/apps.d.ts +108 -5
  17. package/dist/commands/apps.d.ts.map +1 -1
  18. package/dist/commands/apps.js +681 -67
  19. package/dist/commands/apps.js.map +1 -1
  20. package/dist/commands/deploy.d.ts +13 -0
  21. package/dist/commands/deploy.d.ts.map +1 -1
  22. package/dist/commands/deploy.js +80 -8
  23. package/dist/commands/deploy.js.map +1 -1
  24. package/dist/commands/functions.d.ts +7 -0
  25. package/dist/commands/functions.d.ts.map +1 -0
  26. package/dist/commands/functions.js +80 -0
  27. package/dist/commands/functions.js.map +1 -0
  28. package/dist/commands/init.d.ts.map +1 -1
  29. package/dist/commands/init.js +21 -16
  30. package/dist/commands/init.js.map +1 -1
  31. package/dist/index.js +3 -0
  32. package/dist/index.js.map +1 -1
  33. package/dist/utils/api.d.ts +34 -12
  34. package/dist/utils/api.d.ts.map +1 -1
  35. package/dist/utils/api.js +41 -5
  36. package/dist/utils/api.js.map +1 -1
  37. package/dist/utils/apps-presentation.d.ts +7 -0
  38. package/dist/utils/apps-presentation.d.ts.map +1 -1
  39. package/dist/utils/apps-presentation.js +25 -1
  40. package/dist/utils/apps-presentation.js.map +1 -1
  41. package/dist/utils/errors.d.ts.map +1 -1
  42. package/dist/utils/errors.js +41 -0
  43. package/dist/utils/errors.js.map +1 -1
  44. package/dist/utils/msys-path.d.ts +14 -0
  45. package/dist/utils/msys-path.d.ts.map +1 -0
  46. package/dist/utils/msys-path.js +55 -0
  47. package/dist/utils/msys-path.js.map +1 -0
  48. package/dist/utils/package-metadata.d.ts +6 -0
  49. package/dist/utils/package-metadata.d.ts.map +1 -1
  50. package/dist/utils/package-metadata.js +9 -1
  51. package/dist/utils/package-metadata.js.map +1 -1
  52. package/dist/utils/source-pack.d.ts +24 -0
  53. package/dist/utils/source-pack.d.ts.map +1 -0
  54. package/dist/utils/source-pack.js +63 -0
  55. package/dist/utils/source-pack.js.map +1 -0
  56. package/dist/utils/system-config.d.ts +9 -0
  57. package/dist/utils/system-config.d.ts.map +1 -0
  58. package/dist/utils/system-config.js +48 -0
  59. package/dist/utils/system-config.js.map +1 -0
  60. package/package.json +34 -34
  61. package/skill/SKILL.md +73 -73
@@ -2,17 +2,20 @@ import path from 'node:path';
2
2
  import { alertsSubcommand, logHistorySubcommand, metricsSubcommand, OBSERVATION_HELP, OBSERVATION_VALUE_FLAGS } from './apps-observability.js';
3
3
  import { RESOURCES_HELP, RESOURCES_VALUE_FLAGS, resourcesSubcommand } from './apps-resources.js';
4
4
  import { DESCRIBE_HELP, describeSubcommand } from './apps-describe.js';
5
+ import { INSTANCES_HELP, instancesSubcommand, resolveReplicaAllocationId } from './apps-instances.js';
5
6
  import { followPromoteRollout } from './apps-promote.js';
6
7
  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';
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';
10
11
  import { buildApiOptions, runtime } from '../utils/context.js';
11
12
  import { confirm } from '../utils/prompt.js';
12
13
  import { resolveCreateProject } from '../utils/project-context.js';
13
14
  import { CliError, ExitCode, toCliError, usageError } from '../utils/errors.js';
14
15
  import { redact, registerSecret } from '../utils/redact.js';
15
- 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';
16
19
  /** Value flags across every subcommand — readPositionalArgs must know them all. */
17
20
  const VALUE_FLAGS = [
18
21
  ...OBSERVATION_VALUE_FLAGS,
@@ -26,6 +29,7 @@ const VALUE_FLAGS = [
26
29
  '--volume-runner',
27
30
  '--env',
28
31
  '--set',
32
+ '--unset',
29
33
  '--memory',
30
34
  '--cpu',
31
35
  '--drain-grace',
@@ -52,6 +56,8 @@ const VALUE_FLAGS = [
52
56
  '--stabilization',
53
57
  '--scale-down-stabilization',
54
58
  '--replicas',
59
+ '--node-selector',
60
+ '--location',
55
61
  ];
56
62
  export async function appsCommand(args) {
57
63
  const [subcommand, ...rest] = readPositionalArgs(args, { valueFlags: VALUE_FLAGS });
@@ -96,6 +102,8 @@ export async function appsCommand(args) {
96
102
  return resourcesSubcommand(args, rest, apiOptions);
97
103
  case 'describe':
98
104
  return describeSubcommand(args, rest, apiOptions);
105
+ case 'instances':
106
+ return instancesSubcommand(args, rest, apiOptions);
99
107
  case 'promote':
100
108
  return promoteSubcommand(args, rest, apiOptions);
101
109
  case 'rollback':
@@ -144,7 +152,8 @@ function printAppsHelp() {
144
152
  'builds <app> List image builds',
145
153
  'status <app> Endpoints and recent releases',
146
154
  'describe <app> Why it is (not) running: conditions, placement, events',
147
- 'logs <app|release|build> Stream container (or build) logs',
155
+ 'instances <app> Replicas (like pods): name, state, runner, CPU/RAM now',
156
+ 'logs <app|release|build> Recent container (or build) logs, then follow (--no-follow exits)',
148
157
  'log-history <app> Persisted runtime or HTTP logs with time/replica filters',
149
158
  'metrics <app> CPU/RAM, network, disk I/O or HTTP history (--json)',
150
159
  'alerts <app> [action] Configure resource/deployment alerts and read incidents',
@@ -152,29 +161,36 @@ function printAppsHelp() {
152
161
  'promote <release> Send production traffic to a release',
153
162
  'rollback <app> Return production to the previous release',
154
163
  'stop <release> Stop a release that is not serving production',
155
- 'exec <release> -- <cmd> Run a command inside the container',
156
- 'update <app> Change env vars, ports, resources, egress',
164
+ 'exec <app> [--replica <r>] -- <cmd> Run a command in production (one replica: name or index)',
165
+ 'update <app> Change env vars, ports, resources, egress, node selector',
157
166
  'scale <app> [mode] Set always/http/manual scaling (--replicas for always)',
158
167
  'wake <app> Start a sleeping application',
159
168
  'pause <app> Stop an http/manual app (HTTP wakes only http mode)',
160
- 'domain <app> [hostname] Bind a hostname (or list them); --verify / --remove',
169
+ "domain <app> [hostname] Bind a hostname (or list them); --verify / --remove; '*.example.com' needs --yes (not recommended)",
161
170
  'delete <app> Archive an application (stops every release)',
162
171
  '',
163
172
  'Create/deploy flags:',
164
173
  '--project <project> create: this project by id, slug or unique name (overrides projects use)',
165
174
  '--image <ref> Container image, e.g. nginx:alpine (omit to build from source)',
166
175
  '--promote deploy: take production by itself once healthy (needs apps:promote)',
167
- '--template <name> postgres | redis: volume, stable TCP endpoint, generated password, first release',
176
+ '--template <name> postgres | redis: volume, private TCP endpoint, generated password, first release',
168
177
  '--dir <path> Source directory to build (default: current directory)',
169
178
  '--dockerfile <path> Dockerfile relative to the context (default: Dockerfile)',
170
179
  '--context <path> Build context inside the source tree (default: .)',
171
180
  '--build-arg KEY=VALUE Docker build argument (repeatable)',
172
181
  '--build <buildId> Release an existing successful build',
173
182
  '--port <n> HTTP port (omit for a TCP-only service)',
174
- '--tcp <name:port[:public]> Extra TCP port; endpoint port is platform-managed (repeatable)',
183
+ '--tcp <name:port[:public]> Extra TCP port on the private network (repeatable); :public (or a\n' +
184
+ ' numeric host-port pin, which implies it) is refused while the server\n' +
185
+ ' offers no public TCP (inquir.org: not at the moment)',
175
186
  '--volume <name:/path[:ro]> Persistent volume mount (repeatable)',
176
187
  '--volume-runner <id> Pin volumes (and the container) to a worker runner',
177
- '--set KEY=VALUE Environment variable (repeatable)',
188
+ '--node-selector KEY=VALUE update: run only on servers with this node label (repeatable),',
189
+ ' e.g. inquir.io/region=eu; --clear-node-selector removes it',
190
+ '--location <id|auto> update (experimental): run in this location, e.g. nl or ru; auto',
191
+ ' runs wherever there is capacity. Applies from the next deploy',
192
+ '--set KEY=VALUE create/update: environment variable (repeatable)',
193
+ '--unset KEY update: remove an environment variable and its production-only marker (repeatable)',
178
194
  '--memory <mb> Memory ceiling (default 512)',
179
195
  '--cpu <vcpus> CPU ceiling (default 0.5)',
180
196
  '--drain-grace <seconds> Time the old container gets after SIGTERM before it is killed (0-3600)',
@@ -195,8 +211,7 @@ function printAppsHelp() {
195
211
  'inquir apps create web --port 3000',
196
212
  'inquir apps deploy web # builds ./Dockerfile, then releases it',
197
213
  'inquir apps deploy web --image nginx:alpine',
198
- 'inquir apps deploy cache --image redis:7-alpine --tcp redis:6379:public \\',
199
- ' --health-command "redis-cli ping"',
214
+ 'inquir apps create cache --template redis # volume + private TCP endpoint',
200
215
  'inquir apps deploy db --image postgres:16 --volume data:/var/lib/postgresql/data',
201
216
  'inquir apps scale web http --idle-timeout 15m',
202
217
  'inquir apps wake web',
@@ -207,16 +222,24 @@ function printAppsHelp() {
207
222
  ]);
208
223
  }
209
224
  /**
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.
225
+ * Public TCP availability, for hints. Grep marker for when cluster L4 ingress
226
+ * lands: PUBLIC_TCP_UNAVAILABLE (server error code, docs and copy say "not
227
+ * available at the moment").
228
+ */
229
+ const PUBLIC_TCP_NOTE = 'public TCP ports are not available on inquir.org at the moment (PUBLIC_TCP_UNAVAILABLE), so a client outside the platform has no public URL: run it as an application of this workspace';
230
+ /**
231
+ * `--tcp name:port[:public][:hostPort]` — ordinary usage stops at `name:port`
232
+ * (private) or `public` (the platform leases a stable endpoint port). The
233
+ * final numeric modifier remains an API/CLI compatibility escape hatch for an
234
+ * existing installation; it implies `public`, so a server without public TCP
235
+ * refuses it exactly like `:public` (400 PUBLIC_TCP_UNAVAILABLE).
213
236
  */
214
237
  export function parseTcpPortFlag(value) {
215
238
  const parts = value.split(':');
216
239
  const [name, portRaw, ...flags] = parts;
217
240
  const port = Number(portRaw);
218
241
  if (!name || !Number.isInteger(port) || port < 1 || port > 65535) {
219
- throw usageError(`Invalid --tcp value '${value}'`, 'inquir apps deploy <app> --tcp postgres:5432:public');
242
+ throw usageError(`Invalid --tcp value '${value}'`, 'inquir apps deploy <app> --tcp postgres:5432');
220
243
  }
221
244
  const spec = { name, port, protocol: 'tcp' };
222
245
  for (const flag of flags) {
@@ -231,10 +254,81 @@ export function parseTcpPortFlag(value) {
231
254
  spec.public = true;
232
255
  continue;
233
256
  }
234
- throw usageError(`Invalid --tcp modifier '${flag}' in '${value}'`, 'inquir apps deploy <app> --tcp postgres:5432:public');
257
+ throw usageError(`Invalid --tcp modifier '${flag}' in '${value}'`, 'inquir apps deploy <app> --tcp postgres:5432');
235
258
  }
236
259
  return spec;
237
260
  }
261
+ /**
262
+ * `--location <id|auto>`: the service's location, verbatim; the server says
263
+ * which ids it offers. Undefined when the flag is absent.
264
+ */
265
+ export function parseLocationFlag(value) {
266
+ if (value === undefined)
267
+ return undefined;
268
+ const location = value.trim().toLowerCase();
269
+ if (!/^[a-z0-9](?:[a-z0-9-]{0,30}[a-z0-9])?$/.test(location)) {
270
+ throw usageError(`Invalid --location value '${value}'`, 'inquir apps update <app> --location nl (or --location auto)');
271
+ }
272
+ return location;
273
+ }
274
+ /**
275
+ * The status line of a service's location: its setting by name, what
276
+ * production runs with while a change waits for the next deploy, and where
277
+ * the replicas run. Undefined while the platform offers no location choice.
278
+ */
279
+ export function describeServiceLocation(status) {
280
+ if (!status.enabled)
281
+ return undefined;
282
+ const name = (id) => (id === 'auto' ? 'Auto' : status.locations.find((entry) => entry.id === id)?.name ?? id);
283
+ const parts = [status.setting === 'auto' ? 'Auto (wherever there is capacity)' : name(status.setting)];
284
+ if (status.pending && status.applied !== null) {
285
+ parts.push(`applies on the next deploy (production runs ${status.applied === 'auto' ? 'without a location' : `in ${name(status.applied)}`})`);
286
+ }
287
+ const running = [...new Set(status.replicas.map((replica) => (replica.location ? name(replica.location) : 'a server without a location')))];
288
+ if (running.length > 0)
289
+ parts.push(`running in ${running.join(', ')}`);
290
+ return parts.join(' — ');
291
+ }
292
+ /**
293
+ * `--node-selector KEY=VALUE` (repeatable): the node labels a server must
294
+ * carry, verbatim (`inquir.io/region=eu`, `tier=fast`). The server validates
295
+ * keys and values and says which keys it exposes.
296
+ */
297
+ export function parseNodeSelectorFlags(values) {
298
+ const selector = {};
299
+ for (const entry of values) {
300
+ const index = entry.indexOf('=');
301
+ const key = index > 0 ? entry.slice(0, index).trim() : '';
302
+ if (!key) {
303
+ throw usageError(`Invalid --node-selector value '${entry}'`, 'inquir apps update <app> --node-selector inquir.io/region=eu');
304
+ }
305
+ if (Object.hasOwn(selector, key)) {
306
+ throw usageError(`--node-selector repeats the key '${key}'`, 'inquir apps update <app> --node-selector inquir.io/region=eu');
307
+ }
308
+ selector[key] = entry.slice(index + 1).trim();
309
+ }
310
+ return selector;
311
+ }
312
+ /**
313
+ * The runtime defaults with `--node-selector` / `--clear-node-selector`
314
+ * applied over `base` (the flags' runtime, else the stored one); undefined
315
+ * when neither flag is given.
316
+ */
317
+ export function applyNodeSelectorFlags(args, base) {
318
+ const values = readMultiFlag(args, '--node-selector');
319
+ const clear = hasFlag(args, '--clear-node-selector');
320
+ if (values.length === 0 && !clear)
321
+ return undefined;
322
+ if (values.length > 0 && clear) {
323
+ throw usageError('Use either --node-selector or --clear-node-selector', 'inquir apps update <app> --clear-node-selector');
324
+ }
325
+ if (!base) {
326
+ 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');
327
+ }
328
+ const rest = { ...base };
329
+ delete rest.nodeSelector;
330
+ return clear ? rest : { ...rest, nodeSelector: parseNodeSelectorFlags(values) };
331
+ }
238
332
  export function parseSetFlags(values) {
239
333
  const env = {};
240
334
  for (const entry of values) {
@@ -340,7 +434,7 @@ export function parseVolumeFlag(value) {
340
434
  let mountPath = separator > 0 ? value.slice(separator + 1) : '';
341
435
  let readOnly = false;
342
436
  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');
437
+ 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}`);
344
438
  }
345
439
  const modifierAt = mountPath.lastIndexOf(':');
346
440
  if (modifierAt >= 0) {
@@ -419,7 +513,12 @@ export function buildRuntimeFromFlags(args, baseRuntime) {
419
513
  const memory = readFlagValue(args, '--memory');
420
514
  const cpu = readFlagValue(args, '--cpu');
421
515
  const drainGraceRaw = readFlagValue(args, '--drain-grace');
422
- const healthPath = readFlagValue(args, '--health-path');
516
+ const healthPathRaw = readFlagValue(args, '--health-path');
517
+ const healthPathArgument = healthPathRaw !== undefined ? containerPathArgument(healthPathRaw, '--health-path') : undefined;
518
+ if (healthPathArgument?.recoveredFrom) {
519
+ printWarning(`--health-path: read '${healthPathArgument.recoveredFrom}' as ${healthPathArgument.value} (Git Bash path conversion). ${MSYS_PATH_HINT}`);
520
+ }
521
+ const healthPath = healthPathArgument?.value;
423
522
  const healthCommand = readFlagValue(args, '--health-command');
424
523
  const ports = tcpValues.map(parseTcpPortFlag);
425
524
  const volumes = volumeValues.map(parseVolumeFlag);
@@ -589,15 +688,129 @@ async function landingProjectLines(app, requestedProject, apiOptions) {
589
688
  }
590
689
  const TEMPLATE_NAMES = ['postgres', 'redis'];
591
690
  /** `apps <sub> --help` answered per subcommand; the global page is the fallback. */
592
- const SUBCOMMAND_HELP = {
691
+ export const SUBCOMMAND_HELP = {
593
692
  ...OBSERVATION_HELP,
594
693
  ...RESOURCES_HELP,
595
694
  ...DESCRIBE_HELP,
695
+ ...INSTANCES_HELP,
596
696
  logs: [
597
- 'Usage: inquir apps logs <app|release|build> [--tail <n>]',
598
- ' Streams container or build logs. Ctrl-C stops following.',
697
+ 'Usage: inquir apps logs <app|release|build> [--tail <n>] [--no-follow] [--replica <name|index|allocation>]',
698
+ ' Prints the last --tail lines (default 200, at most 2000), then follows new',
699
+ ' ones until Ctrl-C.',
700
+ ' --no-follow Print the last --tail lines and exit (for scripts and agents).',
701
+ ' --replica Only one replica\'s lines (see apps instances <app>).',
599
702
  ' --history selects persisted history instead; see apps log-history --help.',
600
703
  ],
704
+ exec: [
705
+ 'Usage: inquir apps exec <app> [--replica <name|index|allocation>] [--timeout <ms>] -- <command>',
706
+ ' Runs one audited command in the production container. <app> is the',
707
+ ' application name or id; the id of its production release works too.',
708
+ ' --replica One serving replica (see apps instances <app>); default: the projected one.',
709
+ ],
710
+ list: [
711
+ 'Usage: inquir apps list [--json]',
712
+ '',
713
+ ' The workspace\'s applications with their production status.',
714
+ ],
715
+ deploy: [
716
+ 'Usage: inquir apps deploy <app> [--image <ref> | --build <buildId> | --dir <path>] [--promote] [--no-wait]',
717
+ '',
718
+ ' Without --image or --build, packs the source directory (default: the',
719
+ ' current one), builds its Dockerfile and releases the image as a preview.',
720
+ ' The upload honours .dockerignore and .inquirignore and never contains',
721
+ ' .env files, private keys or literal registry tokens.',
722
+ ' --image <ref> Release a container image instead of building',
723
+ ' --build <buildId> Release an existing successful build',
724
+ ' --dir <path> Source directory (default: current directory)',
725
+ ' --dockerfile, --context, --target, --build-arg KEY=VALUE Build options',
726
+ ' --promote Take production by itself once healthy (needs apps:promote)',
727
+ ' --no-wait Return as soon as the release is queued',
728
+ ' Runtime flags (--port, --tcp, --volume, --memory, --cpu, ...) as in apps --help.',
729
+ ' Environment variables are not deploy flags: set them with',
730
+ ' inquir apps update <app> --set KEY=VALUE, then deploy or redeploy.',
731
+ '',
732
+ ' inquir.yaml services.web.env: non-secret defaults, applied by the next release',
733
+ ' where the platform enables it (APPS_MANIFEST_ENV=on); an application variable',
734
+ ' (apps update --set, dashboard, API) with the same name wins. Otherwise the deploy warns that',
735
+ ' the section was not applied. Never put secrets in inquir.yaml.',
736
+ ],
737
+ status: [
738
+ 'Usage: inquir apps status <app> [--json]',
739
+ '',
740
+ ' Production, endpoints (public URL, private address, TCP ports), volumes',
741
+ ' and recent releases.',
742
+ ],
743
+ rollback: [
744
+ 'Usage: inquir apps rollback <app> [--json]',
745
+ '',
746
+ ' Returns production to the previous release: instantly while its container',
747
+ ' still runs, otherwise after it restarts and passes its health check.',
748
+ ],
749
+ stop: [
750
+ 'Usage: inquir apps stop <release>',
751
+ '',
752
+ ' Stops a release that is not serving production; its container and preview',
753
+ ' URL go away.',
754
+ ],
755
+ build: [
756
+ 'Usage: inquir apps build <app> [--dir <path>] [--dockerfile <path>] [--context <path>] [--target <stage>] [--build-arg KEY=VALUE]',
757
+ '',
758
+ ' Builds an image from source without releasing it (release it later with',
759
+ ' apps deploy <app> --build <buildId>). Uploads like apps deploy.',
760
+ ],
761
+ builds: [
762
+ 'Usage: inquir apps builds <app> [--cancel <buildId>] [--json]',
763
+ '',
764
+ ' Lists the application\'s image builds; --cancel stops a queued or running one.',
765
+ ],
766
+ update: [
767
+ 'Usage: inquir apps update <app> [--set KEY=VALUE] [--unset KEY] [--port <n>] [--memory <mb>] [--cpu <vcpus>] [--ingress private|public] [flags]',
768
+ '',
769
+ ' Applied now: --ingress (the generated public domain is created or removed',
770
+ ' at once) and --description.',
771
+ ' Applied by the next release (inquir apps redeploy <app>): --set, --unset, --port,',
772
+ ' --tcp, --volume, --memory, --cpu, --drain-grace, --health-path,',
773
+ ' --health-command, --egress, --location, --node-selector / --clear-node-selector.',
774
+ ],
775
+ scale: [
776
+ 'Usage: inquir apps scale <app> <always|http|manual> [--replicas <n>] [--idle-timeout <d>] [--activation-timeout <d>] [--stabilization <d>]',
777
+ '',
778
+ ' always Keep --replicas replicas running (1..8)',
779
+ ' http Sleep after --idle-timeout without requests (minimum 5m); a request wakes it',
780
+ ' manual Run until paused; only wake starts it again',
781
+ ' Durations: 90s, 15m, 1h30m. Takes effect now.',
782
+ '',
783
+ 'Examples:',
784
+ ' inquir apps scale web always --replicas 2',
785
+ ' inquir apps scale web http --idle-timeout 15m',
786
+ ],
787
+ wake: [
788
+ 'Usage: inquir apps wake <app> [--json]',
789
+ '',
790
+ ' Starts a sleeping (http) or paused (manual) application.',
791
+ ],
792
+ pause: [
793
+ 'Usage: inquir apps pause <app> [--json]',
794
+ '',
795
+ ' Stops an http or manual application; an http one wakes on the next request.',
796
+ ],
797
+ domain: [
798
+ 'Usage: inquir apps domain <app> [hostname] [--verify | --remove] [--yes] [--json]',
799
+ '',
800
+ ' Without a hostname, lists the bound domains. With one, binds it and prints',
801
+ ' the DNS records to create; --verify checks them, --remove unbinds it.',
802
+ '',
803
+ " Advanced, not recommended: a quoted mask '*.example.com' binds every",
804
+ ' first-level subdomain (a.example.com, not a.b.example.com) when the server',
805
+ ' enables it. It asks for confirmation, or --yes. Prove it with a wildcard',
806
+ ' CNAME *.example.com and TXT _inquir-verify.example.com.',
807
+ ],
808
+ delete: [
809
+ 'Usage: inquir apps delete <app>',
810
+ '',
811
+ ' Archives the application and stops every release. Refused while another',
812
+ ' application depends on it (apps connect).',
813
+ ],
601
814
  promote: [
602
815
  'Usage: inquir apps promote <release> [--no-wait] [--json]',
603
816
  '',
@@ -625,9 +838,10 @@ const SUBCOMMAND_HELP = {
625
838
  ' --project <project> Add to this project (id, slug or unique name). Otherwise',
626
839
  ' uses projects use; without a selection, creates a project',
627
840
  ' 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.',
841
+ ' --template <name> postgres | redis: a volume, a private TCP port',
842
+ ' (<slug>.apps.internal, reachable from your other',
843
+ ' services), a generated password and the first release —',
844
+ ' one command from nothing to a running database.',
631
845
  ' --volume-runner <id> Pin volumes + container to a worker runner (id or',
632
846
  ' unique prefix); the data lives on that machine. With',
633
847
  ' --template the worker runs the operator\'s digest-pinned',
@@ -677,8 +891,55 @@ const SUBCOMMAND_HELP = {
677
891
  ],
678
892
  };
679
893
  const BACKUPS_USAGE = 'inquir apps backups <app> [list|create|restore <backupId>|delete <backupId>]';
894
+ /**
895
+ * "2/3 ~10-08 14:00": the row's place among its volume's backups of the same
896
+ * kind, and roughly when it goes; "next cleanup" once past the limit.
897
+ */
898
+ export function formatBackupKeeps(backup) {
899
+ const retention = backup.retention;
900
+ if (!retention)
901
+ return '';
902
+ const place = `${retention.position}/${retention.limit}`;
903
+ if (!retention.kept)
904
+ return `${place} next cleanup`;
905
+ if (!retention.expiresAt)
906
+ return place;
907
+ return `${place} ~${retention.expiresAt.slice(5, 16).replace('T', ' ')}`;
908
+ }
909
+ /** One line on what the workspace's backup policy keeps. */
910
+ export function formatBackupPolicy(policy) {
911
+ const owner = policy.source === 'override' ? 'This workspace' : `Plan ${policy.planCode}`;
912
+ const every = policy.frequencyHours === 24 ? 'daily' : `every ${policy.frequencyHours}h`;
913
+ const automatic = policy.automatic
914
+ ? `the last ${policy.retainCount} automatic backup(s) (${every})`
915
+ : 'no automatic backups';
916
+ let line = `${owner} keeps ${automatic} and ${policy.maxManualBackups} manual backup(s) per volume`;
917
+ if (policy.grace)
918
+ line += `; after a plan change, up to ${policy.grace.retainCount} are kept until ${policy.grace.until.slice(0, 10)}`;
919
+ if (!policy.automaticAvailable && policy.automaticUnavailableReason === 'volume_on_worker') {
920
+ line += '; this app\'s volumes live on a worker, where the platform cannot back them up yet';
921
+ }
922
+ return line;
923
+ }
924
+ /**
925
+ * An empty backup list. A volume on a worker runner cannot be snapshotted by
926
+ * the platform yet, and `create` is refused there too: say so instead of
927
+ * suggesting it.
928
+ */
929
+ export function emptyBackupsLines(appName, policy) {
930
+ if (policy && !policy.automaticAvailable && policy.automaticUnavailableReason === 'volume_on_worker') {
931
+ return [
932
+ `No backups for ${appName}: its volume lives on a worker runner, where the platform cannot back up volumes yet`,
933
+ ' (automatic or on demand; `backups create` is refused there too). Runner-side backups are in progress.',
934
+ ' Until then, see "Backups before risky changes" in the Node.js + Postgres tutorial:',
935
+ ' https://inquir.org/docs/node-postgres',
936
+ ];
937
+ }
938
+ return [`No backups for ${appName} yet — create one: inquir apps backups ${appName} create`];
939
+ }
680
940
  /** The one backup table: `create` prints it too, once the rows settle. */
681
941
  function renderBackupTable(items) {
942
+ const keeps = items.some((b) => b.retention);
682
943
  printLine(formatTable(items.map((b) => ({
683
944
  id: b.id.slice(0, 8),
684
945
  volume: b.volumeName,
@@ -686,7 +947,10 @@ function renderBackupTable(items) {
686
947
  trigger: b.trigger,
687
948
  size: formatBytes(b.sizeBytes ?? b.artifactSizeBytes),
688
949
  created: b.createdAt.replace('T', ' ').slice(0, 19),
689
- })), ['id', 'volume', 'status', 'trigger', 'size', 'created']));
950
+ keeps: formatBackupKeeps(b),
951
+ })), keeps
952
+ ? ['id', 'volume', 'status', 'trigger', 'size', 'created', 'keeps']
953
+ : ['id', 'volume', 'status', 'trigger', 'size', 'created']));
690
954
  }
691
955
  /** Accept a short id, the way the release commands do. */
692
956
  async function resolveBackup(applicationId, needle, identifier, apiOptions) {
@@ -758,9 +1022,12 @@ async function backupsSubcommand(args, positionals, apiOptions) {
758
1022
  if (json)
759
1023
  return printLine(JSON.stringify(page, null, 2));
760
1024
  if (page.items.length === 0) {
761
- printInfo(`No backups for ${app.name} yet — create one: inquir apps backups ${app.name} create`);
1025
+ for (const line of emptyBackupsLines(app.name, page.policy))
1026
+ printInfo(line);
762
1027
  return;
763
1028
  }
1029
+ if (page.policy)
1030
+ printInfo(dim(formatBackupPolicy(page.policy)));
764
1031
  renderBackupTable(page.items);
765
1032
  if (page.nextCursor) {
766
1033
  printInfo(dim(` older: inquir apps backups ${app.name} list --cursor ${page.nextCursor}`));
@@ -883,8 +1150,10 @@ export function explainDatabaseConnectionError(error, context) {
883
1150
  ...base,
884
1151
  cause: 'The control plane offers no private address for this database.',
885
1152
  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.',
1153
+ ? 'connect writes private URLs only. If other apps have private addresses here, the database\'s may just not be verified yet: retry in a minute. ' +
1154
+ 'A public URL exists only where the server offers public TCP and Public Access is on; ' + PUBLIC_TCP_NOTE + '.'
1155
+ : 'If other apps have private addresses here, the database\'s may just not be verified yet: retry in a minute. ' +
1156
+ 'A public URL exists only where the server offers public TCP and Public Access is on; ' + PUBLIC_TCP_NOTE + '.',
888
1157
  command: `inquir apps connection-url ${database} --public`,
889
1158
  });
890
1159
  case 'APP_NO_PRODUCTION_RELEASE':
@@ -981,10 +1250,10 @@ export function selectConnectionUrl(result, preference = 'auto') {
981
1250
  const requested = preference === 'auto' ? 'private or public' : preference;
982
1251
  const reason = preference !== 'public' ? result.privateUnavailable : undefined;
983
1252
  throw usageError(`No ${requested} database connection URL is available${reason ? `: ${reason.message}` : ''}`, preference === 'public'
984
- ? 'Enable Public Access in the application Networking settings.'
1253
+ ? `A public URL needs public TCP and Public Access on the database; ${PUBLIC_TCP_NOTE}.`
985
1254
  : reason?.code === 'APP_PRIVATE_ENDPOINT_NOT_READY'
986
1255
  ? '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.');
1256
+ : `Configure private networking. --public works only where the server offers public TCP; ${PUBLIC_TCP_NOTE}.`);
988
1257
  }
989
1258
  async function deploySubcommand(args, positionals, apiOptions) {
990
1259
  const [identifier] = positionals;
@@ -1033,6 +1302,7 @@ async function deploySubcommand(args, positionals, apiOptions) {
1033
1302
  printInfo(`Release ${queued.releaseId.slice(0, 8)} queued for ${app.name} (${image ?? `build ${buildId.slice(0, 8)}`})`);
1034
1303
  if (promote)
1035
1304
  printInfo(dim(' it takes production by itself once healthy'));
1305
+ printManifestEnvOutcome(queued);
1036
1306
  }
1037
1307
  if (hasFlag(args, '--no-wait')) {
1038
1308
  if (json)
@@ -1043,6 +1313,20 @@ async function deploySubcommand(args, positionals, apiOptions) {
1043
1313
  }
1044
1314
  await followQueuedRelease(app, queued, promote, json, apiOptions);
1045
1315
  }
1316
+ /**
1317
+ * What the server did with inquir.yaml `services.web.env` (docs/MANIFEST_ENV.md).
1318
+ * Read structurally: a control plane that predates manifest env sends neither
1319
+ * field, and then nothing is printed.
1320
+ */
1321
+ export function printManifestEnvOutcome(queued) {
1322
+ const outcome = queued;
1323
+ const applied = Array.isArray(outcome.manifestEnv?.applied) ? outcome.manifestEnv.applied : [];
1324
+ if (applied.length > 0)
1325
+ printInfo(dim(` inquir.yaml env applied: ${applied.join(', ')}`));
1326
+ const warnings = Array.isArray(outcome.warnings) ? outcome.warnings : [];
1327
+ for (const warning of warnings)
1328
+ printWarning(` warning: ${warning}`);
1329
+ }
1046
1330
  /**
1047
1331
  * Wait for a queued release to settle and say where it ended up; with
1048
1332
  * promote-on-ready, also wait for production to point at it.
@@ -1134,6 +1418,7 @@ async function redeploySubcommand(args, positionals, apiOptions) {
1134
1418
  printInfo(`Release ${queued.releaseId.slice(0, 8)} queued for ${app.name} (redeploy of ${queued.redeployOf.slice(0, 8)})`);
1135
1419
  if (promote)
1136
1420
  printInfo(dim(' it takes production by itself once healthy'));
1421
+ printManifestEnvOutcome(queued);
1137
1422
  }
1138
1423
  if (hasFlag(args, '--no-wait')) {
1139
1424
  if (json)
@@ -1154,7 +1439,11 @@ async function runSourceBuild(applicationId, appName, args, apiOptions) {
1154
1439
  const buildArgs = parseSetFlags(readMultiFlag(args, '--build-arg'));
1155
1440
  if (!json)
1156
1441
  printInfo(dim(`— packing ${dir} —`));
1157
- const sourceZipBase64 = await createZipBase64(dir);
1442
+ const sourceZipBase64 = await packSourceForUpload(dir, {
1443
+ json,
1444
+ ...(readFlagValue(args, '--context') ? { context: readFlagValue(args, '--context') } : {}),
1445
+ ...(readFlagValue(args, '--dockerfile') ? { dockerfile: readFlagValue(args, '--dockerfile') } : {}),
1446
+ });
1158
1447
  const queued = await queueBuild(applicationId, {
1159
1448
  sourceZipBase64,
1160
1449
  source: 'cli',
@@ -1336,24 +1625,64 @@ export async function waitForBuild(buildId, apiOptions, options = {}) {
1336
1625
  * reason instead of sitting out its whole timeout.
1337
1626
  */
1338
1627
  const FAILED_STARTS_BEFORE_REPORTING = 3;
1628
+ /** Every how many release polls a running replacement's phase is re-read. */
1629
+ const REPLACEMENT_PROBE_EVERY = 5;
1630
+ /**
1631
+ * The phase of the stop-first production replacement a PENDING release is
1632
+ * the target of. Bounded: the operation is read on the first PENDING poll
1633
+ * and then every REPLACEMENT_PROBE_EVERY polls, and never again once a read
1634
+ * fails or names no replacement for this release (a preview, an older
1635
+ * server), so an ordinary deploy costs one extra request.
1636
+ */
1637
+ export function createReplacementProbe(read, every = REPLACEMENT_PROBE_EVERY) {
1638
+ let polls = 0;
1639
+ let done = false;
1640
+ let phase = '';
1641
+ return {
1642
+ async phaseOf(release) {
1643
+ if (done)
1644
+ return '';
1645
+ polls += 1;
1646
+ if ((polls - 1) % every !== 0)
1647
+ return phase;
1648
+ const operation = await read(release.applicationId).catch(() => null);
1649
+ if (!operation || operation.targetReleaseId !== release.id) {
1650
+ done = true;
1651
+ phase = '';
1652
+ return '';
1653
+ }
1654
+ phase = operation.phase;
1655
+ return phase;
1656
+ },
1657
+ };
1658
+ }
1339
1659
  /** Poll until the release settles; the platform decides when it is healthy. */
1340
1660
  async function waitForRelease(releaseId, apiOptions, timeoutMs = 10 * 60_000) {
1341
1661
  const deadline = Date.now() + timeoutMs;
1342
1662
  const startedAt = Date.now();
1343
1663
  let lastProgress = '';
1344
1664
  const json = readJsonFlag(process.argv);
1665
+ let replacementClient;
1666
+ const replacement = createReplacementProbe(async (applicationId) => {
1667
+ replacementClient ??= await createApiClient(apiOptions);
1668
+ return getProductionReplacement(applicationId, apiOptions, replacementClient);
1669
+ });
1345
1670
  for (;;) {
1346
1671
  const release = await getRelease(releaseId, apiOptions);
1347
1672
  // A failed start on the pinned runner changes the reason, not the status.
1348
1673
  const reason = release.serving ? release.failureReason ?? '' : '';
1349
- if (`${release.status}\n${reason}` !== lastProgress) {
1350
- lastProgress = `${release.status}\n${reason}`;
1674
+ // PENDING is also the whole of a stop-first replacement of production,
1675
+ // which pulls nothing: its phase says what the wait is.
1676
+ const phase = release.status === 'PENDING' ? await replacement.phaseOf(release) : '';
1677
+ if (`${release.status}\n${reason}\n${phase}` !== lastProgress) {
1678
+ lastProgress = `${release.status}\n${reason}\n${phase}`;
1351
1679
  // Say what is happening in words, with elapsed time — a two-minute health
1352
1680
  // gate in silence reads as a hang. Under --json only stdout must stay
1353
1681
  // parseable; progress goes to stderr.
1354
1682
  const shown = presentRelease(release);
1683
+ const meaning = (phase && replacementPhaseMeaning(phase)) || shown.meaning;
1355
1684
  const lines = [
1356
- `— ${shown.label} (${formatElapsed(Date.now() - startedAt)})${shown.meaning ? `: ${shown.meaning}` : ''} —`,
1685
+ `— ${shown.label} (${formatElapsed(Date.now() - startedAt)})${meaning ? `: ${meaning}` : ''} —`,
1357
1686
  ...(reason ? [` ${reason}`] : []),
1358
1687
  ];
1359
1688
  for (const line of lines) {
@@ -1437,9 +1766,16 @@ export function releaseEndpointLines(release, previewUrl, options = {}) {
1437
1766
  */
1438
1767
  export function privateEndpointLines(detail, active, options = {}) {
1439
1768
  if (!detail.privateDomain) {
1440
- return options.privateNetworking
1441
- ? ['(provisioning — private DNS and routing are being verified)']
1442
- : [];
1769
+ if (!options.privateNetworking)
1770
+ return [];
1771
+ // The address routes to production, so without one there is nothing to
1772
+ // verify yet, and with no serving replica it is down, not provisioning.
1773
+ if (!active)
1774
+ return ['(none yet — production gets one once a release is promoted)'];
1775
+ if (active.serving && active.serving.ready === 0) {
1776
+ return ['(unavailable — no production replica is serving; see inquir apps describe <app>)'];
1777
+ }
1778
+ return ['(provisioning — private DNS and routing are being verified)'];
1443
1779
  }
1444
1780
  const lines = [];
1445
1781
  if (detail.privateBaseUrl)
@@ -1517,6 +1853,18 @@ function formatDurationSeconds(seconds) {
1517
1853
  }
1518
1854
  return parts.join(' ');
1519
1855
  }
1856
+ /** One line for a startup probe; defaults as the server applies them (src/apps/startup-probe.ts). */
1857
+ export function describeStartupProbe(probe) {
1858
+ const period = probe.periodSeconds ?? 10;
1859
+ const threshold = probe.failureThreshold ?? 3;
1860
+ const delay = probe.initialDelaySeconds ?? 0;
1861
+ const mode = probe.type ?? (probe.command ? 'command' : undefined);
1862
+ const target = mode === 'command'
1863
+ ? `command ${JSON.stringify(probe.command ?? '')}`
1864
+ : `${mode ?? 'probe'}${probe.port ? ` port ${probe.port}` : ''}${mode !== 'tcp' ? ` ${probe.path ?? '/'}` : ''}`;
1865
+ return `${target}, every ${period}s, ${threshold} failure${threshold === 1 ? '' : 's'} allowed` +
1866
+ `${delay ? ` after ${delay}s` : ''} (up to ${delay + period * threshold}s before the healthcheck)`;
1867
+ }
1520
1868
  export function describeScalingPolicy(policy) {
1521
1869
  const range = `${policy.minReplicas}–${policy.maxReplicas} replicas`;
1522
1870
  if (policy.activation === 'http') {
@@ -1552,6 +1900,15 @@ async function statusSubcommand(args, positionals, apiOptions) {
1552
1900
  const pinnedRunner = detail.volumeRunnerId;
1553
1901
  if (pinnedRunner)
1554
1902
  printLine(` volume pin: runner ${pinnedRunner}`);
1903
+ // Experimental service location: shown only where the platform offers it.
1904
+ if (limits?.features?.serviceLocations) {
1905
+ const location = await getAppLocation(app.id, apiOptions).catch(() => null);
1906
+ const line = location ? describeServiceLocation(location) : undefined;
1907
+ if (line)
1908
+ printLine(` location: ${line}`);
1909
+ }
1910
+ if (detail.runtime?.startupProbe)
1911
+ printLine(` startup: ${describeStartupProbe(detail.runtime.startupProbe)}`);
1555
1912
  const servingRelease = detail.releases.find((r) => r.id === detail.production?.activeReleaseId);
1556
1913
  if (servingRelease?.serving && servingRelease.serving.ready < servingRelease.serving.desired) {
1557
1914
  // Production points at it, but its replica does not answer on the runner yet.
@@ -1692,29 +2049,74 @@ async function logsSubcommand(args, positionals, apiOptions) {
1692
2049
  const build = await getBuild(identifier, apiOptions).catch(() => null);
1693
2050
  if (build) {
1694
2051
  printInfo(dim(`— build ${build.id.slice(0, 8)} (${build.status}) —`));
1695
- for await (const event of streamBuildLogs(build.id, apiOptions)) {
1696
- if (event.type === 'log' && event.line) {
1697
- if (json)
1698
- process.stderr.write(`${redact(event.line)}\n`);
1699
- else
1700
- printLine(event.line);
1701
- }
1702
- else if (event.type === 'complete') {
1703
- printInfo(dim(`— build ${event.status ?? 'finished'} —`));
1704
- break;
1705
- }
1706
- else if (event.type === 'error') {
1707
- printLine(dim(`— ${event.message ?? 'stream error'} —`));
1708
- break;
2052
+ // A finished build replays its log and the stream ends by itself. A
2053
+ // running one follows; with --no-follow, stop once the replay goes quiet.
2054
+ const stop = new AbortController();
2055
+ const quiet = hasFlag(args, '--no-follow') && !isTerminalBuildStatus(build.status)
2056
+ ? createQuietStop(() => stop.abort())
2057
+ : null;
2058
+ try {
2059
+ for await (const event of streamBuildLogs(build.id, apiOptions, stop.signal)) {
2060
+ quiet?.heard();
2061
+ if (event.type === 'log' && event.line) {
2062
+ if (json)
2063
+ process.stderr.write(`${redact(event.line)}\n`);
2064
+ else
2065
+ printLine(event.line);
2066
+ }
2067
+ else if (event.type === 'complete') {
2068
+ printInfo(dim(`— build ${event.status ?? 'finished'} —`));
2069
+ break;
2070
+ }
2071
+ else if (event.type === 'error') {
2072
+ printLine(dim(`— ${event.message ?? 'stream error'} —`));
2073
+ break;
2074
+ }
1709
2075
  }
1710
2076
  }
2077
+ catch (error) {
2078
+ if (!stop.signal.aborted)
2079
+ throw error;
2080
+ }
2081
+ finally {
2082
+ quiet?.cancel();
2083
+ }
2084
+ if (stop.signal.aborted) {
2085
+ printInfo(dim(`— build ${build.id.slice(0, 8)} is still running; follow it: inquir apps logs ${build.id} —`));
2086
+ }
1711
2087
  return;
1712
2088
  }
1713
2089
  const releaseId = await resolveReleaseId(identifier, apiOptions);
1714
2090
  const tailRaw = readFlagValue(args, '--tail');
1715
2091
  const tail = tailRaw ? Number(tailRaw) : 200;
1716
- printInfo(dim(`— streaming logs for release ${releaseId.slice(0, 8)} (ctrl-c to stop) —`));
1717
- for await (const event of streamReleaseLogs(releaseId, { tail }, apiOptions)) {
2092
+ if (!Number.isInteger(tail) || tail < 1 || tail > MAX_LOG_TAIL) {
2093
+ throw usageError(`--tail must be an integer between 1 and ${MAX_LOG_TAIL}`, 'inquir apps logs <app> --tail 100');
2094
+ }
2095
+ const replica = readFlagValue(args, '--replica');
2096
+ if (hasFlag(args, '--replica') && !replica?.trim())
2097
+ throw usageError('Missing --replica value', 'inquir apps instances <app>');
2098
+ let allocationId;
2099
+ if (replica?.trim()) {
2100
+ const release = await getRelease(releaseId, apiOptions);
2101
+ // Only this release's replicas: an index or name of production would be another container.
2102
+ allocationId = await resolveReplicaAllocationId(release.applicationId, replica, apiOptions, { releaseId: release.id });
2103
+ }
2104
+ if (hasFlag(args, '--no-follow')) {
2105
+ const release = await getRelease(releaseId, apiOptions);
2106
+ const client = await createApiClient(apiOptions);
2107
+ const entries = await readRecentRuntimeLogs((query) => getAppLogHistory(client, release.applicationId, query), { releaseId, tail, ...(allocationId ? { allocationId } : {}) });
2108
+ if (json) {
2109
+ printLine(redact(JSON.stringify(entries, null, 2)));
2110
+ return;
2111
+ }
2112
+ for (const entry of entries)
2113
+ printLine(entry.message);
2114
+ if (entries.length === 0)
2115
+ printInfo(dim(`— no stored logs for release ${releaseId.slice(0, 8)} —`));
2116
+ return;
2117
+ }
2118
+ printInfo(dim(`— streaming logs for release ${releaseId.slice(0, 8)}${allocationId ? `, replica ${allocationId.slice(0, 8)}` : ''} (ctrl-c to stop) —`));
2119
+ for await (const event of streamReleaseLogs(releaseId, { tail, ...(allocationId ? { allocationId } : {}) }, apiOptions)) {
1718
2120
  if (event.type === 'log' && event.line) {
1719
2121
  // Under --json the log tail goes to stderr so stdout stays parseable.
1720
2122
  if (json)
@@ -1731,6 +2133,63 @@ async function logsSubcommand(args, positionals, apiOptions) {
1731
2133
  }
1732
2134
  }
1733
2135
  }
2136
+ /** The most lines `apps logs --tail` asks for (the API's own ceiling). */
2137
+ const MAX_LOG_TAIL = 2000;
2138
+ /**
2139
+ * How far back `--no-follow` reads: inside the 7-day runtime retention with
2140
+ * an hour to spare, so a client clock up to an hour off still names a window
2141
+ * the API accepts. `to` is left to the server's clock.
2142
+ */
2143
+ const RUNTIME_LOG_WINDOW_MS = 7 * 86_400_000 - 3_600_000;
2144
+ /** `logs --no-follow` on a running build: stop once its replay is this quiet... */
2145
+ const BUILD_LOG_QUIET_MS = 2_000;
2146
+ /** ...or when nothing at all arrived this long after the request (a slow connection). */
2147
+ const BUILD_LOG_FIRST_EVENT_MS = 15_000;
2148
+ /**
2149
+ * Calls `stop` once a stream goes quiet: BUILD_LOG_FIRST_EVENT_MS before the
2150
+ * first event (connecting may be slow), BUILD_LOG_QUIET_MS after the latest.
2151
+ */
2152
+ export function createQuietStop(stop, timings = { firstEventMs: BUILD_LOG_FIRST_EVENT_MS, quietMs: BUILD_LOG_QUIET_MS }) {
2153
+ let timer = setTimeout(stop, timings.firstEventMs);
2154
+ return {
2155
+ heard() {
2156
+ clearTimeout(timer);
2157
+ timer = setTimeout(stop, timings.quietMs);
2158
+ },
2159
+ cancel() {
2160
+ clearTimeout(timer);
2161
+ },
2162
+ };
2163
+ }
2164
+ const LOG_HISTORY_PAGE = 200;
2165
+ /**
2166
+ * `apps logs --no-follow`: the last `tail` stored lines of one release (one
2167
+ * replica with `allocationId`), oldest first — what the live stream prints
2168
+ * before it starts following, without the following.
2169
+ */
2170
+ export async function readRecentRuntimeLogs(fetchPage, options) {
2171
+ const now = options.now ?? Date.now();
2172
+ // The first page fixes the window; later pages repeat it with the cursor.
2173
+ let window = { from: new Date(now - RUNTIME_LOG_WINDOW_MS).toISOString() };
2174
+ const newestFirst = [];
2175
+ let cursor;
2176
+ while (newestFirst.length < options.tail) {
2177
+ const page = await fetchPage({
2178
+ ...window,
2179
+ releaseId: options.releaseId,
2180
+ ...(options.allocationId ? { allocationId: options.allocationId } : {}),
2181
+ limit: Math.min(LOG_HISTORY_PAGE, options.tail - newestFirst.length),
2182
+ ...(cursor ? { cursor } : {}),
2183
+ });
2184
+ newestFirst.push(...page.entries);
2185
+ if (!page.nextCursor || page.entries.length === 0)
2186
+ break;
2187
+ cursor = page.nextCursor;
2188
+ if (page.from && page.to)
2189
+ window = { from: page.from, to: page.to };
2190
+ }
2191
+ return newestFirst.slice(0, options.tail).reverse();
2192
+ }
1734
2193
  async function promoteSubcommand(args, positionals, apiOptions) {
1735
2194
  const [identifier] = positionals;
1736
2195
  if (!identifier)
@@ -1741,9 +2200,11 @@ async function promoteSubcommand(args, positionals, apiOptions) {
1741
2200
  // Say up front what promotion does to this release: a preview-profile
1742
2201
  // release with production-only variables is restarted with them before
1743
2202
  // traffic moves, and its preview URL retires.
2203
+ let release = null;
2204
+ let app = null;
1744
2205
  if (!json) {
1745
- const release = await getRelease(releaseId, apiOptions).catch(() => null);
1746
- const app = release ? await getApplication(release.applicationId, apiOptions).catch(() => null) : null;
2206
+ release = await getRelease(releaseId, apiOptions).catch(() => null);
2207
+ app = release ? await getApplication(release.applicationId, apiOptions).catch(() => null) : null;
1747
2208
  if (release && release.envProfile !== 'production' && (app?.productionOnlyEnvKeys?.length ?? 0) > 0) {
1748
2209
  printInfo(dim(' the container restarts with production-only variables before traffic moves'));
1749
2210
  }
@@ -1775,12 +2236,33 @@ async function promoteSubcommand(args, positionals, apiOptions) {
1775
2236
  printLine(` ${url}`);
1776
2237
  }
1777
2238
  if ((result.productionUrls ?? []).length === 0 && !result.productionUrl) {
1778
- printInfo(dim(' no hostname — reach it at its TCP endpoints (inquir apps status <app>)'));
2239
+ for (const line of promotedWithoutHostnameLines(release, app))
2240
+ printInfo(dim(line));
1779
2241
  }
1780
2242
  if (result.previousReleaseId) {
1781
2243
  printInfo(dim(` previous release ${result.previousReleaseId.slice(0, 8)} kept for rollback`));
1782
2244
  }
1783
2245
  }
2246
+ /**
2247
+ * What to say after a promotion that produced no public URL. An HTTP service
2248
+ * is private, not unreachable: same-workspace services reach it at its
2249
+ * private address. Only a TCP-only service is reached at its TCP endpoints.
2250
+ */
2251
+ export function promotedWithoutHostnameLines(release, app) {
2252
+ const name = app?.name ?? '<app>';
2253
+ // The server reads a port without a protocol as HTTP (src/apps/app-ports.ts).
2254
+ const http = typeof release?.runtime?.port === 'number'
2255
+ || (release?.runtime?.ports ?? []).some((port) => (port.protocol ?? 'http') === 'http');
2256
+ if (http) {
2257
+ return [
2258
+ app?.privateBaseUrl
2259
+ ? ` private — your other services reach it at ${app.privateBaseUrl}`
2260
+ : ` private — your other services reach it at http://<slug>.apps.internal, listed by inquir apps status ${name} once routing is verified`,
2261
+ ` public URL: inquir apps update ${name} --ingress public`,
2262
+ ];
2263
+ }
2264
+ return [` no hostname — reach it at its TCP endpoints (inquir apps status ${name})`];
2265
+ }
1784
2266
  async function rollbackSubcommand(args, positionals, apiOptions) {
1785
2267
  const [identifier] = positionals;
1786
2268
  if (!identifier)
@@ -1920,7 +2402,23 @@ async function updateSubcommand(args, positionals, apiOptions) {
1920
2402
  await requireAuth(apiOptions);
1921
2403
  const app = await resolveApplication(identifier, apiOptions);
1922
2404
  const envVars = parseSetFlags(readMultiFlag(args, '--set'));
1923
- const runtimeSpec = buildRuntimeFromFlags(args, app.runtime);
2405
+ const unset = readMultiFlag(args, '--unset');
2406
+ const productionOnly = app.productionOnlyEnvKeys ?? [];
2407
+ for (const key of unset) {
2408
+ if (Object.hasOwn(envVars, key))
2409
+ throw usageError(`--set and --unset both name ${key}`, `inquir apps update <app> --unset ${key}`);
2410
+ // A production-only marker without a value is still worth clearing: it
2411
+ // would refuse the same key from inquir.yaml (docs/MANIFEST_ENV.md).
2412
+ if (!Object.hasOwn(app.envVars ?? {}, key) && !productionOnly.includes(key)) {
2413
+ throw usageError(`${app.name} has no variable ${key}`, `inquir apps update ${app.name} --unset <KEY>`);
2414
+ }
2415
+ }
2416
+ const unsetVariables = unset.filter((key) => Object.hasOwn(app.envVars ?? {}, key));
2417
+ const envChanged = Object.keys(envVars).length > 0 || unsetVariables.length > 0;
2418
+ // --unset removes the production-only marker with the variable.
2419
+ const unsetMarkers = unset.filter((key) => productionOnly.includes(key));
2420
+ const flagsRuntime = buildRuntimeFromFlags(args, app.runtime);
2421
+ const runtimeSpec = applyNodeSelectorFlags(args, flagsRuntime ?? app.runtime) ?? flagsRuntime;
1924
2422
  const description = readFlagValue(args, '--description');
1925
2423
  const networkPolicy = buildNetworkPolicyPatch({
1926
2424
  egress: readFlagValue(args, '--egress'),
@@ -1928,13 +2426,22 @@ async function updateSubcommand(args, positionals, apiOptions) {
1928
2426
  current: app.networkPolicy,
1929
2427
  });
1930
2428
  const volumeRunner = readFlagValue(args, '--volume-runner');
2429
+ const location = parseLocationFlag(readFlagValue(args, '--location'));
1931
2430
  const patch = {
1932
2431
  ...(description !== undefined ? { description } : {}),
1933
2432
  ...(volumeRunner ? { volumeRunner } : {}),
2433
+ ...(location !== undefined ? { location } : {}),
1934
2434
  ...(runtimeSpec ? { runtime: runtimeSpec } : {}),
1935
2435
  // Only the keys given here change; the server merges masked values, so
1936
2436
  // untouched secrets survive.
1937
- ...(Object.keys(envVars).length > 0 ? { envVars: { ...(app.envVars ?? {}), ...envVars } } : {}),
2437
+ // The map replaces the stored set, so a key left out is removed (--unset).
2438
+ ...(envChanged ? {
2439
+ envVars: {
2440
+ ...Object.fromEntries(Object.entries(app.envVars ?? {}).filter(([key]) => !unset.includes(key))),
2441
+ ...envVars,
2442
+ },
2443
+ } : {}),
2444
+ ...(unsetMarkers.length > 0 ? { productionOnlyEnvKeys: productionOnly.filter((key) => !unsetMarkers.includes(key)) } : {}),
1938
2445
  ...(networkPolicy ? { networkPolicy } : {}),
1939
2446
  };
1940
2447
  if (Object.keys(patch).length === 0) {
@@ -1946,7 +2453,43 @@ async function updateSubcommand(args, positionals, apiOptions) {
1946
2453
  return;
1947
2454
  }
1948
2455
  printSuccess(`Updated ${updated.name}`);
1949
- printInfo(dim(' changes apply to the next release: inquir apps deploy ' + updated.name));
2456
+ // Advisory: e.g. a node selector no server matches right now.
2457
+ for (const warning of updated.warnings ?? [])
2458
+ printInfo(` warning: ${warning}`);
2459
+ const ingress = readFlagValue(args, '--ingress');
2460
+ for (const line of updateTimingLines(updated.name, {
2461
+ hasProduction: Boolean(updated.productionReleaseId ?? app.productionReleaseId),
2462
+ ingress: ingress === 'public' || ingress === 'private' ? ingress : undefined,
2463
+ nextRelease: envChanged || unsetMarkers.length > 0 || runtimeSpec !== undefined
2464
+ || readFlagValue(args, '--egress') !== undefined || volumeRunner !== undefined
2465
+ || location !== undefined,
2466
+ })) {
2467
+ printInfo(dim(line));
2468
+ }
2469
+ }
2470
+ /**
2471
+ * When an `apps update` takes effect. Public Networking is applied by the
2472
+ * server at once (the generated route is created or removed in the same
2473
+ * request); env, runtime, egress, placement and location are what the next
2474
+ * release is built from.
2475
+ */
2476
+ export function updateTimingLines(appName, changed) {
2477
+ const lines = [];
2478
+ const hasProduction = changed.hasProduction !== false;
2479
+ if (changed.ingress === 'public') {
2480
+ lines.push(hasProduction
2481
+ ? ` public networking: applied now — the generated domain serves production (inquir apps status ${appName})`
2482
+ : ' public networking: on — the generated domain serves production once a release is promoted');
2483
+ }
2484
+ else if (changed.ingress === 'private') {
2485
+ lines.push(' public networking: removed now — the generated domain no longer serves it; the private address stays');
2486
+ }
2487
+ if (changed.nextRelease) {
2488
+ // redeploy re-releases production and refuses without one.
2489
+ const next = hasProduction ? `inquir apps redeploy ${appName}` : `inquir apps deploy ${appName}`;
2490
+ lines.push(` ${changed.ingress ? 'the other changes apply' : 'changes apply'} to the next release: ${next}`);
2491
+ }
2492
+ return lines;
1950
2493
  }
1951
2494
  async function execSubcommand(args, positionals, apiOptions) {
1952
2495
  const [identifier, ...commandParts] = positionals;
@@ -1956,14 +2499,15 @@ async function execSubcommand(args, positionals, apiOptions) {
1956
2499
  if (!command)
1957
2500
  throw usageError('Missing command', 'inquir apps exec <app> -- ls -la');
1958
2501
  await requireAuth(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);
2502
+ const application = await resolveExecApplication(identifier, apiOptions, readJsonFlag(args));
1963
2503
  const timeoutRaw = readFlagValue(args, '--timeout');
2504
+ const replica = readFlagValue(args.slice(0, args.includes('--') ? args.indexOf('--') : args.length), '--replica');
2505
+ if (hasFlag(args.slice(0, args.includes('--') ? args.indexOf('--') : args.length), '--replica') && !replica?.trim()) {
2506
+ throw usageError('Missing --replica value', 'inquir apps exec <app> --replica <name|index> -- <command>');
2507
+ }
1964
2508
  let result;
1965
2509
  try {
1966
- result = await execInApplication(application.id, command, timeoutRaw ? Number(timeoutRaw) : undefined, apiOptions);
2510
+ result = await execInApplication(application.id, command, timeoutRaw ? Number(timeoutRaw) : undefined, apiOptions, replica?.trim() ? { replica: replica.trim() } : {});
1967
2511
  }
1968
2512
  catch (err) {
1969
2513
  if (err instanceof Error)
@@ -1974,6 +2518,9 @@ async function execSubcommand(args, positionals, apiOptions) {
1974
2518
  printLine(JSON.stringify(result, null, 2));
1975
2519
  }
1976
2520
  else {
2521
+ if (replica && result.replicaIndex !== undefined) {
2522
+ printInfo(dim(`— replica ${result.replicaIndex}${result.allocationId ? ` (${result.allocationId.slice(0, 8)})` : ''} —`));
2523
+ }
1977
2524
  if (result.stdout)
1978
2525
  process.stdout.write(result.stdout);
1979
2526
  if (result.stderr)
@@ -1984,8 +2531,62 @@ async function execSubcommand(args, positionals, apiOptions) {
1984
2531
  if (result.exitCode !== 0)
1985
2532
  process.exitCode = ExitCode.ERROR;
1986
2533
  }
2534
+ /**
2535
+ * `apps exec` runs in production through the application-scoped endpoint. An
2536
+ * application name or id is the target; a release id is accepted only when it
2537
+ * is that application's production release. A stale or preview release id
2538
+ * would otherwise silently change the operator's target to production, so it
2539
+ * is refused with the command that does run.
2540
+ */
2541
+ async function resolveExecApplication(identifier, apiOptions, json) {
2542
+ let appError;
2543
+ try {
2544
+ return await resolveApplication(identifier, apiOptions);
2545
+ }
2546
+ catch (error) {
2547
+ appError = error;
2548
+ }
2549
+ const release = await getRelease(identifier, apiOptions).catch(() => null);
2550
+ if (!release)
2551
+ throw appError;
2552
+ const detail = await getApplication(release.applicationId, apiOptions);
2553
+ const production = detail.production?.activeReleaseId;
2554
+ if (production !== release.id) {
2555
+ throw new CliError(`Release ${release.id.slice(0, 8)} is not ${detail.name}'s production release`, {
2556
+ code: 'EXEC_NOT_PRODUCTION',
2557
+ exitCode: ExitCode.USAGE,
2558
+ explanation: 'apps exec runs a command in the production container only.',
2559
+ fix: production
2560
+ ? `Run it in production (release ${production.slice(0, 8)}): inquir apps exec ${detail.name} -- <command>`
2561
+ : `${detail.name} has no production release yet; promote one first: inquir apps promote ${release.id.slice(0, 8)}`,
2562
+ });
2563
+ }
2564
+ const note = `— ${detail.name}, production release ${release.id.slice(0, 8)} —`;
2565
+ if (json)
2566
+ process.stderr.write(`${note}\n`);
2567
+ else
2568
+ printInfo(dim(note));
2569
+ return detail;
2570
+ }
2571
+ /** The warning a wildcard bind must be confirmed against (dashboard text, CLI words). */
2572
+ export function wildcardRiskWarning(hostname) {
2573
+ return [
2574
+ `${hostname} is a mask — not recommended.`,
2575
+ ' It sends every first-level subdomain to this application, including services you may not',
2576
+ ' know about (APIs, webhooks, integrations), and those stop working. Requests meant for them',
2577
+ ' start arriving at the application with their cookies and tokens. Each subdomain gets its own',
2578
+ " certificate on its first request, and only a limited number of new ones per week (the Let's",
2579
+ ' Encrypt limit is shared by the whole domain); anyone can use that allowance up by requesting',
2580
+ ' random names, and new subdomains then get no certificate for up to a week.',
2581
+ ' Prefer binding the subdomains you need one by one; a mask suits',
2582
+ ' an application that serves many hosts itself (for example, a subdomain per customer).',
2583
+ ];
2584
+ }
1987
2585
  function printVerificationInstructions(domain) {
1988
2586
  const v = domain.verification;
2587
+ if (domain.certificates) {
2588
+ printInfo(dim(` ${domain.hostname} — ${domain.certificates.issuedThisWeek} of ${domain.certificates.weeklyLimit} new certificates this week`));
2589
+ }
1989
2590
  if (!v)
1990
2591
  return;
1991
2592
  // A verified hostname keeps its records on display: they are what gets compared with the
@@ -2059,7 +2660,20 @@ async function domainSubcommand(args, positionals, apiOptions) {
2059
2660
  }
2060
2661
  return;
2061
2662
  }
2062
- const domain = await addApplicationDomain(app.id, hostname, apiOptions);
2663
+ const wildcard = hostname.trim().startsWith('*.');
2664
+ if (wildcard) {
2665
+ // Not recommended, so never by accident: the API refuses it unless the
2666
+ // request says the risks were confirmed, which only happens here.
2667
+ for (const line of wildcardRiskWarning(hostname.trim()))
2668
+ printWarning(line);
2669
+ if (!runtime().yes) {
2670
+ const approved = await confirm(`Bind every subdomain matched by ${hostname.trim()} to ${app.name}?`, false);
2671
+ if (!approved) {
2672
+ throw usageError(`Refusing to bind the mask ${hostname.trim()} without confirmation`, `inquir apps domain ${app.name} '${hostname.trim()}' --yes`);
2673
+ }
2674
+ }
2675
+ }
2676
+ const domain = await addApplicationDomain(app.id, hostname, apiOptions, wildcard ? { acknowledgeWildcardRisks: true } : {});
2063
2677
  if (readJsonFlag(args)) {
2064
2678
  printLine(JSON.stringify(domain, null, 2));
2065
2679
  return;