insta 0.0.30 → 0.0.32

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.
@@ -1,4 +1,4 @@
1
- import { ApiClient, requireProject } from '../api.js';
1
+ import { ApiClient, ApiError, requireProject } from '../api.js';
2
2
  import { info, printJson, handleApproval } from '../util.js';
3
3
  import { resolveComputeServiceId, q, parseVolumeGib } from './services.js';
4
4
  // Attach a developer-owned custom domain to a branch's compute service. Fly issues the cert + routes
@@ -116,7 +116,7 @@ export function parseCpu(raw) {
116
116
  throw new Error(`invalid cpu: ${raw} (provider sizes: ${CPU_SIZES.join(', ')})`);
117
117
  return n;
118
118
  }
119
- // ---- volume (the persistent /data disk; attach any time, grow-only, never detach) ----
119
+ // ---- volume (the persistent /data disk; attach any time, grow-only, deletable; never detach) ----
120
120
  // Render the volume read. Pure, exported for tests (mirrors serviceListLine). Every plan may view;
121
121
  // only growth is paid — that gate is the backend's to enforce, so nothing here pre-blocks.
122
122
  export function volumeLines(name, volume, cap) {
@@ -126,7 +126,7 @@ export function volumeLines(name, volume, cap) {
126
126
  ];
127
127
  return [
128
128
  `compute ${name}: volume ${volume.sizeGib}Gi at ${volume.mountPath} (plan max ${cap.volumeGib}Gi)`,
129
- ' billing is actual data stored — the size is a cap, not a price; grow with --size (grow-only)',
129
+ ' billing is actual data stored — the size is a cap, not a price; grow with --size (grow-only), delete with --delete (destroys the data)',
130
130
  ];
131
131
  }
132
132
  // Render the PUT result. Pure, exported for tests. `attached` comes from the backend and is what
@@ -138,16 +138,56 @@ export function volumeWriteLine(name, body) {
138
138
  }
139
139
  return `compute ${name}: volume grown to ${body.volume.sizeGib}Gi at ${body.volume.mountPath} (plan max ${body.cap.volumeGib}Gi)`;
140
140
  }
