insta 0.0.29 → 0.0.31

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.
@@ -1,4 +1,4 @@
1
- import { ApiClient, requireProject } from '../api.js';
1
+ import { ApiClient, ApiError, requireProject } from '../api.js';
2
2
  import { info, printJson, handleApproval } from '../util.js';
3
3
  import { resolveComputeServiceId, q, parseVolumeGib } from './services.js';
4
4
  // Attach a developer-owned custom domain to a branch's compute service. Fly issues the cert + routes
@@ -116,29 +116,78 @@ export function parseCpu(raw) {
116
116
  throw new Error(`invalid cpu: ${raw} (provider sizes: ${CPU_SIZES.join(', ')})`);
117
117
  return n;
118
118
  }
119
- // ---- volume (the persistent /data disk; attach is create-time only) ----
119
+ // ---- volume (the persistent /data disk; attach any time, grow-only, deletable; never detach) ----
120
120
  // Render the volume read. Pure, exported for tests (mirrors serviceListLine). Every plan may view;
121
121
  // only growth is paid — that gate is the backend's to enforce, so nothing here pre-blocks.
122
122
  export function volumeLines(name, volume, cap) {
123
123
  if (!volume)
124
124
  return [
125
- `compute ${name}: no volume attached (attach is create-time only: \`insta services add compute <name> --volume <gi>\`)`,
125
+ `compute ${name}: no volume attached (attach one: \`insta compute volume ${name} --size <gi>\` — it mounts at /data on the next deploy)`,
126
126
  ];
127
127
  return [
128
128
  `compute ${name}: volume ${volume.sizeGib}Gi at ${volume.mountPath} (plan max ${cap.volumeGib}Gi)`,
129
- ' billing is actual data stored — the size is a cap, not a price; grow with --size (grow-only)',
129
+ ' billing is actual data stored — the size is a cap, not a price; grow with --size (grow-only), delete with --delete (destroys the data)',
130
130
  ];
131
131
  }
