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