insta 0.1.24 → 0.1.26

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
@@ -229,7 +229,7 @@ build never reaches a production installer.
229
229
  | `insta branch` | `create` · `list` · `switch` · `delete` · `merge` |
230
230
  | `insta service` (`services`, `svc`) | `add` · `list` · `remove` · `rename` |
231
231
  | `insta secrets` | Write `.env`, plus `list` · `set` · `unset` · `bind` · `unbind` · `bindings` · `sources` · `tree` |
232
- | `insta domain` | Bought or bring-your-own: `attach` · `check` · `detach`; buy through InstaCloud: `search` · `buy` · `list` · `status` · `records …` |
232
+ | `insta domain` | Bought or bring-your-own: `attach` · `check` · `detach`. Domains already bought through InstaCloud: `list` · `status` · `records …` |
233
233
  | `insta compute` | `start` · `stop` · `suspend` · `restart` · `status` · `scale` · `limits` · `volume` · `always-on` · `exec` · `ssh` · `repo` · `connect-repo` · `watch-paths` · `disconnect-repo` · `logs` · `metrics` |
234
234
  | `insta postgres` | `url` (print the DSN) · `connect` (psql) · `stats` · `limits` · `restart` · `volume` · `always-on` · `private-access` · `public-access` · `logs` · `metrics` — every verb takes `[service]` |
235
235
  | `insta redis` · `mysql` · `mongodb` | `query` · `status` · `limits` · `volume` · `always-on` · `logs` · `metrics` |
@@ -165,11 +165,11 @@ export function jobShowLines(j, serviceName) {
165
165
  // decrypts it. Saying so beats an empty "value" column that reads as "this header is set to
166
166
  // nothing". Secret-backed headers get a row each, because the secret NAME is readable and is the
167
167
  // thing an operator needs when a rotation or a missing secret is the question.
168
- const refs = Object.entries(refsOf(j.request));
169
- const literal = j.request.headerNames.filter((n) => findHeader(refsOf(j.request), n) === undefined);
168
+ const refs = refsOf(j.request);
169
+ const literal = j.request.headerNames.filter((n) => findHeader(refs, n) === undefined);
170
170
  const rows = [
171
171
  ...(literal.length ? [`${literal.join(', ')} (names only — values are encrypted at rest and never returned)`] : []),
172
- ...refs.map(([h, s]) => `${h} ← secret ${s} (resolved at send time)`),
172
+ ...Object.entries(refs).map(([h, s]) => `${h} ← secret ${s} (resolved at send time)`),
173
173
  ];
174
174
  if (!rows.length)
175
175
  lines.push(' headers (none)');
@@ -6,7 +6,7 @@ import { info, die, printJson, handleApproval, renderNextActions, CliExit } from
6
6
  import { flyctlBuildAndPush, ensureFlyctl, defaultBuildRunner, stderrBuildRunner } from '../flyctl-build.js';
7
7
  import { packDirectory, windowsModeCaveat } from '../pack.js';
8
8
  import { deployArchive, uploadArchive } from '../deploy-archive.js';
9
- import { parseEndpoint, parsePort } from './services.js';
9
+ import { isV6Endpoint, parseEndpoint, parsePort } from './services.js';
10
10
  // With --json, stdout must carry exactly one JSON document (the deploy result), so every progress
11
11
  // line moves to stderr.
12
12
  const note = (opts) => (opts.json ? (m) => void process.stderr.write(m + '\n') : info);
@@ -23,11 +23,19 @@ export function deployRequestBody(source, branch, opts) {
23
23
  replaceSource: opts.replaceSource ? true : undefined,
24
24
  };
25
25
  }
