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