insta 0.0.31 → 0.0.33

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.
@@ -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
@@ -43,20 +53,24 @@ export function resolveServiceId(services, type, name) {
43
53
  throw new Error(`service not found: ${type} ${name}`);
44
54
  return svc.id;
45
55
  }
46
- // Resolve a compute service id: by name, or the sole compute service when name is omitted.
47
- export function resolveComputeServiceId(services, name) {
48
- const compute = services.filter((s) => s.type === 'compute');
56
+ // Resolve one service of a type: by name, or the sole one of that type when name is omitted.
57
+ export function resolveSoleService(services, type, name) {
58
+ const of = services.filter((s) => s.type === type);
49
59
  if (name) {
50
- const svc = compute.find((s) => s.name === name);
60
+ const svc = of.find((s) => s.name === name);
51
61
  if (!svc)
52
- throw new Error(`compute service not found: ${name}`);
53
- return svc.id;
62
+ throw new Error(`${type} service not found: ${name}`);
63
+ return svc;
54
64
  }
55
- if (compute.length === 0)
56
- throw new Error('no compute service in this project (add one with `insta services add compute <name>`)');
57
- if (compute.length > 1)
58
- throw new Error(`multiple compute services — specify one: ${compute.map((s) => s.name).join(', ')}`);
59
- return compute[0].id;
65
+ if (of.length === 0)
66
+ throw new Error(`no ${type} service in this project (add one with \`insta services add ${type} <name>\`)`);
67
+ if (of.length > 1)
68
+ throw new Error(`multiple ${type} services — specify one: ${of.map((s) => s.name).join(', ')}`);
69
+ return of[0];
70
+ }
71
+ // Resolve a compute service id: by name, or the sole compute service when name is omitted.
72
+ export function resolveComputeServiceId(services, name) {
73
+ return resolveSoleService(services, 'compute', name).id;
60
74
  }
61
75
  // Map service-add options to the platform POST body. Pure, so it's unit-tested without a network
62
76
  // mock (mirrors deployRequestBody in deploy.ts). Validation (which options are valid for which
@@ -64,7 +78,7 @@ export function resolveComputeServiceId(services, name) {
64
78
  export function servicesAddRequestBody(type, name, branch, opts) {
65
79
  return {
66
80
  type, name, ...(branch ? { branch } : {}), public: !!opts.public,
67
- ...(opts.image ? { image: opts.image } : {}), ...(opts.port ? { port: Number(opts.port) } : {}),
81
+ ...(opts.image ? { image: opts.image } : {}), ...(opts.port ? { port: parsePort(opts.port) } : {}),
68
82
  ...(opts.region ? { region: opts.region } : {}),
69
83
  ...(opts.alwaysOn ? { alwaysOn: true } : {}),
70
84
  ...(opts.volume !== undefined ? { volumeGib: parseVolumeGib(opts.volume) } : {}),
@@ -78,8 +92,11 @@ export async function servicesAdd(type, name, opts = {}) {
78
92
  throw new Error('--region is not valid for storage services');
79
93
  if (opts.image && type !== 'compute')
80
94
  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');
95
+ if (opts.port) {
96
+ if (type !== 'compute')
97
+ throw new Error('--port is only valid for compute services');
98
+ parsePort(opts.port); // junk fails here, before any config/network access
99
+ }
83
100
  if (opts.alwaysOn && type !== 'compute')
84
101
  throw new Error('--always-on is only valid for compute services (for postgres, use `insta db always-on on` after creation)');
85
102
  if (opts.volume !== undefined) {
@@ -93,6 +110,8 @@ export async function servicesAdd(type, name, opts = {}) {
93
110
  const res = await api.rawRequest('POST', `/projects/${p.projectId}/services`, servicesAddRequestBody(type, name, branch, opts));
94
111
  if (handleApproval(res))
95
112
  return;
113
+ if (opts.json)
114
+ return printJson(res.body.service);
96
115
  const svc = res.body.service;
97
116
  const access = svc.type === 'storage' ? ` [${svc.public ? 'public' : 'private'}]` : '';
98
117
  const img = svc.image ? ` running ${svc.image}${svc.port ? `:${svc.port}` : ''}` : '';
@@ -0,0 +1,173 @@
1
+ // `insta storage` — browse, download, and delete the objects in a storage service's bucket.
2
+ import { chmod, rename, rm, stat } from 'node:fs/promises';
3
+ import { createWriteStream, rmSync } from 'node:fs';
4
+ import { randomBytes } from 'node:crypto';
5
+ import { Readable } from 'node:stream';
6
+ import { pipeline } from 'node:stream/promises';
7
+ import { ApiClient, requireProject } from '../api.js';
8
+ import { info, printJson, handleApproval } from '../util.js';
9
+ import { q, resolveSoleService } from './services.js';
10
+ import { fmtBytes } from './db.js'; // the repo's tested bytes formatter — don't grow a third copy
11
+ function qs(params) {
12
+ const u = new URLSearchParams();
13
+ for (const [k, v] of Object.entries(params))
14
+ if (v !== undefined && v !== '')
15
+ u.set(k, String(v));
16
+ const s = u.toString();
17
+ return s ? `?${s}` : '';
18
+ }
19
+ // Page size the listing route accepts. Junk must fail here, not travel as `limit=NaN`.
20
+ export function parseObjectLimit(raw) {
21
+ const n = Number(raw);
22
+ if (!Number.isInteger(n) || n < 1 || n > 1000)
23
+ throw new Error(`--limit must be an integer 1..1000, got: ${raw}`);
24
+ return n;
25
+ }
26
+ // pure: platform path for the objects collection — GET lists it, DELETE removes one `key`.
27
+ export function objectsPath(projectId, serviceId, params) {
28
+ const { limit, ...rest } = params;
29
+ return `/projects/${projectId}/services/${serviceId}/objects${qs({ ...rest, limit: limit === undefined ? undefined : String(limit) })}`;
30
+ }
31
+ // pure: the presign route — a static subpath, so keys containing `/` stay in the query.
32
+ export function objectDownloadPath(projectId, serviceId, params) {
33
+ return `/projects/${projectId}/services/${serviceId}/objects/download${qs(params)}`;
34
+ }
35
+ // pure: one `storage list` row, size-first so the columns line up over variable-length keys.
36
+ export function objectListLine(o) {
37
+ const size = typeof o.size === 'number' ? fmtBytes(o.size) : '—';
38
+ return `${size.padStart(10)} ${(o.lastModified ?? '—').padEnd(24)} ${o.key}`;
39
+ }
40
+ // Resolve the branch's storage service (named, or the sole one) — its bucket is what we browse.
41
+ async function storageTarget(api, projectId, branch, name) {
42
+ const { services } = await api.request('GET', `/projects/${projectId}/services${q(branch)}`);
43
+ return resolveSoleService(services, 'storage', name);
44
+ }
45
+ // S3 filters by prefix only — there is no substring search, so `--prefix` is the query surface.
46
+ export async function storageList(opts) {
47
+ const limit = opts.limit === undefined ? undefined : parseObjectLimit(opts.limit);
48
+ const api = await ApiClient.load();
49
+ const p = await requireProject();
50
+ const branch = opts.branch ?? p.branch;
51
+ const svc = await storageTarget(api, p.projectId, branch, opts.service);
52
+ const res = await api.rawRequest('GET', objectsPath(p.projectId, svc.id, { branch, prefix: opts.prefix, cursor: opts.cursor, limit }));
53
+ if (handleApproval(res))
54
+ return;
55
+ if (opts.json)
56
+ return printJson(res.body);
57
+ const objects = res.body?.objects ?? [];
58
+ if (!objects.length) {
59
+ return info(opts.prefix ? `(no objects under prefix ${opts.prefix} in storage/${svc.name})` : `(storage/${svc.name} is empty)`);
60
+ }
61
+ for (const o of objects)
62
+ info(objectListLine(o));
63
+ const next = res.body?.nextCursor;
64
+ if (next)
65
+ info(` (more — next page: ${nextPageCommand({ ...opts, limit }, next)})`);
66
+ }
67
+ // Safe unquoted in every shell we care about — no spaces, quotes, or expansion characters.
68
+ const SHELL_SAFE = /^[\w./:@=+-]+$/;
69
+ // The continuation must repeat the filters, or following it lists a different set. Quoting rules
70
+ // differ between sh and PowerShell, so rather than guess the shell, a value that would need quotes
71
+ // gets a sentence instead of syntax that is broken somewhere.
72
+ export function nextPageCommand(opts, cursor) {
73
+ const values = [opts.service, opts.branch, opts.prefix, cursor].filter((v) => !!v);
74
+ if (!values.every((v) => SHELL_SAFE.test(v))) {
75
+ return `re-run this command with --cursor set to ${cursor}`;
76
+ }
77
+ const flags = [
78
+ opts.service ? `--service ${opts.service}` : '',
79
+ opts.branch ? `--branch ${opts.branch}` : '',
80
+ opts.prefix ? `--prefix ${opts.prefix}` : '',
81
+ opts.limit === undefined ? '' : `--limit ${opts.limit}`,
82
+ `--cursor ${cursor}`,
83
+ ].filter(Boolean);
84
+ return `insta storage list ${flags.join(' ')}`;
85
+ }
86
+ // pure: where the bytes land. Only the last segment is used, so no key can escape cwd.
87
+ export function outputPath(key, output) {
88
+ if (output)
89
+ return output;
90
+ // Split on `\` too: a key may contain one, and on Windows that is also a separator.
91
+ const base = key.split(/[\\/]/).pop() ?? '';
92
+ if (!base)
93
+ throw new Error(`cannot infer a filename from key "${key}" — pass -o <file>`);
94
+ return base;
95
+ }
96
+ // Stream from the provider (never through the platform, which only signs) straight to disk, so a
97
+ // multi-gigabyte object never has to fit in memory. Returns the byte count written.
98
+ export async function streamPresignedTo(url, out, fetchImpl = fetch) {
99
+ const res = await fetchImpl(url);
100
+ if (!res.ok || !res.body)
101
+ throw new Error(`download failed: HTTP ${res.status} (a presigned URL lives ~60s — re-run to mint a fresh one)`);
102
+ let written = 0;
103
+ const counting = new TransformStream({
104
+ transform(chunk, controller) { written += chunk.byteLength; controller.enqueue(chunk); },
105
+ });
106
+ // Write beside the target, then rename: opening `out` directly would truncate an existing file
107
+ // that a failed download then deletes. Same directory keeps the rename atomic.
108
+ const part = `${out}.insta-part-${randomBytes(4).toString('hex')}`;
109
+ // A signal kills the process without unwinding, so the part file needs a synchronous sweep.
110
+ // Exit 128+signo, so a supervisor still reads interrupted (130) apart from terminated (143).
111
+ const sweep = (signo) => () => { rmSync(part, { force: true }); process.exit(128 + signo); };
112
+ const onInt = sweep(2);
113
+ const onTerm = sweep(15);
114
+ process.once('SIGINT', onInt);
115
+ process.once('SIGTERM', onTerm);
116
+ try {
117
+ await pipeline(Readable.fromWeb(res.body.pipeThrough(counting)), createWriteStream(part));
118
+ // Replacing a 0600 file must not widen it to the umask default the part was created with.
119
+ const mode = await stat(out).then((s) => s.mode, () => undefined);
120
+ if (mode !== undefined)
121
+ await chmod(part, mode);
122
+ await rename(part, out);
123
+ }
124
+ catch (e) {
125
+ await rm(part, { force: true });
126
+ throw e;
127
+ }
128
+ finally {
129
+ process.off('SIGINT', onInt);
130
+ process.off('SIGTERM', onTerm);
131
+ }
132
+ return written;
133
+ }
134
+ // Core, dependency-injected for tests (mirrors runWithSecrets): stream → disk, return byte count.
135
+ export async function saveObject(url, out, deps = {}) {
136
+ return (deps.streamTo ?? streamPresignedTo)(url, out);
137
+ }
138
+ export async function storageGet(key, opts, deps = {}) {
139
+ if (!key)
140
+ throw new Error('key is required');
141
+ const api = await ApiClient.load();
142
+ const p = await requireProject();
143
+ const branch = opts.branch ?? p.branch;
144
+ const svc = await storageTarget(api, p.projectId, branch, opts.service);
145
+ const res = await api.rawRequest('GET', objectDownloadPath(p.projectId, svc.id, { branch, key }));
146
+ if (handleApproval(res))
147
+ return;
148
+ // --json hands over the presigned URL instead of downloading, as `insta secrets --json` does.
149
+ // Before outputPath, so a key with no filename still works when nothing is written to disk.
150
+ if (opts.json)
151
+ return printJson(res.body);
152
+ const out = outputPath(key, opts.output);
153
+ if (!res.body?.url)
154
+ throw new Error('the platform returned no download URL');
155
+ const bytes = await saveObject(res.body.url, out, deps);
156
+ info(`wrote ${fmtBytes(bytes)} to ${out} (${key} from storage/${svc.name}, branch ${branch})`);
157
+ }
158
+ // No prompt, matching every other destructive command here — the governance gate is the guard.
159
+ export async function storageDelete(key, opts) {
160
+ if (!key)
161
+ throw new Error('key is required');
162
+ const api = await ApiClient.load();
163
+ const p = await requireProject();
164
+ const branch = opts.branch ?? p.branch;
165
+ const svc = await storageTarget(api, p.projectId, branch, opts.service);
166
+ const res = await api.rawRequest('DELETE', objectsPath(p.projectId, svc.id, { branch, key }));
167
+ if (handleApproval(res))
168
+ return;
169
+ if (opts.json)
170
+ return printJson(res.body);
171
+ info(`deleted ${key} from storage/${svc.name} (branch ${branch})`);
172
+ }
173
+ //# sourceMappingURL=storage.js.map
package/dist/index.js CHANGED
@@ -13,11 +13,13 @@ 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';
19
20
  import * as computeCmd from './commands/compute.js';
20
21
  import * as dbCmd from './commands/db.js';
22
+ import * as storageCmd from './commands/storage.js';
21
23
  import { manifest } from './commands/manifest.js';
22
24
  import * as govern from './commands/govern.js';
23
25
  import * as observe from './commands/observe.js';
@@ -108,7 +110,10 @@ br.command('merge <source>').description('Merge a branch service set into anothe
108
110
  .option('--into <branch>', 'target branch (default: current)').action(guard((source, o) => branch.branchMerge(source, o)));
109
111
  // ---- services (opt-in postgres/storage/compute) ----
110
112
  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)')
113
+ // [type] [name] are optional so the command can answer "what can I add?" — a terminal is walked
114
+ // through the dashboard's Add Service kinds, anything else gets that list back as an error
115
+ // (resolve-service.ts). Picking Docker Image also fills in --image/--port from the answers.
116
+ 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
117
  .option('--branch <branch>', 'target branch (default: current)')
113
118
  .option('--region <region>', 'region for postgres/compute, e.g. us-east (see `insta regions`)')
114
119
  .option('--public', 'storage only: serve the bucket with anonymous public-read (default private)')
@@ -116,7 +121,11 @@ svc.command('add <type> <name>').description('Provision a service on demand (ass
116
121
  .option('--port <n>', 'compute only: port the image listens on (default 8080)')
117
122
  .option('--always-on', 'compute only: create as always-on — never scales to zero (all plans; billing is actual usage either way)')
118
123
  .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)));
124
+ .option('--json')
125
+ .action(guard(async (type, name, o) => {
126
+ const a = await resolveServiceArgs(type, name, serviceArgsDeps(o.json), o);
127
+ return services.servicesAdd(a.type, a.name, { ...o, image: a.image ?? o.image, port: a.port ?? o.port });
128
+ }));
120
129
  svc.command('list').option('--json').option('--branch <branch>', 'branch (default: current)')
121
130
  .action(guard((o) => services.servicesList(o)));
122
131
  svc.command('remove <type> <name>').description('Remove a service and destroy its resources')
@@ -129,7 +138,7 @@ svc.command('set-access <type> <name> <access>').description('Set a storage serv
129
138
  .option('--json').action(guard((type, name, access, o) => services.servicesSetAccess(type, name, access, o)));
130
139
  svc.command('scale <type> <name> <number> [region]').description('Set a compute service machine count (paid plans only)')
131
140
  .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)')
141
+ 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
142
  .option('--json').option('--branch <branch>', 'branch (default: current)').action(guard((type, name, spec, o) => services.servicesUpgrade(type, name, spec, o)));
134
143
  svc.command('secrets <type> <name>').description("List a service's secret names")
135
144
  .option('--branch <b>').option('--json').action(guard((type, name, o) => services.servicesSecrets(type, name, o)));
@@ -191,6 +200,25 @@ db.command('volume').description("Show or grow a postgres service's provisioned
191
200
  .option('--size <gi>', 'new size in whole Gi, e.g. 10 (must be ≥ the current size)')
192
201
  .option('--json').option('--branch <branch>', 'branch (default: current)').option('--group <g>', 'postgres service name (default: the sole/default one)')
193
202
  .action(guard((o) => dbCmd.dbVolume(o)));
203
+ // ---- storage (bucket objects) ----
204
+ const storage = program.command('storage').description("Browse, download, and delete a storage service's bucket objects");
205
+ storage.command('list').description("List the bucket's objects. S3 filters by prefix only — there is no substring search")
206
+ .option('--prefix <p>', 'only keys starting with this prefix (applied server-side)')
207
+ .option('--cursor <c>', 'continue from the nextCursor a previous page printed')
208
+ .option('--limit <n>', 'page size, 1..1000 (default 100)')
209
+ .option('--service <name>', 'storage service (default: the sole one on the branch)')
210
+ .option('--branch <b>', 'branch (default: current)').option('--json')
211
+ .action(guard((o) => storageCmd.storageList(o)));
212
+ storage.command('get <key>').description('Download one object to disk through a short-lived presigned URL (bytes come straight from the provider)')
213
+ .option('-o, --output <file>', "output file (default: the key's last segment)")
214
+ .option('--service <name>', 'storage service (default: the sole one on the branch)')
215
+ .option('--branch <b>', 'branch (default: current)')
216
+ .option('--json', 'print the presigned URL + expiry instead of downloading')
217
+ .action(guard((key, o) => storageCmd.storageGet(key, o)));
218
+ storage.command('delete <key>').description('DELETES one object from the bucket immediately — no undo, and an already-gone key still reports success (gated: storage.delete)')
219
+ .option('--service <name>', 'storage service (default: the sole one on the branch)')
220
+ .option('--branch <b>', 'branch (default: current)').option('--json')
221
+ .action(guard((key, o) => storageCmd.storageDelete(key, o)));
194
222
  // ---- manifest ----
195
223
  program.command('manifest').description('Print an agent-legible view of the project environments').option('--json').action(guard((o) => manifest(o)));
196
224
  // ---- regions ----
@@ -230,7 +258,7 @@ ob.command('sync').description('Upload findings into the project timeline').acti
230
258
  // ---- policy ----
231
259
  const pol = program.command('policy').description('Governance policy');
232
260
  pol.command('get').option('--json').action(guard((o) => govern.policyGet(o)));
233
- pol.command('set <action> <decision>').description('action: secrets.read|secrets.write|deploy|project.delete|branch.delete|service.add|service.remove|service.scale|service.upgrade|service.setAccess; decision: allow|deny|approve').action(guard((a, d) => govern.policySet(a, d)));
261
+ pol.command('set <action> <decision>').description('action: secrets.read|secrets.write|deploy|project.delete|branch.delete|service.add|service.remove|service.scale|service.upgrade|service.setAccess|storage.read|storage.write|storage.delete; decision: allow|deny|approve').action(guard((a, d) => govern.policySet(a, d)));
234
262
  // ---- self-update ----
235
263
  program.command('upgrade').description('Update the insta CLI to the latest release (binary or npm install)')
236
264
  .action(guard(() => selfUpdate.upgrade()));
@@ -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.31",
3
+ "version": "0.0.33",
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": {