insta 0.1.12 → 0.1.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -74,11 +74,12 @@ insta deploy .
74
74
 
75
75
  `project create` makes an empty project and links the current directory. Services are
76
76
  opt-in, so you add only what you need. `secrets` writes the current branch's user-defined
77
- secrets to `./.env` (the postgres connection string is read with `insta postgres url`). `deploy .`
77
+ secrets plus the primary provider credentials of each service type to `./.env`. A service created with
78
+ `insta service add compute` starts with no provider credential bindings; bind the sources it needs before deploying. `deploy .`
78
79
  builds the directory remotely and ships it to the branch's compute
79
80
  service, with no local Docker. Whether it needs a `Dockerfile` depends on where the
80
81
  service runs: on insta-compute it is optional, and a directory without one is built
81
- by nixpacks on the build gateway; on Fly-backed services one is still required.
82
+ by nixpacks on the build gateway; on legacy-plane services one is still required.
82
83
 
83
84
  ## Authentication
84
85
 
@@ -116,12 +117,21 @@ the two branches diverge independently. A project is capped at 10 branches.
116
117
 
117
118
  ### Credentials come from the secret seam, not a file you maintain
118
119
 
119
- `insta secrets` fetches the current branch's **user-defined** secrets and writes `./.env`.
120
- `insta run <cmd>` does the same without touching disk, injecting them into the child process
121
- only. Provider-minted service credentials (`DATABASE_URL`, `BUCKET_NAME`,
122
- `AWS_ACCESS_KEY_ID`, …) are not in that bundle — they reach compute through explicit
123
- `insta secrets bind` rules, and the postgres connection string is read directly with
124
- `insta postgres url` (or `insta postgres connect` for a psql session).
120
+ `insta secrets` writes the current branch's **user-defined secrets plus primary provider
121
+ credentials** to `./.env`: one credential set per service type, from that type's primary service.
122
+ `insta run <cmd>` injects the same bundle into a local child process without touching disk.
123
+ Fetching this bundle does not bind credentials to compute. A compute service receives provider
124
+ credentials only through explicit `insta secrets bind` rules; `insta service add compute` creates
125
+ a service with no provider credential bindings inherited from other services. Bind each source it
126
+ needs, then deploy or restart to apply the bindings. For a specific postgres service's connection string, use
127
+ `insta postgres url <name>` (or `insta postgres connect <name>` for a psql session).
128
+
129
+ A postgres database is reachable on its public endpoint (`DATABASE_URL`) by default.
130
+ `insta postgres private-access on` adds `DATABASE_PRIVATE_URL` beside it, which resolves only inside
131
+ InstaCloud compute; bind it into a compute with `insta secrets bind DATABASE_URL postgres/db
132
+ --source-name DATABASE_PRIVATE_URL --to compute/api`. `insta postgres public-access off` then makes the
133
+ database not reachable from the internet: it prints what will break and asks first (`--yes` when
134
+ there is no terminal), and external clients, CI and local development can no longer connect.
125
135
 
126
136
  Secrets can be scoped per compute service, so several services may each define the same name — and
127
137
  a flat bundle cannot carry two values for one name. Such a name is **withheld** from the bundle and
@@ -221,7 +231,7 @@ build never reaches a production installer.
221
231
  | `insta secrets` | Write `.env`, plus `list` · `set` · `unset` · `bind` · `unbind` · `bindings` · `sources` · `tree` |
222
232
  | `insta domain` | Bought or bring-your-own: `attach` · `check` · `detach`; buy through InstaCloud: `search` · `buy` · `list` · `status` · `records …` |
223
233
  | `insta compute` | `start` · `stop` · `suspend` · `restart` · `status` · `scale` · `limits` · `volume` · `always-on` · `exec` · `ssh` · `repo` · `connect-repo` · `watch-paths` · `disconnect-repo` · `logs` · `metrics` |
224
- | `insta postgres` | `url` (print the DSN) · `connect` (psql) · `stats` · `limits` · `volume` · `always-on` · `logs` · `metrics` — every verb takes `[service]` |
234
+ | `insta postgres` | `url` (print the DSN) · `connect` (psql) · `stats` · `limits` · `volume` · `always-on` · `private-access` · `public-access` · `logs` · `metrics` — every verb takes `[service]` |
225
235
  | `insta redis` · `mysql` · `mongodb` | `query` · `status` · `limits` · `volume` · `always-on` · `logs` · `metrics` |
226
236
  | `insta storage` | `list` · `get` · `delete` · `set-access` |
227
237
  | `insta build [dir]` · `deploy [dir]` | Verify a source dir would build; deploy a source directory (built remotely) or `--image <url>`. `insta build logs <id>` reads source-build output: `--source archive` (default) uses the deploy operation ID, `--source github` uses a GitHub build ID; `--follow` watches output, `--json` returns one snapshot |
