@inquir/compute-cli 0.1.3 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/README.md +64 -5
  2. package/dist/commands/apps.d.ts +21 -0
  3. package/dist/commands/apps.d.ts.map +1 -0
  4. package/dist/commands/apps.js +857 -0
  5. package/dist/commands/apps.js.map +1 -0
  6. package/dist/commands/deploy.d.ts.map +1 -1
  7. package/dist/commands/deploy.js +207 -93
  8. package/dist/commands/deploy.js.map +1 -1
  9. package/dist/commands/init.d.ts.map +1 -1
  10. package/dist/commands/init.js +4 -2
  11. package/dist/commands/init.js.map +1 -1
  12. package/dist/commands/mcp.d.ts +15 -0
  13. package/dist/commands/mcp.d.ts.map +1 -0
  14. package/dist/commands/mcp.js +269 -0
  15. package/dist/commands/mcp.js.map +1 -0
  16. package/dist/index.js +11 -1
  17. package/dist/index.js.map +1 -1
  18. package/dist/utils/api.d.ts +52 -2
  19. package/dist/utils/api.d.ts.map +1 -1
  20. package/dist/utils/api.js +95 -2
  21. package/dist/utils/api.js.map +1 -1
  22. package/dist/utils/apps-presentation.d.ts +25 -0
  23. package/dist/utils/apps-presentation.d.ts.map +1 -0
  24. package/dist/utils/apps-presentation.js +71 -0
  25. package/dist/utils/apps-presentation.js.map +1 -0
  26. package/dist/utils/auth.d.ts.map +1 -1
  27. package/dist/utils/auth.js +13 -5
  28. package/dist/utils/auth.js.map +1 -1
  29. package/dist/utils/context.d.ts.map +1 -1
  30. package/dist/utils/context.js +3 -1
  31. package/dist/utils/context.js.map +1 -1
  32. package/dist/utils/detect.d.ts +45 -0
  33. package/dist/utils/detect.d.ts.map +1 -0
  34. package/dist/utils/detect.js +358 -0
  35. package/dist/utils/detect.js.map +1 -0
  36. package/dist/utils/ensure-auth.d.ts.map +1 -1
  37. package/dist/utils/ensure-auth.js +7 -1
  38. package/dist/utils/ensure-auth.js.map +1 -1
  39. package/package.json +5 -4
  40. package/skill/SKILL.md +60 -0
