insta 0.0.31 → 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.
@@ -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}` : ''}` : '';
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)));
@@ -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.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": {