@@ -123,11 +123,11 @@ export async function buildReport(dirArg, opts, deps) {
123
123
  severity: 'warning',
124
124
  status: 'fail',
125
125
  title: 'Dockerfile',
126
- detail: `${dockerfileDetail} — \`insta deploy <dir>\` builds this on the gateway with nixpacks when the service runs on insta-compute, and needs your own Dockerfile on a Fly-backed one`,
126
+ detail: `${dockerfileDetail} — \`insta deploy <dir>\` builds this on the gateway with nixpacks when the service runs on insta-compute, and needs your own Dockerfile on a legacy-plane one`,
127
127
  // NOT "save the generated Dockerfile here": it is not standalone (it COPYs the
128
128
  // .nixpacks/nixpkgs-<hash>.nix support files nixpacks writes beside it, which this
129
129
  // directory does not have). The detected commands above are the reusable part.
130
- nextAction: `on insta-compute this deploys as-is (nixpacks builds it on the gateway). On a Fly-backed service, write a Dockerfile at ${userDockerfilePath} — the detected install/start commands above are the starting point — or connect the GitHub repo (\`insta compute connect-repo <owner/repo>\`)`,
130
+ nextAction: `on insta-compute this deploys as-is (nixpacks builds it on the gateway). On a legacy-plane service, write a Dockerfile at ${userDockerfilePath} — the detected install/start commands above are the starting point — or connect the GitHub repo (\`insta compute connect-repo <owner/repo>\`)`,
131
131
  }
