insta 0.1.23 → 0.1.25

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.
@@ -16,7 +16,7 @@
16
16
  // forward because it can (see buildRequest).
17
17
  import { randomUUID } from 'node:crypto';
18
18
  import { ApiClient, ApiError, requireProject } from '../api.js';
19
- import { die, handleApproval, info, printJson, refuse } from '../util.js';
19
+ import { die, handleApproval, info, isWebUrl, printJson, refuse } from '../util.js';
20
20
  import { q, resolveSoleService } from './services.js';
21
21
  // ---- pure, unit-tested helpers (throw plain Errors; the CLI guard turns them into clean output) ----
22
22
  // The API's own bounds (openapi/schemas/cron.ts), enforced here so junk fails before any network or
@@ -245,7 +245,7 @@ export function assertTargetFlags(o, partial = false) {
245
245
  throw new Error('--path applies to --service (an external target carries its path in the --url)');
246
246
  if (!partial && !o.url && !o.service)
247
247
  throw new Error('name a target: --url <https://…> or --service <name> [--path /api/cron]');
248
- if (o.url !== undefined && !/^https?:\/\//i.test(o.url))
248
+ if (o.url !== undefined && !isWebUrl(o.url))
249
249
  throw new Error(`--url must be an absolute http(s) URL, got: ${o.url}`);
250
250
  if (o.path !== undefined && !o.path.startsWith('/'))
251
251
  throw new Error(`--path must start with /, got: ${o.path}`);
@@ -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 { 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);
@@ -19,9 +19,25 @@ export function deployRequestBody(source, branch, opts) {
19
19
  group: opts.group,
20
20
  port: opts.port ? Number(opts.port) : undefined,
21
21
  websocket: opts.websocket ? true : undefined,
22
+ endpoint: opts.endpoint !== undefined ? parseEndpoint(opts.endpoint) : undefined,
22
23
  replaceSource: opts.replaceSource ? true : undefined,
23
24
  };
24
25
  }
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
+ }
39
+ return r.url || '(no URL: a worker)';
40
+ }
25
41
  // Ask the platform which lane serves this target, so the CLI stops knowing which provider backs
26
42
  // its service. A 404 means the platform predates the contract — and because this is a GET, it
27
43
  // 404s the same way for a human and an agent, which a POST would not.
@@ -143,6 +159,19 @@ export async function deploy(dir, opts) {
143
159
  die('pick one: a source <dir> OR --image <url>, not both');
144
160
  if (!dir && !opts.image)
145
161
  die('usage: insta deploy <dir> | --image <url> [--branch <b>] [--group <g>] [--port <n>]');
162
+ // Normalized here, once, so both lanes get the same validated value: the archive lane's body
163
+ // (deployArchive) otherwise silently drops anything that isn't exactly one of the endpoints to
164
+ // undefined, which let a typo like --endpoint tpc build and deploy using the service's existing
165
+ // endpoint instead of failing locally. Checked before ApiClient.load()/requireProject(): those
166
+ // can resolve a project and persist an automatic link for an unlinked directory, so a typo must
167
+ // die before either runs, not after.
168
+ let endpoint;
169
+ try {
170
+ endpoint = opts.endpoint === undefined ? undefined : parseEndpoint(opts.endpoint);
171
+ }
172
+ catch (e) {
173
+ die(e.message);
174
+ }
146
175
  const api = await ApiClient.load();
147
176
  const p = await requireProject();
148
177
  const branch = opts.branch ?? p.branch;
@@ -162,7 +191,7 @@ export async function deploy(dir, opts) {
162
191
  log(`using port ${exposed} (Dockerfile EXPOSE) — override with --port`);
163
192
  }
164
193
  }
165
- const effOpts = { ...opts, port: port?.toString() };
194
+ const effOpts = { ...opts, port: port?.toString(), endpoint };
166
195
  const source = dir ? await prepareSource(api, p.projectId, dir, branch, effOpts) : { image: opts.image };
167
196
  if (!source)
168
197
  return; // an approval is pending; the user approves and re-runs
@@ -171,8 +200,8 @@ export async function deploy(dir, opts) {
171
200
  // nextActions, which the operation read does not carry.
172
201
  const d = source.deployed;
173
202
  if (opts.json)
174
- return printJson({ image: d.image, url: d.url, branch: d.branch, group: d.group, machineId: d.machineId });
175
- info(`deployed ${d.image} -> ${d.url} (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})`);
176
205
  return;
177
206
  }
178
207
  const res = await api.rawRequest('POST', `/projects/${p.projectId}/deploy`, deployRequestBody(source, branch, effOpts))
@@ -182,7 +211,7 @@ export async function deploy(dir, opts) {
182
211
  const what = source.image;
183
212
  if (opts.json)
184
213
  return printJson({ image: source.image, ...res.body });
185
- info(`deployed ${what} -> ${res.body.url} (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})`);
186
215
  renderNextActions(res.body.nextActions);
187
216
  }
188
217
  // The platform refuses an image deploy onto a repo-connected service and names the body field it