141
- // Show, attach, or grow a compute service's /data volume. No --size: a safe read (size + mount
142
- // path + the plan cap). --size: PUT .../volume — attaches when no volume exists, grows otherwise.
143
- // The paid/cap/machine-count gates all belong to the backend, whose 403/400 messages carry the
144
- // upgrade hints and must reach the user verbatim (the guard prints ApiError messages as-is).
141
+ // Render the DELETE result. Pure, exported for tests. Deleting is the only way off the volume
142
+ // path (there is no detach), so the line says what came back with it: the two constraints the
143
+ // volume imposed.
144
+ export function volumeDeleteLine(name) {
145
+ return `compute ${name}: volume deleted — the disk and its data are gone; suspend fast-wake and scale-out are back`;
146
+ }
147
+ // Map a DELETE .../volume failure. Pure, exported for tests (r2d2 review rounds 1+2: this is the
148
+ // close-call branch worth pinning). An older backend has no DELETE route, and what its 404 looks
149
+ // like depends on who answered: the real platform (Fastify, no custom notFound handler) sends its
150
+ // default body {"message":"Route DELETE:/… not found","error":"Not Found"} → ApiError message
151
+ // "Not Found"; a proxy or bodyless 404 leaves ApiError's own "HTTP 404" fallback. BOTH are the
152
+ // generic route-miss shape and mean version skew, not a bug — parroting them would send the user
153
+ // hunting the wrong thing. A backend that HAS the route names the real problem in a DOMAIN
154
+ // message ("this service has no volume", …), which must flow verbatim, 404 or not.
155
+ const GENERIC_404 = /^(HTTP 404|Not Found)$/i;
156
+ export function volumeDeleteError(e) {
157
+ if (e instanceof ApiError && e.status === 404 && GENERIC_404.test(e.message.trim())) {
158
+ return new Error('this backend does not support volume delete yet — update the platform, or delete the service to remove its volume');
159
+ }
160
+ return e;
161
+ }
162
+ // Show, attach, grow, or delete a compute service's /data volume. No flag: a safe read (size +
163
+ // mount path + the plan cap). --size: PUT .../volume — attaches when no volume exists, grows
164
+ // otherwise. --delete: DELETE .../volume — destroys the disk and its data immediately (no detach,
165
+ // no undo; billing stops now). The paid/cap/machine-count gates all belong to the backend, whose
166
+ // 403/400 messages carry the upgrade hints and must reach the user verbatim (the guard prints
167
+ // ApiError messages as-is).
145
168
  export async function computeVolume(serviceName, opts) {
169
+ if (opts.delete && opts.size)
170
+ throw new Error('--delete cannot be combined with --size (one changes the volume, the other destroys it)');
146
171
  const api = await ApiClient.load();
147
172
  const p = await requireProject();
148
173
  const branch = opts.branch ?? p.branch;
149
174
  const { services } = await api.request('GET', `/projects/${p.projectId}/services${q(branch)}`);
150
175
  const id = resolveComputeServiceId(services, serviceName);
176
+ if (opts.delete) {
177
+ let res;
178
+ try {
179
+ res = await api.rawRequest('DELETE', `/projects/${p.projectId}/services/${id}/volume`);
180
+ }
181
+ catch (e) {
182
+ throw volumeDeleteError(e);
183
+ }
184
+ if (handleApproval(res))
185
+ return;
186
+ if (opts.json)
187
+ return printJson(res.body);
188
+ info(volumeDeleteLine(res.body.service?.name ?? serviceName ?? id));
189
+ return;
190
+ }
151
191
  if (!opts.size) {
152
192
  const r = await api.request('GET', `/projects/${p.projectId}/services/${id}/volume`);
153
193
  if (opts.json)
@@ -4,8 +4,9 @@ import { parseVolumeGib } from './services.js';
4
4
  // Toggle a postgres service between scale-to-zero (the default: instance suspends when idle,
5
5
  // cold-starts on the next connection) and always-on (instance stays warm; idle RAM bills at
6
6
  // actual usage). Thin wrapper over PATCH /database/settings {scaleToZero} — insta-db-backed
7
- // postgres only; Neon-backed services manage their own autosuspend and the platform returns an
8
- // error for them.
7
+ // postgres only. Legacy Neon path: Neon is no longer used by any environment (postgres is 100%
8
+ // insta-db) and this code is retained, not live — Neon-backed services managed their own
9
+ // autosuspend and the platform returned an error for them.
9
10
  export async function dbAlwaysOn(mode, opts) {
10
11
  if (mode !== 'on' && mode !== 'off')
11
12
  throw new Error('mode must be on|off');
@@ -53,7 +54,8 @@ export async function fetchDbInstance(api, projectId, suffix) {
53
54
  }
54
55
  catch (e) {
55
56
  // The platform answers a provider-shaped 502 for services with no manageable instance
56
- // (Neon-backed): a soft case, not a failure. Everything else stays an error — an expired
57
+ // (the legacy Neon path — Neon is no longer used by any environment; this branch is retained,
58
+ // not live): a soft case, not a failure. Everything else stays an error — an expired
57
59
  // token must not render as "no ceiling set" — but wrapped so the user sees what failed.
58
60
  if (e instanceof ApiError && e.status === 502)
59
61
  return { kind: 'no-instance' };
@@ -78,7 +80,7 @@ export async function dbLimits(opts) {
78
80
  if (!opts.cpu && !opts.memory) {
79
81
  const read = await fetchDbInstance(api, p.projectId, suffix);
80
82
  if (read.kind === 'no-instance') {
81
- info(`postgres ${opts.group ?? 'default'}: no manageable instance (Neon-backed services manage their own resources)`);
83
+ info(`postgres ${opts.group ?? 'default'}: no manageable instance (this service manages its own resources)`);
82
84
  return;
83
85
  }
84
86
  if (opts.json)
@@ -161,9 +163,11 @@ export function dbStatsLines(group, body) {
161
163
  }
162
164
  // Point-in-time stats snapshot for a postgres service: connections vs the server's ceiling, cache
163
165
  // hit rate, database size. Read-only. insta-db-backed: a suspended instance answers from the
164
- // provider's control plane (shown as "(suspended)" with structural zeros), never dialed.
165
- // Neon-backed: the platform reads over a direct SQL connection, so a one-shot call may wake a
166
- // suspended endpoint — acceptable for an explicit command, which is why nothing here polls.
166
+ // provider's control plane (shown as "(suspended)" with structural zeros), never dialed. That is
167
+ // every environment today — the Neon-backed contrast below is historical: Neon is no longer used
168
+ // anywhere, and the code that handled it is retained, not live. Neon-backed: the platform read
169
+ // over a direct SQL connection, so a one-shot call could wake a suspended endpoint — acceptable
170
+ // for an explicit command, which is why nothing here polls.
167
171
  export async function dbStats(opts) {
168
172
  const api = await ApiClient.load();
169
173
  const p = await requireProject();
@@ -207,7 +211,7 @@ export async function dbVolume(opts) {
207
211
  if (!opts.size) {
208
212
  const read = await fetchDbInstance(api, p.projectId, suffix);
209
213
  if (read.kind === 'no-instance') {
210
- info(`postgres ${opts.group ?? 'default'}: no manageable instance (Neon-backed services manage their own storage)`);
214
+ info(`postgres ${opts.group ?? 'default'}: no manageable instance (this service manages its own storage)`);
211
215
  return;
212
216
  }
213
217
  if (opts.json)
@@ -97,7 +97,9 @@ function printDimensions(dims) {
97
97
  }
98
98
  // insta usage — usage across the 5 billing dimensions (cpu/memory/volume/egress/storage) for the
99
99
  // current billing cycle. Shows the whole ORG by default (with a per-project breakdown); pass --proj
100
- // [id] for a single project (the linked one, or a given id). Billed dimensions, not raw fly/neon meters.
100
+ // [id] for a single project (the linked one, or a given id). Billed dimensions, not raw provider
101
+ // meters. (Historical: those were fly/neon meters — Neon is no longer used by any environment,
102
+ // though the adapter code is retained, not live.)
101
103
  export async function usage(opts) {
102
104
  const api = await ApiClient.load();
103
105
  const p = await requireProject();
@@ -23,6 +23,16 @@ export function parseCount(raw) {
23
23
  throw new Error(`count must be a positive integer, got: ${raw}`);
24
24
  return n;
25
25
  }
26
+ // Parse a TCP port. Junk fails here rather than reaching the API as NaN (the parseCpu lesson).
27
+ // Decimal digits only, as parseVolumeGib: `Number()` alone would quietly read 0x1f90 as 8080 and
28
+ // 1e3 as 1000, and a port written in hex is a typo worth reporting, not one worth honouring.
29
+ export function parsePort(raw) {
30
+ const m = /^\s*(\d+)\s*$/.exec(raw);
31
+ const n = m ? Number(m[1]) : NaN;
32
+ if (!Number.isInteger(n) || n < 1 || n > 65535)
33
+ throw new Error(`port must be an integer between 1 and 65535, got: ${raw}`);
34
+ return n;
35
+ }
26
36
  // Parse a volume size in whole Gi: "10" or "10Gi" (suffix case-insensitive — unlike the db
27
37
  // quantity strings this is not a provider pass-through; the wire value is an integer). Volumes
28
38
  // are provisioned block disks, so fractional and Mi values are rejected locally with an example
@@ -64,7 +74,7 @@ export function resolveComputeServiceId(services, name) {
64
74
  export function servicesAddRequestBody(type, name, branch, opts) {
65
75
  return {
66
76
  type, name, ...(branch ? { branch } : {}), public: !!opts.public,
67
- ...(opts.image ? { image: opts.image } : {}), ...(opts.port ? { port: Number(opts.port) } : {}),
77
+ ...(opts.image ? { image: opts.image } : {}), ...(opts.port ? { port: parsePort(opts.port) } : {}),
68
78
  ...(opts.region ? { region: opts.region } : {}),
69
79
  ...(opts.alwaysOn ? { alwaysOn: true } : {}),
70
80
  ...(opts.volume !== undefined ? { volumeGib: parseVolumeGib(opts.volume) } : {}),
@@ -78,8 +88,11 @@ export async function servicesAdd(type, name, opts = {}) {
78
88
  throw new Error('--region is not valid for storage services');
79
89
  if (opts.image && type !== 'compute')
80
90
  throw new Error('--image is only valid for compute services');
81
- if (opts.port && type !== 'compute')
82
- throw new Error('--port is only valid for compute services');
91
+ if (opts.port) {
92
+ if (type !== 'compute')
93
+ throw new Error('--port is only valid for compute services');
94
+ parsePort(opts.port); // junk fails here, before any config/network access
95
+ }
83
96
  if (opts.alwaysOn && type !== 'compute')
84
97
  throw new Error('--always-on is only valid for compute services (for postgres, use `insta db always-on on` after creation)');
85
98
  if (opts.volume !== undefined) {
@@ -93,6 +106,8 @@ export async function servicesAdd(type, name, opts = {}) {
93
106
  const res = await api.rawRequest('POST', `/projects/${p.projectId}/services`, servicesAddRequestBody(type, name, branch, opts));
94
107
  if (handleApproval(res))
95
108
  return;
109
+ if (opts.json)
110
+ return printJson(res.body.service);
96
111
  const svc = res.body.service;
97
112
  const access = svc.type === 'storage' ? ` [${svc.public ? 'public' : 'private'}]` : '';
98
113
  const img = svc.image ? ` running ${svc.image}${svc.port ? `:${svc.port}` : ''}` : '';
@@ -72,6 +72,24 @@ export async function ensureFlyctl() {
72
72
  info((await ok('brew', ['install', 'flyctl'], true)) ? 'flyctl installed ✓' : 'flyctl install failed — install manually: https://fly.io/docs/flyctl/install/');
73
73
  return;
74
74
  }
75
+ if (process.platform === 'linux') {
76
+ // A fresh Linux machine (CI containers included — insta-e2e run 31284364163) has no flyctl
77
+ // and no brew; without this branch `insta deploy <dir>` dead-ends on a hand-install of a
78
+ // third-party CLI. Official installer, pinned into ~/.fly; the current process extends its
79
+ // own PATH because the installer's shell-profile edit can't reach an already-running process.
80
+ info('flyctl not found — installing to ~/.fly (one-time)…');
81
+ const flyHome = `${process.env.HOME ?? '~'}/.fly`;
82
+ const installed = await ok('sh', ['-c', `curl -fsSL https://fly.io/install.sh | FLYCTL_INSTALL="${flyHome}" sh`], true);
83
+ if (installed) {
84
+ process.env.PATH = `${process.env.PATH}:${flyHome}/bin`;
85
+ if (await ok('flyctl', ['version'])) {
86
+ info('flyctl installed ✓');
87
+ return;
88
+ }
89
+ }
90
+ info('flyctl install failed — install manually: https://fly.io/docs/flyctl/install/');
91
+ return;
92
+ }
75
93
  info('flyctl (fly CLI) not found — install it to deploy from source: https://fly.io/docs/flyctl/install/');
76
94
  }
77
95
  catch { /* best-effort convenience */ }
package/dist/index.js CHANGED
@@ -13,6 +13,7 @@ import * as org from './commands/org.js';
13
13
  import * as project from './commands/project.js';
14
14
  import * as branch from './commands/branch.js';
15
15
  import * as services from './commands/services.js';
16
+ import { resolveServiceArgs, serviceArgsDeps } from './resolve-service.js';
16
17
  import * as regions from './commands/regions.js';
17
18
  import * as secretsCmd from './commands/secrets.js';
18
19
  import { deploy } from './commands/deploy.js';
@@ -108,7 +109,10 @@ br.command('merge <source>').description('Merge a branch service set into anothe
108
109
  .option('--into <branch>', 'target branch (default: current)').action(guard((source, o) => branch.branchMerge(source, o)));
109
110
  // ---- services (opt-in postgres/storage/compute) ----
110
111
  const svc = program.command('services').alias('svc').description('Manage project services (postgres|storage|compute)');
111
- svc.command('add <type> <name>').description('Provision a service on demand (assigns a default domain for postgres/compute)')
112
+ // [type] [name] are optional so the command can answer "what can I add?" — a terminal is walked
113
+ // through the dashboard's Add Service kinds, anything else gets that list back as an error
114
+ // (resolve-service.ts). Picking Docker Image also fills in --image/--port from the answers.
115
+ svc.command('add [type] [name]').description('Provision a service on demand (assigns a default domain for postgres/compute); with no type/name, a terminal picks from the service kinds')
112
116
  .option('--branch <branch>', 'target branch (default: current)')
113
117
  .option('--region <region>', 'region for postgres/compute, e.g. us-east (see `insta regions`)')
114
118
  .option('--public', 'storage only: serve the bucket with anonymous public-read (default private)')
@@ -116,7 +120,11 @@ svc.command('add <type> <name>').description('Provision a service on demand (ass
116
120
  .option('--port <n>', 'compute only: port the image listens on (default 8080)')
117
121
  .option('--always-on', 'compute only: create as always-on — never scales to zero (all plans; billing is actual usage either way)')
118
122
  .option('--volume <gi>', 'compute only: attach a persistent /data volume of this many whole Gi (also attachable later: `insta compute volume <name> --size <gi>`; any plan may attach at the default 1; larger sizes are paid and plan-capped). Volume services keep 1 machine and stop (cold wake) instead of suspend when idle')
119
- .action(guard((type, name, o) => services.servicesAdd(type, name, o)));
123
+ .option('--json')
124
+ .action(guard(async (type, name, o) => {
125
+ const a = await resolveServiceArgs(type, name, serviceArgsDeps(o.json), o);
126
+ return services.servicesAdd(a.type, a.name, { ...o, image: a.image ?? o.image, port: a.port ?? o.port });
127
+ }));
120
128
  svc.command('list').option('--json').option('--branch <branch>', 'branch (default: current)')
121
129
  .action(guard((o) => services.servicesList(o)));
122
130
  svc.command('remove <type> <name>').description('Remove a service and destroy its resources')
@@ -129,7 +137,7 @@ svc.command('set-access <type> <name> <access>').description('Set a storage serv
129
137
  .option('--json').action(guard((type, name, access, o) => services.servicesSetAccess(type, name, access, o)));
130
138
  svc.command('scale <type> <name> <number> [region]').description('Set a compute service machine count (paid plans only)')
131
139
  .option('--json').option('--branch <branch>', 'branch (default: current)').action(guard((type, name, number, region, o) => services.servicesScale(type, name, number, region, o)));
132
- svc.command('upgrade <type> <name> <spec>').description('Change a compute/postgres service spec (paid plans only)')
140
+ svc.command('upgrade <type> <name> <spec>').description('Change a compute service spec (paid plans only). Postgres upgrades are rejected by the platform — use `insta db limits` instead')
133
141
  .option('--json').option('--branch <branch>', 'branch (default: current)').action(guard((type, name, spec, o) => services.servicesUpgrade(type, name, spec, o)));
134
142
  svc.command('secrets <type> <name>').description("List a service's secret names")
135
143
  .option('--branch <b>').option('--json').action(guard((type, name, o) => services.servicesSecrets(type, name, o)));
@@ -171,8 +179,9 @@ compute.command('limits [service]').description("Show or set a compute service's
171
179
  .option('--json').option('--branch <branch>', 'branch (default: current)').action(guard((service, o) => computeCmd.computeLimits(service, o)));
172
180
  compute.command('always-on <mode> [service]').description('Set a compute service always-on (mode: on|off). on = machines never scale to zero; off = default scale-to-zero. All plans; billing is actual usage either way')
173
181
  .option('--json').option('--branch <branch>', 'branch (default: current)').action(guard((mode, service, o) => computeCmd.computeAlwaysOn(mode, service, o)));
174
- compute.command('volume [service]').description("Show, attach, or grow a compute service's persistent /data volume. No --size: print size, mount path, and the plan cap (any plan). --size on a volumeless service ATTACHES one (any plan at the default 1Gi; larger is paid and plan-capped; the disk mounts at /data on the next deploy); on a volume-bearing one it grows (paid plans; grow-only — a provisioned disk cannot shrink). Billing is actual data stored — the size is a cap, not a price")
182
+ compute.command('volume [service]').description("Show, attach, grow, or delete a compute service's persistent /data volume. No flag: print size, mount path, and the plan cap (any plan). --size on a volumeless service ATTACHES one (any plan at the default 1Gi; larger is paid and plan-capped; the disk mounts at /data on the next deploy); on a volume-bearing one it grows (paid plans; grow-only — a provisioned disk cannot shrink). --delete DESTROYS the disk and ALL its data immediately (no detach, no undo; billing stops now, and suspend fast-wake + scale-out return). Billing is actual data stored — the size is a cap, not a price")
175
183
  .option('--size <gi>', 'new size in whole Gi, e.g. 10 (must be ≥ the current size)')
184
+ .option('--delete', 'destroy the volume and ALL its data (irreversible; download anything you need first)')
176
185
  .option('--json').option('--branch <branch>', 'branch (default: current)').action(guard((service, o) => computeCmd.computeVolume(service, o)));
177
186
  // ---- db (postgres service controls) ----
178
187
  const db = program.command('db').description('Postgres service controls (limits / volume / always-on / scale-to-zero)');
@@ -0,0 +1,161 @@
1
+ // `insta services add` with no type (or no name): the kinds are otherwise only discoverable by
2
+ // guessing wrong and reading `type must be postgres|storage|compute`, so missing arguments answer
3
+ // "what can I add?" instead. The list mirrors the dashboard's Add Service menu (frontend
4
+ // `add-service-button.tsx`) — Docker Image sits BESIDE Empty Service, not under it, because
5
+ // picking an image is a different intent rather than a compute flag. An agent gets the same list
6
+ // as an error, because nothing was created and a silent exit 0 would read as success.
7
+ import * as clack from '@clack/prompts';
8
+ import { SERVICE_TYPES, assertServiceName, parsePort } from './commands/services.js';
9
+ // Same order, labels and default names as the dashboard's Add Service menu. Github Repo is left
10
+ // out: the platform has no repo path yet, so a CLI entry could only say "coming soon".
11
+ export const SERVICE_KINDS = [
12
+ { id: 'image', label: 'Docker Image', type: 'compute', hint: 'run an existing container image', needsImage: true },
13
+ { id: 'postgres', label: 'Postgres', type: 'postgres', hint: 'relational DB, usable as soon as it is added', defaultName: 'main-db' },
14
+ { id: 'storage', label: 'Storage', type: 'storage', hint: 'S3-compatible bucket, private by default', defaultName: 'assets' },
15
+ { id: 'compute', label: 'Empty Service', type: 'compute', hint: 'an app to deploy code to (empty until `insta deploy`)', defaultName: 'compute' },
16
+ ];
17
+ // The platform's own default; the dialog prefills the same number.
18
+ export const DEFAULT_IMAGE_PORT = '8080';
19
+ /** Registry refs aren't URLs — quietly strip a pasted scheme prefix (mirrors the dashboard). */
20
+ export function normalizeImageRef(raw) {
21
+ return raw.trim().replace(/^https?:\/\//, '');
22
+ }
23
+ /**
24
+ * Name from an image ref: last path segment, sans tag/digest, kebab-safe (the dashboard's rule).
25
+ * Also capped at the 39 chars `assertServiceName` allows — a suggestion the user cannot accept
26
+ * unchanged is worse than none.
27
+ */
28
+ export function suggestServiceName(ref) {
29
+ const last = ref.split('@')[0].split('/').pop() ?? '';
30
+ return last
31
+ .split(':')[0]
32
+ .toLowerCase()
33
+ .replace(/[^a-z0-9-]+/g, '-')
34
+ .replace(/^-+|-+$/g, '')
35
+ .slice(0, 39)
36
+ .replace(/-+$/g, '');
37
+ }
38
+ /** The non-interactive command for a kind — what an agent should run instead of being asked. */
39
+ export function kindCommand(k) {
40
+ if (k.needsImage)
41
+ return `insta services add compute <name> --image <ref> --port <n>`;
42
+ return `insta services add ${k.type} ${k.defaultName}`;
43
+ }
44
+ /** The kind list, one line each — what a terminal picks from and an agent reads. */
45
+ export function serviceKindLines() {
46
+ return SERVICE_KINDS.map((k) => ` ${k.label.padEnd(14)} ${kindCommand(k)}`);
47
+ }
48
+ /** What to say when there is no terminal to ask: the missing half, and how to supply it. */
49
+ export function missingArgsMessage(type) {
50
+ // A bare type names the plain kind, never Docker Image — that one is reached with --image.
51
+ const known = SERVICE_KINDS.find((k) => k.type === type && !k.needsImage);
52
+ if (known)
53
+ return `name the service: ${kindCommand(known)}`;
54
+ return ['what to add:', ...serviceKindLines()].join('\n');
55
+ }
56
+ /**
57
+ * Fill in whatever `insta services add` was not given. An unknown type passes straight through so
58
+ * `assertType` — not this — reports it, keeping one wording for a bad type everywhere. Flags that
59
+ * were already supplied are never asked for again.
60
+ */
61
+ export async function resolveServiceArgs(type, name, deps, given = {}) {
62
+ if (type && name)
63
+ return { type, name };
64
+ if (type && !SERVICE_TYPES.includes(type))
65
+ return { type, name: name ?? '' };
66
+ if (!deps.tty)
67
+ throw new Error(missingArgsMessage(type));
68
+ // A bad --port is a typo in the command, not an answer: fail before asking anything.
69
+ if (given.port !== undefined)
70
+ parsePort(given.port);
71
+ const kind = type
72
+ ? SERVICE_KINDS.find((k) => k.type === type && !k.needsImage)
73
+ : await deps.selectKind(SERVICE_KINDS);
74
+ if (!kind)
75
+ return { type: type, name: name ?? '' };
76
+ if (!kind.needsImage) {
77
+ return { type: kind.type, name: name ?? (await deps.askName(kind, kind.defaultName ?? '')) };
78
+ }
79
+ // The prompt validates a typed ref; a --image that normalizes away would slip past it and
80
+ // provision a plain empty compute instead (servicesAddRequestBody drops a falsy image).
81
+ const image = normalizeImageRef(given.image ?? (await deps.askImage()));
82
+ if (!image)
83
+ throw new Error('an image reference is required');
84
+ return {
85
+ type: kind.type,
86
+ name: name ?? (await deps.askName(kind, suggestServiceName(image))),
87
+ image,
88
+ port: given.port ?? (await deps.askPort(DEFAULT_IMAGE_PORT)),
89
+ };
90
+ }
91
+ /** Real prompts (clack, as the InsForge CLI's `create`); cancelling exits without provisioning. */
92
+ export async function promptServiceKind(kinds) {
93
+ const picked = await clack.select({
94
+ message: 'What do you want to add?',
95
+ options: kinds.map((k) => ({ value: k.id, label: k.label, hint: k.hint })),
96
+ });
97
+ if (clack.isCancel(picked))
98
+ process.exit(0);
99
+ // Resolve against the list that was displayed — a subset must not fall through to the registry.
100
+ return kinds.find((k) => k.id === picked);
101
+ }
102
+ export async function promptImageRef() {
103
+ const answer = await clack.text({
104
+ message: 'Image reference:',
105
+ placeholder: 'nginx:latest',
106
+ validate: (v) => (normalizeImageRef(v) ? undefined : 'an image reference is required'),
107
+ });
108
+ if (clack.isCancel(answer))
109
+ process.exit(0);
110
+ return answer;
111
+ }
112
+ export async function promptServiceName(kind, suggested) {
113
+ const answer = await clack.text({
114
+ message: `Name this ${kind.type} service:`,
115
+ initialValue: suggested,
116
+ // The same rule the command enforces, reported before Enter rather than after a round trip.
117
+ validate: (v) => {
118
+ try {
119
+ assertServiceName(v.trim());
120
+ return undefined;
121
+ }
122
+ catch (e) {
123
+ return e.message;
124
+ }
125
+ },
126
+ });
127
+ if (clack.isCancel(answer))
128
+ process.exit(0);
129
+ return answer.trim();
130
+ }
131
+ export async function promptPort(fallback) {
132
+ const answer = await clack.text({
133
+ message: 'Port the image listens on:',
134
+ initialValue: fallback,
135
+ // The rule the command enforces, so the prompt and a --port can never disagree.
136
+ validate: (v) => {
137
+ try {
138
+ parsePort(v.trim());
139
+ return undefined;
140
+ }
141
+ catch (e) {
142
+ return e.message;
143
+ }
144
+ },
145
+ });
146
+ if (clack.isCancel(answer))
147
+ process.exit(0);
148
+ return answer.trim();
149
+ }
150
+ /** Prompts on a real terminal only — an agent's stdin is not one, and must never block. */
151
+ export function serviceArgsDeps(json) {
152
+ return {
153
+ selectKind: promptServiceKind,
154
+ askImage: promptImageRef,
155
+ askName: promptServiceName,
156
+ askPort: promptPort,
157
+ // --json asked for parseable output, so a caller that happens to own a TTY still gets the error.
158
+ tty: !json && !!process.stdin.isTTY && !!process.stdout.isTTY,
159
+ };
160
+ }
161
+ //# sourceMappingURL=resolve-service.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "insta",
3
- "version": "0.0.30",
3
+ "version": "0.0.32",
4
4
  "type": "module",
5
5
  "description": "InstaCloud CLI — a thin client of the platform control-plane API.",
6
6
  "keywords": [
@@ -42,6 +42,7 @@
42
42
  "prepublishOnly": "npm run build"
43
43
  },
44
44
  "dependencies": {
45
+ "@clack/prompts": "^0.9.1",
45
46
  "commander": "^12.1.0"
46
47
  },
47
48
  "devDependencies": {