132
132
  : {
133
133
  id: 'dockerfile',
@@ -190,7 +190,7 @@ export function renderReport(r, explain) {
190
190
  // The builder line is the first thing read (and the thing an agent scrapes), so it carries the
191
191
  // lane caveat too — "builder: nixpacks" on its own reads as a promise that is only true on some
192
192
  // targets. It has to say the SAME thing the detail below it says, or a scrape sees both claims.
193
- const lane = r.plan.builder === 'nixpacks' ? ' — server-side: insta-compute builds this as-is, a Fly-backed service needs your own Dockerfile' : '';
193
+ const lane = r.plan.builder === 'nixpacks' ? ' — server-side: insta-compute builds this as-is, a legacy-plane service needs your own Dockerfile' : '';
194
194
  lines.push(` builder: ${r.plan.builder ?? 'none'}${r.plan.providers.length ? ` (providers: ${r.plan.providers.join(', ')})` : ''}${lane}`);
195
195
  if (r.plan.installCommand)
196
196
  lines.push(` install: ${r.plan.installCommand}`);
@@ -147,12 +147,9 @@ export async function deploy(dir, opts) {
147
147
  const p = await requireProject();
148
148
  const branch = opts.branch ?? p.branch;
149
149
  const log = note(opts);
150
- // Junk fails here, before a directory is packed and uploaded for a body the platform would only
151
- // refuse: the same parser every other --port in this CLI runs. `Number()` alone sent NaN as
152
- // `null` and let 0 or 70000 travel to the server.
153
150
  let port;
154
151
  try {
155
- port = opts.port === undefined ? undefined : parsePort(opts.port);
152
+ port = opts.port === undefined ? undefined : parsePort(opts.port, { allowZero: true });
156
153
  }
157
154
  catch (e) {
158
155
  die(`--${e.message}`);
@@ -1,7 +1,8 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  import { constants as osConstants } from 'node:os';
3
3
  import { ApiClient, ApiError, requireProject } from '../api.js';
4
- import { info, printJson, handleApproval, relayExitCode } from '../util.js';
4
+ import * as clack from '@clack/prompts';
5
+ import { info, printJson, handleApproval, refuse, relayExitCode } from '../util.js';
5
6
  import { parseVolumeGib, q, resolveSoleService } from './services.js';
6
7
  export function alwaysOnArgs(first, second) {
7
8
  if (first === 'on' || first === 'off')
@@ -281,14 +282,6 @@ export async function dbVolume(service, opts) {
281
282
  const vg = res.body?.volumeGib;
282
283
  info(`postgres ${service ?? 'default'}: volume ${typeof vg === 'number' ? `grown to ${vg}Gi` : `set to ${sizeGib}Gi`}`);
283
284
  }
284
- // Resolve the postgres service (sole, or the named one) and its connection string. Two reads: the
285
- // branch's services list names the service; GET /services/:id/credentials (gated secrets.read)
286
- // carries the value. Provider-minted credentials are canonical within their source service
287
- // (DATABASE_URL) and deliberately absent from the general `insta secrets` bundle, so this is the
288
- // read that yields the DSN. The credentials call carries no branch param — the service id is
289
- // already branch-scoped by the list. Returns null when the read parked on an approval
290
- // (handleApproval already spoke). Takes the client as an argument so tests drive it with a stub,
291
- // per this repo's pure-seam convention.
292
285
  export async function resolveDbUrl(api, projectId, branch, service, json) {
293
286
  const { services } = await api.request('GET', `/projects/${projectId}/services${q(branch)}`);
294
287
  const svc = resolveSoleService(services, 'postgres', service);
@@ -374,4 +367,189 @@ export async function dbConnect(service, opts) {
374
367
  process.stderr.write(`psql → postgres/${r.serviceName}${branch ? ` (branch ${branch})` : ''} — a suspended instance wakes on connect, so the first prompt can take a few seconds\n`);
375
368
  relayExitCode(await connectWithPsql(r.url));
376
369
  }
370
+ async function confirmOnTerminal(question) {
371
+ const answer = await clack.confirm({ message: question, initialValue: false });
372
+ if (clack.isCancel(answer))
373
+ return false;
374
+ return answer === true;
375
+ }
376
+ const onOff = (v) => (v === true ? 'on' : v === false ? 'off' : 'unknown');
377
+ // The access block of a database/instance read. Pure, exported for tests. privateConnString in the
378
+ // instance view carries NO password (the full URL is DATABASE_PRIVATE_URL in the credentials), so
379
+ // printing it is safe.
380
+ export function dbAccessLines(group, body) {
381
+ const pub = body?.publicAccess;
382
+ const lines = [
383
+ `postgres ${group}: public access ${onOff(pub)}${pub === false ? ' — not reachable from the internet; only the private network lane connects' : pub === true ? ' — reachable on the public endpoint (DATABASE_URL)' : ' — the provider did not report it'}`,
384
+ `postgres ${group}: private access ${onOff(body?.privateAccess)}${body?.privateAccess === true ? ' — DATABASE_PRIVATE_URL is in the service credentials (resolves only inside InstaCloud compute)' : ''}`,
385
+ ];
386
+ if (typeof body?.privateConnString === 'string' && body.privateConnString)
387
+ lines.push(` private: ${body.privateConnString}`);
388
+ if (body?.privateLane?.enabled === false)
389
+ lines.push(' the private network lane is not yet available on this deployment — private access cannot be turned on, nor public access closed');
390
+ return lines;
391
+ }
392
+ // Human lines for what closing public access will break. The platform's warnings already name each
393
+ // affected compute (and always end with the external-clients one), so the services array is left
394
+ // to --json rather than repeated.
395
+ export function impactLines(impact) {
396
+ const warnings = (impact?.warnings ?? []).filter((w) => typeof w === 'string' && w);
397
+ if (!warnings.length)
398
+ return [];
399
+ return ['closing public access will break:', ...warnings.map((w) => ` ! ${w}`)];
400
+ }
401
+ // Map an access-change refusal to a CLI-shaped message. Pure, exported for tests. The platform's
402
+ // 400 texts name REST routes; the CLI names its own command instead, keeping the reason.
403
+ export function accessErrorMessage(e, ctx) {
404
+ const code = e.body?.code;
405
+ const b = ctx.branch ? ` --branch ${ctx.branch}` : '';
406
+ if (code === 'private_lane_disabled') {
407
+ return `private network access for postgres is not yet available on this deployment — postgres ${ctx.service} stays reachable on its public endpoint (DATABASE_URL)`;
408
+ }
409
+ if (code === 'private_lane_unavailable_in_region') {
410
+ return `the region of postgres ${ctx.service} has no private network lane yet — private access was turned back off and nothing was minted; DATABASE_URL is unchanged`;
411
+ }
412
+ if (e.status === 400 && /private network access first/i.test(e.message)) {
413
+ return `turn on private access first: \`insta postgres private-access on ${ctx.service}${b}\` — closing public access without it would leave the database with no way to connect`;
414
+ }
415
+ if (e.status === 400 && /re-open public access first/i.test(e.message)) {
416
+ return `re-open public access first: \`insta postgres public-access on ${ctx.service}${b}\` — turning private access off while public access is closed would leave the database with no way to connect`;
417
+ }
418
+ return `${ctx.what} failed (${e.status}): ${e.message}`;
419
+ }
420
+ async function resolvePostgres(api, projectId, branch, service) {
421
+ const { services } = await api.request('GET', `/projects/${projectId}/services${q(branch)}`);
422
+ return resolveSoleService(services, 'postgres', service);
423
+ }
424
+ function settingsSuffix(branch, group) {
425
+ const qs = new URLSearchParams();
426
+ if (branch)
427
+ qs.set('branch', branch);
428
+ qs.set('group', group);
429
+ return `?${qs}`;
430
+ }
431
+ // `insta postgres public-access [on|off] [service]` — show, open, or close the public endpoint.
432
+ // Reads GET /database/instance; writes PUT /services/:id/access {public} (gated: service.setAccess).
433
+ // Closing previews GET /services/:id/access/impact?public=false first and asks before acting; a
434
+ // non-interactive caller (agent, CI, --json) must pass --yes, having read the preview it printed.
435
+ export async function dbPublicAccess(first, second, opts, deps = {}) {
436
+ const { mode, service } = alwaysOnArgs(first, second);
437
+ const api = await ApiClient.load();
438
+ const p = await requireProject();
439
+ const branch = opts.branch ?? p.branch;
440
+ const svc = await resolvePostgres(api, p.projectId, branch, service);
441
+ if (!mode)
442
+ return showAccess(api, p.projectId, branch, svc.name, opts);
443
+ const what = mode === 'off' ? 'closing public access' : 'opening public access';
444
+ const ctx = { service: svc.name, branch: opts.branch, what };
445
+ if (mode === 'off') {
446
+ let impact;
447
+ try {
448
+ impact = await api.request('GET', `/projects/${p.projectId}/services/${svc.id}/access/impact?public=false`);
449
+ }
450
+ catch (e) {
451
+ if (e instanceof ApiError)
452
+ throw new Error(`previewing the impact failed (${e.status}): ${e.message}`);
453
+ throw e;
454
+ }
455
+ const lines = impactLines(impact);
456
+ const tty = deps.tty ?? (!!process.stdin.isTTY && !!process.stdout.isTTY);
457
+ if (!opts.yes) {
458
+ if (opts.json || !tty) {
459
+ refuse([
460
+ ...lines,
461
+ `postgres ${svc.name}: closing public access makes the database not reachable from the internet — re-run with --yes to confirm`,
462
+ ]);
463
+ }
464
+ for (const l of lines)
465
+ info(l);
466
+ const ok = await (deps.confirm ?? confirmOnTerminal)(`Close public access to postgres ${svc.name}?`);
467
+ if (!ok) {
468
+ info(`postgres ${svc.name}: public access unchanged`);
469
+ return;
470
+ }
471
+ }
472
+ else if (!opts.json) {
473
+ for (const l of lines)
474
+ info(l);
475
+ }
476
+ }
477
+ let res;
478
+ try {
479
+ res = await api.rawRequest('PUT', `/projects/${p.projectId}/services/${svc.id}/access`, { public: mode === 'on' });
480
+ }
481
+ catch (e) {
482
+ if (e instanceof ApiError)
483
+ throw new Error(accessErrorMessage(e, ctx));
484
+ throw e;
485
+ }
486
+ if (handleApproval(res, opts.json))
487
+ return;
488
+ if (opts.json)
489
+ return printJson(res.body);
490
+ for (const l of publicAccessSetLines(svc.name, mode, res.body))
491
+ info(l);
492
+ }
493
+ export function publicAccessSetLines(group, mode, body) {
494
+ const notice = typeof body?.notice === 'string' && body.notice ? body.notice : undefined;
495
+ const head = mode === 'off'
496
+ ? `postgres ${group}: public access CLOSED — not reachable from the internet; external clients, CI and local development can no longer connect. Compute services on InstaCloud connect through DATABASE_PRIVATE_URL`
497
+ : `postgres ${group}: public access open — reachable on the public endpoint (DATABASE_URL)${notice ? '' : ' (it already was; nothing changed)'}`;
498
+ return notice ? [head, ` ${notice}`] : [head];
499
+ }
500
+ // `insta postgres private-access [on|off] [service]` — show or toggle the private network lane.
501
+ // Writes PATCH /database/settings {privateAccess} (gated: service.setAccess). On mints
502
+ // DATABASE_PRIVATE_URL beside DATABASE_URL (unchanged); off retracts it (refused while public
503
+ // access is closed). No confirmation: neither direction takes a working connection away without
504
+ // the platform refusing or warning (off answers `warnings` naming computes still bound to it).
505
+ export async function dbPrivateAccess(first, second, opts) {
506
+ const { mode, service } = alwaysOnArgs(first, second);
507
+ const api = await ApiClient.load();
508
+ const p = await requireProject();
509
+ const branch = opts.branch ?? p.branch;
510
+ const svc = await resolvePostgres(api, p.projectId, branch, service);
511
+ if (!mode)
512
+ return showAccess(api, p.projectId, branch, svc.name, opts);
513
+ const ctx = { service: svc.name, branch: opts.branch, what: `turning private access ${mode}` };
514
+ let res;
515
+ try {
516
+ res = await api.rawRequest('PATCH', `/projects/${p.projectId}/database/settings${settingsSuffix(branch, svc.name)}`, { privateAccess: mode === 'on' });
517
+ }
518
+ catch (e) {
519
+ if (e instanceof ApiError)
520
+ throw new Error(accessErrorMessage(e, ctx));
521
+ throw e;
522
+ }
523
+ if (handleApproval(res, opts.json))
524
+ return;
525
+ if (opts.json)
526
+ return printJson(res.body);
527
+ for (const l of privateAccessSetLines(svc.name, mode, res.body))
528
+ info(l);
529
+ }
530
+ export function privateAccessSetLines(group, mode, body) {
531
+ const lines = mode === 'on'
532
+ ? [
533
+ `postgres ${group}: private access ON — DATABASE_PRIVATE_URL minted beside DATABASE_URL (unchanged)`,
534
+ ' it resolves only inside InstaCloud compute (insta-compute), not from a laptop or CI — keep DATABASE_URL for those',
535
+ ` switch a compute over: insta secrets bind DATABASE_URL postgres/${group} --source-name DATABASE_PRIVATE_URL --to compute/<name>, then \`insta compute restart <name>\``,
536
+ ' other workloads on the compute plane can reach the private endpoint too — the password is what authenticates',
537
+ ]
538
+ : [`postgres ${group}: private access off — DATABASE_PRIVATE_URL retracted; DATABASE_URL is unchanged`];
539
+ for (const w of Array.isArray(body?.warnings) ? body.warnings : [])
540
+ if (typeof w === 'string' && w)
541
+ lines.push(` ! ${w}`);
542
+ return lines;
543
+ }
544
+ async function showAccess(api, projectId, branch, group, opts) {
545
+ const read = await fetchDbInstance(api, projectId, settingsSuffix(branch, group));
546
+ if (read.kind === 'no-instance') {
547
+ info(`postgres ${group}: no manageable instance (network access is not configurable for this service)`);
548
+ return;
549
+ }
550
+ if (opts.json)
551
+ return printJson(read.body);
552
+ for (const l of dbAccessLines(group, read.body))
553
+ info(l);
554
+ }
377
555
  //# sourceMappingURL=postgres.js.map
@@ -47,14 +47,13 @@ export function parseCount(raw) {
47
47
  }
48
48
  return n;
49
49
  }
50
- // Parse a TCP port. Junk fails here rather than reaching the API as NaN (the parseCpu lesson).
51
- // Decimal digits only, as parseVolumeGib: `Number()` alone would quietly read 0x1f90 as 8080 and
52
- // 1e3 as 1000, and a port written in hex is a typo worth reporting, not one worth honouring.
53
- export function parsePort(raw) {
50
+ // Keep TCP-only callers strict; port 0 is reserved for explicitly opted-in compute workers.
51
+ export function parsePort(raw, opts = {}) {
52
+ const min = opts.allowZero ? 0 : 1;
54
53
  const m = /^\s*(\d+)\s*$/.exec(raw);
55
54
  const n = m ? Number(m[1]) : NaN;
56
- if (!Number.isInteger(n) || n < 1 || n > 65535)
57
- throw new Error(`port must be an integer between 1 and 65535, got: ${raw}`);
55
+ if (!Number.isInteger(n) || n < min || n > 65535)
56
+ throw new Error(`port must be an integer between ${min} and 65535, got: ${raw}`);
58
57
  return n;
59
58
  }
60
59
  // Parse a volume size in whole Gi: "10" or "10Gi" (suffix case-insensitive — unlike the db
@@ -70,6 +69,13 @@ export function parseVolumeGib(raw) {
70
69
  throw new Error(`invalid volume size: ${raw} (whole Gi — try 1 or 10)`);
71
70
  return n;
72
71
  }
72
+ // Decimal digits only, as parsePort; which majors exist is the platform's answer (a 400 naming them).
73
+ export function parsePgVersion(raw) {
74
+ const m = /^\s*(\d+)\s*$/.exec(raw);
75
+ if (!m)
76
+ throw new Error(`invalid Postgres major: ${raw} (a whole major version, e.g. 17)`);
77
+ return Number(m[1]);
78
+ }
73
79
  // Resolve a service id from a `services list` result by (type, name).
74
80
  export function resolveServiceId(services, type, name) {
75
81
  const svc = services.find((s) => s.type === type && s.name === name);
@@ -105,13 +111,15 @@ function defaultDatabasePort(type) {
105
111
  export function servicesAddRequestBody(type, name, branch, opts) {
106
112
  return {
107
113
  type, name, ...(branch ? { branch } : {}), public: !!opts.public,
108
- ...(opts.image ? { image: opts.image } : {}), ...(opts.port ? { port: parsePort(opts.port) } : {}),
114
+ ...(opts.image ? { image: opts.image } : {}), ...(opts.port !== undefined ? { port: parsePort(opts.port, { allowZero: type === 'compute' }) } : {}),
109
115
  ...(opts.region ? { region: opts.region } : {}),
110
- // Sent whenever the flag was given, false included: compute is born always-on by default,
111
- // so `--no-always-on` must reach the API as an explicit false. Omitted means the platform default.
116
+ // Sent whenever the flag was given, false included: the birth default is platform config
117
+ // (INSTA_COMPUTE_ALWAYS_ON_DEFAULT, scale-to-zero unless set), so an explicit --no-always-on
118
+ // must reach the API as false to hold whatever that config says. Omitted means the platform default.
112
119
  ...(opts.alwaysOn !== undefined ? { alwaysOn: opts.alwaysOn } : {}),
113
120
  ...(opts.volume !== undefined ? { volumeGib: parseVolumeGib(opts.volume) } : {}),
114
121
  ...(opts.mountPath !== undefined ? { volumeMountPath: opts.mountPath } : {}),
122
+ ...(opts.pgVersion !== undefined ? { pgVersion: parsePgVersion(opts.pgVersion) } : {}),
115
123
  };
116
124
  }
117
125
  export async function servicesAdd(type, name, opts = {}) {
@@ -122,10 +130,10 @@ export async function servicesAdd(type, name, opts = {}) {
122
130
  throw new Error('--region is not valid for storage services');
123
131
  if (opts.image && type !== 'compute')
124
132
  throw new Error('--image is only valid for compute services');
125
- if (opts.port) {
133
+ if (opts.port !== undefined) {
126
134
  if (type !== 'compute')
127
135
  throw new Error('--port is only valid for compute services');
128
- parsePort(opts.port); // junk fails here, before any config/network access
136
+ parsePort(opts.port, { allowZero: true });
129
137
  }
130
138
  // Presence, not truthiness: `--no-always-on` is an explicit false and is just as compute-only.
131
139
  if (opts.alwaysOn !== undefined && type !== 'compute')
@@ -137,6 +145,11 @@ export async function servicesAdd(type, name, opts = {}) {
137
145
  throw new Error(volumeTypeError(type));
138
146
  parseVolumeGib(opts.volume); // junk fails here, before any config/network access
139
147
  }
148
+ if (opts.pgVersion !== undefined) {
149
+ if (type !== 'postgres')
150
+ throw new Error('--pg-version is only valid for postgres services');
151
+ parsePgVersion(opts.pgVersion);
152
+ }
140
153
  const api = await ApiClient.load();
141
154
  const p = await requireProject();
142
155
  const branch = opts.branch ?? p.branch;
@@ -121,7 +121,7 @@ export async function deployArchive(api, projectId, ref, branch, opts, now = Dat
121
121
  if (state === 'live') {
122
122
  const image = res.body?.imageRef;
123
123
  const url = res.body?.url;
124
- if (typeof image !== 'string' || !image || typeof url !== 'string' || !url) {
124
+ if (typeof image !== 'string' || !image || typeof url !== 'string' || (!url && opts.port !== '0')) {
125
125
  throw new Error('the deploy finished but the platform returned no image or URL for it — check `insta status`');
126
126
  }
127
127
  const optionalString = (field, v) => {
package/dist/index.js CHANGED
@@ -153,11 +153,12 @@ svc.command('add [type] [name]').description('Provision a service on demand (com
153
153
  .option('--region <region>', 'region for postgres/compute/managed databases, e.g. us-east (see `insta config regions`)')
154
154
  .option('--public', 'storage only: serve the bucket with anonymous public-read (default private)')
155
155
  .option('--image <url>', 'compute only: run this container image at creation')
156
- .option('--port <n>', 'compute only: port the image listens on (default 8080)')
157
- .option('--always-on', 'compute only: create as always-on — never scales to zero (the default for new compute services; all plans; billing is actual usage either way)')
158
- .option('--no-always-on', 'compute only: create as scale-to-zero — idle machines suspend and wake on the next request')
156
+ .option('--port <n>', 'compute only: port the image listens on (default 8080; 0 for a worker without a routed port)')
157
+ .option('--always-on', 'compute only: create as always-on — never scales to zero (all plans; billing is actual usage either way). A worker (--port 0) is always-on regardless')
158
+ .option('--no-always-on', 'compute only: create as scale-to-zero — idle machines suspend and wake on the next request (the default for new compute services; refused with --port 0, a worker cannot scale to zero)')
159
159
  .option('--mount-path <path>', 'compute only: container mount path for a new volume (requires --volume; default /data)')
160
160
  .option('--volume <gi>', 'compute only: attach a persistent volume of this many whole Gi (also attachable later: `insta compute volume <name> --size <gi>`). Any plan may attach up to its own plan cap (10Gi free, 50Gi paid by default; the bare `insta compute volume <name>` read prints it as plan max); a size above the free cap is paid. Volume services keep 1 machine and stop (cold wake) instead of suspend when idle')
161
+ .option('--pg-version <major>', 'postgres only: the Postgres major version to create the database on — 16, 17 or 18 (default 16). Fixed for the database\'s lifetime; branches keep it')
161
162
  .option('--json')
162
163
  .action(guard(async (type, name, o) => {
163
164
  const a = await resolveServiceArgs(type, name, serviceArgsDeps(o.json), o);
@@ -172,10 +173,11 @@ svc.command('rename <type> <name> <new-name>').description('Rename a service and
172
173
  .option('--json').option('--branch <branch>', 'branch (default: current)')
173
174
  .action(guard((type, name, newName, o) => services.servicesRename(type, name, newName, o)));
174
175
  // ---- secrets (seam) ----
175
- const sec = program.command('secrets').description('Fetch the credential bundle (secret seam) into .env')
176
+ const sec = program.command('secrets').description('Write a local .env bundle: user secrets plus primary provider credentials; compute credentials require explicit bindings')
176
177
  .option('--branch <branch>')
177
178
  .option('--service <type/name>', "read one compute service's own slice of the bundle instead of the branch-wide merge, e.g. compute/api")
178
179
  .option('-o, --output <file>', 'output file (default .env)').option('--print', 'print instead of writing').option('--json')
180
+ .addHelpText('after', '\nFetching this bundle does not bind credentials to compute. A service created with `insta service add compute` starts with no provider credential bindings; bind each source it needs, then deploy.\n\nExample: insta secrets bind DATABASE_URL postgres/db --to compute/app')
179
181
  .action(guard((o) => secretsCmd.secrets(o)));
180
182
  // commander 12 defaults allowExcessArguments to true, so a mistyped/retired subcommand (e.g.
181
183
  // `secrets lst`) would otherwise run this group's own action instead of failing.
@@ -188,11 +190,12 @@ sec.command('unset <name>').description('Remove a user secret')
188
190
  .option('--branch <branch>', 'scope to one branch')
189
191
  .option('--service <type/name>', "remove only that service's copy, e.g. compute/api")
190
192
  .option('--json').action(guard((n, o) => secretsCmd.secretsUnset(n, o)));
191
- sec.command('bind <env-name> <source>').description('Bind a service credential into a compute env var')
193
+ sec.command('bind <env-name> <source>').description('Bind one provider credential to a compute service (applied on next deploy or restart)')
192
194
  .option('--branch <branch>', 'branch (default: current)')
193
195
  .option('--to <compute-service>', 'target compute service, e.g. compute/api')
194
196
  .option('--source-name <name>', 'source credential name when the source exposes more than one')
195
197
  .option('--json')
198
+ .addHelpText('after', '\nThe local .env bundle includes primary provider credentials without bindings. Compute services require their own explicit bindings: `insta service add compute` creates a service with no provider credential bindings inherited from other services.\n\nExample: insta secrets bind DATABASE_URL postgres/db --to compute/app')
196
199
  .action(guard((n, source, o) => secretsCmd.secretsBind(n, source, o)));
197
200
  sec.command('unbind <env-name>').description('Remove a service credential binding from a compute env var')
198
201
  .option('--branch <branch>', 'branch (default: current)')
@@ -376,7 +379,7 @@ compute.command('scale [count] [service]').description('Set a compute service sa
376
379
  compute.command('limits [service]').description("Show or set a compute service's resource ceiling (any plan within the free cap; raising above it needs a paid plan). --memory is the dial; cpu derives from it unless --cpu is given. Billing is actual usage — the ceiling caps what the app may burn, it is not a price")
377
380
  .option('--memory <size>', 'memory ceiling, e.g. 512mb or 1gb').option('--cpu <n>', 'vCPU ceiling override (provider sizes: 1, 2, 4, 6, 8)')
378
381
  .option('--json').option('--branch <branch>', 'branch (default: current)').action(guard((service, o) => computeCmd.computeLimits(service, o)));
379
- compute.command('always-on <mode> [service]').description('Set a compute service always-on (mode: on|off). on = machines never scale to zero (the default for new compute services); off = scale-to-zero. All plans; billing is actual usage either way')
382
+ compute.command('always-on <mode> [service]').description('Set a compute service always-on (mode: on|off). on = machines never scale to zero; off = scale-to-zero (the default for new compute services; refused on a worker, port 0). All plans; billing is actual usage either way')
380
383
  .option('--json').option('--branch <branch>', 'branch (default: current)').action(guard((mode, service, o) => computeCmd.computeAlwaysOn(mode, service, o)));
381
384
  compute.command('ssh [service]')
382
385
  .description("Issue a short-lived SSH certificate for a compute service and print the command that uses it -- this command does NOT open the session itself, it makes `ssh` work. `--setup` does the one-time work: it generates a dedicated key under ~/.insta/ssh (your existing keys are never touched), has the platform sign a SHORT-LIVED certificate for it, adds one @cert-authority line to ~/.ssh/known_hosts so every region is trusted without per-node fingerprint prompts, and writes an ssh_config block AT THE TOP of ~/.ssh/config giving each compute service the alias `<service>.insta`. After that it is plain `ssh api.insta`, scp and -L: the block renews that alias's certificate for you while OpenSSH parses the config. Needs an interactive login -- API keys are refused; use `insta compute exec` for one-shot commands from CI")
@@ -420,8 +423,8 @@ compute.command('volume [service]').description("Show, attach, grow, remount, or
420
423
  .option('--json').option('--branch <branch>', 'branch (default: current)').action(guard((service, o) => computeCmd.computeVolume(service, o)));
421
424
  addObservability(compute, 'compute', 'compute');
422
425
  // ---- postgres ----
423
- const pg = program.command('postgres').description('Postgres services: connection string, psql, stats, resource ceiling, volume, always-on, logs, metrics');
424
- pg.command('url [service]').description('Print the postgres connection string (DSN) — bare on stdout for piping, e.g. `psql "$(insta postgres url)"` (gated: secrets.read). Provider credentials are not in `insta secrets` — this is the command that yields the DSN')
426
+ const pg = program.command('postgres').description('Postgres services: connection string, psql, stats, resource ceiling, volume, always-on, network access, logs, metrics');
427
+ pg.command('url [service]').description('Print the postgres connection string (DSN) — bare on stdout for piping, e.g. `psql "$(insta postgres url)"` (gated: secrets.read). `insta secrets` includes the primary postgres service\'s DATABASE_URL; use this command to select a specific postgres service')
425
428
  .option('--json').option('--branch <branch>', 'branch (default: current)')
426
429
  .action(guard((service, o) => pgCmd.dbUrl(service, o)));
427
430
  pg.command('connect [service]').description("Open an interactive psql session on the postgres service (needs psql on PATH; gated: secrets.read). A suspended instance wakes on connect — the first prompt can take a few seconds. Exits with psql's own exit code")
@@ -441,8 +444,15 @@ pg.command('volume [service]').description("Show or grow a postgres service's pr
441
444
  pg.command('always-on [mode] [service]').description('Show or set a postgres service always-on (mode: on|off; no mode = print the current setting). on = instance stays warm, no cold starts; off = default scale-to-zero (idle instance suspends; first connection cold-starts). insta-db-backed services only')
442
445
  .option('--json').option('--branch <branch>', 'branch (default: current)')
443
446
  .action(guard((mode, service, o) => pgCmd.dbAlwaysOn(mode, service, o)));
447
+ pg.command('private-access [mode] [service]').description('Show or set private network access (mode: on|off; no mode = print public and private access). on = mint DATABASE_PRIVATE_URL beside DATABASE_URL (unchanged) — it resolves only inside InstaCloud compute, not from a laptop or CI; bind it into a compute with `insta secrets bind … --source-name DATABASE_PRIVATE_URL`. off = retract it (refused while public access is closed). insta-db-backed only (gated: service.setAccess)')
448
+ .option('--json').option('--branch <branch>', 'branch (default: current)')
449
+ .action(guard((mode, service, o) => pgCmd.dbPrivateAccess(mode, service, o)));
450
+ pg.command('public-access [mode] [service]').description("Show, close or re-open the public endpoint (mode: on|off; no mode = print public and private access). off = the database is not reachable from the internet: external clients, CI and local development can no longer connect, only InstaCloud compute via DATABASE_PRIVATE_URL. Needs private access on first. Prints what will break and asks first; takes effect within about 30 seconds. insta-db-backed only (gated: service.setAccess)")
451
+ .option('-y, --yes', 'close without asking (required when there is no terminal, or with --json) — read the printed impact first')
452
+ .option('--json').option('--branch <branch>', 'branch (default: current)')
453
+ .action(guard((mode, service, o) => pgCmd.dbPublicAccess(mode, service, o)));
444
454
  addObservability(pg, 'db', 'postgres');
445
- // ---- redis / mysql / mongodb (managed Fly databases) ----
455
+ // ---- redis / mysql / mongodb (managed databases) ----
446
456
  for (const type of ['redis', 'mysql', 'mongodb']) {
447
457
  const g = program.command(type).description(`Managed ${type} services: query, status, resource ceiling, volume, always-on, logs, metrics`);
448
458
  const query = g.command('query <service> [args...]').description(type === 'redis'
@@ -505,7 +515,7 @@ buildCmd.command('logs <build-id>').description('Read source-build output for a
505
515
  .action(guard((id, opts) => buildLogs(id, opts)));
506
516
  // ---- deploy ----
507
517
  program.command('deploy [dir]').description('Deploy a source directory (built remotely; on insta-compute a Dockerfile is optional and nixpacks detects the runtime) or a prebuilt --image to a branch compute group')
508
- .option('--image <url>', 'prebuilt container image to deploy (instead of a source dir)').option('--branch <b>').option('--group <g>').option('--port <p>')
518
+ .option('--image <url>', 'prebuilt container image to deploy (instead of a source dir)').option('--branch <b>').option('--group <g>').option('--port <p>', 'port the app listens on (0 for a worker without a routed port)')
509
519
  .option('--websocket', 'run a WebSocket app (larger guest + connection-based concurrency)')
510
520
  .option('--replace-source', 'the service deploys from a connected GitHub repo: switch it to this image and remove the repo connection (admin); without it such a deploy is refused')
511
521
  .option('--json', 'print the deploy result as JSON (build progress goes to stderr)')
package/dist/pack.js CHANGED
@@ -109,31 +109,8 @@ function walk(root, rel, out, links, ig, files, flavour, cap = Infinity) {
109
109
  }
110
110
  }
111
111
  }
112
- // The walk classifies with lstat and the read happens later, so a plain readFileSync would
113
- // FOLLOW a symlink that replaced the file in between and put a file from outside the directory
114
- // into an archive that promises none. Two guards, and neither is a full one on its own:
115
- //
116
- // O_NOFOLLOW refuses when the final component is a symlink AT OPEN TIME, closing the swap the
117
- // walk cannot see. Undefined on Windows, where it degrades to the check below.
118
- //
119
- // fstat on the OPEN HANDLE must still describe the file the walk measured: same inode, same
120
- // device, same size. Its job is the tar's own consistency -- a file rewritten to a different
121
- // length mid-pack would otherwise produce a header whose count disagrees with its payload.
122
- //
123
- // Two things neither closes, and both are stated rather than implied away:
124
- //
125
- // An ANCESTOR directory swapped for a symlink. Node exposes no openat, so resolving each
126
- // component against a directory handle is not available here.
127
- //
128
- // A same-size plain file deleted and recreated. Measured on linux rather than assumed: the
129
- // inode is REUSED and mtimeNs/ctimeNs are byte-identical for a delete+create inside one
130
- // timestamp tick, so no stat-based identity can see it. It is also the least interesting case
131
- // -- the symlink promise still holds, the tar stays well formed because the length did not
132
- // move, and the archive simply carries a slightly newer copy of a file the caller owns.
133
- //
134
- // The residual on both is narrow: someone able to rewrite files and directories inside the tree
135
- // being packed can already put any bytes they like into it by writing them.
136
- export function readEntry(abs, e) {
112
+ // O_NOFOLLOW closes final-component symlink swaps only on platforms that expose it.
113
+ export function readEntry(abs, e, platform = process.platform) {
137
114
  const noFollow = constants.O_NOFOLLOW ?? 0;
138
115
  let fd;
139
116
  try {
@@ -147,8 +124,10 @@ export function readEntry(abs, e) {
147
124
  }
148
125
  try {
149
126
  const st = fstatSync(fd);
127
+ // Some Windows libuv versions omit lstat's device ID; inode and size must still match.
128
+ const missingDevice = platform === 'win32' && (e.dev === 0 || e.dev === 0n);
150
129
  const same = st.isFile() && st.size === e.size
151
- && (e.ino === undefined || st.ino === e.ino) && (e.dev === undefined || st.dev === e.dev);
130
+ && (e.ino === undefined || st.ino === e.ino) && (e.dev === undefined || missingDevice || st.dev === e.dev);
152
131
  if (!same)
153
132
  throw new Error(`${e.path} changed while packing — re-run the deploy`);
154
133
  return readFileSync(fd);
@@ -70,7 +70,7 @@ export async function resolveServiceArgs(type, name, deps, given = {}) {
70
70
  throw new Error(missingArgsMessage(type));
71
71
  // A bad --port is a typo in the command, not an answer: fail before asking anything.
72
72
  if (given.port !== undefined)
73
- parsePort(given.port);
73
+ parsePort(given.port, { allowZero: !type || type === 'compute' });
74
74
  const kind = type
75
75
  ? SERVICE_KINDS.find((k) => k.type === type && !k.needsImage)
76
76
  : await deps.selectKind(SERVICE_KINDS);
@@ -133,12 +133,12 @@ export async function promptServiceName(kind, suggested) {
133
133
  }
134
134
  export async function promptPort(fallback) {
135
135
  const answer = await clack.text({
136
- message: 'Port the image listens on:',
136
+ message: 'Port the image listens on (0 for a worker):',
137
137
  initialValue: fallback,
138
138
  // The rule the command enforces, so the prompt and a --port can never disagree.
139
139
  validate: (v) => {
140
140
  try {
141
- parsePort(v.trim());
141
+ parsePort(v.trim(), { allowZero: true });
142
142
  return undefined;
143
143
  }
144
144
  catch (e) {
package/dist/telemetry.js CHANGED
@@ -36,6 +36,7 @@ const SAFE_ARGS = {
36
36
  'storage set-access': { 0: oneOf(['public', 'private']) },
37
37
  'compute scale': { 0: (v) => /^\d+$/.test(v) },
38
38
  'compute always-on': { 0: ON_OFF }, 'postgres always-on': { 0: ON_OFF },
39
+ 'postgres private-access': { 0: ON_OFF }, 'postgres public-access': { 0: ON_OFF },
39
40
  'redis always-on': { 0: ON_OFF }, 'mysql always-on': { 0: ON_OFF }, 'mongodb always-on': { 0: ON_OFF },
40
41
  'template info': { 0: SLUG }, 'billing subscribe': { 0: oneOf(['pro', 'team']) },
41
42
  'agent approvals approve': { 0: ID }, 'agent approvals deny': { 0: ID },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "insta",
3
- "version": "0.1.12",
3
+ "version": "0.1.15",
4
4
  "type": "module",
5
5
  "description": "InstaCloud CLI — a thin client of the platform control-plane API.",
6
6
  "keywords": [