132
- // Show or grow a compute service's /data volume. No --size: a safe read (size + mount path + the
133
- // plan cap). --size: grow via PUT .../volume — paid and grow-only, but both gates belong to the
134
- // backend, whose 403/400 messages carry the upgrade hints and must reach the user verbatim (the
135
- // guard prints ApiError messages as-is).
132
+ // Render the PUT result. Pure, exported for tests. `attached` comes from the backend and is what
133
+ // tells a FIRST attach (no disk yet — it mounts on the next deploy) apart from a grow (the live
134
+ // disk was already extended); the wire size is authoritative in both cases.
135
+ export function volumeWriteLine(name, body) {
136
+ if (body.attached) {
137
+ return `compute ${name}: volume ${body.volume.sizeGib}Gi attached — mounts at ${body.volume.mountPath} on the next deploy (plan max ${body.cap.volumeGib}Gi)`;
138
+ }
139
+ return `compute ${name}: volume grown to ${body.volume.sizeGib}Gi at ${body.volume.mountPath} (plan max ${body.cap.volumeGib}Gi)`;
140
+ }
141
+ // Render the DELETE result. Pure, exported for tests. Deleting is the only way off the volume
142
+ // path (there is no detach), so the line says what came back with it: the two constraints the
143
+ // volume imposed.
144
+ export function volumeDeleteLine(name) {
145
+ return `compute ${name}: volume deleted — the disk and its data are gone; suspend fast-wake and scale-out are back`;
146
+ }
147
+ // Map a DELETE .../volume failure. Pure, exported for tests (r2d2 review rounds 1+2: this is the
148
+ // close-call branch worth pinning). An older backend has no DELETE route, and what its 404 looks
149
+ // like depends on who answered: the real platform (Fastify, no custom notFound handler) sends its
150
+ // default body {"message":"Route DELETE:/… not found","error":"Not Found"} → ApiError message
151
+ // "Not Found"; a proxy or bodyless 404 leaves ApiError's own "HTTP 404" fallback. BOTH are the
152
+ // generic route-miss shape and mean version skew, not a bug — parroting them would send the user
153
+ // hunting the wrong thing. A backend that HAS the route names the real problem in a DOMAIN
154
+ // message ("this service has no volume", …), which must flow verbatim, 404 or not.
155
+ const GENERIC_404 = /^(HTTP 404|Not Found)$/i;
156
+ export function volumeDeleteError(e) {
157
+ if (e instanceof ApiError && e.status === 404 && GENERIC_404.test(e.message.trim())) {
158
+ return new Error('this backend does not support volume delete yet — update the platform, or delete the service to remove its volume');
159
+ }
160
+ return e;
161
+ }
162
+ // Show, attach, grow, or delete a compute service's /data volume. No flag: a safe read (size +
163
+ // mount path + the plan cap). --size: PUT .../volume — attaches when no volume exists, grows
164
+ // otherwise. --delete: DELETE .../volume — destroys the disk and its data immediately (no detach,
165
+ // no undo; billing stops now). The paid/cap/machine-count gates all belong to the backend, whose
166
+ // 403/400 messages carry the upgrade hints and must reach the user verbatim (the guard prints
167
+ // ApiError messages as-is).
136
168
  export async function computeVolume(serviceName, opts) {
169
+ if (opts.delete && opts.size)
170
+ throw new Error('--delete cannot be combined with --size (one changes the volume, the other destroys it)');
137
171
  const api = await ApiClient.load();
138
172
  const p = await requireProject();
139
173
  const branch = opts.branch ?? p.branch;
140
174
  const { services } = await api.request('GET', `/projects/${p.projectId}/services${q(branch)}`);
141
175
  const id = resolveComputeServiceId(services, serviceName);
176
+ if (opts.delete) {
177
+ let res;
178
+ try {
179
+ res = await api.rawRequest('DELETE', `/projects/${p.projectId}/services/${id}/volume`);
180
+ }
181
+ catch (e) {
182
+ throw volumeDeleteError(e);
183
+ }
184
+ if (handleApproval(res))
185
+ return;
186
+ if (opts.json)
187
+ return printJson(res.body);
188
+ info(volumeDeleteLine(res.body.service?.name ?? serviceName ?? id));
189
+ return;
190
+ }
142
191
  if (!opts.size) {
143
192
  const r = await api.request('GET', `/projects/${p.projectId}/services/${id}/volume`);
144
193
  if (opts.json)
@@ -153,8 +202,7 @@ export async function computeVolume(serviceName, opts) {
153
202
  return;
154
203
  if (opts.json)
155
204
  return printJson(res.body);
156
- const v = res.body.volume;
157
- info(`compute ${res.body.service?.name ?? serviceName ?? id}: volume grown to ${v.sizeGib}Gi at ${v.mountPath} (plan max ${res.body.cap.volumeGib}Gi)`);
205
+ info(volumeWriteLine(res.body.service?.name ?? serviceName ?? id, res.body));
158
206
  }
159
207
  // Show or set a compute service's ceiling. With no --memory it PRINTS the current limits and the
160
208
  // plan cap (so `insta compute limits` is a safe read), which is also what a UI renders as a slider
@@ -72,6 +72,24 @@ export async function ensureFlyctl() {
72
72
  info((await ok('brew', ['install', 'flyctl'], true)) ? 'flyctl installed ✓' : 'flyctl install failed — install manually: https://fly.io/docs/flyctl/install/');
73
73
  return;
74
74
  }
75
+ if (process.platform === 'linux') {
76
+ // A fresh Linux machine (CI containers included — insta-e2e run 31284364163) has no flyctl
77
+ // and no brew; without this branch `insta deploy <dir>` dead-ends on a hand-install of a
78
+ // third-party CLI. Official installer, pinned into ~/.fly; the current process extends its
79
+ // own PATH because the installer's shell-profile edit can't reach an already-running process.
80
+ info('flyctl not found — installing to ~/.fly (one-time)…');
81
+ const flyHome = `${process.env.HOME ?? '~'}/.fly`;
82
+ const installed = await ok('sh', ['-c', `curl -fsSL https://fly.io/install.sh | FLYCTL_INSTALL="${flyHome}" sh`], true);
83
+ if (installed) {
84
+ process.env.PATH = `${process.env.PATH}:${flyHome}/bin`;
85
+ if (await ok('flyctl', ['version'])) {
86
+ info('flyctl installed ✓');
87
+ return;
88
+ }
89
+ }
90
+ info('flyctl install failed — install manually: https://fly.io/docs/flyctl/install/');
91
+ return;
92
+ }
75
93
  info('flyctl (fly CLI) not found — install it to deploy from source: https://fly.io/docs/flyctl/install/');
76
94
  }
77
95
  catch { /* best-effort convenience */ }
package/dist/index.js CHANGED
@@ -115,7 +115,7 @@ svc.command('add <type> <name>').description('Provision a service on demand (ass
115
115
  .option('--image <url>', 'compute only: run this container image at creation')
116
116
  .option('--port <n>', 'compute only: port the image listens on (default 8080)')
117
117
  .option('--always-on', 'compute only: create as always-on — never scales to zero (all plans; billing is actual usage either way)')
118
- .option('--volume <gi>', 'compute only: attach a persistent /data volume of this many whole Gi (create-time only; any plan may attach at the default 1; larger sizes are paid and plan-capped). Volume services keep 1 machine and stop (cold wake) instead of suspend when idle')
118
+ .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 at the default 1; larger sizes are paid and plan-capped). Volume services keep 1 machine and stop (cold wake) instead of suspend when idle')
119
119
  .action(guard((type, name, o) => services.servicesAdd(type, name, o)));
120
120
  svc.command('list').option('--json').option('--branch <branch>', 'branch (default: current)')
121
121
  .action(guard((o) => services.servicesList(o)));
@@ -171,8 +171,9 @@ compute.command('limits [service]').description("Show or set a compute service's
171
171
  .option('--json').option('--branch <branch>', 'branch (default: current)').action(guard((service, o) => computeCmd.computeLimits(service, o)));
172
172
  compute.command('always-on <mode> [service]').description('Set a compute service always-on (mode: on|off). on = machines never scale to zero; off = default scale-to-zero. All plans; billing is actual usage either way')
173
173
  .option('--json').option('--branch <branch>', 'branch (default: current)').action(guard((mode, service, o) => computeCmd.computeAlwaysOn(mode, service, o)));
174
- compute.command('volume [service]').description("Show or grow a compute service's persistent /data volume. No --size: print size, mount path, and the plan cap (any plan). --size grows it (paid plans; grow-only — a provisioned disk cannot shrink). Attach is create-time only: `insta services add compute <name> --volume <gi>`. Billing is actual data stored — the size is a cap, not a price")
174
+ 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 at the default 1Gi; larger is paid and plan-capped; 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")
175
175
  .option('--size <gi>', 'new size in whole Gi, e.g. 10 (must be ≥ the current size)')
176
+ .option('--delete', 'destroy the volume and ALL its data (irreversible; download anything you need first)')
176
177
  .option('--json').option('--branch <branch>', 'branch (default: current)').action(guard((service, o) => computeCmd.computeVolume(service, o)));
177
178
  // ---- db (postgres service controls) ----
178
179
  const db = program.command('db').description('Postgres service controls (limits / volume / always-on / scale-to-zero)');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "insta",
3
- "version": "0.0.29",
3
+ "version": "0.0.31",
4
4
  "type": "module",
5
5
  "description": "InstaCloud CLI — a thin client of the platform control-plane API.",
6
6
  "keywords": [