insta 0.1.22 → 0.1.24

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.
@@ -1723,11 +1723,30 @@ export function acquireLockFile(path, now, staleMs) {
1723
1723
  // ask whether the holder still exists.
1724
1724
  const token = `${process.pid}:${randomUUID()}`;
1725
1725
  const release = () => {
1726
+ // The claim a takeover of this token takes, so a release and a takeover never interleave.
1727
+ const claim = takeoverClaim(path, token);
1728
+ let claimed = false;
1729
+ try {
1730
+ linkSync(path, claim);
1731
+ claimed = true;
1732
+ }
1733
+ catch (e) {
1734
+ // Leave the lock unless the disk has no hardlinks, where no takeover can claim it either.
1735
+ if (!LINK_UNSUPPORTED.has(String(e?.code)))
1736
+ return;
1737
+ }
1726
1738
  try {
1727
1739
  if (readFileSync(path, 'utf8') === token)
1728
1740
  unlinkSync(path);
1729
1741
  }
1730
1742
  catch { /* already gone, or taken over by someone else */ }
1743
+ finally {
1744
+ if (claimed)
1745
+ try {
1746
+ unlinkSync(claim);
1747
+ }
1748
+ catch { /* the inode keeps its other name */ }
1749
+ }
1731
1750
  };
1732
1751
  const take = () => {
1733
1752
  try {
@@ -1859,21 +1878,22 @@ function processAlive(pid) {
1859
1878
  * `path` keeps its inode throughout, so there is no moment at which the lock
1860
1879
  * at `path` can have become somebody else's between the check and the act:
1861
1880
  * the holder judged dead cannot release, every other breaker is behind the
1862
- * claim, and a release by anyone else checks for its own token first. Should
1863
- * the old holder's release ever run, it reads a foreign token and leaves the
1864
- * file alone.
1881
+ * claim, and a release by anyone else checks for its own token first.
1882
+ * The old holder's release takes this same claim, so it never lands inside a takeover.
1883
+ * A release that cannot take it leaves the lock, to be broken like an abandoned one.
1865
1884
  *
1866
1885
  * Residual, and deliberately not "fixed": a breaker killed between the link
1867
- * and its write leaves the claim behind, and that one token can then no
1868
- * longer be broken (removing the claim by hand is the way out, and the busy
1869
- * message names the lock). Every scheme for reaping an abandoned claim needs
1886
+ * and its write, or a release killed before its unlink, leaves the claim
1887
+ * behind, and that one token can then no longer be broken or released
1888
+ * (removing the claim by hand is the way out, and the busy message names
1889
+ * the lock). Every scheme for reaping an abandoned claim needs
1870
1890
  * to decide the claim is dead and then remove it -- the same check-then-act
1871
1891
  * this function exists to eliminate, one level up. A claim left behind AFTER
1872
1892
  * the write is inert: the lock now carries the breaker's token, and the next
1873
1893
  * breaker keys its claim on that. A wedged lock is recoverable and says so;
1874
1894
  * two writers in the same section silently lose the user's config. */
1875
1895
  function breakStaleLock(path, stale, mine) {
1876
- const claim = `${path}.stale.${createHash('sha256').update(stale).digest('hex').slice(0, 32)}`;
1896
+ const claim = takeoverClaim(path, stale);
1877
1897
  try {
1878
1898
  linkSync(path, claim);
1879
1899
  }
@@ -1906,6 +1926,12 @@ function breakStaleLock(path, stale, mine) {
1906
1926
  catch { /* the inode keeps its other name */ }
1907
1927
  }
1908
1928
  }
1929
+ /** link() errors from a disk without hardlinks, where a release may skip its claim. */
1930
+ const LINK_UNSUPPORTED = new Set(['EPERM', 'ENOTSUP', 'EOPNOTSUPP', 'ENOSYS']);
1931
+ /** The hardlink that arbitrates breaking or releasing the lock `token` names. */
1932
+ function takeoverClaim(path, token) {
1933
+ return `${path}.stale.${createHash('sha256').update(token).digest('hex').slice(0, 32)}`;
1934
+ }
1909
1935
  export async function computeSSH(serviceName, opts, deps = {}) {
1910
1936
  if (opts.ensureCert !== undefined)
1911
1937
  return ensureCertForAlias(opts.ensureCert);
@@ -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 { 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,17 @@ 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 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 ?? '?'}`;
31
+ return r.url || '(no URL: a worker)';
32
+ }
25
33
  // Ask the platform which lane serves this target, so the CLI stops knowing which provider backs
26
34
  // its service. A 404 means the platform predates the contract — and because this is a GET, it
27
35
  // 404s the same way for a human and an agent, which a POST would not.
@@ -143,6 +151,19 @@ export async function deploy(dir, opts) {
143
151
  die('pick one: a source <dir> OR --image <url>, not both');
144
152
  if (!dir && !opts.image)
145
153
  die('usage: insta deploy <dir> | --image <url> [--branch <b>] [--group <g>] [--port <n>]');
154
+ // 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
156
+ // undefined, which let a typo like --endpoint tpc build and deploy using the service's existing
157
+ // endpoint instead of failing locally. Checked before ApiClient.load()/requireProject(): those
158
+ // can resolve a project and persist an automatic link for an unlinked directory, so a typo must
159
+ // die before either runs, not after.
160
+ let endpoint;
161
+ try {
162
+ endpoint = opts.endpoint === undefined ? undefined : parseEndpoint(opts.endpoint);
163
+ }
164
+ catch (e) {
165
+ die(e.message);
166
+ }
146
167
  const api = await ApiClient.load();
147
168
  const p = await requireProject();
148
169
  const branch = opts.branch ?? p.branch;
@@ -162,7 +183,7 @@ export async function deploy(dir, opts) {
162
183
  log(`using port ${exposed} (Dockerfile EXPOSE) — override with --port`);
163
184
  }
164
185
  }
165
- const effOpts = { ...opts, port: port?.toString() };
186
+ const effOpts = { ...opts, port: port?.toString(), endpoint };
166
187
  const source = dir ? await prepareSource(api, p.projectId, dir, branch, effOpts) : { image: opts.image };
167
188
  if (!source)
168
189
  return; // an approval is pending; the user approves and re-runs
@@ -171,8 +192,8 @@ export async function deploy(dir, opts) {
171
192
  // nextActions, which the operation read does not carry.
172
193
  const d = source.deployed;
173
194
  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})`);
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})`);
176
197
  return;
177
198
  }
178
199
  const res = await api.rawRequest('POST', `/projects/${p.projectId}/deploy`, deployRequestBody(source, branch, effOpts))
@@ -182,7 +203,7 @@ export async function deploy(dir, opts) {
182
203
  const what = source.image;
183
204
  if (opts.json)
184
205
  return printJson({ image: source.image, ...res.body });
185
- info(`deployed ${what} -> ${res.body.url} (branch ${res.body.branch}, group ${res.body.group})`);
206
+ info(`deployed ${what} -> ${deployedTarget(res.body, effOpts.port)} (branch ${res.body.branch}, group ${res.body.group})`);
186
207
  renderNextActions(res.body.nextActions);
187
208
  }
188
209
  // 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();
@@ -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,20 @@ 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.
111
+ export function parseEndpoint(raw) {
112
+ const v = raw.trim().toLowerCase();
113
+ if (v !== 'http' && v !== 'tcp')
114
+ throw new Error(`--endpoint must be http or tcp, got: ${raw}`);
115
+ return v;
116
+ }
117
+ // Where a tcp service is dialed. The host is set once a deploy has confirmed it; before that the
118
+ // row only knows its port.
119
+ export function tcpAddress(s) {
120
+ return s.endpoint_host ? `tcp://${s.endpoint_host}:${s.port}` : `tcp/${s.port ?? '?'} (address after the first deploy)`;
121
+ }
108
122
  // Map service-add options to the platform POST body. Pure, so it's unit-tested without a network
109
123
  // mock (mirrors deployRequestBody in deploy.ts). Validation (which options are valid for which
110
124
  // type) stays in servicesAdd, ahead of any network/config access.
@@ -112,6 +126,7 @@ export function servicesAddRequestBody(type, name, branch, opts) {
112
126
  return {
113
127
  type, name, ...(branch ? { branch } : {}), public: !!opts.public,
114
128
  ...(opts.image ? { image: opts.image } : {}), ...(opts.port !== undefined ? { port: parsePort(opts.port, { allowZero: type === 'compute' }) } : {}),
129
+ ...(opts.endpoint !== undefined ? { endpoint: parseEndpoint(opts.endpoint) } : {}),
115
130
  ...(opts.region ? { region: opts.region } : {}),
116
131
  // Sent whenever the flag was given, false included: the birth default is platform config
117
132
  // (INSTA_COMPUTE_ALWAYS_ON_DEFAULT, scale-to-zero unless set), so an explicit --no-always-on
@@ -135,6 +150,11 @@ export async function servicesAdd(type, name, opts = {}) {
135
150
  throw new Error('--port is only valid for compute services');
136
151
  parsePort(opts.port, { allowZero: true });
137
152
  }
153
+ if (opts.endpoint !== undefined) {
154
+ if (type !== 'compute')
155
+ throw new Error('--endpoint is only valid for compute services');
156
+ parseEndpoint(opts.endpoint);
157
+ }
138
158
  // Presence, not truthiness: `--no-always-on` is an explicit false and is just as compute-only.
139
159
  if (opts.alwaysOn !== undefined && type !== 'compute')
140
160
  throw new Error(alwaysOnTypeError(type));
@@ -179,7 +199,8 @@ export function serviceAddedLine(type, name, branch, svc) {
179
199
  const vol = svc.volume_gib ? ` vol ${svc.volume_gib}Gi at ${svc.volume_mount_path ?? "/data"}` : '';
180
200
  // The major belongs next to the connect hint: it decides which psql/pg_dump to reach for.
181
201
  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}` : ''}`;
202
+ const where = svc.endpoint === 'tcp' ? ` — ${tcpAddress(svc)}` : svc.domain ? ` — ${svc.domain}` : '';
203
+ return `added ${type} service ${name} on ${branch ?? 'default'} (${svc.id})${access}${svc.region ? ` ${svc.region}` : ''}${img}${vol}${pg}${where}`;
183
204
  }
184
205
  // ` pg <major>` for a postgres row, or '' when the platform sent nothing usable. The field arrives
185
206
  // as untyped API JSON: only a positive integer renders, so a malformed or nonsense value (true,
@@ -197,7 +218,9 @@ export function serviceListLine(s) {
197
218
  // Postgres major, so the reader picks matching pg_dump/psql BEFORE connecting (a newer client
198
219
  // dumps statements an older server cannot restore). Older platforms send no pg_version.
199
220
  : s.type === 'postgres' ? pgBadge(s.pg_version) : '';
200
- return `${s.type}/${s.name} [${s.status}]${extra}${s.domain ? ` ${s.domain}` : ''} ${s.id}`;
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}`;
201
224
  }
202
225
  export async function servicesList(opts) {
203
226
  const api = await ApiClient.load();
@@ -65,6 +65,8 @@ function serviceText(s) {
65
65
  const bits = [
66
66
  kind,
67
67
  s.port != null ? `port ${s.port}` : undefined,
68
+ // Only a web service has one, and a platform that does not serve the field says nothing.
69
+ s.type === 'web' && s.healthcheck !== undefined ? (s.healthcheck ? `health check ${s.healthcheck}` : 'no health check') : undefined,
68
70
  s.image ? `image ${s.image}` : undefined,
69
71
  // A null mount path on a volume is the default /data.
70
72
  s.volume ? `volume at ${s.mountPath ?? '/data'}` : undefined,
@@ -32,6 +32,7 @@ export function templateListLines(templates) {
32
32
  export function normalizeInfoServices(raw) {
33
33
  const one = (name, s) => ({
34
34
  name, type: s?.type, port: s?.port,
35
+ healthcheck: typeof s?.healthcheck === 'string' ? s.healthcheck : undefined,
35
36
  volumeGib: s?.volumeGib ?? s?.volume?.size,
36
37
  volume: s?.volume === true || s?.volumeGib != null || s?.volume?.size != null,
37
38
  mountPath: typeof s?.mountPath === 'string' ? s.mountPath : undefined,
@@ -91,7 +92,9 @@ export function templateInfoLines(t, bold = (s) => s) {
91
92
  // A size only when the registry still carries one: a manifest names no size any more, so
92
93
  // "persistent /data" is all there is to say until the service exists.
93
94
  const disk = s.volumeGib ? `${s.volumeGib}Gi volume` : s.volume ? `persistent ${s.mountPath ?? '/data'}` : undefined;
94
- const bits = [infoKind(s), s.port ? `port ${s.port}` : undefined, disk].filter(Boolean);
95
+ // Only a web service has a path to probe, and a manifest that names none has no health check.
96
+ const health = s.type === 'web' ? (s.healthcheck ? `health check ${s.healthcheck}` : 'no health check') : undefined;
97
+ const bits = [infoKind(s), s.port ? `port ${s.port}` : undefined, health, disk].filter(Boolean);
95
98
  return `${s.name}${bits.length ? ` (${bits.join(', ')})` : ''}`;
96
99
  });
97
100
  lines.push(`services (${services.length}): ${summary.join(', ')}`);
@@ -126,11 +129,17 @@ export function parseSetFlags(pairs) {
126
129
  for (const pair of pairs) {
127
130
  const m = /^([A-Z][A-Z0-9_]{0,63})=([\s\S]*)$/.exec(pair);
128
131
  if (!m)
129
- 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)}`);
130
133
  values[m[1]] = m[2];
131
134
  }