@@ -14,9 +14,9 @@ export function alwaysOnArgs(first, second) {
14
14
  export function alwaysOnLine(group, scaleToZero) {
15
15
  return `postgres ${group}: always-on ${scaleToZero === false ? 'on — instance stays warm' : scaleToZero === true ? 'off — scales to zero when idle' : 'unknown — the provider did not report it'}`;
16
16
  }
17
- // Show or set a postgres service's idle mode: scale-to-zero (the default: instance suspends when
18
- // idle, cold-starts on the next connection) or always-on (instance stays warm; idle RAM bills at
19
- // actual usage). Reads GET /database/instance, sets PATCH /database/settings {scaleToZero} —
17
+ // Show or set a postgres service's idle mode: always-on (the default for databases since platform
18
+ // #666: instance stays warm; idle RAM bills at actual usage) or scale-to-zero (instance suspends
19
+ // when idle, cold-starts on the next connection). Reads GET /database/instance, sets PATCH /database/settings {scaleToZero} —
20
20
  // insta-db-backed postgres only.
21
21
  export async function dbAlwaysOn(first, second, opts) {
22
22
  const { mode, service } = alwaysOnArgs(first, second);
@@ -70,7 +70,7 @@ export function alwaysOnSetLine(service, branch, mode, scaleToZero) {
70
70
  const group = service ?? 'default';
71
71
  if (scaleToZero !== (mode === 'off'))
72
72
  return `postgres ${group}: always-on ${mode} requested — the database has not applied it yet; confirm with \`insta postgres always-on${service ? ` ${service}` : ''}${branch ? ` --branch ${branch}` : ''}\``;
73
- return `postgres ${group}: always-on ${mode === 'on' ? 'ENABLED — instance stays warm (no cold starts; idle RAM bills at actual usage)' : 'disabled — scales to zero when idle (default; first connection after idle cold-starts)'}`;
73
+ return `postgres ${group}: always-on ${mode === 'on' ? 'ENABLED — instance stays warm (no cold starts; idle RAM bills at actual usage; the default for databases)' : 'disabled — scales to zero when idle (first connection after idle cold-starts)'}`;
74
74
  }
75
75
  // Validated pass-throughs for the provider's quantity strings. The insta-db resize API takes
76
76
  // k8s-style quantities (cpu: "2", "2500m"; memory: "4Gi", "2048Mi"), so unlike the compute path
@@ -160,9 +160,20 @@ export async function dbLimits(service, opts) {
160
160
  return;
161
161
  if (opts.json)
162
162
  return printJson(res.body);
163
- const cpu = typeof res.body?.cpuMilli === 'number' ? `${res.body.cpuMilli / 1000} vCPU` : (opts.cpu ?? 'unchanged');
164
- const mem = typeof res.body?.memoryMib === 'number' ? fmtMib(res.body.memoryMib) : (opts.memory ?? 'unchanged');
165
- info(`postgres ${service ?? 'default'}: ceiling set to ${cpu} / ${mem}`);
163
+ info(limitsSetLine(service, opts.branch, { cpu: body.cpu, memory: body.memory }, res));
164
+ }
165
+ // insta-db queues the resize and answers 202 (the platform adds pending: true) with the row from
166
+ // BEFORE the patch, so on pending the body's cpuMilli/memoryMib are the old ceiling: print what was
167
+ // asked for and point at the read-back, the way alwaysOnSetLine does.
168
+ export function limitsSetLine(service, branch, requested, res) {
169
+ const group = service ?? 'default';
170
+ if (res.status === 202 || res.body?.pending === true) {
171
+ const asked = [requested.cpu && `${requested.cpu} vCPU`, requested.memory].filter(Boolean).join(' / ');
172
+ return `postgres ${group}: ceiling ${asked} requested, not applied yet — confirm with \`insta postgres limits${service ? ` ${service}` : ''}${branch ? ` --branch ${branch}` : ''}\``;
173
+ }
174
+ const cpu = typeof res.body?.cpuMilli === 'number' ? `${res.body.cpuMilli / 1000} vCPU` : (requested.cpu ?? 'unchanged');
175
+ const mem = typeof res.body?.memoryMib === 'number' ? fmtMib(res.body.memoryMib) : (requested.memory ?? 'unchanged');
176
+ return `postgres ${group}: ceiling set to ${cpu} / ${mem}`;
166
177
  }
167
178
  // Bytes → human units, one decimal above KiB. Local because the metrics payload is the only
168
179
  // bytes-denominated read in this file (fmtMib serves the MiB-denominated resize path).
@@ -204,12 +215,8 @@ export function dbStatsLines(group, body) {
204
215
  ];
205
216
  }
206
217
  // Point-in-time stats snapshot for a postgres service: connections vs the server's ceiling, cache
207
- // hit rate, database size. Read-only. insta-db-backed: a suspended instance answers from the
208
- // provider's control plane (shown as "(suspended)" with structural zeros), never dialed. That is
209
- // every environment today — the Neon-backed contrast below is historical: Neon is no longer used
210
- // anywhere, and the code that handled it is retained, not live. Neon-backed: the platform read
211
- // over a direct SQL connection, so a one-shot call could wake a suspended endpoint — acceptable
212
- // for an explicit command, which is why nothing here polls.
218
+ // hit rate, database size. Read-only. A suspended instance answers from the provider's control
219
+ // plane (shown as "(suspended)" with structural zeros), never dialed.
213
220
  export async function dbStats(service, opts) {
214
221
  const api = await ApiClient.load();
215
222
  const p = await requireProject();
@@ -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
@@ -190,6 +190,23 @@ export function applyServiceLines(services) {
190
190
  return ` = ${s.serviceId}${s.reason ? ` (${s.reason})` : ''}`;
191
191
  });
192
192
  }
193
+ // POST one entry to /apply and settle what `secrets set` and `secrets unset` share: the approval
194
+ // gate, the exit code, and the --json document. Returns null when the caller has nothing left to
195
+ // print — the platform gated the write, or --json already carried the outcome.
196
+ async function applyEntry(d, name, branch, entry, opts) {
197
+ const res = await d.api.rawRequest('POST', `/projects/${d.projectId}/apply`, { branch: branch ?? d.linkedBranch, entries: [entry] });
198
+ if (handleApproval(res, opts.json))
199
+ return null;
200
+ const entries = res.body.entries ?? [];
201
+ const services = res.body.services ?? [];
202
+ const verdict = applyVerdict(entries, services);
203
+ process.exitCode = applyExitCode(verdict);
204
+ if (opts.json) {
205
+ printJson({ ok: verdict === 'ok', verdict, name, branch: branch ?? null, service: opts.service ?? null, entries, services });
206
+ return null;
207
+ }
208
+ return { verdict, services };
209
+ }
193
210
  // Set a user secret. Project-wide by default; --branch scopes it to one branch. --service binds