26
- // Where the deploy answers: the HTTPS URL, or for an endpoint tcp service its tcp address (the
27
- // platform reports no URL for one). Pure, so it's unit-tested.
28
- export function deployedTarget(r, port) {
29
- if (r.endpointHost)
30
- return `tcp://${r.endpointHost}:${port ?? '?'}`;
26
+ // Where the deploy answers: the HTTPS URL, or for a v6-lane service (tcp, udp, tcp+udp) its
27
+ // address, one scheme per protocol (the platform reports no URL for one). The scheme comes from the
28
+ // RESULT's `endpoint` (instacloud-platform#681 reports it beside endpointHost, including on a
29
+ // redeploy that passed none -- the endpoint the service kept); --endpoint is only the fallback for
30
+ // an older platform that names the host but not the endpoint. With neither, the address prints
31
+ // without a scheme rather than guessing tcp for what may be a udp service. Pure, so it's unit-tested.
32
+ export function deployedTarget(r, port, flag) {
33
+ if (r.endpointHost) {
34
+ const endpoint = isV6Endpoint(r.endpoint) ? r.endpoint : isV6Endpoint(flag) ? flag : undefined;
35
+ if (endpoint === undefined)
36
+ return `${r.endpointHost}:${port ?? '?'} (endpoint unchanged; \`insta service list\` shows its protocol)`;
37
+ return (endpoint === 'tcp+udp' ? ['tcp', 'udp'] : [endpoint]).map((pr) => `${pr}://${r.endpointHost}:${port ?? '?'}`).join(', ');
38
+ }
31
39
  return r.url || '(no URL: a worker)';
32
40
  }
33
41
  // Ask the platform which lane serves this target, so the CLI stops knowing which provider backs
@@ -152,7 +160,7 @@ export async function deploy(dir, opts) {
152
160
  if (!dir && !opts.image)
153
161
  die('usage: insta deploy <dir> | --image <url> [--branch <b>] [--group <g>] [--port <n>]');
154
162
  // Normalized here, once, so both lanes get the same validated value: the archive lane's body
155
- // (deployArchive) otherwise silently drops anything that isn't exactly 'http'/'tcp' to
163
+ // (deployArchive) otherwise silently drops anything that isn't exactly one of the endpoints to
156
164
  // undefined, which let a typo like --endpoint tpc build and deploy using the service's existing
157
165
  // endpoint instead of failing locally. Checked before ApiClient.load()/requireProject(): those
158
166
  // can resolve a project and persist an automatic link for an unlinked directory, so a typo must
@@ -192,8 +200,8 @@ export async function deploy(dir, opts) {
192
200
  // nextActions, which the operation read does not carry.
193
201
  const d = source.deployed;
194
202
  if (opts.json)
195
- return printJson({ image: d.image, url: d.url, ...(d.endpointHost ? { endpointHost: d.endpointHost } : {}), branch: d.branch, group: d.group, machineId: d.machineId });
196
- info(`deployed ${d.image} -> ${deployedTarget(d, effOpts.port)} (branch ${d.branch}, group ${d.group})`);
203
+ return printJson({ image: d.image, url: d.url, ...(d.endpointHost ? { endpointHost: d.endpointHost } : {}), ...(d.endpoint ? { endpoint: d.endpoint } : {}), branch: d.branch, group: d.group, machineId: d.machineId });
204
+ info(`deployed ${d.image} -> ${deployedTarget(d, effOpts.port, endpoint)} (branch ${d.branch}, group ${d.group})`);
197
205
  return;
198
206
  }
199
207
  const res = await api.rawRequest('POST', `/projects/${p.projectId}/deploy`, deployRequestBody(source, branch, effOpts))
@@ -203,7 +211,7 @@ export async function deploy(dir, opts) {
203
211
  const what = source.image;
204
212
  if (opts.json)
205
213
  return printJson({ image: source.image, ...res.body });
206
- info(`deployed ${what} -> ${deployedTarget(res.body, effOpts.port)} (branch ${res.body.branch}, group ${res.body.group})`);
214
+ info(`deployed ${what} -> ${deployedTarget(res.body, effOpts.port, endpoint)} (branch ${res.body.branch}, group ${res.body.group})`);
207
215
  renderNextActions(res.body.nextActions);
208
216
  }
209
217
  // The platform refuses an image deploy onto a repo-connected service and names the body field it
@@ -30,9 +30,6 @@ export async function domainSearch(keyword, opts, deps) {
30
30
  return printJson(r);
31
31
  for (const line of searchLines(r.results))
32
32
  info(line);
33
- const buyable = r.results.find((x) => x.purchasable);
34
- if (buyable)
35
- info(`buy one: insta domain buy ${buyable.domainName}`);
36
33
  }
37
34
  export async function domainBuy(name, opts, deps) {
38
35
  const years = whole('--years', opts.years);
@@ -117,10 +114,10 @@ function domainLines(d, linked = true) {
117
114
  out.push(` ${h.hostname.padEnd(w)} ${h.state}${h.service ? ` → ${h.service}` : ''}${h.reason ? ` — ${h.reason}` : ''}`);
118
115
  return out;
119
116
  }
120
- function orderStatusLines(o, linked = true) {
117
+ function orderStatusLines(o) {
121
118
  const out = [`order ${o.id}: ${o.domainName} — ${o.status}${o.failedReason ? ` — ${o.failedReason}` : ''}`];
122
- if (linked && o.status === 'canceled')
123
- out.push(` the checkout closed without payment — order again: insta domain buy ${o.domainName}`);
119
+ if (o.status === 'canceled')
120
+ out.push(' the checkout closed without payment');
124
121
  return out;
125
122
  }
126
123
  export async function domainList(opts, deps) {
@@ -129,7 +126,7 @@ export async function domainList(opts, deps) {
129
126
  if (opts.json)
130
127
  return printJson(r);
131
128
  if (!r.items.length)
132
- return info('no domains bought through InstaCloud in this org (search: insta domain search <keyword>)');
129
+ return info('no domains bought through InstaCloud in this org');
133
130
  for (const d of r.items)
134
131
  for (const line of domainLines(d, !opts.org))
135
132
  info(line);
@@ -147,7 +144,7 @@ export async function domainStatus(name, opts, deps) {
147
144
  die(`${host} was not bought through this org`);
148
145
  if (opts.json)
149
146
  return printJson({ domain, order });
150
- for (const line of domain ? domainLines(domain, !opts.org) : orderStatusLines(order, !opts.org))
147
+ for (const line of domain ? domainLines(domain, !opts.org) : orderStatusLines(order))
151
148
  info(line);
152
149
  }
153
150
  // ---- nameservers and transferring out ----
@@ -369,10 +366,9 @@ export function zoneRecordLines(records) {
369
366
  * into the zone itself — apexes included — instead of printing them for hand-copying. The zone is
370
367
  * SEEDED by the provider's record scan, which is a heuristic: the review-then-switch contract
371
368
  * (printed, and enforced by nothing else) is to compare `zone records` against the domain's current
372
- * DNS and add what is missing at the CURRENT provider — re-running delegate re-imports — before
373
- * re-pointing. A domain carrying live MX records is refused outright: a DNS move that can drop
374
- * mail is never done implicitly. 202 approval_required in agent mode (zone.delegate); org admin
375
- * either way.
369
+ * DNS before re-pointing. A domain carrying live MX records is refused outright: a DNS move that
370
+ * can drop mail is never done implicitly. 202 approval_required in agent mode (zone.delegate); org
371
+ * admin either way.
376
372
  */
377
373
  const orgArgOf = (opts) => (opts.org ? ` --org ${opts.org}` : '');
378
374
  export async function zoneDelegate(domainName, opts, deps) {
@@ -391,7 +387,7 @@ export async function zoneDelegate(domainName, opts, deps) {
391
387
  const z = res.body;
392
388
  for (const line of zoneLines(z, opts.org))
393
389
  info(line);
394
- info(`the zone was seeded by a provider scan — a heuristic. Check \`insta domain zone records ${z.domainName}${orgArgOf(opts)}\` against your current DNS, add anything missing at your CURRENT provider (re-running delegate re-imports), and only then switch the nameservers.`);
390
+ info(`the zone was seeded by a provider scan, which can miss records. Check \`insta domain zone records ${z.domainName}${orgArgOf(opts)}\` against your current DNS, and only then switch the nameservers.`);
395
391
  }
396
392
  export async function zoneList(opts, deps) {
397
393
  const { api, orgId } = await orgDeps(opts, deps);
@@ -412,7 +408,7 @@ export async function zoneList(opts, deps) {
412
408
  if (opts.json)
413
409
  return printJson(r);
414
410
  if (!r.items.length)
415
- return info('no delegated zones — start one: insta domain zone delegate <domain>');
411
+ return info('no delegated zones');
416
412
  for (const z of r.items)
417
413
  for (const line of zoneLines(z, opts.org))
418
414
  info(line);
@@ -425,7 +421,7 @@ export async function zoneRecords(domainName, opts, deps) {
425
421
  for (const line of zoneRecordLines(r.items))
426
422
  info(line);
427
423
  if (r.items.length)
428
- info(`every type shows here (the scan is a heuristic) — add anything missing at your current DNS provider and re-run \`insta domain zone delegate ${domainName.trim().toLowerCase()}${orgArgOf(opts)}\` to re-import before switching nameservers`);
424
+ info('every type shows here. The provider scan can miss records, so compare them against your current DNS before switching nameservers');
429
425
  }
430
426
  /**
431
427
  * Release a delegated zone: the platform prunes the records it published and deletes the managed
@@ -0,0 +1,101 @@
1
+ // `insta compute public-access` and `insta <redis|mysql|mongodb> public-access`: show, close or
2
+ // re-open a compute-plane service's public access (the platform's PUT .../services/:id/access).
3
+ //
4
+ // Every insta-compute service has a private address in its project branch: `<name>.insta.internal`
5
+ // for compute, `<name>-<type>.insta.internal` for a managed database (the platform's private_host;
6
+ // this file only prints what the platform reports, never composes one). Closing public access makes
7
+ // the service private-only: its URL answers 403 (a database's public *_URL stops connecting) and
8
+ // only services in the same branch reach it. Like `insta postgres public-access off`, closing asks
9
+ // first, and a non-interactive caller (agent, CI, --json) must pass --yes.
10
+ import { ApiClient, ApiError, requireProject } from '../api.js';
11
+ import { handleApproval, info, printJson, refuse } from '../util.js';
12
+ import { q, resolveSoleService } from './services.js';
13
+ import { alwaysOnArgs, confirmOnTerminal } from './postgres.js';
14
+ const isDb = (type) => type !== 'compute';
15
+ const credential = (type) => `${type.toUpperCase()}_URL`;
16
+ const privateCredential = (type) => `${type.toUpperCase()}_PRIVATE_URL`;
17
+ /** `<host>:<port>` for the private address, or undefined when the platform reports none. */
18
+ export function privateAddress(s) {
19
+ return s.private_host ? `${s.private_host}${s.port ? `:${s.port}` : ''}` : undefined;
20
+ }
21
+ // The show block. Pure, exported for tests.
22
+ export function publicAccessLines(s) {
23
+ const pub = s.public;
24
+ const head = `${s.type} ${s.name}: public access ${pub === false ? 'off' : pub === true ? 'on' : 'unknown'}`;
25
+ const why = pub === false
26
+ ? (isDb(s.type) ? ` — ${credential(s.type)} does not connect; apps in this branch use ${privateCredential(s.type)}` : ' — its URL answers 403; only services in this branch reach it')
27
+ : pub === true ? (isDb(s.type) ? '' : ' — reachable from the internet at its URL') : '';
28
+ const lines = [head + why];
29
+ const addr = privateAddress(s);
30
+ if (addr) {
31
+ lines.push(` private: ${addr}${s.type === 'compute' ? ' (TCP and UDP, from services in this branch)' : ` (from services in this branch; ${privateCredential(s.type)} holds the connection string)`}`);
32
+ if (s.private_fqdn)
33
+ lines.push(` fqdn: ${s.private_fqdn} (for tools that skip the search domain, e.g. nslookup, dig)`);
34
+ }
35
+ else {
36
+ lines.push(' private: none yet — the platform reports no private address for this service (not yet back-filled, a worker, or a region without the private network)');
37
+ }
38
+ return lines;
39
+ }
40
+ // Map a refusal to a CLI-shaped message. Pure, exported for tests.
41
+ export function publicAccessErrorMessage(e, ctx) {
42
+ const code = e.body?.code;
43
+ const b = ctx.branch ? ` --branch ${ctx.branch}` : '';
44
+ if (code === 'private_network_disabled')
45
+ return `the private network is not yet available on this deployment — ${ctx.type} ${ctx.service} stays public`;
46
+ if (code === 'private_network_unavailable_in_region')
47
+ return `the region of ${ctx.type} ${ctx.service} has no private network yet — it stays public`;
48
+ if (code === 'no_private_name')
49
+ return `${ctx.type} ${ctx.service} has no private network name, so closing public access would leave it unreachable — rename it first: \`insta service rename ${ctx.type} ${ctx.service} <new-name>${b}\``;
50
+ if (code === 'private_name_taken')
51
+ return `${e.message} — rename one of the services: \`insta service rename ${ctx.type} ${ctx.service} <new-name>${b}\``;
52
+ return `${ctx.what} failed (${e.status}): ${e.message}`;
53
+ }
54
+ // What closing will break, said before asking. Pure, exported for tests.
55
+ export function closeWarning(s) {
56
+ return isDb(s.type)
57
+ ? `closing public access: ${credential(s.type)} (the public address) stops connecting — external clients, CI and local development can no longer reach ${s.type} ${s.name}, and apps in this branch must use ${privateCredential(s.type)} (bind it and redeploy them)`
58
+ : `closing public access: ${s.type} ${s.name}'s URL will answer 403 and stop waking it — browsers, webhooks and every caller outside this branch lose it; services in this branch keep reaching it at its private address`;
59
+ }
60
+ export async function servicePublicAccess(type, first, second, opts, deps = {}) {
61
+ const { mode, service } = alwaysOnArgs(first, second);
62
+ const api = await ApiClient.load();
63
+ const p = await requireProject();
64
+ const branch = opts.branch ?? p.branch;
65
+ const { services } = await api.request('GET', `/projects/${p.projectId}/services${q(branch)}`);
66
+ const svc = resolveSoleService(services, type, service);
67
+ if (!mode) {
68
+ if (opts.json)
69
+ return printJson({ name: svc.name, type: svc.type, public: svc.public ?? null, private_host: svc.private_host ?? null, private_fqdn: svc.private_fqdn ?? null, port: svc.port ?? null });
70
+ for (const l of publicAccessLines(svc))
71
+ info(l);
72
+ return;
73
+ }
74
+ if (mode === 'off' && !opts.yes) {
75
+ const tty = deps.tty ?? (!!process.stdin.isTTY && !!process.stdout.isTTY);
76
+ if (opts.json || !tty)
77
+ refuse([closeWarning(svc), `${type} ${svc.name}: re-run with --yes to confirm`]);
78
+ info(closeWarning(svc));
79
+ const ok = await (deps.confirm ?? confirmOnTerminal)(`Close public access to ${type} ${svc.name}?`);
80
+ if (!ok)
81
+ return info(`${type} ${svc.name}: public access unchanged`);
82
+ }
83
+ let res;
84
+ try {
85
+ res = await api.rawRequest('PUT', `/projects/${p.projectId}/services/${svc.id}/access`, { public: mode === 'on' });
86
+ }
87
+ catch (e) {
88
+ if (e instanceof ApiError)
89
+ throw new Error(publicAccessErrorMessage(e, { type, service: svc.name, branch: opts.branch, what: mode === 'off' ? 'closing public access' : 'opening public access' }));
90
+ throw e;
91
+ }
92
+ if (handleApproval(res, opts.json))
93
+ return;
94
+ if (opts.json)
95
+ return printJson(res.body);
96
+ const notice = typeof res.body?.notice === 'string' && res.body.notice ? res.body.notice : undefined;
97
+ info(`${type} ${svc.name}: public access ${mode === 'off' ? 'CLOSED' : 'open'}`);
98
+ if (notice)
99
+ info(` ${notice}`);
100
+ }
101
+ //# sourceMappingURL=private-network.js.map
@@ -105,20 +105,32 @@ export function resolveComputeServiceId(services, name) {
105
105
  function defaultDatabasePort(type) {
106
106
  return type === 'mysql' ? 3306 : type === 'mongodb' ? 27017 : 6379;
107
107
  }
108
- // How a compute service is reached: http (the default HTTPS domain) or tcp (raw TCP on its port at
109
- // its own IPv6 address). Checked locally so a typo fails before any network call; which ports tcp
110
- // accepts is the platform's answer, not repeated here.
108
+ export const ENDPOINTS = ['http', 'tcp', 'udp', 'tcp+udp'];
111
109
  export function parseEndpoint(raw) {
112
110
  const v = raw.trim().toLowerCase();
113
- if (v !== 'http' && v !== 'tcp')
114
- throw new Error(`--endpoint must be http or tcp, got: ${raw}`);
111
+ if (!ENDPOINTS.includes(v))
112
+ throw new Error(`--endpoint must be http, tcp, udp or tcp+udp, got: ${raw}`);
115
113
  return v;
116
114
  }
115
+ // tcp, udp and tcp+udp are the same v6 lane with a different protocol set: no HTTP domain, and the
116
+ // address is endpoint_host on the service port.
117
+ export const isV6Endpoint = (e) => e === 'tcp' || e === 'udp' || e === 'tcp+udp';
117
118
  // Where a tcp service is dialed. The host is set once a deploy has confirmed it; before that the
118
119
  // row only knows its port.
119
120
  export function tcpAddress(s) {
120
121
  return s.endpoint_host ? `tcp://${s.endpoint_host}:${s.port}` : `tcp/${s.port ?? '?'} (address after the first deploy)`;
121
122
  }
123
+ // Where a v6-lane service (tcp, udp or tcp+udp) is reached, one scheme per protocol it serves:
124
+ // tcp://host:port, udp://host:port, or both for tcp+udp. tcp is exactly tcpAddress. Before a
125
+ // deploy has confirmed the host, the row only knows the protocol and port.
126
+ export function v6Address(s) {
127
+ if (s.endpoint === 'tcp')
128
+ return tcpAddress(s);
129
+ const protos = s.endpoint === 'tcp+udp' ? ['tcp', 'udp'] : ['udp'];
130
+ if (!s.endpoint_host)
131
+ return `${s.endpoint === 'tcp+udp' ? 'tcp+udp' : 'udp'}/${s.port ?? '?'} (address after the first deploy)`;
132
+ return protos.map((pr) => `${pr}://${s.endpoint_host}:${s.port}`).join(', ');
133
+ }
122
134
  // Map service-add options to the platform POST body. Pure, so it's unit-tested without a network
123
135
  // mock (mirrors deployRequestBody in deploy.ts). Validation (which options are valid for which
124
136
  // type) stays in servicesAdd, ahead of any network/config access.
@@ -199,7 +211,7 @@ export function serviceAddedLine(type, name, branch, svc) {
199
211
  const vol = svc.volume_gib ? ` vol ${svc.volume_gib}Gi at ${svc.volume_mount_path ?? "/data"}` : '';
200
212
  // The major belongs next to the connect hint: it decides which psql/pg_dump to reach for.
201
213
  const pg = svc.type === 'postgres' ? pgBadge(svc.pg_version) : '';
202
- const where = svc.endpoint === 'tcp' ? ` — ${tcpAddress(svc)}` : svc.domain ? ` — ${svc.domain}` : '';
214
+ const where = isV6Endpoint(svc.endpoint) ? ` — ${v6Address(svc)}` : svc.domain ? ` — ${svc.domain}` : '';
203
215
  return `added ${type} service ${name} on ${branch ?? 'default'} (${svc.id})${access}${svc.region ? ` ${svc.region}` : ''}${img}${vol}${pg}${where}`;
204
216
  }
205
217
  // ` pg <major>` for a postgres row, or '' when the platform sent nothing usable. The field arrives
@@ -218,9 +230,13 @@ export function serviceListLine(s) {
218
230
  // Postgres major, so the reader picks matching pg_dump/psql BEFORE connecting (a newer client
219
231
  // dumps statements an older server cannot restore). Older platforms send no pg_version.
220
232
  : s.type === 'postgres' ? pgBadge(s.pg_version) : '';
221
- // A tcp compute service has no HTTP domain; its address is the tcp one.
222
- const where = s.type === 'compute' && s.endpoint === 'tcp' ? ` ${tcpAddress(s)}` : s.domain ? ` ${s.domain}` : '';
223
- return `${s.type}/${s.name} [${s.status}]${extra}${where} ${s.id}`;
233
+ // A tcp/udp/tcp+udp compute service has no HTTP domain; its address is the v6-lane one.
234
+ const where = s.type === 'compute' && isV6Endpoint(s.endpoint) ? ` ${v6Address(s)}` : s.domain ? ` ${s.domain}` : '';
235
+ // The private address the platform reports (never composed here), and a closed compute service
236
+ // says so beside the URL it no longer serves.
237
+ const closed = s.type === 'compute' && s.public === false ? ' public off' : '';
238
+ const priv = s.private_host ? ` private ${s.private_host}${s.port ? `:${s.port}` : ''}` : '';
239
+ return `${s.type}/${s.name} [${s.status}]${extra}${where}${closed}${priv} ${s.id}`;
224
240
  }
225
241
  export async function servicesList(opts) {
226
242
  const api = await ApiClient.load();
@@ -344,6 +344,9 @@ async function promptVariable(v) {
344
344
  throw new CliCancel();
345
345
  return answer.trim();
346
346
  }
347
+ // The poll route is keyed by deployment id, not project: name the project so agent mode signs
348
+ // with the project-bound session (a bootstrap session is rejected as "for a different project").
349
+ const readDeployment = (api, projectId) => (id) => api.request('GET', `/template-deployments/${id}`, undefined, { projectId });
347
350
  export async function templateDeploy(target, opts = {}, deps = {}) {
348
351
  const given = parseSetFlags(opts.set ?? []); // a typo'd --set fails before any network access
349
352
  // --json asked for parseable output: every human progress line is suppressed so stdout carries
@@ -424,9 +427,7 @@ export async function templateDeploy(target, opts = {}, deps = {}) {
424
427
  for (const line of publicBucketLines(services))
425
428
  info(line);
426
429
  }
427
- // The poll route is keyed by deployment id, not project: name the project so agent mode signs
428
- // with the project-bound session (a bootstrap session is rejected as "for a different project").
429
- const dep = await watchDeployment((id) => api.request('GET', `/template-deployments/${id}`, undefined, { projectId: p.projectId }), deploymentId, quiet ? () => { } : info, deps.wait);
430
+ const dep = await watchDeployment(readDeployment(api, p.projectId), deploymentId, quiet ? () => { } : info, deps.wait);
430
431
  if (opts.json)
431
432
  return printJson(source ? { source, ...dep } : dep);
432
433
  info(`template ${codeLabel} deployed to branch ${branchName}`);
@@ -526,10 +527,10 @@ export async function templateUpgrade(service, opts = {}, deps = {}) {
526
527
  const branchName = opts.branch ?? p.branch;
527
528
  const ask = deps.ask ?? promptVariable;
528
529
  const target = await deploymentOfService(api, p.projectId, branchName, service, 'upgrade');
529
- // The poll and preview routes are keyed by deployment id, not project: name the project so agent
530
- // mode signs with the project-bound session, exactly as templateDeploy does.
531
- // The preview is a GET and raises coded 409s of its own (template_superseded among them), so it
532
- // gets the same reading as the POST rather than the raw "<sentence> (HTTP 409)" the guard prints.
530
+ // The preview route is keyed by deployment id, not project, so it names the project as
531
+ // readDeployment does. It is a GET and raises coded 409s of its own (template_superseded among
532
+ // them), so it gets the same reading as the POST rather than the raw "<sentence> (HTTP 409)" the
533
+ // guard prints.
533
534
  let plan;
534
535
  try {
535
536
  ;
@@ -576,7 +577,7 @@ export async function templateUpgrade(service, opts = {}, deps = {}) {
576
577
  const deploymentId = res.body.deploymentId ?? (res.body.deployment ?? res.body).id;
577
578
  if (!quiet)
578
579
  info(`upgrading ${service} to ${plan.to_version} (${deploymentId})`);
579
- const dep = await watchDeployment((id) => api.request('GET', `/template-deployments/${id}`, undefined, { projectId: p.projectId }), deploymentId, quiet ? () => { } : info, deps.wait);
580
+ const dep = await watchDeployment(readDeployment(api, p.projectId), deploymentId, quiet ? () => { } : info, deps.wait);
580
581
  if (quiet)
581
582
  return printJson(dep);
582
583
  info(`${service} is on ${plan.to_version}`);
@@ -635,7 +636,7 @@ export async function templateRollback(service, opts = {}, deps = {}) {
635
636
  return;
636
637
  const deploymentId = res.body.deploymentId ?? (res.body.deployment ?? res.body).id;
637
638
  out(`rolling ${service} back (${deploymentId})`);
638
- const dep = await watchDeployment((id) => api.request('GET', `/template-deployments/${id}`, undefined, { projectId: p.projectId }), deploymentId, out, deps.wait);
639
+ const dep = await watchDeployment(readDeployment(api, p.projectId), deploymentId, out, deps.wait);
639
640
  if (quiet)
640
641
  return printJson(dep);
641
642
  info(`${service} is back on ${(dep.deployment ?? dep).template_version ?? 'its previous version'}`);
@@ -1,5 +1,6 @@
1
1
  import { setTimeout as delay } from 'node:timers/promises';
2
2
  import { handleApproval } from './util.js';
3
+ import { ENDPOINTS, isV6Endpoint } from './commands/services.js';
3
4
  export function archiveBuildSpec(hasDockerfile) {
4
5
  return hasDockerfile ? { type: 'dockerfile' } : { type: 'nixpacks' };
5
6
  }
@@ -72,7 +73,7 @@ export async function deployArchive(api, projectId, ref, branch, opts, now = Dat
72
73
  archive: ref,
73
74
  port: opts.port ? Number(opts.port) : undefined,
74
75
  websocket: typeof opts.websocket === 'boolean' ? opts.websocket : undefined,
75
- endpoint: opts.endpoint === 'http' || opts.endpoint === 'tcp' ? opts.endpoint : undefined,
76
+ endpoint: ENDPOINTS.includes(opts.endpoint ?? '') ? opts.endpoint : undefined,
76
77
  replaceSource: opts.replaceSource === true ? true : undefined,
77
78
  });
78
79
  // An approval is a 202 too, told apart by its status word; anything else here is our operation.
@@ -122,8 +123,11 @@ export async function deployArchive(api, projectId, ref, branch, opts, now = Dat
122
123
  if (state === 'live') {
123
124
  const image = res.body?.imageRef;
124
125
  const url = res.body?.url;
125
- // An endpoint tcp service has no URL: its answer is the tcp host instead. A worker has neither.
126
+ // A tcp/udp/tcp+udp (v6-lane) service has no URL: its answer is the v6 host instead. A worker has neither.
126
127
  const endpointHost = typeof res.body?.endpointHost === 'string' && res.body.endpointHost ? res.body.endpointHost : undefined;
128
+ // The endpoint the service is on (instacloud-platform#681), so the address gets its scheme;
129
+ // an older platform omits it, and a value this CLI does not know is treated as absent.
130
+ const endpoint = endpointHost && isV6Endpoint(res.body?.endpoint) ? res.body.endpoint : undefined;
127
131
  if (typeof image !== 'string' || !image || typeof url !== 'string' || (!url && opts.port !== '0' && !endpointHost)) {
128
132
  throw new Error('the deploy finished but the platform returned no image or URL for it — check `insta status`');
129
133
  }
@@ -135,7 +139,7 @@ export async function deployArchive(api, projectId, ref, branch, opts, now = Dat
135
139
  return v;
136
140
  };
137
141
  return {
138
- image, url, ...(endpointHost ? { endpointHost } : {}),
142
+ image, url, ...(endpointHost ? { endpointHost } : {}), ...(endpoint ? { endpoint } : {}),
139
143
  branch: optionalString('branch', res.body.branch) ?? branch,
140
144
  group: optionalString('group', res.body.group) ?? opts.group ?? '',
141
145
  machineId: optionalString('machineId', res.body.machineId),
package/dist/index.js CHANGED
@@ -28,6 +28,7 @@ import { buildLogs } from './commands/build-logs.js';
28
28
  import * as computeCmd from './commands/compute.js';
29
29
  import * as githubCmd from './commands/github.js';
30
30
  import * as pgCmd from './commands/postgres.js';
31
+ import * as privateNet from './commands/private-network.js';
31
32
  import * as dbQueryCmd from './commands/db-query.js';
32
33
  import * as managedDb from './commands/managed-db.js';
33
34
  import * as storageCmd from './commands/storage.js';
@@ -155,7 +156,7 @@ svc.command('add [type] [name]').description('Provision a service on demand (com
155
156
  .option('--public', 'storage only: serve the bucket with anonymous public-read (default private)')
156
157
  .option('--image <url>', 'compute only: run this container image at creation')
157
158
  .option('--port <n>', 'compute only: port the image listens on (default 8080; 0 for a worker without a routed port)')
158
- .option('--endpoint <http|tcp>', 'compute only: how the service is reached — http (default: HTTPS at its domain) or tcp (raw TCP on --port at its own public IPv6 address; IPv6 clients only, no platform TLS, no custom domain). tcp needs --port and refuses commonly scanned, web (80/443) and platform-reserved ports')
159
+ .option('--endpoint <http|tcp|udp|tcp+udp>', 'compute only: how the service is reached — http (default: HTTPS at its domain), or tcp / udp / tcp+udp (raw TCP, UDP or both on --port at its own public IPv6 address; IPv6 clients only, no platform TLS, no custom domain). tcp needs --port and refuses commonly scanned, web (80/443) and platform-reserved ports; udp refuses amplification-prone (DNS, NTP, SNMP, memcached, …) and platform-reserved ports; tcp+udp refuses both lists. A udp datagram wakes a suspended service and what the wake held is re-sent until the service first replies (an app may see it twice); a receive-only udp service (never replies) needs --always-on to stay awake')
159
160
  .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')
160
161
  .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)')
161
162
  .option('--mount-path <path>', 'compute only: container mount path for a new volume (requires --volume; default /data)')
@@ -272,11 +273,12 @@ cron.command('runs <name>').description('Run history, most recent first: status,
272
273
  cron.command('preview <expression>').description('Validate an expression and print its next five UTC fire times — answered by the same parser the scheduler uses. Exits 1 when the expression is invalid')
273
274
  .option('--json').action(guard((expression, o) => cronCmd.cronPreview(expression, o)));
274
275
  // ---- domain (bought here, or bring your own; hostnames on compute services; DNS of bought zones) ----
275
- const dom = program.command('domain').description('Domains: buy through InstaCloud or bring your own — attach / check / detach hostnames on compute services; DNS records of bought domains');
276
- dom.command('search <keyword>').description('Search purchasable names with prices (a label like "myapp" or a full name like "myapp.com")')
276
+ const dom = program.command('domain').description('Domains: attach, check and detach hostnames on compute services, and manage domains already bought through InstaCloud');
277
+ // search, buy and zone delegate are hidden while the platform pauses new orders and BYO delegation.
278
+ dom.command('search <keyword>', { hidden: true }).description('Search purchasable names with prices (a label like "myapp" or a full name like "myapp.com")')
277
279
  .option('--tlds <list>', 'comma-separated TLDs to include').option('--org <id>', "target org (default: linked project's org)").option('--json')
278
280
  .action(guard((keyword, o) => domainCmd.domainSearch(keyword, o)));
279
- dom.command('buy <name>').description('Buy a domain — pay at the printed Stripe Checkout link. It serves nothing until you attach it (gated: domain.purchase)')
281
+ dom.command('buy <name>', { hidden: true }).description('Buy a domain — pay at the printed Stripe Checkout link. It serves nothing until you attach it (gated: domain.purchase)')
280
282
  .option('--years <n>', 'registration term in years (default 1)')
281
283
  .option('--no-open', 'print the checkout URL instead of opening a browser').option('--json')
282
284
  .action(guard((name, o) => domainCmd.domainBuy(name, o)));
@@ -308,14 +310,14 @@ ns.command('set <domain> <nameservers...>').description("Delegate the zone to na
308
310
  ns.command('reset <domain>').description("Put the zone back on the registrar's own nameservers — from your own delegation or from an InstaCloud-managed zone alike; a hostname `set` took down is re-attached with `insta domain attach`, one a managed zone was serving re-verifies on its own")
309
311
  .option('--org <id>', "target org (default: linked project's org)").option('--json')
310
312
  .action(guard((domain, o) => domainCmd.domainNameserversReset(domain, o)));
311
- const zone = dom.command('zone').description("Bring-your-own domains served from an InstaCloud-managed zone (nameserver delegation): point the domain's registrar at the pair `zone delegate` answers, and every attach's records — apexes included — are published for you. The alternative stays available: `insta domain attach` alone prints records to paste into your own zone");
312
- zone.command('delegate <domain>').description("Delegate a domain you own elsewhere: builds its managed zone (seeded by a provider record scan — a HEURISTIC, so review `zone records` and add anything missing at your current provider BEFORE switching nameservers; re-running delegate re-imports) and answers the two nameservers to set at your registrar. A domain carrying live MX records is refused — move mail first or keep the records path (org admin; gated: zone.delegate — agent mode gates from a linked project, an unlinked --org call falls under org administration instead)")
313
+ const zone = dom.command('zone').description("Bring-your-own domains already delegated to an InstaCloud-managed zone: list them, review their records, or release them. For a hostname under a domain you own elsewhere, `insta domain attach` prints the records to add in your own DNS");
314
+ zone.command('delegate <domain>', { hidden: true }).description("Delegate a domain you own elsewhere: builds its managed zone (seeded by a provider record scan — a HEURISTIC, so review `zone records` against your current DNS BEFORE switching nameservers) and answers the two nameservers to set at your registrar. A domain carrying live MX records is refused — move mail first or keep the records path (org admin; gated: zone.delegate — agent mode gates from a linked project, an unlinked --org call falls under org administration instead)")
313
315
  .option('--org <id>', "target org (default: linked project's org)").option('--json')
314
316
  .action(guard((domain, o) => domainCmd.zoneDelegate(domain, o)));
315
317
  zone.command('list').description("The org's delegated zones: which are still waiting for the nameserver switch and which are serving")
316
318
  .option('--org <id>', "target org (default: linked project's org)").option('--json')
317
319
  .action(guard((o) => domainCmd.zoneList(o)));
318
- zone.command('records <domain>').description("Every record in the delegated zone — the pre-switch review. The scan seeds common records but is not exhaustive: compare against your current DNS and add what is missing at your CURRENT provider, then re-run `zone delegate` to re-import")
320
+ zone.command('records <domain>').description("Every record in the delegated zone, for review before you switch nameservers. The provider scan seeds common records but is not exhaustive, so compare it against your current DNS first")
319
321
  .option('--org <id>', "target org (default: linked project's org)").option('--json')
320
322
  .action(guard((domain, o) => domainCmd.zoneRecords(domain, o)));
321
323
  zone.command('release <domain>').description("Release a delegated zone: the platform's records are pruned and the zone deleted. Point the nameservers back at your own provider; hostnames re-verify on the records path (org admin; gated: zone.delegate — agent mode gates from a linked project, an unlinked --org call falls under org administration instead)")
@@ -365,7 +367,7 @@ function addObservability(group, component, noun) {
365
367
  // optional makes commander unable to hold that boundary itself).
366
368
  const { argv: computeArgv, command: execCommand, windowsFallback: execWindowsFallback, } = computeCmd.splitExecArgs(process.argv);
367
369
  // ---- compute ----
368
- const compute = program.command('compute').description('Compute services: lifecycle (start/stop/suspend/restart/status), scale, limits, volume, always-on, exec, ssh, GitHub source, logs, metrics');
370
+ const compute = program.command('compute').description('Compute services: lifecycle (start/stop/suspend/restart/status), scale, limits, volume, public access, always-on, exec, ssh, GitHub source, logs, metrics');
369
371
  compute.command('start [service]').description('Bring a compute service online (persistent — re-enables auto-wake)')
370
372
  .option('--json').option('--branch <branch>', 'branch (default: current)').action(guard((service, o) => computeCmd.computeStart(service, o)));
371
373
  compute.command('stop [service]').description('Take a compute service offline; traffic will NOT wake it until `start`')
@@ -384,6 +386,10 @@ compute.command('scale [count] [service]').description('Set a compute service sa
384
386
  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")
385
387
  .option('--memory <size>', 'memory ceiling, e.g. 512mb or 1gb').option('--cpu <n>', 'vCPU ceiling override (provider sizes: 1, 2, 4, 6, 8)')
386
388
  .option('--json').option('--branch <branch>', 'branch (default: current)').action(guard((service, o) => computeCmd.computeLimits(service, o)));
389
+ compute.command('public-access [mode] [service]').description("Show, close or re-open a compute service's public access (mode: on|off; no mode = print it and the service's private address). Every compute service is reachable by the other services in its project branch at <name>.insta.internal:<port> (TCP and UDP); off = private-only: its URL answers 403 and no longer wakes it, so only services in the same branch reach it. Immediate, no redeploy. Asks first; --yes when there is no terminal or with --json. insta-compute only (gated: service.setAccess)")
390
+ .option('-y, --yes', 'close without asking (required when there is no terminal, or with --json)')
391
+ .option('--json').option('--branch <branch>', 'branch (default: current)')
392
+ .action(guard((mode, service, o) => privateNet.servicePublicAccess('compute', mode, service, o)));
387
393
  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')
388
394
  .option('--json').option('--branch <branch>', 'branch (default: current)').action(guard((mode, service, o) => computeCmd.computeAlwaysOn(mode, service, o)));
389
395
  compute.command('ssh [service]')
@@ -463,7 +469,7 @@ pg.command('public-access [mode] [service]').description("Show, close or re-open
463
469
  addObservability(pg, 'db', 'postgres');
464
470
  // ---- redis / mysql / mongodb (managed databases) ----
465
471
  for (const type of ['redis', 'mysql', 'mongodb']) {
466
- const g = program.command(type).description(`Managed ${type} services: query, status, resource ceiling, volume, always-on, logs, metrics`);
472
+ const g = program.command(type).description(`Managed ${type} services: query, status, resource ceiling, volume, public access, always-on, logs, metrics`);
467
473
  const query = g.command('query <service> [args...]').description(type === 'redis'
468
474
  ? 'Run a redis command against the service via the console exec API — a pre-tokenized argv, e.g. `GET mykey`'
469
475
  : `Run one quoted ${type} statement against the service via the console exec API`)
@@ -482,6 +488,10 @@ for (const type of ['redis', 'mysql', 'mongodb']) {
482
488
  .option('--size <gi>', 'new size in whole Gi, e.g. 10 (must be ≥ the current size)')
483
489
  .option('--json').option('--branch <branch>', 'branch (default: current)')
484
490
  .action(guard((service, o) => computeCmd.serviceVolume(type, service, o)));
491
+ g.command('public-access [mode] [service]').description(`Show, close or re-open a ${type} service's public access (mode: on|off; no mode = print it and the private address). On insta-compute every ${type} is reachable from services in its project branch at <name>-${type}.insta.internal (${type.toUpperCase()}_PRIVATE_URL); off = ${type.toUpperCase()}_URL stops connecting and only apps in the branch reach it, through ${type.toUpperCase()}_PRIVATE_URL. Asks first; --yes when there is no terminal or with --json (gated: service.setAccess)`)
492
+ .option('-y, --yes', 'close without asking (required when there is no terminal, or with --json)')
493
+ .option('--json').option('--branch <branch>', 'branch (default: current)')
494
+ .action(guard((mode, service, o) => privateNet.servicePublicAccess(type, mode, service, o)));
485
495
  g.command('always-on <mode> [service]').description(`Set a ${type} service always-on (mode: on|off). on = machines never scale to zero; off = scale-to-zero. Billing is actual usage either way`)
486
496
  .option('--json').option('--branch <branch>', 'branch (default: current)')
487
497
  .action(guard((mode, service, o) => computeCmd.serviceAlwaysOn(type, mode, service, o)));
@@ -526,7 +536,7 @@ buildCmd.command('logs <build-id>').description('Read source-build output for a
526
536
  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')
527
537
  .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)')
528
538
  .option('--websocket', 'run a WebSocket app (larger guest + connection-based concurrency)')
529
- .option('--endpoint <http|tcp>', 'switch how the service is reached from this deploy on: tcp (raw TCP on --port at its own public IPv6 address; IPv6 clients only) or http (back to its HTTPS domain). Omitted: keep the current endpoint')
539
+ .option('--endpoint <http|tcp|udp|tcp+udp>', 'switch how the service is reached from this deploy on: tcp / udp / tcp+udp (raw TCP, UDP or both on --port at its own public IPv6 address; IPv6 clients only) or http (back to its HTTPS domain). Omitted: keep the current endpoint')
530
540
  .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')
531
541
  .option('--json', 'print the deploy result as JSON (build progress goes to stderr)')
532
542
  .action(guard((dir, o) => deploy(dir, o)));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "insta",
3
- "version": "0.1.24",
3
+ "version": "0.1.26",
4
4
  "type": "module",
5
5
  "description": "InstaCloud CLI — a thin client of the platform control-plane API.",
6
6
  "keywords": [