132
135
  return values;
133
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
+ }
134
143
  export function missingVariablesMessage(missing) {
135
144
  return [
136
145
  'missing required template variables:',
@@ -233,8 +242,13 @@ export function deploymentUrls(dep) {
233
242
  return lines;
234
243
  }
235
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.
236
248
  export function serviceStateLines(dep) {
237
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]`;
238
252
  const mark = s?.state === 'healthy' ? '✓' : s?.state === 'failed' ? '✗' : '•';
239
253
  return ` ${mark} ${s?.name ?? 'service'}${s?.url ? ` — ${s.url}` : ''}${s?.state && s.state !== 'healthy' ? ` [${s.state}]` : ''}`;
240
254
  });
@@ -244,9 +258,13 @@ export function serviceStateLines(dep) {
244
258
  // deploy, or clean up), not just that something went wrong.
245
259
  export function partialMessage(dep) {
246
260
  const services = dep?.services ?? [];
247
- 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;
248
266
  return [
249
- `template deployment finished partial: ${healthy}/${services.length} services healthy`,
267
+ `template deployment finished partial: ${healthy}/${ran.length} services healthy`,
250
268
  ...serviceStateLines(dep),
251
269
  ...(dep?.error ? [` ${dep.error}`] : []),
252
270
  ...(dep?.logsTail ? ['--- log tail ---', String(dep.logsTail).trimEnd()] : []),
@@ -418,4 +436,209 @@ export async function templateDeploy(target, opts = {}, deps = {}) {
418
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');
419
437
  renderNextActions(dep.nextActions);
420
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
+ }
421
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;
@@ -72,6 +72,7 @@ export async function deployArchive(api, projectId, ref, branch, opts, now = Dat
72
72
  archive: ref,
73
73
  port: opts.port ? Number(opts.port) : undefined,
74
74
  websocket: typeof opts.websocket === 'boolean' ? opts.websocket : undefined,
75
+ endpoint: opts.endpoint === 'http' || opts.endpoint === 'tcp' ? opts.endpoint : undefined,
75
76
  replaceSource: opts.replaceSource === true ? true : undefined,
76
77
  });
77
78
  // An approval is a 202 too, told apart by its status word; anything else here is our operation.
@@ -121,7 +122,9 @@ export async function deployArchive(api, projectId, ref, branch, opts, now = Dat
121
122
  if (state === 'live') {
122
123
  const image = res.body?.imageRef;
123
124
  const url = res.body?.url;
124
- if (typeof image !== 'string' || !image || typeof url !== 'string' || (!url && opts.port !== '0')) {
125
+ // An endpoint tcp service has no URL: its answer is the tcp host instead. A worker has neither.
126
+ const endpointHost = typeof res.body?.endpointHost === 'string' && res.body.endpointHost ? res.body.endpointHost : undefined;
127
+ if (typeof image !== 'string' || !image || typeof url !== 'string' || (!url && opts.port !== '0' && !endpointHost)) {
125
128
  throw new Error('the deploy finished but the platform returned no image or URL for it — check `insta status`');
126
129
  }
127
130
  const optionalString = (field, v) => {
@@ -132,7 +135,7 @@ export async function deployArchive(api, projectId, ref, branch, opts, now = Dat
132
135
  return v;
133
136
  };
134
137
  return {
135
- image, url,
138
+ image, url, ...(endpointHost ? { endpointHost } : {}),
136
139
  branch: optionalString('branch', res.body.branch) ?? branch,
137
140
  group: optionalString('group', res.body.group) ?? opts.group ?? '',
138
141
  machineId: optionalString('machineId', res.body.machineId),
package/dist/index.js CHANGED
@@ -155,6 +155,7 @@ svc.command('add [type] [name]').description('Provision a service on demand (com
155
155
  .option('--public', 'storage only: serve the bucket with anonymous public-read (default private)')
156
156
  .option('--image <url>', 'compute only: run this container image at creation')
157
157
  .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')
158
159
  .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
160
  .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
161
  .option('--mount-path <path>', 'compute only: container mount path for a new volume (requires --volume; default /data)')
@@ -449,7 +450,7 @@ pg.command('volume [service]').description("Show or grow a postgres service's pr
449
450
  .option('--size <gi>', 'new size in whole Gi, e.g. 10 (must be ≥ the current size)')
450
451
  .option('--json').option('--branch <branch>', 'branch (default: current)')
451
452
  .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')
453
+ 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
454
  .option('--json').option('--branch <branch>', 'branch (default: current)')
454
455
  .action(guard((mode, service, o) => pgCmd.dbAlwaysOn(mode, service, o)));
455
456
  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)')
@@ -525,6 +526,7 @@ buildCmd.command('logs <build-id>').description('Read source-build output for a
525
526
  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
527
  .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
528
  .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')
528
530
  .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
531
  .option('--json', 'print the deploy result as JSON (build progress goes to stderr)')
530
532
  .action(guard((dir, o) => deploy(dir, o)));
@@ -547,6 +549,18 @@ tpl.command('deploy <code-or-dir-or-url>').description('Deploy a template onto a
547
549
  .option('-y, --yes', 'non-interactive: missing required variables fail with a --set list instead of prompting')
548
550
  .option('--json')
549
551
  .action(guard((target, o) => template.templateDeploy(target, o)));
552
+ 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')
553
+ .option('--branch <b>', 'branch the service is on (default: current)')
554
+ .option('--set <NAME=value>', 'set a template variable the new version requires (repeatable)', (v, prev) => [...prev, v], [])
555
+ .option('-y, --yes', 'do not ask for confirmation')
556
+ .option('--json', 'machine-readable output: alone it prints the plan and does not upgrade; with --yes it upgrades and prints the finished deployment')
557
+ .action(guard((service, o) => template.templateUpgrade(service, o)));
558
+ 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')
559
+ .option('--branch <b>', 'branch the service is on (default: current)')
560
+ .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], [])
561
+ .option('-y, --yes', 'do not ask for confirmation')
562
+ .option('--json', 'print the finished deployment as JSON and suppress progress lines; requires --yes')
563
+ .action(guard((service, o) => template.templateRollback(service, o)));
550
564
  // Authoring: the org's community templates, the same drafts the console editor changes.
551
565
  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
566
  .option('--project <id>', 'project to generate the draft from (default: the linked project)')
@@ -556,7 +570,8 @@ tpl.command('create').description("Create a community template draft from the li
556
570
  .option('--json')
557
571
  .action(guard((o) => templateAuthor.templateCreate(o)));
558
572
  tpl.command('drafts').description("List your org's community templates, drafts and published, with their status")
559
- .option('--org <id>', "target org (default: linked project's org)").option('--json')
573
+ .option('--org <id>', "target org (default: linked project's org)")
574
+ .option('--json', 'print one summary per template as the platform serves it, with no services, variables or publish requirements. insta template draft <code> --json prints one draft in full')
560
575
  .action(guard((o) => templateAuthor.templateDrafts(o)));
561
576
  tpl.command('draft <code>').description('Show one community template draft: its services, every variable with its choice, and the publish requirements')
562
577
  .option('--org <id>', "target org (default: linked project's org)").option('--json')
@@ -14,6 +14,8 @@ export const MANIFEST_FILE = 'insta.template.yaml';
14
14
  const CODE_RE = /^[a-z0-9][a-z0-9-]{0,38}$/;
15
15
  export const ENV_NAME_RE = /^[A-Z][A-Z0-9_]{0,63}$/;
16
16
  const GENERATOR_RE = /^secret:([1-9]\d{0,2})$/;
17
+ // The platform's HEALTHCHECK_RE (src/provisioning/templateManifest.ts), verbatim.
18
+ const HEALTHCHECK_RE = /^\/(?!\/)[A-Za-z0-9\-._~!$&'()*+,;=:@%/?]*$/;
17
19
  // The compute vocabulary every CLI has known. Any other type is the platform's to accept or refuse.
18
20
  const COMPUTE_TYPES = ['web', 'worker'];
19
21
  // Datastores that never gain a url or host. Storage is left off because a public bucket may get an address.
@@ -116,10 +118,16 @@ export function validateManifest(m) {
116
118
  if (svc.alwaysOn === false)
117
119
  problems.push(`${where}.alwaysOn: a worker cannot scale to zero — nothing is routed to it, so nothing would wake it; remove alwaysOn or set it true`);
118
120
  }
119
- if (svc.type === 'web' && !svc.healthcheck)
120
- problems.push(`${where}: web services must declare a healthcheck path`);
121
- if (svc.healthcheck && !String(svc.healthcheck).startsWith('/'))
122
- problems.push(`${where}: healthcheck must be an absolute path (start with /)`);
121
+ // Optional on a web service, and a given path must pass the platform's grammar.
122
+ if (svc.type !== 'worker' && svc.healthcheck !== undefined) {
123
+ const healthcheck = scalarString(svc.healthcheck);
124
+ if (healthcheck === null)
125
+ problems.push(`${where}.healthcheck must be a string`);
126
+ else if (!healthcheck.startsWith('/'))
127
+ problems.push(`${where}: healthcheck must be an absolute path (start with /)`);
128
+ else if (!HEALTHCHECK_RE.test(healthcheck))
129
+ problems.push(`${where}: healthcheck must be a single-slash absolute path on the service itself (no '//host', scheme, backslash or control characters), got: ${healthcheck}`);
130
+ }
123
131
  // Sizing is the platform's, capped for the org's plan. Same answers the
124
132
  // publish endpoint gives, said here so an author does not upload to find out. Read as unknown:
125
133
  // the type above admits only what is SUPPORTED, and the document is a cast over YAML.parse, so
@@ -131,7 +139,6 @@ export function validateManifest(m) {
131
139
  if (authored.spec !== undefined) {
132
140
  problems.push(`${where}: compute size is the platform's to choose — remove spec`);
133
141
  }
134
- // Validate runtime fields: command and mountPath
135
142
  if (svc.command !== undefined && (typeof svc.command !== 'string' || !svc.command.trim())) {
136
143
  problems.push(`${where}.command must be a non-empty string`);
137
144
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "insta",
3
- "version": "0.1.22",
3
+ "version": "0.1.24",
4
4
  "type": "module",
5
5
  "description": "InstaCloud CLI — a thin client of the platform control-plane API.",
6
6
  "keywords": [