insta 0.0.74 → 0.0.76

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.
@@ -672,6 +672,10 @@ export function volumeDeleteError(e) {
672
672
  // 403/400 messages carry the upgrade hints and must reach the user verbatim (the guard prints
673
673
  // ApiError messages as-is).
674
674
  export async function computeVolume(serviceName, opts) {
675
+ if (opts.delete && opts.mountPath !== undefined)
676
+ throw new Error('--delete cannot be combined with --mount-path');
677
+ if (opts.mountPath !== undefined && !opts.size)
678
+ throw new Error('--mount-path requires --size when attaching a volume');
675
679
  if (opts.delete && opts.size)
676
680
  throw new Error('--delete cannot be combined with --size (one changes the volume, the other destroys it)');
677
681
  const api = await ApiClient.load();
@@ -703,7 +707,7 @@ export async function computeVolume(serviceName, opts) {
703
707
  return;
704
708
  }
705
709
  const sizeGib = parseVolumeGib(opts.size);
706
- const res = await api.rawRequest('PUT', `/projects/${p.projectId}/services/${id}/volume`, { sizeGib });
710
+ const res = await api.rawRequest('PUT', `/projects/${p.projectId}/services/${id}/volume`, { sizeGib, ...(opts.mountPath !== undefined ? { mountPath: opts.mountPath } : {}) });
707
711
  if (handleApproval(res, opts.json))
708
712
  return;
709
713
  if (opts.json)
@@ -15,7 +15,9 @@ export function searchLines(results) {
15
15
  const w = Math.max(...results.map((r) => r.domainName.length));
16
16
  return results.map((r) => r.purchasable
17
17
  ? ` ${r.domainName.padEnd(w)} ${usd(r.priceCents)}${r.renewalPriceCents !== undefined ? ` (renews ${usd(r.renewalPriceCents)}/yr)` : ''}`
18
- : ` ${r.domainName.padEnd(w)} unavailable${r.reason ? ` — ${r.reason}` : ''}`);
18
+ // The platform's default reason for every unpurchasable row IS `unavailable`, so passing it
19
+ // through prints the word twice.
20
+ : ` ${r.domainName.padEnd(w)} unavailable${r.reason && r.reason !== 'unavailable' ? ` — ${r.reason}` : ''}`);
19
21
  }
20
22
  export async function domainSearch(keyword, opts, deps) {
21
23
  const { api, orgId } = await orgDeps(opts, deps);
@@ -33,8 +35,6 @@ export async function domainSearch(keyword, opts, deps) {
33
35
  }
34
36
  export async function domainBuy(name, opts, deps) {
35
37
  const { api, project: p } = await domainDeps(deps);
36
- const branch = opts.branch ?? p.branch;
37
- const { target } = await domainTarget(api, p.projectId, branch, name, opts.group);
38
38
  // JSON.stringify drops undefined but keeps NaN as null, which the platform rejects as a type
39
39
  // error rather than a bad term — so a malformed --years is refused here, with the reason.
40
40
  let years;
@@ -43,38 +43,65 @@ export async function domainBuy(name, opts, deps) {
43
43
  if (!Number.isInteger(years))
44
44
  die(`--years must be a whole number of years, not ${opts.years}`);
45
45
  }
46
- const res = await api.rawRequest('POST', `/projects/${p.projectId}/domains/orders`, { domainName: name, years, branch, group: target.name });
46
+ const res = await api.rawRequest('POST', `/projects/${p.projectId}/domains/orders`, { domainName: name, years });
47
47
  if (handleApproval(res, opts.json))
48
48
  return;
49
49
  if (opts.json)
50
50
  return printJson(res.body);
51
51
  const { order } = res.body;
52
52
  info(`${order.domainName} — ${usd(order.priceCents)} for ${order.years} year${order.years === 1 ? '' : 's'}${order.renewalPriceCents !== null ? `, then ${usd(order.renewalPriceCents)}/yr` : ''}`);
53
- info(`attaches to ${target.name}${order.branch ? ` (branch ${order.branch})` : ''} as ${order.domainName} and www.${order.domainName} once paid`);
54
53
  presentUrl(order.checkoutUrl, 'Complete the payment in your browser:', opts.open);
55
- info(`then: insta domain status ${order.domainName}`);
54
+ info(`then attach it: insta domain attach ${order.domainName}`);
55
+ }
56
+ // The bought name `host` sits under. At most one name can match: only apex names are sold, so no bought
57
+ // domain is ever a subdomain of another.
58
+ export function ownerOf(host, owned) {
59
+ return owned.find((d) => host === d.domainName || host.endsWith(`.${d.domainName}`)) ?? null;
56
60
  }
57
- export async function domainAttach(name, opts, deps) {
61
+ /**
62
+ * Point one hostname at a compute service. `host` is a bought name — which binds it and its www —
63
+ * or any subdomain of one, which binds only that; the rest of the domain is left as it is.
64
+ */
65
+ export async function domainAttach(host, opts, deps) {
58
66
  const { api, project: p } = await domainDeps(deps);
67
+ const name = host.trim().toLowerCase();
68
+ const { items } = await api.request('GET', `/projects/${p.projectId}/domains`);
69
+ const owner = ownerOf(name, items);
70
+ if (!owner) {
71
+ // The domains list holds registered names only; a bought name still registering is an order.
72
+ const { items: orders } = await api.request('GET', `/projects/${p.projectId}/domains/orders`);
73
+ const o = ownerOf(name, orders);
74
+ if (o)
75
+ die(`${o.domainName} is not registered yet — its order is ${o.status}: insta domain status ${o.domainName}`);
76
+ die(`no domain this org bought covers ${name} — for a domain you own elsewhere: insta compute set-domain ${name}`);
77
+ }
59
78
  const branch = opts.branch ?? p.branch;
60
79
  const { target } = await domainTarget(api, p.projectId, branch, name, opts.group);
61
- const res = await api.rawRequest('POST', `/projects/${p.projectId}/domains/${encodeURIComponent(name)}/attach`, { branch, group: target.name });
80
+ const hostname = name === owner.domainName ? undefined : name;
81
+ const res = await api.rawRequest('POST', `/projects/${p.projectId}/domains/${encodeURIComponent(owner.domainName)}/attach`, { hostname, branch, group: target.name });
62
82
  if (handleApproval(res, opts.json))
63
83
  return;
64
84
  if (opts.json)
65
85
  return printJson(res.body);
66
- const d = res.body;
67
- info(`${d.domainName} will attach to ${target.name} as ${d.hostnames.map((h) => h.hostname).join(' and ')}`);
68
- info(`then: insta domain status ${d.domainName}`);
86
+ // What THIS call asked for. Reading it back off the answer would also name a hostname some
87
+ // earlier attach left pending, which this command did not touch.
88
+ const asked = hostname ? [hostname] : [owner.domainName, `www.${owner.domainName}`];
89
+ info(`${asked.join(' and ')} will attach to ${target.name}`);
90
+ info(`then: insta domain status ${owner.domainName}`);
69
91
  }
70
92
  // ---- list / status ----
71
93
  function domainLines(d) {
72
- const out = [`${d.domainName} ${d.status}${d.service ? ` → ${d.service}` : ''}${d.expiresAt ? ` (expires ${d.expiresAt.slice(0, 10)}${d.autorenew ? ', auto-renews' : ''})` : ''}`];
73
- if (d.status === 'detached' || d.status === 'attach_failed')
74
- out.push(` attach it again: insta domain attach ${d.domainName}`);
94
+ const out = [`${d.domainName} ${d.status}${d.expiresAt ? ` (expires ${d.expiresAt.slice(0, 10)}${d.autorenew ? ', auto-renews' : ''})` : ''}`];
95
+ // Vacuously true for a domain with no hostnames, which is every domain until something attaches.
96
+ if (d.hostnames.every((h) => h.state === 'failed')) {
97
+ const names = d.hostnames.map((h) => h.hostname);
98
+ // Attaching the bought name itself re-attaches its www.
99
+ const retry = names.includes(d.domainName) ? names.filter((h) => h !== `www.${d.domainName}`) : names;
100
+ out.push(` nothing serving — ${(retry.length ? retry : [d.domainName]).map((h) => `insta domain attach ${h}`).join('; ')}`);
101
+ }
75
102
  const w = Math.max(0, ...d.hostnames.map((x) => x.hostname.length));
76
103
  for (const h of d.hostnames)
77
- out.push(` ${h.hostname.padEnd(w)} ${h.state}${h.reason ? ` — ${h.reason}` : ''}`);
104
+ out.push(` ${h.hostname.padEnd(w)} ${h.state}${h.service ? ` → ${h.service}` : ''}${h.reason ? ` — ${h.reason}` : ''}`);
78
105
  return out;
79
106
  }
80
107
  function orderStatusLines(o) {
@@ -89,7 +116,7 @@ export async function domainList(opts, deps) {
89
116
  if (opts.json)
90
117
  return printJson(r);
91
118
  if (!r.items.length)
92
- return info('no domains bought through InstaCloud in this project (search: insta domain search <keyword>)');
119
+ return info('no domains bought through InstaCloud in this org (search: insta domain search <keyword>)');
93
120
  for (const d of r.items)
94
121
  for (const line of domainLines(d))
95
122
  info(line);
@@ -97,6 +124,7 @@ export async function domainList(opts, deps) {
97
124
  export async function domainStatus(name, opts, deps) {
98
125
  const { api, project: p } = await domainDeps(deps);
99
126
  const host = name.trim().toLowerCase();
127
+ // Both are the ORG's; the project in the path is the scope the agent policy is read at.
100
128
  const [{ items: domains }, { items: orders }] = await Promise.all([
101
129
  api.request('GET', `/projects/${p.projectId}/domains`),
102
130
  api.request('GET', `/projects/${p.projectId}/domains/orders`),
@@ -104,7 +132,7 @@ export async function domainStatus(name, opts, deps) {
104
132
  const domain = domains.find((d) => d.domainName === host) ?? null;
105
133
  const order = orders.find((o) => o.domainName === host) ?? null;
106
134
  if (!domain && !order)
107
- die(`${host} was not bought through this project`);
135
+ die(`${host} was not bought through this org`);
108
136
  if (opts.json)
109
137
  return printJson({ domain, order });
110
138
  for (const line of domain ? domainLines(domain) : orderStatusLines(order))
@@ -90,6 +90,7 @@ export function servicesAddRequestBody(type, name, branch, opts) {
90
90
  // false. Omitted means the platform default.
91
91
  ...(opts.alwaysOn !== undefined ? { alwaysOn: opts.alwaysOn } : {}),
92
92
  ...(opts.volume !== undefined ? { volumeGib: parseVolumeGib(opts.volume) } : {}),
93
+ ...(opts.mountPath !== undefined ? { volumeMountPath: opts.mountPath } : {}),
93
94
  };
94
95
  }
95
96
  export async function servicesAdd(type, name, opts = {}) {
@@ -108,6 +109,8 @@ export async function servicesAdd(type, name, opts = {}) {
108
109
  // Presence, not truthiness: `--no-always-on` is an explicit false and is just as compute-only.
109
110
  if (opts.alwaysOn !== undefined && type !== 'compute')
110
111
  throw new Error('--always-on / --no-always-on is only valid for compute services (for postgres, use `insta db always-on on|off` after creation)');
112
+ if (opts.mountPath !== undefined && (type !== 'compute' || opts.volume === undefined))
113
+ throw new Error('--mount-path requires --volume on a compute service');
111
114
  if (opts.volume !== undefined) {
112
115
  if (type !== 'compute')
113
116
  throw new Error('--volume is only valid for compute services (postgres has one by default — grow it with `insta db volume --size`)');
@@ -138,7 +141,7 @@ export async function servicesAdd(type, name, opts = {}) {
138
141
  export function serviceAddedLine(type, name, branch, svc) {
139
142
  const access = svc.type === 'storage' ? ` [${svc.public ? 'public' : 'private'}]` : '';
140
143
  const img = svc.image ? ` running ${svc.image}${svc.port ? `:${svc.port}` : ''}` : '';
141
- const vol = svc.volume_gib ? ` vol ${svc.volume_gib}Gi at /data` : '';
144
+ const vol = svc.volume_gib ? ` vol ${svc.volume_gib}Gi at ${svc.volume_mount_path ?? "/data"}` : '';
142
145
  // The major belongs next to the connect hint: it decides which psql/pg_dump to reach for.
143
146
  const pg = svc.type === 'postgres' ? pgBadge(svc.pg_version) : '';
144
147
  return `added ${type} service ${name} on ${branch ?? 'default'} (${svc.id})${access}${svc.region ? ` ${svc.region}` : ''}${img}${vol}${pg}${svc.domain ? ` — ${svc.domain}` : ''}`;
@@ -153,7 +156,7 @@ export function pgBadge(v) {
153
156
  // billingLines in billing.ts). Compute rows show the running image when the platform reports one.
154
157
  export function serviceListLine(s) {
155
158
  const extra = s.type === 'compute'
156
- ? ` x${s.machine_count}${s.volume_gib ? ` vol ${s.volume_gib}Gi` : ''}${s.image ? ` running ${s.image}${s.port ? `:${s.port}` : ''}` : ''}`
159
+ ? ` x${s.machine_count}${s.volume_gib ? ` vol ${s.volume_gib}Gi at ${s.volume_mount_path ?? '/data'}` : ''}${s.image ? ` running ${s.image}${s.port ? `:${s.port}` : ''}` : ''}`
157
160
  : ['redis', 'mysql', 'mongodb'].includes(s.type) ? ` tcp/${s.port ?? defaultDatabasePort(s.type)}${s.volume_gib ? ` vol ${s.volume_gib}Gi` : ''}`
158
161
  : s.type === 'storage' ? ` ${s.public ? 'public' : 'private'}`
159
162
  // Postgres major, so the reader picks matching pg_dump/psql BEFORE connecting (a newer client
@@ -352,7 +352,14 @@ export async function templateDeploy(target, opts = {}, deps = {}) {
352
352
  const onAutoResolved = quiet ? undefined : (v) => info(` ${v.name}: ${v.generate ? `platform-generated (${v.generate})` : `default (${v.default})`}`);
353
353
  const variables = await resolveVariables(vars, given, { tty, ask, onAutoResolved });
354
354
  // The endpoint takes the branch NAME directly (branchId is its uuid alias) — no lookup needed.
355
- const body = { ...(mode.kind === 'registry' ? { templateCode: mode.code } : { manifest }), branch: branchName, variables };
355
+ // Presence, not truthiness: `--region ''` must reach the platform's 400 rather than be dropped
356
+ // into the default region. An omitted flag still sends no key, so a retry keeps the recorded one.
357
+ const body = {
358
+ ...(mode.kind === 'registry' ? { templateCode: mode.code } : { manifest }),
359
+ branch: branchName,
360
+ variables,
361
+ ...(opts.region !== undefined ? { region: opts.region } : {}),
362
+ };
356
363
  let res;
357
364
  try {
358
365
  res = await api.rawRequest('POST', `/projects/${p.projectId}/template-deployments`, body);
@@ -371,9 +378,10 @@ export async function templateDeploy(target, opts = {}, deps = {}) {
371
378
  if (handleApproval(res, opts.json))
372
379
  return;
373
380
  const deploymentId = res.body.deploymentId ?? (res.body.deployment ?? res.body).id;
381
+ const acceptedRegion = (res.body.deployment ?? res.body).region;
374
382
  const codeLabel = manifest?.code ?? target;
375
383
  if (!quiet)
376
- info(`deploying template ${codeLabel} to branch ${branchName} (${deploymentId})`);
384
+ info(`deploying template ${codeLabel} to branch ${branchName}${acceptedRegion ? ` in ${acceptedRegion}` : ''} (${deploymentId})`);
377
385
  // The poll route is keyed by deployment id, not project: name the project so agent mode signs
378
386
  // with the project-bound session (a bootstrap session is rejected as "for a different project").
379
387
  const dep = await watchDeployment((id) => api.request('GET', `/template-deployments/${id}`, undefined, { projectId: p.projectId }), deploymentId, quiet ? () => { } : info, deps.wait);
package/dist/index.js CHANGED
@@ -150,7 +150,8 @@ svc.command('add [type] [name]').description('Provision a service on demand (ass
150
150
  .option('--port <n>', 'compute only: port the image listens on (default 8080)')
151
151
  .option('--always-on', 'compute only: create as always-on — never scales to zero (the default for new compute services; all plans; billing is actual usage either way)')
152
152
  .option('--no-always-on', 'compute only: create as scale-to-zero — idle machines suspend and wake on the next request')
153
- .option('--volume <gi>', 'compute only: attach a persistent /data volume of this many whole Gi (also attachable later: `insta compute volume <name> --size <gi>`). Any plan may attach up to its own plan cap (10Gi free, 50Gi paid by default; the bare `insta compute volume <name>` read prints it as plan max); a size above the free cap is paid. Volume services keep 1 machine and stop (cold wake) instead of suspend when idle')
153
+ .option('--mount-path <path>', 'compute only: container mount path for a new volume (requires --volume; default /data; fixed after attachment)')
154
+ .option('--volume <gi>', 'compute only: attach a persistent volume of this many whole Gi (also attachable later: `insta compute volume <name> --size <gi>`). Any plan may attach up to its own plan cap (10Gi free, 50Gi paid by default; the bare `insta compute volume <name>` read prints it as plan max); a size above the free cap is paid. Volume services keep 1 machine and stop (cold wake) instead of suspend when idle')
154
155
  .option('--json')
155
156
  .action(guard(async (type, name, o) => {
156
157
  const a = await resolveServiceArgs(type, name, serviceArgsDeps(o.json), o);
@@ -278,7 +279,8 @@ compute.command('watch-paths [service]').description("Show or change which paths
278
279
  .option('--json').option('--branch <branch>', 'branch (default: current)').action(guard((service, o) => githubCmd.computeWatchPaths(service, o)));
279
280
  compute.command('disconnect-repo [service]').description('Disconnect the GitHub repository from a compute service. The service keeps running its current image; pushes no longer deploy it, and its build history stays')
280
281
  .option('--json').option('--branch <branch>', 'branch (default: current)').action(guard((service, o) => githubCmd.computeDisconnectRepo(service, o)));
281
- compute.command('volume [service]').description("Show, attach, grow, or delete a compute service's persistent /data volume. No flag: print size, mount path, and the plan cap (any plan). --size on a volumeless service ATTACHES one (any plan up to its own plan cap — 10Gi free, 50Gi paid by default — which is also what a disk with no size named is born at; a size above the free cap is paid; the disk mounts at /data on the next deploy); on a volume-bearing one it grows (paid plans; grow-only — a provisioned disk cannot shrink). --delete DESTROYS the disk and ALL its data immediately (no detach, no undo; billing stops now, and suspend fast-wake + scale-out return). Billing is actual data stored — the size is a cap, not a price")
282
+ compute.command('volume [service]').description("Show, attach, grow, or delete a compute service's persistent volume. No flag: print size, mount path, and the plan cap (any plan). --size on a volumeless service ATTACHES one (any plan up to its own plan cap — 10Gi free, 50Gi paid by default — which is also what a disk with no size named is born at; a size above the free cap is paid; the disk mounts at --mount-path (default /data) on the next deploy); on a volume-bearing one it grows (paid plans; grow-only — a provisioned disk cannot shrink). --delete DESTROYS the disk and ALL its data immediately (no detach, no undo; billing stops now, and suspend fast-wake + scale-out return). Billing is actual data stored — the size is a cap, not a price")
283
+ .option('--mount-path <path>', 'container mount path for a new volume (requires --size; default /data; fixed after attachment)')
282
284
  .option('--size <gi>', 'new size in whole Gi, e.g. 10 (must be ≥ the current size)')
283
285
  .option('--delete', 'destroy the volume and ALL its data (irreversible; download anything you need first)')
284
286
  .option('--json').option('--branch <branch>', 'branch (default: current)').action(guard((service, o) => computeCmd.computeVolume(service, o)));
@@ -335,6 +337,7 @@ tpl.command('info <code>').description('Show a template: version, upstream pin,
335
337
  .option('--json').action(guard((code, o) => template.templateInfo(code, o)));
336
338
  tpl.command('deploy <code-or-dir-or-url>').description('Deploy a template onto a branch — a registry code, a local directory containing insta.template.yaml (a path-looking target is always read as a directory), or a github.com URL (https://github.com/<owner>/<repo>[/tree/<ref>[/<dir>]]) whose manifest is fetched with your own git credentials. Missing required variables are prompted for on a terminal; generator-backed (secret:N) and defaulted ones are resolved by the platform')
337
339
  .option('--branch <b>', 'target branch (default: current)')
340
+ .option('--region <region>', 'region for every service the template creates, e.g. us-east (see `insta regions`)')
338
341
  .option('--set <NAME=value>', 'set a template variable (repeatable)', (v, prev) => [...prev, v], [])
339
342
  .option('-y, --yes', 'non-interactive: missing required variables fail with a --set list instead of prompting')
340
343
  .option('--json')
@@ -361,14 +364,14 @@ const dom = program.command('domain').description('Buy a domain through InstaClo
361
364
  dom.command('search <keyword>').description('Search purchasable names with prices (a label like "myapp" or a full name like "myapp.com")')
362
365
  .option('--tlds <list>', 'comma-separated TLDs to include').option('--org <id>', "target org (default: linked project's org)").option('--json')
363
366
  .action(guard((keyword, o) => domainCmd.domainSearch(keyword, o)));
364
- dom.command('buy <name>').description('Buy a domain and attach it to a branch compute service — pay at the printed Stripe Checkout link (gated: domain.purchase)')
365
- .option('--years <n>', 'registration term in years (default 1)').option('--branch <b>').option('--group <g>', "compute service (default: the branch's sole compute service)")
367
+ dom.command('buy <name>').description('Buy a domain — pay at the printed Stripe Checkout link. It serves nothing until you attach it (gated: domain.purchase)')
368
+ .option('--years <n>', 'registration term in years (default 1)')
366
369
  .option('--no-open', 'print the checkout URL instead of opening a browser').option('--json')
367
370
  .action(guard((name, o) => domainCmd.domainBuy(name, o)));
368
- dom.command('attach <name>').description('Attach a bought domain whose service was deleted (or whose attach failed) to a compute service (gated: deploy)')
371
+ dom.command('attach <hostname>').description('Point a bought domain, or any subdomain of one, at a compute service — `abc.com` binds it and its www, `api.abc.com` binds only that (gated: deploy)')
369
372
  .option('--branch <b>').option('--group <g>', "compute service (default: the branch's sole compute service)").option('--json')
370
- .action(guard((name, o) => domainCmd.domainAttach(name, o)));
371
- dom.command('list').description('Domains bought through InstaCloud in this project, with attach state per hostname').option('--json')
373
+ .action(guard((hostname, o) => domainCmd.domainAttach(hostname, o)));
374
+ dom.command('list').description("Domains bought through InstaCloud in this org — a domain belongs to the org, each of its hostnames to a service").option('--json')
372
375
  .action(guard((o) => domainCmd.domainList(o)));
373
376
  dom.command('status <name>').description("A bought domain's order and attach state").option('--json')
374
377
  .action(guard((name, o) => domainCmd.domainStatus(name, o)));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "insta",
3
- "version": "0.0.74",
3
+ "version": "0.0.76",
4
4
  "type": "module",
5
5
  "description": "InstaCloud CLI — a thin client of the platform control-plane API.",
6
6
  "keywords": [