194
211
  // it to a branch service instead, which implies the current branch (binding requires one). Value
195
212
  // comes from the argument, or stdin when omitted (keeps secret values out of shell history).
@@ -205,15 +222,10 @@ export async function secretsSet(name, value, opts, deps) {
205
222
  const branch = opts.service ? (opts.branch ?? d.linkedBranch) : opts.branch;
206
223
  // The batch always deploys on ONE branch, so the top level falls back to the linked one even when the entry itself is project-wide.
207
224
  const entry = { kind: 'set', name, value: v, ...(branch ? { branch } : {}), ...(opts.service ? { service: opts.service } : {}) };
208
- const res = await d.api.rawRequest('POST', `/projects/${d.projectId}/apply`, { branch: branch ?? d.linkedBranch, entries: [entry] });
209
- if (handleApproval(res, opts.json))
225
+ const outcome = await applyEntry(d, name, branch, entry, opts);
226
+ if (!outcome)
210
227
  return;
211
- const entries = res.body.entries ?? [];
212
- const services = res.body.services ?? [];
213
- const verdict = applyVerdict(entries, services);
214
- process.exitCode = applyExitCode(verdict);
215
- if (opts.json)
216
- return printJson({ ok: verdict === 'ok', verdict, name, branch: branch ?? null, service: opts.service ?? null, entries, services });
228
+ const { verdict, services } = outcome;
217
229
  info(`set ${name}${opts.service ? ` → ${opts.service}` : ''} (${branch ? `branch ${branch}` : 'project-wide'})`);
218
230
  for (const line of applyServiceLines(services))
219
231
  info(line);
@@ -232,17 +244,12 @@ export async function secretsUnset(name, opts, deps) {
232
244
  // pair without one) — so --service defaults to the linked branch, exactly as `secrets set` does.
233
245
  const branch = opts.service ? (opts.branch ?? d.linkedBranch) : opts.branch;
234
246
  const entry = { kind: 'delete', name, ...(branch ? { branch } : {}), ...(opts.service ? { service: opts.service } : {}) };
235
- const res = await d.api.rawRequest('POST', `/projects/${d.projectId}/apply`, { branch: branch ?? d.linkedBranch, entries: [entry] });
236
- if (handleApproval(res, opts.json))
237
- return;
238
- const entries = res.body.entries ?? [];
239
- const services = res.body.services ?? [];
240
- const verdict = applyVerdict(entries, services);
241
- process.exitCode = applyExitCode(verdict);
242
247
  // The EFFECTIVE branch, not the flag: with --service and no --branch the scope that was deleted
243
248
  // is the linked branch's, and the output has to say which scope it actually touched.
244
- if (opts.json)
245
- return printJson({ ok: verdict === 'ok', verdict, name, branch: branch ?? null, service: opts.service ?? null, entries, services });
249
+ const outcome = await applyEntry(d, name, branch, entry, opts);
250
+ if (!outcome)
251
+ return;
252
+ const { verdict, services } = outcome;
246
253
  const scope = opts.service ? `${opts.service}, branch ${branch}` : branch ? `branch ${branch}` : 'project-wide';
247
254
  info(`unset ${name} (${scope})`);
248
255
  for (const line of applyServiceLines(services))
@@ -105,6 +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
+ export const ENDPOINTS = ['http', 'tcp', 'udp', 'tcp+udp'];
109
+ export function parseEndpoint(raw) {
110
+ const v = raw.trim().toLowerCase();
111
+ if (!ENDPOINTS.includes(v))
112
+ throw new Error(`--endpoint must be http, tcp, udp or tcp+udp, got: ${raw}`);
113
+ return v;
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';
118
+ // Where a tcp service is dialed. The host is set once a deploy has confirmed it; before that the
119
+ // row only knows its port.
120
+ export function tcpAddress(s) {
121
+ return s.endpoint_host ? `tcp://${s.endpoint_host}:${s.port}` : `tcp/${s.port ?? '?'} (address after the first deploy)`;
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
+ }
108
134
  // Map service-add options to the platform POST body. Pure, so it's unit-tested without a network
109
135
  // mock (mirrors deployRequestBody in deploy.ts). Validation (which options are valid for which
110
136
  // type) stays in servicesAdd, ahead of any network/config access.
@@ -112,6 +138,7 @@ export function servicesAddRequestBody(type, name, branch, opts) {
112
138
  return {
113
139
  type, name, ...(branch ? { branch } : {}), public: !!opts.public,
114
140
  ...(opts.image ? { image: opts.image } : {}), ...(opts.port !== undefined ? { port: parsePort(opts.port, { allowZero: type === 'compute' }) } : {}),
141
+ ...(opts.endpoint !== undefined ? { endpoint: parseEndpoint(opts.endpoint) } : {}),
115
142
  ...(opts.region ? { region: opts.region } : {}),
116
143
  // Sent whenever the flag was given, false included: the birth default is platform config
117
144
  // (INSTA_COMPUTE_ALWAYS_ON_DEFAULT, scale-to-zero unless set), so an explicit --no-always-on
@@ -135,6 +162,11 @@ export async function servicesAdd(type, name, opts = {}) {
135
162
  throw new Error('--port is only valid for compute services');
136
163
  parsePort(opts.port, { allowZero: true });
137
164
  }
165
+ if (opts.endpoint !== undefined) {
166
+ if (type !== 'compute')
167
+ throw new Error('--endpoint is only valid for compute services');
168
+ parseEndpoint(opts.endpoint);
169
+ }
138
170
  // Presence, not truthiness: `--no-always-on` is an explicit false and is just as compute-only.
139
171
  if (opts.alwaysOn !== undefined && type !== 'compute')
140
172
  throw new Error(alwaysOnTypeError(type));
@@ -179,7 +211,8 @@ export function serviceAddedLine(type, name, branch, svc) {
179
211
  const vol = svc.volume_gib ? ` vol ${svc.volume_gib}Gi at ${svc.volume_mount_path ?? "/data"}` : '';
180
212
  // The major belongs next to the connect hint: it decides which psql/pg_dump to reach for.
181
213
  const pg = svc.type === 'postgres' ? pgBadge(svc.pg_version) : '';
182
- return `added ${type} service ${name} on ${branch ?? 'default'} (${svc.id})${access}${svc.region ? ` ${svc.region}` : ''}${img}${vol}${pg}${svc.domain ? ` — ${svc.domain}` : ''}`;
214
+ const where = isV6Endpoint(svc.endpoint) ? ` — ${v6Address(svc)}` : svc.domain ? ` — ${svc.domain}` : '';
215
+ return `added ${type} service ${name} on ${branch ?? 'default'} (${svc.id})${access}${svc.region ? ` ${svc.region}` : ''}${img}${vol}${pg}${where}`;
183
216
  }
184
217
  // ` pg <major>` for a postgres row, or '' when the platform sent nothing usable. The field arrives
185
218
  // as untyped API JSON: only a positive integer renders, so a malformed or nonsense value (true,
@@ -197,7 +230,13 @@ export function serviceListLine(s) {
197
230
  // Postgres major, so the reader picks matching pg_dump/psql BEFORE connecting (a newer client
198
231
  // dumps statements an older server cannot restore). Older platforms send no pg_version.
199
232
  : s.type === 'postgres' ? pgBadge(s.pg_version) : '';
200
- return `${s.type}/${s.name} [${s.status}]${extra}${s.domain ? ` ${s.domain}` : ''} ${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}`;
201
240
  }
202
241
  export async function servicesList(opts) {
203
242
  const api = await ApiClient.load();
@@ -129,11 +129,17 @@ export function parseSetFlags(pairs) {
129
129
  for (const pair of pairs) {
130
130
  const m = /^([A-Z][A-Z0-9_]{0,63})=([\s\S]*)$/.exec(pair);
131
131
  if (!m)
132
- throw new Error(`--set expects NAME=value (NAME matching ^[A-Z][A-Z0-9_]{0,63}$), got: ${pair}`);
132
+ throw new Error(`--set expects NAME=value (NAME matching ^[A-Z][A-Z0-9_]{0,63}$), got: ${redactSetPair(pair)}`);
133
133
  values[m[1]] = m[2];
134
134
  }
135
135
  return values;
136
136
  }
137
+ // --set carries secrets and this error reaches stderr and CI logs, so only the name is ever shown.
138
+ function redactSetPair(pair) {
139
+ const eq = pair.indexOf('=');
140
+ // Nothing of a pair with no `=`: it was never split, so the whole token may be the value itself.
141
+ return eq >= 0 ? `${pair.slice(0, eq)}=<value hidden>` : '<value hidden: no NAME= in front of it>';
142
+ }
137
143
  export function missingVariablesMessage(missing) {
138
144
  return [
139
145
  'missing required template variables:',
@@ -236,8 +242,13 @@ export function deploymentUrls(dep) {
236
242
  return lines;
237
243
  }
238
244
  // One line per service with its terminal state — the anatomy of a partial/failed run.
245
+ // `skipped` is the platform saying the user deleted that service and the run stepped over it. Its
246
+ // `state` stays `pending`, which under the marks below would read as "not reached yet" — a run that
247
+ // is still going — so it gets its own mark and names the reason instead.
239
248
  export function serviceStateLines(dep) {
240
249
  return (dep?.services ?? []).map((s) => {
250
+ if (s?.skipped)
251
+ return ` - ${s?.name ?? 'service'} [skipped: it no longer exists, so the run stepped over it]`;
241
252
  const mark = s?.state === 'healthy' ? '✓' : s?.state === 'failed' ? '✗' : '•';
242
253
  return ` ${mark} ${s?.name ?? 'service'}${s?.url ? ` — ${s.url}` : ''}${s?.state && s.state !== 'healthy' ? ` [${s.state}]` : ''}`;
243
254
  });
@@ -247,9 +258,13 @@ export function serviceStateLines(dep) {
247
258
  // deploy, or clean up), not just that something went wrong.
248
259
  export function partialMessage(dep) {
249
260
  const services = dep?.services ?? [];
250
- const healthy = services.filter((s) => s?.state === 'healthy').length;
261
+ // A skipped service was never part of this run — the user had deleted it — so counting it in the
262
+ // denominator would report a run that did everything it could as having left something behind.
263
+ // It is still LISTED below, because "where did it go" is the question the count then raises.
264
+ const ran = services.filter((s) => !s?.skipped);
265
+ const healthy = ran.filter((s) => s?.state === 'healthy').length;
251
266
  return [
252
- `template deployment finished partial: ${healthy}/${services.length} services healthy`,
267
+ `template deployment finished partial: ${healthy}/${ran.length} services healthy`,
253
268
  ...serviceStateLines(dep),
254
269
  ...(dep?.error ? [` ${dep.error}`] : []),
255
270
  ...(dep?.logsTail ? ['--- log tail ---', String(dep.logsTail).trimEnd()] : []),
@@ -421,4 +436,209 @@ export async function templateDeploy(target, opts = {}, deps = {}) {
421
436
  info('next: `insta postgres url` prints the postgres DSN; bind service credentials into compute with `insta secrets bind`; `insta secrets` refreshes user-defined secrets in .env');
422
437
  renderNextActions(dep.nextActions);
423
438
  }
439
+ /** The review, as lines. Current is the LIVE value: what the upgrade will overwrite. */
440
+ export function upgradePlanLines(plan) {
441
+ if (plan.refusals.length) {
442
+ return ['This upgrade cannot run:', ...plan.refusals.map((r) => ` ${r}`)];
443
+ }
444
+ const lines = [`${plan.from_version} → ${plan.to_version}`];
445
+ for (const s of plan.services) {
446
+ const who = s.service_name ?? s.key;
447
+ if (s.gone) {
448
+ lines.push(` ${who} no longer exists, skipped`);
449
+ continue;
450
+ }
451
+ // Created, not reconciled: there is no live value to compare, so its rows follow below as they come.
452
+ if (s.added)
453
+ lines.push(` ${who} added by ${plan.to_version}, created by this upgrade`);
454
+ for (const f of s.fields) {
455
+ const drift = f.drifted ? ' (you changed this, the upgrade will overwrite it)' : '';
456
+ lines.push(` ${who} ${f.label} ${f.live ?? '(none)'} → ${f.next}${drift}`);
457
+ }
458
+ for (const name of s.missing_variables) {
459
+ lines.push(` ${who} ${name} is required by ${plan.to_version} and has no value, pass --set ${name}=...`);
460
+ }
461
+ }
462
+ for (const key of plan.removed)
463
+ lines.push(` ${key} dropped by ${plan.to_version}, left running`);
464
+ return lines;
465
+ }
466
+ // The 409 codes a user will actually hit, in a sentence that says what to do next.
467
+ const UPGRADE_409 = {
468
+ template_version_changed: 'The template was republished since the plan was shown. Nothing was changed. Run the command again to review the new version.',
469
+ };
470
+ const ROLLBACK_409 = {
471
+ template_version_not_recorded: 'The earlier version of this template is no longer recorded, so it cannot be restored. Nothing was changed.',
472
+ };
473
+ /**
474
+ * Codes whose platform sentence names the deployment or the version to act on. Replacing it with
475
+ * wording of our own would drop the one name that makes it actionable, so the platform's sentence
476
+ * is printed as given and only the next step is appended.
477
+ */
478
+ const KEEP_PLATFORM_SENTENCE = {
479
+ // "this deployment was already replaced by <code>@<version> — act on that deployment instead".
480
+ // Reachable here only for a service a NEWER version dropped: it keeps pointing at the deployment
481
+ // the instance has moved off, which is the one this CLI resolves from the service name.
482
+ template_superseded: 'Nothing was changed. This command addresses a service, so name one that the newer deployment carries.',
483
+ // "<code>@<version> was republished with different content after this instance ran it …".
484
+ template_version_content_changed: 'Nothing was changed.',
485
+ // Two causes now, and the second one names the version: "this instance was already rolled back
486
+ // to <version> … upgrade it to move forward", beside the original "was not upgraded from
487
+ // anything". Our own wording could only say one of them, and said the wrong one for a rollback.
488
+ template_no_step_back: 'Nothing was changed.',
489
+ };
490
+ /** The sentence to raise for a coded 409, or undefined when this is not one we explain. */
491
+ function coded409(e, advice) {
492
+ if (!(e instanceof ApiError) || e.status !== 409)
493
+ return undefined;
494
+ const code = typeof e.body?.code === 'string' ? e.body.code : '';
495
+ if (advice[code])
496
+ return new Error(advice[code]);
497
+ const next = KEEP_PLATFORM_SENTENCE[code];
498
+ return next ? new Error(`${e.message}. ${next}`) : undefined;
499
+ }
500
+ async function postWithAdvice(api, path, body, advice) {
501
+ try {
502
+ return await api.rawRequest('POST', path, body);
503
+ }
504
+ catch (e) {
505
+ throw coded409(e, advice) ?? e;
506
+ }
507
+ }
508
+ /**
509
+ * A service name is what a person has; a deployment uuid is not. The services list already carries
510
+ * the attribution, so the lookup lives here rather than in the user's head.
511
+ */
512
+ async function deploymentOfService(api, projectId, branch, service, verb) {
513
+ const body = await api.request('GET', `/projects/${projectId}/services?branch=${encodeURIComponent(branch)}`);
514
+ const row = (body.services ?? []).find((s) => s.name === service);
515
+ if (!row)
516
+ throw new Error(`no service named ${service} on branch ${branch}`);
517
+ // Named by the caller: the same sentence under `rollback` would send the user to upgrade instead.
518
+ if (!row.template_deployment_id)
519
+ throw new Error(`${service} was not deployed from a template, so there is nothing to ${verb}`);
520
+ return { deploymentId: row.template_deployment_id };
521
+ }
522
+ export async function templateUpgrade(service, opts = {}, deps = {}) {
523
+ const given = parseSetFlags(opts.set ?? []); // a typo'd --set fails before any network access
524
+ const api = deps.api ?? (await ApiClient.load());
525
+ const p = deps.project ?? (await requireProject());
526
+ const branchName = opts.branch ?? p.branch;
527
+ const ask = deps.ask ?? promptVariable;
528
+ 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.
533
+ let plan;
534
+ try {
535
+ ;
536
+ ({ plan } = await api.request('GET', `/template-deployments/${target.deploymentId}/upgrade`, undefined, { projectId: p.projectId }));
537
+ }
538
+ catch (e) {
539
+ throw coded409(e, UPGRADE_409) ?? e;
540
+ }
541
+ // --json alone is "tell me" (the plan, no upgrade); --json --yes is "do it and tell me".
542
+ const quiet = !!opts.json;
543
+ if (opts.json && (!opts.yes || plan.refusals.length)) {
544
+ printJson(plan);
545
+ if (plan.refusals.length)
546
+ process.exitCode = 1;
547
+ return;
548
+ }
549
+ if (!quiet)
550
+ for (const line of upgradePlanLines(plan))
551
+ info(line);
552
+ // A refusal is not a prompt to confirm past: there is nothing to confirm.
553
+ if (plan.refusals.length) {
554
+ process.exitCode = 1;
555
+ return;
556
+ }
557
+ // Only what the new version ADDS is ever asked for: the platform recovers every value the
558
+ // instance already holds, so re-prompting would invite the user to overwrite a live credential.
559
+ const missing = plan.services.flatMap((s) => s.missing_variables.map((name) => ({ name, required: true })));
560
+ const tty = !opts.yes && !!process.stdin.isTTY && !!process.stdout.isTTY;
561
+ const variables = missing.length ? await resolveVariables(missing, given, { tty, ask }) : given;
562
+ if (!opts.yes && tty) {
563
+ const go = await clack.confirm({ message: `Upgrade ${service} from ${plan.from_version} to ${plan.to_version}?` });
564
+ if (clack.isCancel(go))
565
+ throw new CliCancel();
566
+ if (!go)
567
+ return;
568
+ }
569
+ // The version and digest the user just read. A republish in between answers 409 rather than
570
+ // running something they never saw.
571
+ const res = await postWithAdvice(api, `/projects/${p.projectId}/template-deployments/${target.deploymentId}/upgrade`, {
572
+ variables, expectedVersion: plan.to_version, expectedDigest: plan.to_digest,
573
+ }, UPGRADE_409);
574
+ if (handleApproval(res, opts.json))
575
+ return;
576
+ const deploymentId = res.body.deploymentId ?? (res.body.deployment ?? res.body).id;
577
+ if (!quiet)
578
+ 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
+ if (quiet)
581
+ return printJson(dep);
582
+ info(`${service} is on ${plan.to_version}`);
583
+ info(`next: \`insta template rollback ${service}\` returns it to ${plan.from_version}`);
584
+ renderNextActions(dep.nextActions);
585
+ }
586
+ export async function templateRollback(service, opts = {}, deps = {}) {
587
+ const given = parseSetFlags(opts.set ?? []); // a typo'd --set fails before any network access
588
+ const quiet = !!opts.json;
589
+ const out = quiet ? () => { } : info;
590
+ // Destructive, and its two warnings are load-bearing: a machine-readable run must say --yes out
591
+ // loud rather than roll back unseen through a pipe.
592
+ // Same rule as `template publish`: ask on a terminal, anywhere else (a pipe, --json) say --yes.
593
+ const tty = !opts.json && !opts.yes && !!process.stdin.isTTY && !!process.stdout.isTTY;
594
+ if (!opts.yes && !tty)
595
+ throw new Error('rollback needs --yes without a terminal to confirm on: it does not restore data the app migrated, so nothing was rolled back');
596
+ const api = deps.api ?? (await ApiClient.load());
597
+ const p = deps.project ?? (await requireProject());
598
+ const branchName = opts.branch ?? p.branch;
599
+ const ask = deps.ask ?? promptVariable;
600
+ const target = await deploymentOfService(api, p.projectId, branchName, service, 'roll back');
601
+ // Said BEFORE the confirmation, not after the fact: rollback prints no plan, so these lines are
602
+ // the whole disclosure, and what they warn of is not recoverable by running the command again.
603
+ out('Going back restores every setting that version declares: the image, the start command, the port, always-on, public access, the volume mount path and the recorded variables.');
604
+ out('A setting the older version does not declare keeps its current value.');
605
+ out('Public access on a storage bucket reaches that bucket on every branch that has a copy, not only this one.');
606
+ out('It does not restore data the app migrated under the newer version, and a volume only grows.');
607
+ if (tty) {
608
+ const go = await clack.confirm({ message: `Return ${service} to the version it was upgraded from?` });
609
+ if (clack.isCancel(go))
610
+ throw new CliCancel();
611
+ if (!go)
612
+ return;
613
+ }
614
+ // The platform recovers every value the services still hold, so a rollback normally sends
615
+ // nothing. What it cannot recover is a variable the NEWER version stopped declaring: the upgrade
616
+ // deleted that value and the older manifest still requires it. Rollback prints no plan, so the
617
+ // platform's 400 naming them is the only place those names exist — prompt from it and retry
618
+ // once, exactly as templateDeploy does with its own missing_variables.
619
+ const rollbackPath = `/projects/${p.projectId}/template-deployments/${target.deploymentId}/rollback`;
620
+ const variables = { ...given };
621
+ let res;
622
+ try {
623
+ res = await postWithAdvice(api, rollbackPath, { variables }, ROLLBACK_409);
624
+ }
625
+ catch (e) {
626
+ const missing = e instanceof ApiError ? missingVariablesFrom(e.body) : null;
627
+ if (!missing?.length)
628
+ throw e;
629
+ // No TTY (or --yes) fails here with the `--set NAME=value` list, the same rule the deploy and
630
+ // upgrade paths follow — nothing has been rolled back at this point.
631
+ Object.assign(variables, await resolveVariables(missing, {}, { tty, ask }));
632
+ res = await postWithAdvice(api, rollbackPath, { variables }, ROLLBACK_409);
633
+ }
634
+ if (handleApproval(res, opts.json))
635
+ return;
636
+ const deploymentId = res.body.deploymentId ?? (res.body.deployment ?? res.body).id;
637
+ 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
+ if (quiet)
640
+ return printJson(dep);
641
+ info(`${service} is back on ${(dep.deployment ?? dep).template_version ?? 'its previous version'}`);
642
+ renderNextActions(dep.nextActions);
643
+ }
424
644
  //# sourceMappingURL=template.js.map
@@ -346,8 +346,6 @@ export async function autoupdate(mode) {
346
346
  const enabled = cfg.autoUpdate !== false && !process.env.INSTA_NO_AUTOUPDATE;
347
347
  info(`autoupdate: ${enabled ? 'on' : 'off'} (default on while the CLI is pre-1.0 — \`insta config autoupdate off\` to disable)`);
348
348
  }
349
- // Called once at CLI start-up. Never blocks: reads the cache synchronously, prints at most one
350
- // stderr line, and (when due) spawns detached children for the registry check / quiet upgrade.
351
349
  /** Commands that must never nudge about an update or spawn a background one.
352
350
  *
353
351
  * `upgrade`/`autoupdate` are the update machinery itself. Every `__` command
@@ -368,6 +366,8 @@ export function skipsUpdateCheck(cmd, sub) {
368
366
  return true;
369
367
  return cmd === 'upgrade' || cmd === 'autoupdate' || !!cmd?.startsWith('__');
370
368
  }
369
+ // Called once at CLI start-up. Never blocks: reads the cache synchronously, prints at most one
370
+ // stderr line, and (when due) spawns detached children for the registry check / quiet upgrade.
371
371
  export function maybeUpdate(current, argv) {
372
372
  if (skipsUpdateCheck(argv[2], argv[3]))
373
373
  return;
@@ -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,6 +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,
76
+ endpoint: ENDPOINTS.includes(opts.endpoint ?? '') ? opts.endpoint : undefined,
75
77
  replaceSource: opts.replaceSource === true ? true : undefined,
76
78
  });
77
79
  // An approval is a 202 too, told apart by its status word; anything else here is our operation.
@@ -121,7 +123,12 @@ export async function deployArchive(api, projectId, ref, branch, opts, now = Dat
121
123
  if (state === 'live') {
122
124
  const image = res.body?.imageRef;
123
125
  const url = res.body?.url;
124
- if (typeof image !== 'string' || !image || typeof url !== 'string' || (!url && opts.port !== '0')) {
126
+ // A tcp/udp/tcp+udp (v6-lane) service has no URL: its answer is the v6 host instead. A worker has neither.
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;
131
+ if (typeof image !== 'string' || !image || typeof url !== 'string' || (!url && opts.port !== '0' && !endpointHost)) {
125
132
  throw new Error('the deploy finished but the platform returned no image or URL for it — check `insta status`');
126
133
  }
127
134
  const optionalString = (field, v) => {
@@ -132,7 +139,7 @@ export async function deployArchive(api, projectId, ref, branch, opts, now = Dat
132
139
  return v;
133
140
  };
134
141
  return {
135
- image, url,
142
+ image, url, ...(endpointHost ? { endpointHost } : {}), ...(endpoint ? { endpoint } : {}),
136
143
  branch: optionalString('branch', res.body.branch) ?? branch,
137
144
  group: optionalString('group', res.body.group) ?? opts.group ?? '',
138
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,6 +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)')
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')
158
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')
159
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)')
160
162
  .option('--mount-path <path>', 'compute only: container mount path for a new volume (requires --volume; default /data)')
@@ -364,7 +366,7 @@ function addObservability(group, component, noun) {
364
366
  // optional makes commander unable to hold that boundary itself).
365
367
  const { argv: computeArgv, command: execCommand, windowsFallback: execWindowsFallback, } = computeCmd.splitExecArgs(process.argv);
366
368
  // ---- compute ----
367
- 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');
369
+ 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');
368
370
  compute.command('start [service]').description('Bring a compute service online (persistent — re-enables auto-wake)')
369
371
  .option('--json').option('--branch <branch>', 'branch (default: current)').action(guard((service, o) => computeCmd.computeStart(service, o)));
370
372
  compute.command('stop [service]').description('Take a compute service offline; traffic will NOT wake it until `start`')
@@ -383,6 +385,10 @@ compute.command('scale [count] [service]').description('Set a compute service sa
383
385
  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")
384
386
  .option('--memory <size>', 'memory ceiling, e.g. 512mb or 1gb').option('--cpu <n>', 'vCPU ceiling override (provider sizes: 1, 2, 4, 6, 8)')
385
387
  .option('--json').option('--branch <branch>', 'branch (default: current)').action(guard((service, o) => computeCmd.computeLimits(service, o)));
388
+ 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)")
389
+ .option('-y, --yes', 'close without asking (required when there is no terminal, or with --json)')
390
+ .option('--json').option('--branch <branch>', 'branch (default: current)')
391
+ .action(guard((mode, service, o) => privateNet.servicePublicAccess('compute', mode, service, o)));
386
392
  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')
387
393
  .option('--json').option('--branch <branch>', 'branch (default: current)').action(guard((mode, service, o) => computeCmd.computeAlwaysOn(mode, service, o)));
388
394
  compute.command('ssh [service]')
@@ -449,7 +455,7 @@ pg.command('volume [service]').description("Show or grow a postgres service's pr
449
455
  .option('--size <gi>', 'new size in whole Gi, e.g. 10 (must be ≥ the current size)')
450
456
  .option('--json').option('--branch <branch>', 'branch (default: current)')
451
457
  .action(guard((service, o) => pgCmd.dbVolume(service, o)));
452
- 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')
458
+ 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 (the default for databases); off = scale-to-zero (idle instance suspends; first connection cold-starts). insta-db-backed services only')
453
459
  .option('--json').option('--branch <branch>', 'branch (default: current)')
454
460
  .action(guard((mode, service, o) => pgCmd.dbAlwaysOn(mode, service, o)));
455
461
  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)')
@@ -462,7 +468,7 @@ pg.command('public-access [mode] [service]').description("Show, close or re-open
462
468
  addObservability(pg, 'db', 'postgres');
463
469
  // ---- redis / mysql / mongodb (managed databases) ----
464
470
  for (const type of ['redis', 'mysql', 'mongodb']) {
465
- const g = program.command(type).description(`Managed ${type} services: query, status, resource ceiling, volume, always-on, logs, metrics`);
471
+ const g = program.command(type).description(`Managed ${type} services: query, status, resource ceiling, volume, public access, always-on, logs, metrics`);
466
472
  const query = g.command('query <service> [args...]').description(type === 'redis'
467
473
  ? 'Run a redis command against the service via the console exec API — a pre-tokenized argv, e.g. `GET mykey`'
468
474
  : `Run one quoted ${type} statement against the service via the console exec API`)
@@ -481,6 +487,10 @@ for (const type of ['redis', 'mysql', 'mongodb']) {
481
487
  .option('--size <gi>', 'new size in whole Gi, e.g. 10 (must be ≥ the current size)')
482
488
  .option('--json').option('--branch <branch>', 'branch (default: current)')
483
489
  .action(guard((service, o) => computeCmd.serviceVolume(type, service, o)));
490
+ 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)`)
491
+ .option('-y, --yes', 'close without asking (required when there is no terminal, or with --json)')
492
+ .option('--json').option('--branch <branch>', 'branch (default: current)')
493
+ .action(guard((mode, service, o) => privateNet.servicePublicAccess(type, mode, service, o)));
484
494
  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`)
485
495
  .option('--json').option('--branch <branch>', 'branch (default: current)')
486
496
  .action(guard((mode, service, o) => computeCmd.serviceAlwaysOn(type, mode, service, o)));
@@ -525,6 +535,7 @@ buildCmd.command('logs <build-id>').description('Read source-build output for a
525
535
  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')
526
536
  .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)')
527
537
  .option('--websocket', 'run a WebSocket app (larger guest + connection-based concurrency)')
538
+ .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')
528
539
  .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')
529
540
  .option('--json', 'print the deploy result as JSON (build progress goes to stderr)')
530
541
  .action(guard((dir, o) => deploy(dir, o)));
@@ -547,6 +558,18 @@ tpl.command('deploy <code-or-dir-or-url>').description('Deploy a template onto a
547
558
  .option('-y, --yes', 'non-interactive: missing required variables fail with a --set list instead of prompting')
548
559
  .option('--json')
549
560
  .action(guard((target, o) => template.templateDeploy(target, o)));
561
+ tpl.command('upgrade <service>').description('Move a service deployed from a template to the version the registry serves now. Shows what will change and asks before it runs. A service that already exists is reused, and every setting the new version declares is applied: image, start command, port, always-on, public access, the volume and its mount path, and the variables, each of them listed in the plan first. A service the new version ADDS is created by the upgrade: the plan names it as added and then lists every setting it will be created with, each written `(none) -> <new value>` because there is no live service to compare against, followed by the variables it requires. A service the new version no longer declares is left running, named in the plan as dropped. A volume the new version adds is attached at your plan\'s size and mounted where the manifest says; an existing one is never resized. For a storage bucket, a public-access change reaches that bucket on every branch that has a copy. Refused with a 409 when the template was republished since the plan, or when this instance has already moved off this deployment')
562
+ .option('--branch <b>', 'branch the service is on (default: current)')
563
+ .option('--set <NAME=value>', 'set a template variable the new version requires (repeatable)', (v, prev) => [...prev, v], [])
564
+ .option('-y, --yes', 'do not ask for confirmation')
565
+ .option('--json', 'machine-readable output: alone it prints the plan and does not upgrade; with --yes it upgrades and prints the finished deployment')
566
+ .action(guard((service, o) => template.templateUpgrade(service, o)));
567
+ tpl.command('rollback <service>').description('Return a service to the template version it was upgraded from, one step. Restores every setting that version declares: image, start command, port, always-on, public access, the volume mount path and the recorded variables, while a setting it does not declare keeps its current value. For a storage bucket, a public-access change reaches that bucket on every branch that has a copy. It does not restore data the app migrated, and a volume only grows. Refused with a 409 when the service was never upgraded, when that version\'s manifest is no longer recorded, or when that version was republished with different content after this instance ran it')
568
+ .option('--branch <b>', 'branch the service is on (default: current)')
569
+ .option('--set <NAME=value>', "set a template variable the older version requires that the instance no longer holds — an upgrade to a version that stopped declaring it deleted the value (repeatable)", (v, prev) => [...prev, v], [])
570
+ .option('-y, --yes', 'do not ask for confirmation')
571
+ .option('--json', 'print the finished deployment as JSON and suppress progress lines; requires --yes')
572
+ .action(guard((service, o) => template.templateRollback(service, o)));
550
573
  // Authoring: the org's community templates, the same drafts the console editor changes.
551
574
  tpl.command('create').description("Create a community template draft from the linked project (or --project), or an empty one with --blank. Prints its code, status and the console editor link")
552
575
  .option('--project <id>', 'project to generate the draft from (default: the linked project)')
@@ -139,7 +139,6 @@ export function validateManifest(m) {
139
139
  if (authored.spec !== undefined) {
140
140
  problems.push(`${where}: compute size is the platform's to choose — remove spec`);
141
141
  }
142
- // Validate runtime fields: command and mountPath
143
142
  if (svc.command !== undefined && (typeof svc.command !== 'string' || !svc.command.trim())) {
144
143
  problems.push(`${where}.command must be a non-empty string`);
145
144
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "insta",
3
- "version": "0.1.23",
3
+ "version": "0.1.25",
4
4
  "type": "module",
5
5
  "description": "InstaCloud CLI — a thin client of the platform control-plane API.",
6
6
  "keywords": [