@@ -0,0 +1,857 @@
1
+ import path from 'node:path';
2
+ import { hasFlag, readFlagValue, readJsonFlag, readMultiFlag, readPositionalArgs } from '../utils/args.js';
3
+ import { addApplicationDomain, archiveApplication, cancelBuild, createApplication, getBuild, listApplicationDomains, listBuilds, queueBuild, removeApplicationDomain, createRelease, execInRelease, getApplication, getRelease, listApplications, promoteRelease, requireAuth, resolveApplication, rollbackApplication, stopRelease, streamBuildLogs, streamReleaseLogs, updateApplication, verifyApplicationDomain, } from '../utils/api.js';
4
+ import { createZipBase64, isSettledReleaseStatus, isTerminalBuildStatus } from '../sdk.js';
5
+ import { formatElapsed, humanizeApiError, presentRelease } from '../utils/apps-presentation.js';
6
+ import { buildApiOptions, runtime } from '../utils/context.js';
7
+ import { CliError, ExitCode, usageError } from '../utils/errors.js';
8
+ import { redact } from '../utils/redact.js';
9
+ import { dim, formatTable, printCommandHelp, printInfo, printLine, printSuccess } from '../utils/output.js';
10
+ /** Value flags across every subcommand — readPositionalArgs must know them all. */
11
+ const VALUE_FLAGS = [
12
+ '--image',
13
+ '--port',
14
+ '--tcp',
15
+ '--env',
16
+ '--set',
17
+ '--memory',
18
+ '--cpu',
19
+ '--health-path',
20
+ '--health-command',
21
+ '--description',
22
+ '--tail',
23
+ '--timeout',
24
+ '--limit',
25
+ '--dir',
26
+ '--dockerfile',
27
+ '--context',
28
+ '--target',
29
+ '--build-arg',
30
+ '--build',
31
+ '--egress',
32
+ ];
33
+ export async function appsCommand(args) {
34
+ const [subcommand, ...rest] = readPositionalArgs(args, { valueFlags: VALUE_FLAGS });
35
+ if (!subcommand || hasFlag(args, '--help') || hasFlag(args, '-h')) {
36
+ printAppsHelp();
37
+ if (!subcommand && !hasFlag(args, '--help') && !hasFlag(args, '-h')) {
38
+ throw usageError('Missing apps subcommand', 'inquir apps list');
39
+ }
40
+ return;
41
+ }
42
+ const apiOptions = buildApiOptions(args);
43
+ switch (subcommand) {
44
+ case 'list':
45
+ return listAppsSubcommand(args, apiOptions);
46
+ case 'create':
47
+ return createAppSubcommand(args, rest, apiOptions);
48
+ case 'deploy':
49
+ return deploySubcommand(args, rest, apiOptions);
50
+ case 'status':
51
+ return statusSubcommand(args, rest, apiOptions);
52
+ case 'logs':
53
+ return logsSubcommand(args, rest, apiOptions);
54
+ case 'promote':
55
+ return promoteSubcommand(args, rest, apiOptions);
56
+ case 'rollback':
57
+ return rollbackSubcommand(args, rest, apiOptions);
58
+ case 'stop':
59
+ return stopSubcommand(args, rest, apiOptions);
60
+ case 'exec':
61
+ return execSubcommand(args, rest, apiOptions);
62
+ case 'build':
63
+ return buildSubcommand(args, rest, apiOptions);
64
+ case 'builds':
65
+ return buildsSubcommand(args, rest, apiOptions);
66
+ case 'update':
67
+ return updateSubcommand(args, rest, apiOptions);
68
+ case 'domain':
69
+ case 'domains':
70
+ return domainSubcommand(args, rest, apiOptions);
71
+ case 'delete':
72
+ return deleteSubcommand(args, rest, apiOptions);
73
+ default:
74
+ throw usageError(`Unknown apps subcommand: ${subcommand}`, 'inquir apps list');
75
+ }
76
+ }
77
+ function printAppsHelp() {
78
+ printCommandHelp('apps', 'apps <subcommand> [options]', [
79
+ 'list List applications',
80
+ 'create <name> Create an application',
81
+ 'deploy <app> [--image <ref>] Release from an image, or build the current directory',
82
+ 'build <app> [--dir <path>] Build an image from source (Dockerfile) without releasing',
83
+ 'builds <app> List image builds',
84
+ 'status <app> Endpoints and recent releases',
85
+ 'logs <app|release|build> Stream container (or build) logs',
86
+ 'promote <release> Send production traffic to a release',
87
+ 'rollback <app> Return production to the previous release',
88
+ 'stop <release> Stop a release that is not serving production',
89
+ 'exec <release> -- <cmd> Run a command inside the container',
90
+ 'update <app> Change env vars, ports, resources, egress',
91
+ 'domain <app> [hostname] Bind a hostname (or list them); --verify / --remove',
92
+ 'delete <app> Archive an application (stops every release)',
93
+ '',
94
+ 'Create/deploy flags:',
95
+ '--image <ref> Container image, e.g. nginx:alpine (omit to build from source)',
96
+ '--dir <path> Source directory to build (default: current directory)',
97
+ '--dockerfile <path> Dockerfile relative to the context (default: Dockerfile)',
98
+ '--context <path> Build context inside the source tree (default: .)',
99
+ '--build-arg KEY=VALUE Docker build argument (repeatable)',
100
+ '--build <buildId> Release an existing successful build',
101
+ '--port <n> HTTP port (omit for a TCP-only service)',
102
+ '--tcp <name:port[:public][:hostPort]> Extra TCP port (repeatable)',
103
+ '--set KEY=VALUE Environment variable (repeatable)',
104
+ '--memory <mb> Memory ceiling (default 512)',
105
+ '--cpu <vcpus> CPU ceiling (default 0.5)',
106
+ '--egress <none|full> Outbound network policy',
107
+ '--health-path </path> HTTP health check path',
108
+ '--health-command <cmd> Health command inside the container (databases)',
109
+ '--no-wait Return as soon as the release is queued',
110
+ '--json Machine-readable JSON output',
111
+ ], [
112
+ 'inquir apps create web --port 3000',
113
+ 'inquir apps deploy web # builds ./Dockerfile, then releases it',
114
+ 'inquir apps deploy web --image nginx:alpine',
115
+ 'inquir apps deploy cache --image redis:7-alpine --tcp redis:6379:public:26379 \\',
116
+ ' --health-command "redis-cli ping"',
117
+ 'inquir apps logs web --tail 100',
118
+ 'inquir apps promote <releaseId>',
119
+ 'inquir apps domain web shop.example.com && inquir apps domain web shop.example.com --verify',
120
+ ]);
121
+ }
122
+ /**
123
+ * `--tcp name:port[:public][:hostPort]` — the shorthand people actually want
124
+ * to type for a database or SSH endpoint.
125
+ */
126
+ export function parseTcpPortFlag(value) {
127
+ const parts = value.split(':');
128
+ const [name, portRaw, ...flags] = parts;
129
+ const port = Number(portRaw);
130
+ if (!name || !Number.isInteger(port) || port < 1 || port > 65535) {
131
+ throw usageError(`Invalid --tcp value '${value}'`, 'inquir apps deploy <app> --tcp postgres:5432:public:25432');
132
+ }
133
+ const spec = { name, port, protocol: 'tcp' };
134
+ for (const flag of flags) {
135
+ if (flag === 'public') {
136
+ spec.public = true;
137
+ continue;
138
+ }
139
+ const hostPort = Number(flag);
140
+ if (Number.isInteger(hostPort) && hostPort > 0) {
141
+ spec.hostPort = hostPort;
142
+ // Pinning a host port is only meaningful for a port clients can reach.
143
+ spec.public = true;
144
+ continue;
145
+ }
146
+ throw usageError(`Invalid --tcp modifier '${flag}' in '${value}'`, 'inquir apps deploy <app> --tcp postgres:5432:public:25432');
147
+ }
148
+ return spec;
149
+ }
150
+ export function parseSetFlags(values) {
151
+ const env = {};
152
+ for (const entry of values) {
153
+ const index = entry.indexOf('=');
154
+ if (index <= 0) {
155
+ throw usageError(`Invalid --set value '${entry}'`, 'inquir apps deploy <app> --set KEY=VALUE');
156
+ }
157
+ env[entry.slice(0, index)] = entry.slice(index + 1);
158
+ }
159
+ return env;
160
+ }
161
+ /** Runtime spec assembled from flags; undefined when nothing was specified. */
162
+ export function buildRuntimeFromFlags(args) {
163
+ const portRaw = readFlagValue(args, '--port');
164
+ const tcpValues = readMultiFlag(args, '--tcp');
165
+ const memory = readFlagValue(args, '--memory');
166
+ const cpu = readFlagValue(args, '--cpu');
167
+ const healthPath = readFlagValue(args, '--health-path');
168
+ const healthCommand = readFlagValue(args, '--health-command');
169
+ const ports = tcpValues.map(parseTcpPortFlag);
170
+ const port = portRaw ? Number(portRaw) : undefined;
171
+ if (portRaw && (!Number.isInteger(port) || port < 1 || port > 65535)) {
172
+ throw usageError(`Invalid --port value '${portRaw}'`, 'inquir apps create <name> --port 3000');
173
+ }
174
+ if (port === undefined && ports.length === 0 && !memory && !cpu && !healthPath && !healthCommand) {
175
+ return undefined;
176
+ }
177
+ const spec = {
178
+ ...(port !== undefined ? { port } : {}),
179
+ ...(ports.length > 0 ? { ports } : {}),
180
+ resources: { cpuVCpus: cpu ? Number(cpu) : 0.5, memoryMb: memory ? Number(memory) : 512 },
181
+ };
182
+ if (healthCommand) {
183
+ spec.healthcheck = {
184
+ type: 'command',
185
+ command: healthCommand,
186
+ intervalSeconds: 30,
187
+ timeoutSeconds: 5,
188
+ };
189
+ }
190
+ else if (port !== undefined) {
191
+ spec.healthcheck = {
192
+ type: 'http',
193
+ path: healthPath || '/',
194
+ intervalSeconds: 30,
195
+ timeoutSeconds: 3,
196
+ };
197
+ }
198
+ return spec;
199
+ }
200
+ async function listAppsSubcommand(args, apiOptions) {
201
+ await requireAuth(apiOptions);
202
+ const result = await listApplications({ limit: 100 }, apiOptions);
203
+ if (readJsonFlag(args)) {
204
+ printLine(JSON.stringify(result, null, 2));
205
+ return;
206
+ }
207
+ if (result.items.length === 0) {
208
+ printLine('No applications yet. Create one with: inquir apps create <name> --port 3000');
209
+ return;
210
+ }
211
+ // Health at a glance: what production is doing and where it answers.
212
+ printLine(formatTable(result.items.map((app) => ({
213
+ name: app.name,
214
+ production: app.production
215
+ ? presentRelease({ status: app.production.status, kind: 'production', failureReason: app.production.failureReason }).label
216
+ : 'nothing promoted',
217
+ url: app.production?.url ?? (app.production ? '(tcp only)' : ''),
218
+ ports: describeRuntimePorts(app.runtime) || '(per release)',
219
+ egress: app.networkPolicy?.egress ?? 'full',
220
+ id: app.id,
221
+ })), ['name', 'production', 'url', 'ports', 'egress', 'id']));
222
+ }
223
+ async function createAppSubcommand(args, positionals, apiOptions) {
224
+ const [name] = positionals;
225
+ if (!name)
226
+ throw usageError('Missing application name', 'inquir apps create <name> --port 3000');
227
+ await requireAuth(apiOptions);
228
+ const envVars = parseSetFlags(readMultiFlag(args, '--set'));
229
+ const app = await createApplication({
230
+ name,
231
+ description: readFlagValue(args, '--description'),
232
+ runtime: buildRuntimeFromFlags(args),
233
+ ...(Object.keys(envVars).length > 0 ? { envVars } : {}),
234
+ }, apiOptions);
235
+ if (readJsonFlag(args)) {
236
+ printLine(JSON.stringify(app, null, 2));
237
+ return;
238
+ }
239
+ printSuccess(`Created application ${app.name}`);
240
+ printInfo(dim(` id: ${app.id}`));
241
+ printInfo(dim(` deploy it: inquir apps deploy ${app.name} (builds the Dockerfile in the current directory)`));
242
+ printInfo(dim(` or: inquir apps deploy ${app.name} --image <ref>`));
243
+ }
244
+ async function deploySubcommand(args, positionals, apiOptions) {
245
+ const [identifier] = positionals;
246
+ const image = readFlagValue(args, '--image');
247
+ const existingBuild = readFlagValue(args, '--build');
248
+ if (!identifier) {
249
+ throw usageError('Missing application', 'inquir apps deploy <app> [--image nginx:alpine]');
250
+ }
251
+ if (image && existingBuild) {
252
+ throw usageError('Use either --image or --build, not both', 'inquir apps deploy <app> --image nginx:alpine');
253
+ }
254
+ await requireAuth(apiOptions);
255
+ const app = await resolveApplication(identifier, apiOptions);
256
+ const runtimeSpec = buildRuntimeFromFlags(args);
257
+ const json = readJsonFlag(args);
258
+ // No image and no build id: build the source directory first, then release
259
+ // the produced image. That is the Railway-shaped default — a Dockerfile in
260
+ // the current directory is all a project needs.
261
+ let buildId = existingBuild;
262
+ if (!image && !buildId) {
263
+ const build = await runSourceBuild(app.id, app.name, args, apiOptions);
264
+ if (build.status !== 'success') {
265
+ throw new CliError(`Build ${build.id.slice(0, 8)} ${build.status}: ${build.error ?? 'see logs'}`, {
266
+ code: 'BUILD_FAILED',
267
+ exitCode: ExitCode.ERROR,
268
+ fix: `Inspect it: inquir apps logs ${build.id}`,
269
+ });
270
+ }
271
+ buildId = build.id;
272
+ }
273
+ const queued = await createRelease(app.id, {
274
+ ...(buildId ? { buildId } : { imageRef: image }),
275
+ ...(runtimeSpec ? { runtime: runtimeSpec } : {}),
276
+ }, apiOptions);
277
+ if (!json) {
278
+ printInfo(`Release ${queued.releaseId.slice(0, 8)} queued for ${app.name} (${image ?? `build ${buildId.slice(0, 8)}`})`);
279
+ }
280
+ if (hasFlag(args, '--no-wait')) {
281
+ if (json)
282
+ printLine(JSON.stringify(queued, null, 2));
283
+ else
284
+ printInfo(dim(` status: inquir apps status ${app.name}`));
285
+ return;
286
+ }
287
+ const release = await waitForRelease(queued.releaseId, apiOptions);
288
+ if (json) {
289
+ printLine(JSON.stringify(release, null, 2));
290
+ }
291
+ else {
292
+ renderRelease(release, queued.previewUrl);
293
+ }
294
+ // A release that never became healthy is a failed command, not a note.
295
+ if (release.status === 'FAILED')
296
+ process.exitCode = ExitCode.ERROR;
297
+ }
298
+ /**
299
+ * Zip a source directory, queue a build for it and follow the build log until
300
+ * it settles. Returns the final build row.
301
+ */
302
+ async function runSourceBuild(applicationId, appName, args, apiOptions) {
303
+ const dir = path.resolve(readFlagValue(args, '--dir') ?? process.cwd());
304
+ const json = readJsonFlag(args);
305
+ const buildArgs = parseSetFlags(readMultiFlag(args, '--build-arg'));
306
+ if (!json)
307
+ printInfo(dim(`— packing ${dir} —`));
308
+ const sourceZipBase64 = await createZipBase64(dir);
309
+ const queued = await queueBuild(applicationId, {
310
+ sourceZipBase64,
311
+ source: 'cli',
312
+ force: hasFlag(args, '--force'),
313
+ build: {
314
+ ...(readFlagValue(args, '--dockerfile') ? { dockerfile: readFlagValue(args, '--dockerfile') } : {}),
315
+ ...(readFlagValue(args, '--context') ? { context: readFlagValue(args, '--context') } : {}),
316
+ ...(readFlagValue(args, '--target') ? { target: readFlagValue(args, '--target') } : {}),
317
+ ...(Object.keys(buildArgs).length > 0 ? { args: buildArgs } : {}),
318
+ },
319
+ }, apiOptions);
320
+ if (!json) {
321
+ printInfo(queued.deduplicated
322
+ ? `Build ${queued.buildId.slice(0, 8)} reused (identical source already built)`
323
+ : `Build ${queued.buildId.slice(0, 8)} queued for ${appName}`);
324
+ }
325
+ if (queued.deduplicated || isTerminalBuildStatus(queued.status)) {
326
+ return getBuild(queued.buildId, apiOptions);
327
+ }
328
+ // Follow the build log to the terminal event; the SSE stream ends on completion.
329
+ for await (const event of streamBuildLogs(queued.buildId, apiOptions)) {
330
+ if (event.type === 'log' && event.line) {
331
+ if (json)
332
+ process.stderr.write(`${redact(event.line)}\n`);
333
+ else
334
+ printLine(dim(` ${event.line}`));
335
+ }
336
+ else if (event.type === 'complete') {
337
+ break;
338
+ }
339
+ else if (event.type === 'error') {
340
+ printLine(dim(`— ${event.message ?? 'stream error'} —`));
341
+ break;
342
+ }
343
+ }
344
+ return waitForBuild(queued.buildId, apiOptions);
345
+ }
346
+ async function waitForBuild(buildId, apiOptions, timeoutMs = 20 * 60_000) {
347
+ const deadline = Date.now() + timeoutMs;
348
+ for (;;) {
349
+ const build = await getBuild(buildId, apiOptions);
350
+ if (isTerminalBuildStatus(build.status))
351
+ return build;
352
+ if (Date.now() > deadline) {
353
+ throw new CliError(`Build ${buildId} did not finish within ${Math.round(timeoutMs / 1000)}s`, {
354
+ code: 'BUILD_TIMEOUT',
355
+ exitCode: ExitCode.ERROR,
356
+ fix: `Check its logs: inquir apps logs ${buildId}`,
357
+ });
358
+ }
359
+ await new Promise((resolve) => setTimeout(resolve, 1000));
360
+ }
361
+ }
362
+ /** Poll until the release settles; the platform decides when it is healthy. */
363
+ async function waitForRelease(releaseId, apiOptions, timeoutMs = 10 * 60_000) {
364
+ const deadline = Date.now() + timeoutMs;
365
+ const startedAt = Date.now();
366
+ let lastStatus = '';
367
+ const json = readJsonFlag(process.argv);
368
+ for (;;) {
369
+ const release = await getRelease(releaseId, apiOptions);
370
+ if (release.status !== lastStatus) {
371
+ lastStatus = release.status;
372
+ // Say what is happening in words, with elapsed time — a two-minute health
373
+ // gate in silence reads as a hang. Under --json only stdout must stay
374
+ // parseable; progress goes to stderr.
375
+ const shown = presentRelease(release);
376
+ const line = `— ${shown.label} (${formatElapsed(Date.now() - startedAt)})${shown.meaning ? `: ${shown.meaning}` : ''} —`;
377
+ if (json)
378
+ process.stderr.write(`${line}\n`);
379
+ else
380
+ printInfo(dim(line));
381
+ }
382
+ if (isSettledReleaseStatus(release.status))
383
+ return release;
384
+ if (Date.now() > deadline) {
385
+ throw new CliError(`Release ${releaseId} did not settle within ${Math.round(timeoutMs / 1000)}s`, {
386
+ code: 'RELEASE_TIMEOUT',
387
+ exitCode: ExitCode.ERROR,
388
+ fix: `Check its logs: inquir apps logs ${releaseId}`,
389
+ });
390
+ }
391
+ await new Promise((resolve) => setTimeout(resolve, 1000));
392
+ }
393
+ }
394
+ function renderRelease(release, previewUrl) {
395
+ const shown = presentRelease(release);
396
+ if (release.status === 'FAILED') {
397
+ printLine(`✗ Release ${release.id.slice(0, 8)} failed: ${release.failureReason ?? 'unknown reason'}`);
398
+ if (shown.hint)
399
+ printInfo(dim(` ${shown.hint}`));
400
+ printInfo(dim(` logs: inquir apps logs ${release.id}`));
401
+ return;
402
+ }
403
+ printSuccess(`Release ${release.id.slice(0, 8)} is ${shown.label}`);
404
+ const endpoints = releaseEndpointLines(release, previewUrl);
405
+ if (endpoints.length === 0) {
406
+ printInfo(dim(' no reachable endpoint — the ports are internal to the host'));
407
+ }
408
+ else {
409
+ for (const line of endpoints)
410
+ printLine(` ${line}`);
411
+ }
412
+ if (release.kind !== 'production') {
413
+ printInfo(dim(` send production traffic to it: inquir apps promote ${release.id}`));
414
+ }
415
+ }
416
+ /**
417
+ * Where a client can actually connect. Public endpoints first; a host-local
418
+ * port is still printed (marked as such) because on an installation with no
419
+ * public hostname it is the only way in.
420
+ */
421
+ export function releaseEndpointLines(release, previewUrl, options = {}) {
422
+ const lines = [];
423
+ if (previewUrl)
424
+ lines.push(previewUrl);
425
+ for (const url of release.urls ?? []) {
426
+ if (!lines.includes(url.url))
427
+ lines.push(url.url);
428
+ }
429
+ for (const port of release.ports ?? []) {
430
+ if (port.protocol === 'tcp' && port.public) {
431
+ lines.push(`${port.endpoint ?? `localhost:${port.hostPort ?? '?'}`} (${port.name})`);
432
+ }
433
+ }
434
+ if (lines.length === 0 && options.includeHostLocal !== false) {
435
+ for (const port of release.ports ?? []) {
436
+ if (port.hostPort === undefined)
437
+ continue;
438
+ const address = port.protocol === 'http'
439
+ ? `http://localhost:${port.hostPort}`
440
+ : `localhost:${port.hostPort}`;
441
+ lines.push(`${address} (${port.name}, host-local)`);
442
+ }
443
+ }
444
+ return lines;
445
+ }
446
+ /** A build-sourced release is best named by its build; a pulled one by its ref. */
447
+ export function describeImage(release) {
448
+ if (release.imageSource === 'build' && release.buildId)
449
+ return `build ${release.buildId.slice(0, 8)}`;
450
+ return release.imageRef ?? release.imageDigest.slice(0, 19);
451
+ }
452
+ function describeRuntimePorts(spec) {
453
+ if (!spec)
454
+ return '';
455
+ const parts = [];
456
+ if (spec.port)
457
+ parts.push(`http:${spec.port}`);
458
+ for (const port of spec.ports ?? []) {
459
+ if (port.port === spec.port)
460
+ continue;
461
+ parts.push(`${port.protocol}:${port.port}${port.public ? '*' : ''}`);
462
+ }
463
+ return parts.join(' ');
464
+ }
465
+ async function statusSubcommand(args, positionals, apiOptions) {
466
+ const [identifier] = positionals;
467
+ if (!identifier)
468
+ throw usageError('Missing application', 'inquir apps status <app>');
469
+ await requireAuth(apiOptions);
470
+ const app = await resolveApplication(identifier, apiOptions);
471
+ const detail = await getApplication(app.id, apiOptions);
472
+ if (readJsonFlag(args)) {
473
+ printLine(JSON.stringify(detail, null, 2));
474
+ return;
475
+ }
476
+ printLine(`${detail.name}${detail.description ? dim(` — ${detail.description}`) : ''}`);
477
+ for (const url of detail.production?.urls ?? [])
478
+ printLine(` production: ${url}`);
479
+ if (detail.production && (detail.production.urls ?? []).length === 0) {
480
+ printLine(` production: release ${detail.production.activeReleaseId?.slice(0, 8) ?? '?'} (no hostname)`);
481
+ }
482
+ const active = detail.releases.find((r) => r.id === detail.production?.activeReleaseId);
483
+ // With a production hostname the loopback port is noise; without one it is
484
+ // the only way in and must be shown.
485
+ const hasProductionUrl = (detail.production?.urls ?? []).length > 0;
486
+ const endpoints = active ? releaseEndpointLines(active, undefined, { includeHostLocal: !hasProductionUrl }) : [];
487
+ for (const endpoint of endpoints)
488
+ printLine(` endpoint: ${endpoint}`);
489
+ for (const domain of detail.domains ?? []) {
490
+ if (domain.custom && !domain.verified) {
491
+ printLine(` domain: ${domain.hostname} ${dim('(unverified — inquir apps domain ' + detail.name + ' ' + domain.hostname + ' --verify)')}`);
492
+ }
493
+ }
494
+ if (detail.releases.length === 0) {
495
+ printLine(' no releases yet');
496
+ return;
497
+ }
498
+ printLine('');
499
+ printLine(formatTable(detail.releases.slice(0, 10).map((release) => ({
500
+ release: release.id.slice(0, 8),
501
+ status: presentRelease(release).label,
502
+ kind: release.kind,
503
+ image: describeImage(release),
504
+ restarts: String(release.restartCount),
505
+ created: release.createdAt,
506
+ })), ['release', 'status', 'kind', 'image', 'restarts', 'created']));
507
+ }
508
+ /** Accepts a release id, or an app whose running release we pick. */
509
+ async function resolveReleaseId(identifier, apiOptions) {
510
+ try {
511
+ const release = await getRelease(identifier, apiOptions);
512
+ return release.id;
513
+ }
514
+ catch {
515
+ /* not a release id — fall through to the application lookup */
516
+ }
517
+ const app = await resolveApplication(identifier, apiOptions);
518
+ const detail = await getApplication(app.id, apiOptions);
519
+ const active = detail.releases.find((r) => r.id === detail.production?.activeReleaseId) ??
520
+ detail.releases.find((r) => r.status === 'ACTIVE' || r.status === 'READY') ??
521
+ detail.releases[0];
522
+ if (!active) {
523
+ throw new CliError(`Application ${app.name} has no releases`, {
524
+ code: 'NO_RELEASES',
525
+ exitCode: ExitCode.ERROR,
526
+ fix: `Deploy one: inquir apps deploy ${app.name} --image <image>`,
527
+ });
528
+ }
529
+ return active.id;
530
+ }
531
+ async function logsSubcommand(args, positionals, apiOptions) {
532
+ const [identifier] = positionals;
533
+ if (!identifier)
534
+ throw usageError('Missing application or release', 'inquir apps logs <app>');
535
+ await requireAuth(apiOptions);
536
+ const json = readJsonFlag(args);
537
+ // A build id streams the build log instead of container output.
538
+ const build = await getBuild(identifier, apiOptions).catch(() => null);
539
+ if (build) {
540
+ printInfo(dim(`— build ${build.id.slice(0, 8)} (${build.status}) —`));
541
+ for await (const event of streamBuildLogs(build.id, apiOptions)) {
542
+ if (event.type === 'log' && event.line) {
543
+ if (json)
544
+ process.stderr.write(`${redact(event.line)}\n`);
545
+ else
546
+ printLine(event.line);
547
+ }
548
+ else if (event.type === 'complete') {
549
+ printInfo(dim(`— build ${event.status ?? 'finished'} —`));
550
+ break;
551
+ }
552
+ else if (event.type === 'error') {
553
+ printLine(dim(`— ${event.message ?? 'stream error'} —`));
554
+ break;
555
+ }
556
+ }
557
+ return;
558
+ }
559
+ const releaseId = await resolveReleaseId(identifier, apiOptions);
560
+ const tailRaw = readFlagValue(args, '--tail');
561
+ const tail = tailRaw ? Number(tailRaw) : 200;
562
+ printInfo(dim(`— streaming logs for release ${releaseId.slice(0, 8)} (ctrl-c to stop) —`));
563
+ for await (const event of streamReleaseLogs(releaseId, { tail }, apiOptions)) {
564
+ if (event.type === 'log' && event.line) {
565
+ // Under --json the log tail goes to stderr so stdout stays parseable.
566
+ if (json)
567
+ process.stderr.write(`${redact(event.line)}\n`);
568
+ else
569
+ printLine(event.line);
570
+ }
571
+ else if (event.type === 'error') {
572
+ printLine(dim(`— ${event.message ?? 'stream error'} —`));
573
+ }
574
+ else if (event.type === 'complete') {
575
+ printInfo(dim('— stream ended —'));
576
+ break;
577
+ }
578
+ }
579
+ }
580
+ async function promoteSubcommand(args, positionals, apiOptions) {
581
+ const [identifier] = positionals;
582
+ if (!identifier)
583
+ throw usageError('Missing release', 'inquir apps promote <release>');
584
+ await requireAuth(apiOptions);
585
+ const releaseId = await resolveReleaseId(identifier, apiOptions);
586
+ const json = readJsonFlag(args);
587
+ // Say up front what promotion does to this release: a preview-profile
588
+ // release with production-only variables is restarted with them before
589
+ // traffic moves, and its preview URL retires.
590
+ if (!json) {
591
+ const release = await getRelease(releaseId, apiOptions).catch(() => null);
592
+ const app = release ? await getApplication(release.applicationId, apiOptions).catch(() => null) : null;
593
+ if (release && release.envProfile !== 'production' && (app?.productionOnlyEnvKeys?.length ?? 0) > 0) {
594
+ printInfo(dim(' the container restarts with production-only variables before traffic moves'));
595
+ }
596
+ if (release?.urls?.some((u) => u.kind === 'preview')) {
597
+ printInfo(dim(' its preview URL retires once it serves production'));
598
+ }
599
+ }
600
+ let result;
601
+ try {
602
+ result = await promoteRelease(releaseId, apiOptions);
603
+ }
604
+ catch (err) {
605
+ if (err instanceof Error)
606
+ err.message = humanizeApiError(err.message);
607
+ throw err;
608
+ }
609
+ if (json) {
610
+ printLine(JSON.stringify(result, null, 2));
611
+ return;
612
+ }
613
+ printSuccess(`Production now serves ${releaseId.slice(0, 8)}`);
614
+ for (const url of result.productionUrls ?? (result.productionUrl ? [result.productionUrl] : [])) {
615
+ printLine(` ${url}`);
616
+ }
617
+ if ((result.productionUrls ?? []).length === 0 && !result.productionUrl) {
618
+ printInfo(dim(' no hostname — reach it at its TCP endpoints (inquir apps status <app>)'));
619
+ }
620
+ if (result.previousReleaseId) {
621
+ printInfo(dim(` previous release ${result.previousReleaseId.slice(0, 8)} kept for rollback`));
622
+ }
623
+ }
624
+ async function rollbackSubcommand(args, positionals, apiOptions) {
625
+ const [identifier] = positionals;
626
+ if (!identifier)
627
+ throw usageError('Missing application', 'inquir apps rollback <app>');
628
+ await requireAuth(apiOptions);
629
+ const app = await resolveApplication(identifier, apiOptions);
630
+ const result = await rollbackApplication(app.id, apiOptions);
631
+ if (readJsonFlag(args)) {
632
+ printLine(JSON.stringify(result, null, 2));
633
+ return;
634
+ }
635
+ printSuccess(result.mode === 'instant'
636
+ ? `Rolled back to ${result.releaseId.slice(0, 8)} — traffic moved instantly (its container was still running)`
637
+ : `Restarting ${result.releaseId.slice(0, 8)} to roll back — traffic moves once it passes its health check`);
638
+ if (result.mode !== 'instant')
639
+ printInfo(dim(` watch it: inquir apps status ${app.name}`));
640
+ }
641
+ async function stopSubcommand(args, positionals, apiOptions) {
642
+ const [identifier] = positionals;
643
+ if (!identifier)
644
+ throw usageError('Missing release', 'inquir apps stop <release>');
645
+ await requireAuth(apiOptions);
646
+ const releaseId = await resolveReleaseId(identifier, apiOptions);
647
+ await stopRelease(releaseId, apiOptions);
648
+ printSuccess(`Stopped release ${releaseId.slice(0, 8)} — its container is gone and its URL no longer resolves`);
649
+ }
650
+ /** `apps build <app> [--dir] [--dockerfile] [--context] [--build-arg]` — build only, no release. */
651
+ async function buildSubcommand(args, positionals, apiOptions) {
652
+ const [identifier] = positionals;
653
+ if (!identifier)
654
+ throw usageError('Missing application', 'inquir apps build <app> --dir .');
655
+ await requireAuth(apiOptions);
656
+ const app = await resolveApplication(identifier, apiOptions);
657
+ const build = await runSourceBuild(app.id, app.name, args, apiOptions);
658
+ if (readJsonFlag(args)) {
659
+ printLine(JSON.stringify(build, null, 2));
660
+ }
661
+ else if (build.status === 'success') {
662
+ printSuccess(`Build ${build.id.slice(0, 8)} succeeded (${build.imageDigest?.slice(0, 19) ?? 'no digest'})`);
663
+ printInfo(dim(` release it: inquir apps deploy ${app.name} --build ${build.id}`));
664
+ }
665
+ else {
666
+ printLine(`✗ Build ${build.id.slice(0, 8)} ${build.status}: ${build.error ?? 'see logs'}`);
667
+ }
668
+ if (build.status !== 'success')
669
+ process.exitCode = ExitCode.ERROR;
670
+ }
671
+ /** `apps builds <app>` — build history; `--cancel <buildId>` drops a queued one. */
672
+ async function buildsSubcommand(args, positionals, apiOptions) {
673
+ const [identifier] = positionals;
674
+ if (!identifier)
675
+ throw usageError('Missing application', 'inquir apps builds <app>');
676
+ await requireAuth(apiOptions);
677
+ const cancelId = readFlagValue(args, '--cancel');
678
+ if (cancelId) {
679
+ await cancelBuild(cancelId, apiOptions);
680
+ printSuccess(`Cancelled build ${cancelId.slice(0, 8)}`);
681
+ return;
682
+ }
683
+ const app = await resolveApplication(identifier, apiOptions);
684
+ const { items } = await listBuilds(app.id, apiOptions);
685
+ if (readJsonFlag(args)) {
686
+ printLine(JSON.stringify(items, null, 2));
687
+ return;
688
+ }
689
+ if (items.length === 0) {
690
+ printLine(`No builds yet for ${app.name}. Build one: inquir apps build ${app.name}`);
691
+ return;
692
+ }
693
+ printLine(formatTable(items.map((b) => ({
694
+ build: b.id.slice(0, 8),
695
+ status: b.status,
696
+ dockerfile: b.dockerfile ?? 'Dockerfile',
697
+ image: b.imageDigest?.slice(0, 19) ?? '',
698
+ cached: b.reusedFromBuildId ? 'yes' : '',
699
+ created: b.createdAt,
700
+ })), ['build', 'status', 'dockerfile', 'image', 'cached', 'created']));
701
+ }
702
+ /** `apps update <app>` — env vars, ports, resources, health, egress. */
703
+ async function updateSubcommand(args, positionals, apiOptions) {
704
+ const [identifier] = positionals;
705
+ if (!identifier)
706
+ throw usageError('Missing application', 'inquir apps update <app> --set KEY=VALUE');
707
+ await requireAuth(apiOptions);
708
+ const app = await resolveApplication(identifier, apiOptions);
709
+ const envVars = parseSetFlags(readMultiFlag(args, '--set'));
710
+ const runtimeSpec = buildRuntimeFromFlags(args);
711
+ const egress = readFlagValue(args, '--egress');
712
+ const description = readFlagValue(args, '--description');
713
+ if (egress && egress !== 'none' && egress !== 'full') {
714
+ throw usageError(`Invalid --egress '${egress}'`, 'inquir apps update <app> --egress none');
715
+ }
716
+ const patch = {
717
+ ...(description !== undefined ? { description } : {}),
718
+ ...(runtimeSpec ? { runtime: runtimeSpec } : {}),
719
+ // Only the keys given here change; the server merges masked values, so
720
+ // untouched secrets survive.
721
+ ...(Object.keys(envVars).length > 0 ? { envVars: { ...(app.envVars ?? {}), ...envVars } } : {}),
722
+ ...(egress ? { networkPolicy: { egress: egress, allow: [] } } : {}),
723
+ };
724
+ if (Object.keys(patch).length === 0) {
725
+ throw usageError('Nothing to update', 'inquir apps update <app> --set KEY=VALUE');
726
+ }
727
+ const updated = await updateApplication(app.id, patch, apiOptions);
728
+ if (readJsonFlag(args)) {
729
+ printLine(JSON.stringify(updated, null, 2));
730
+ return;
731
+ }
732
+ printSuccess(`Updated ${updated.name}`);
733
+ printInfo(dim(' changes apply to the next release: inquir apps deploy ' + updated.name));
734
+ }
735
+ async function execSubcommand(args, positionals, apiOptions) {
736
+ const [identifier, ...commandParts] = positionals;
737
+ if (!identifier)
738
+ throw usageError('Missing release', 'inquir apps exec <app> -- <command>');
739
+ const command = commandParts.join(' ').trim();
740
+ if (!command)
741
+ throw usageError('Missing command', 'inquir apps exec <app> -- ls -la');
742
+ await requireAuth(apiOptions);
743
+ const releaseId = await resolveReleaseId(identifier, apiOptions);
744
+ const timeoutRaw = readFlagValue(args, '--timeout');
745
+ let result;
746
+ try {
747
+ result = await execInRelease(releaseId, command, timeoutRaw ? Number(timeoutRaw) : undefined, apiOptions);
748
+ }
749
+ catch (err) {
750
+ if (err instanceof Error)
751
+ err.message = humanizeApiError(err.message);
752
+ throw err;
753
+ }
754
+ if (readJsonFlag(args)) {
755
+ printLine(JSON.stringify(result, null, 2));
756
+ }
757
+ else {
758
+ if (result.stdout)
759
+ process.stdout.write(result.stdout);
760
+ if (result.stderr)
761
+ process.stderr.write(result.stderr);
762
+ if (result.truncated)
763
+ printInfo(dim('— output truncated —'));
764
+ }
765
+ if (result.exitCode !== 0)
766
+ process.exitCode = ExitCode.ERROR;
767
+ }
768
+ function printVerificationInstructions(domain) {
769
+ const v = domain.verification;
770
+ if (!v)
771
+ return;
772
+ printInfo(dim(' prove ownership, then verify:'));
773
+ printLine(` TXT ${v.txtName} → ${v.txtValue}`);
774
+ for (const target of v.cnameTargets)
775
+ printLine(` CNAME ${domain.hostname} → ${target}`);
776
+ for (const ip of v.ingressIps)
777
+ printLine(` A ${domain.hostname} → ${ip}`);
778
+ if (v.ingressAliasTarget)
779
+ printLine(` ALIAS ${domain.hostname} → ${v.ingressAliasTarget}`);
780
+ }
781
+ /**
782
+ * `apps domain <app> [hostname] [--verify|--remove]` — bind a custom hostname
783
+ * to an application. A new hostname starts unverified: publish the TXT record
784
+ * and point DNS at the platform, then run again with --verify.
785
+ */
786
+ async function domainSubcommand(args, positionals, apiOptions) {
787
+ const [identifier, hostname] = positionals;
788
+ if (!identifier)
789
+ throw usageError('Missing application', 'inquir apps domain <app> <hostname>');
790
+ await requireAuth(apiOptions);
791
+ const app = await resolveApplication(identifier, apiOptions);
792
+ if (!hostname) {
793
+ const { items } = await listApplicationDomains(app.id, apiOptions);
794
+ if (readJsonFlag(args)) {
795
+ printLine(JSON.stringify(items, null, 2));
796
+ return;
797
+ }
798
+ if (items.length === 0) {
799
+ printLine(`No hostnames bound to ${app.name}`);
800
+ printInfo(dim(` bind one: inquir apps domain ${app.name} app.example.com`));
801
+ return;
802
+ }
803
+ printLine(formatTable(items.map((d) => ({
804
+ hostname: d.hostname,
805
+ kind: d.kind,
806
+ source: d.custom ? 'custom' : 'generated',
807
+ verified: d.verified ? 'yes' : 'no',
808
+ })), ['hostname', 'kind', 'source', 'verified']));
809
+ for (const d of items)
810
+ if (!d.verified)
811
+ printVerificationInstructions(d);
812
+ return;
813
+ }
814
+ if (hasFlag(args, '--remove')) {
815
+ await removeApplicationDomain(app.id, hostname, apiOptions);
816
+ printSuccess(`Unbound ${hostname} from ${app.name}`);
817
+ return;
818
+ }
819
+ if (hasFlag(args, '--verify')) {
820
+ const verified = await verifyApplicationDomain(app.id, hostname, apiOptions);
821
+ if (readJsonFlag(args)) {
822
+ printLine(JSON.stringify(verified, null, 2));
823
+ return;
824
+ }
825
+ printSuccess(`${verified.hostname} verified — it now serves ${app.name}`);
826
+ if (!verified.activeReleaseId) {
827
+ printInfo(dim(' no release is promoted yet: inquir apps promote <release>'));
828
+ }
829
+ return;
830
+ }
831
+ const domain = await addApplicationDomain(app.id, hostname, apiOptions);
832
+ if (readJsonFlag(args)) {
833
+ printLine(JSON.stringify(domain, null, 2));
834
+ return;
835
+ }
836
+ printSuccess(`${domain.hostname} bound to ${app.name} (unverified)`);
837
+ printVerificationInstructions(domain);
838
+ printInfo(dim(` then: inquir apps domain ${app.name} ${domain.hostname} --verify`));
839
+ }
840
+ async function deleteSubcommand(args, positionals, apiOptions) {
841
+ const [identifier] = positionals;
842
+ if (!identifier)
843
+ throw usageError('Missing application', 'inquir apps delete <app>');
844
+ await requireAuth(apiOptions);
845
+ const app = await resolveApplication(identifier, apiOptions);
846
+ if (!runtime().yes) {
847
+ throw new CliError(`Refusing to archive ${app.name} without confirmation`, {
848
+ code: 'CONFIRMATION_REQUIRED',
849
+ exitCode: ExitCode.USAGE,
850
+ fix: `Re-run with --yes: inquir apps delete ${app.name} --yes`,
851
+ });
852
+ }
853
+ await archiveApplication(app.id, apiOptions);
854
+ printSuccess(`Archived ${app.name}`);
855
+ printInfo(dim(' every release was stopped and every hostname unbound'));
856
+ }
857
+ //# sourceMappingURL=apps.js.map