insta 0.0.27 → 0.0.29

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/api.js CHANGED
@@ -11,6 +11,13 @@ export class ApiError extends Error {
11
11
  this.name = 'ApiError';
12
12
  }
13
13
  }
14
+ // Store a durable insta_ key as the credential: set it as the bearer and drop any refresh token (an insta_ key never rotates; a stale one would leak to /auth/refresh on a 401).
15
+ export function storeApiKeyCredential(cfg, token, user) {
16
+ cfg.accessToken = token;
17
+ delete cfg.refreshToken;
18
+ if (user)
19
+ cfg.user = user;
20
+ }
14
21
  export class ApiClient {
15
22
  cfg;
16
23
  constructor(cfg) {
@@ -27,6 +34,10 @@ export class ApiClient {
27
34
  if (user)
28
35
  this.cfg.user = user;
29
36
  }
37
+ // Adopt a durable insta_ key as the credential (non-interactive `login --api-key`).
38
+ setApiKey(token, user) {
39
+ storeApiKeyCredential(this.cfg, token, user);
40
+ }
30
41
  clearSession() {
31
42
  delete this.cfg.accessToken;
32
43
  delete this.cfg.refreshToken;
@@ -17,6 +17,13 @@ function targetApiUrl(opts) {
17
17
  return ENVS[want].api;
18
18
  }
19
19
  export async function login(opts) {
20
+ // Login modes are exclusive — pick one. Check presence (not truthiness) so an explicit
21
+ // empty --api-key= is rejected by validation rather than silently falling through.
22
+ if (opts.apiKey !== undefined) {
23
+ if (opts.device || opts.oauth || opts.email)
24
+ die('choose one login mode: --api-key, --device, --oauth, or --email');
25
+ return loginApiKey(opts.apiKey, opts);
26
+ }
20
27
  if (opts.device)
21
28
  return loginDevice(opts);
22
29
  if (opts.oauth)
@@ -65,6 +72,36 @@ export async function loginDevice(opts) {
65
72
  await api.persist();
66
73
  info(`logged in as ${me.user.email ?? me.user.id} @ ${api.apiUrl}`);
67
74
  }
75
+ // Non-interactive login with a durable insta_ key (minted via POST /tokens): store it and confirm against /me. No browser, no polling.
76
+ export async function loginApiKey(key, opts) {
77
+ const api = await ApiClient.load();
78
+ const target = targetApiUrl(opts);
79
+ if (target)
80
+ api.setApiUrl(target);
81
+ const user = await applyApiKeyLogin(api, key);
82
+ await api.persist();
83
+ info(`logged in as ${user.email ?? user.id} @ ${api.apiUrl}`);
84
+ }
85
+ // Verify an insta_ key and store it: set it first so the /me probe is authed with the key itself, then re-store with the resolved user (401 → bad/revoked).
86
+ export async function applyApiKeyLogin(client, key) {
87
+ key = key.trim(); // tolerate a trailing newline / stray whitespace from `--api-key "$(cat token)"`
88
+ if (!key.startsWith('insta_'))
89
+ throw new Error('--api-key expects an insta_ token (mint one with POST /tokens)');
90
+ client.setApiKey(key);
91
+ let me;
92
+ try {
93
+ me = await client.request('GET', '/me');
94
+ }
95
+ catch (e) {
96
+ if (e instanceof ApiError && e.status === 401)
97
+ throw new Error('that insta_ API key was rejected (invalid or revoked) — check it or mint a new one');
98
+ throw e;
99
+ }
100
+ if (!me?.user)
101
+ throw new Error('unexpected response while verifying the API key');
102
+ client.setApiKey(key, me.user);
103
+ return me.user;
104
+ }
68
105
  const sleepSeconds = (s) => new Promise((r) => setTimeout(r, s * 1000));
69
106
  // Drives the device grant against the platform's Better Auth mount (/api/auth/device*) and
70
107
  // returns the approved session token. Injectable poster + wait keep this testable without a
@@ -1,6 +1,6 @@
1
1
  import { ApiClient, requireProject } from '../api.js';
2
2
  import { info, printJson, handleApproval } from '../util.js';
3
- import { resolveComputeServiceId, q } from './services.js';
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
5
5
  // it; the platform returns the DNS records to set in your OWN zone.
6
6
  export async function setDomain(host, opts) {
@@ -116,6 +116,46 @@ 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) ----
120
+ // Render the volume read. Pure, exported for tests (mirrors serviceListLine). Every plan may view;
121
+ // only growth is paid — that gate is the backend's to enforce, so nothing here pre-blocks.
122
+ export function volumeLines(name, volume, cap) {
123
+ if (!volume)
124
+ return [
125
+ `compute ${name}: no volume attached (attach is create-time only: \`insta services add compute <name> --volume <gi>\`)`,
126
+ ];
127
+ return [
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)',
130
+ ];
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).
136
+ export async function computeVolume(serviceName, opts) {
137
+ const api = await ApiClient.load();
138
+ const p = await requireProject();
139
+ const branch = opts.branch ?? p.branch;
140
+ const { services } = await api.request('GET', `/projects/${p.projectId}/services${q(branch)}`);
141
+ const id = resolveComputeServiceId(services, serviceName);
142
+ if (!opts.size) {
143
+ const r = await api.request('GET', `/projects/${p.projectId}/services/${id}/volume`);
144
+ if (opts.json)
145
+ return printJson(r);
146
+ for (const line of volumeLines(serviceName ?? id, r.volume, r.cap))
147
+ info(line);
148
+ return;
149
+ }
150
+ const sizeGib = parseVolumeGib(opts.size);
151
+ const res = await api.rawRequest('PUT', `/projects/${p.projectId}/services/${id}/volume`, { sizeGib });
152
+ if (handleApproval(res))
153
+ return;
154
+ if (opts.json)
155
+ 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)`);
158
+ }
119
159
  // Show or set a compute service's ceiling. With no --memory it PRINTS the current limits and the
120
160
  // plan cap (so `insta compute limits` is a safe read), which is also what a UI renders as a slider
121
161
  // with its plan-limit marker.
@@ -1,5 +1,6 @@
1
1
  import { ApiClient, ApiError, requireProject } from '../api.js';
2
2
  import { info, printJson, handleApproval } from '../util.js';
3
+ import { parseVolumeGib } from './services.js';
3
4
  // Toggle a postgres service between scale-to-zero (the default: instance suspends when idle,
4
5
  // cold-starts on the next connection) and always-on (instance stays warm; idle RAM bills at
5
6
  // actual usage). Thin wrapper over PATCH /database/settings {scaleToZero} — insta-db-backed
@@ -116,4 +117,120 @@ export async function dbLimits(opts) {
116
117
  const mem = typeof res.body?.memoryMib === 'number' ? fmtMib(res.body.memoryMib) : (opts.memory ?? 'unchanged');
117
118
  info(`postgres ${opts.group ?? 'default'}: ceiling set to ${cpu} / ${mem}`);
118
119
  }
120
+ // Render the instance's volume from a database/instance read. Pure, exported for tests. Reads the
121
+ // CANONICAL volume* names only — storageSize/storageGiB are deprecated aliases the platform drops
122
+ // next release, so depending on them here would be a scheduled breakage.
123
+ // Bytes → human units, one decimal above KiB. Local because the metrics payload is the only
124
+ // bytes-denominated read in this file (fmtMib serves the MiB-denominated resize path).
125
+ export function fmtBytes(n) {
126
+ if (n < 1024)
127
+ return `${n} B`;
128
+ const units = ['KiB', 'MiB', 'GiB', 'TiB'];
129
+ let v = n / 1024;
130
+ let i = 0;
131
+ while (v >= 1024 && i < units.length - 1) {
132
+ v /= 1024;
133
+ i++;
134
+ }
135
+ return `${v.toFixed(1)} ${units[i]}`;
136
+ }
137
+ // Human-readable stats lines from GET /database/metrics. Pure seam for tests. "—" for anything
138
+ // unmeasured (old platform, suspended instance, no cache traffic yet) — never a fake 0: the
139
+ // platform omits cacheHitRatio and sends max 0 in exactly those cases.
140
+ export function dbStatsLines(group, body) {
141
+ const c = body?.connections ?? {};
142
+ const max = typeof c.max === 'number' && c.max > 0 ? c.max : null;
143
+ const total = typeof c.total === 'number' ? c.total : null;
144
+ const conn = total === null ? '—'
145
+ : (max === null ? String(total) : `${total} / ${max}`)
146
+ + (typeof c.active === 'number' && max !== null ? ` (${c.active} active)` : '');
147
+ const ratio = body?.cacheHitRatio;
148
+ const cache = typeof ratio === 'number' ? `${(ratio * 100).toFixed(1)}%` : '—';
149
+ const size = typeof body?.dbSizeBytes === 'number' ? fmtBytes(body.dbSizeBytes) : '—';
150
+ const bits = [
151
+ typeof body?.state === 'string' ? body.state : null,
152
+ typeof body?.serverVersion === 'string' && body.serverVersion ? `PG ${body.serverVersion}` : null,
153
+ ].filter(Boolean);
154
+ const state = bits.length ? ` (${bits.join(' · ')})` : '';
155
+ return [
156
+ `postgres ${group}${state}`,
157
+ ` connections ${conn}`,
158
+ ` cache hit ${cache}`,
159
+ ` size ${size}`,
160
+ ];
161
+ }
162
+ // Point-in-time stats snapshot for a postgres service: connections vs the server's ceiling, cache
163
+ // hit rate, database size. Read-only. insta-db-backed: a suspended instance answers from the
164
+ // provider's control plane (shown as "(suspended)" with structural zeros), never dialed.
165
+ // Neon-backed: the platform reads over a direct SQL connection, so a one-shot call may wake a
166
+ // suspended endpoint — acceptable for an explicit command, which is why nothing here polls.
167
+ export async function dbStats(opts) {
168
+ const api = await ApiClient.load();
169
+ const p = await requireProject();
170
+ const qs = new URLSearchParams();
171
+ const branch = opts.branch ?? p.branch;
172
+ if (branch)
173
+ qs.set('branch', branch);
174
+ if (opts.group)
175
+ qs.set('group', opts.group);
176
+ const res = await api.rawRequest('GET', `/projects/${p.projectId}/database/metrics${qs.toString() ? `?${qs}` : ''}`);
177
+ if (opts.json)
178
+ return printJson(res.body);
179
+ for (const line of dbStatsLines(opts.group ?? 'default', res.body))
180
+ info(line);
181
+ }
182
+ export function dbVolumeLines(group, body) {
183
+ const gib = typeof body?.volumeGib === 'number' ? `${body.volumeGib}Gi` : (typeof body?.volumeSize === 'string' ? body.volumeSize : undefined);
184
+ if (gib === undefined)
185
+ return [`postgres ${group}: provider reported no volume size`];
186
+ const cap = body?.cap?.volumeGib;
187
+ const region = typeof body?.region === 'string' ? ` ${body.region}` : '';
188
+ return [
189
+ `postgres ${group}: volume ${gib}${typeof cap === 'number' ? ` (plan max ${cap}Gi)` : ''}${region}`,
190
+ ' billing is actual data stored — the size is a cap, not a price; grow with --size (grow-only)',
191
+ ];
192
+ }
193
+ // Show or grow a postgres service's provisioned volume (block disk; insta-db-backed only). Viewing
194
+ // is available on every plan; growth is paid and grow-only — both gates are the backend's to
195
+ // enforce, so nothing here pre-blocks: its 403/400 messages carry the upgrade hints and are wrapped
196
+ // with context but kept verbatim.
197
+ export async function dbVolume(opts) {
198
+ const api = await ApiClient.load();
199
+ const p = await requireProject();
200
+ const qs = new URLSearchParams();
201
+ const branch = opts.branch ?? p.branch;
202
+ if (branch)
203
+ qs.set('branch', branch);
204
+ if (opts.group)
205
+ qs.set('group', opts.group);
206
+ const suffix = qs.toString() ? `?${qs}` : '';
207
+ if (!opts.size) {
208
+ const read = await fetchDbInstance(api, p.projectId, suffix);
209
+ if (read.kind === 'no-instance') {
210
+ info(`postgres ${opts.group ?? 'default'}: no manageable instance (Neon-backed services manage their own storage)`);
211
+ return;
212
+ }
213
+ if (opts.json)
214
+ return printJson(read.body);
215
+ for (const line of dbVolumeLines(opts.group ?? 'default', read.body))
216
+ info(line);
217
+ return;
218
+ }
219
+ const sizeGib = parseVolumeGib(opts.size);
220
+ let res;
221
+ try {
222
+ res = await api.rawRequest('PATCH', `/projects/${p.projectId}/database/settings${suffix}`, { volumeSize: `${sizeGib}Gi` });
223
+ }
224
+ catch (e) {
225
+ if (e instanceof ApiError)
226
+ throw new Error(`growing the volume failed (${e.status}): ${e.message}`);
227
+ throw e;
228
+ }
229
+ if (handleApproval(res))
230
+ return;
231
+ if (opts.json)
232
+ return printJson(res.body);
233
+ const vg = res.body?.volumeGib;
234
+ info(`postgres ${opts.group ?? 'default'}: volume ${typeof vg === 'number' ? `grown to ${vg}Gi` : `set to ${sizeGib}Gi`}`);
235
+ }
119
236
  //# sourceMappingURL=db.js.map
@@ -8,6 +8,58 @@ function qs(params) {
8
8
  const s = u.toString();
9
9
  return s ? `?${s}` : '';
10
10
  }
11
+ // One printed series line. Pure seam so the formatting is testable without a backend.
12
+ //
13
+ // Byte-rate series (compute's egress/ingress) arrive as raw bytes per second, which is unreadable at
14
+ // real traffic volumes — 20480031 bytes/s is 20 MB/s. Percent and vCPU units are already
15
+ // human-sized, so only bytes and byte rates get scaled.
16
+ export function metricLine(s) {
17
+ const last = s.points?.[s.points.length - 1];
18
+ const value = last ? formatMetricValue(last[1], s.unit) : 'n/a';
19
+ const unit = s.unit && !isScaled(s.unit) ? ` (${s.unit})` : '';
20
+ return `${s.name}${unit}: ${value} [${s.points?.length ?? 0} points]`;
21
+ }
22
+ // The platform's unit strings are the contract here (`bytes` for memory/storage, `bytes/s` for
23
+ // egress/ingress — src/adapters/fly.ts and insta-db.ts). Matching them loosely is deliberate: an
24
+ // unrecognised unit fails SILENTLY back to the raw 8-digit number this scaling exists to fix, so a
25
+ // casing or spacing change on the platform side must not be enough to regress it.
26
+ function scaleOf(unit) {
27
+ const u = unit?.trim().toLowerCase();
28
+ if (u === 'bytes' || u === 'byte' || u === 'b')
29
+ return 'bytes';
30
+ if (u === 'bytes/s' || u === 'byte/s' || u === 'b/s' || u === 'bytes/sec')
31
+ return 'bytes/s';
32
+ return undefined;
33
+ }
34
+ function isScaled(unit) {
35
+ return scaleOf(unit) !== undefined;
36
+ }
37
+ function formatMetricValue(v, unit) {
38
+ if (!Number.isFinite(v))
39
+ return 'n/a';
40
+ const scale = scaleOf(unit);
41
+ // Traffic scales by 1000 because egress is BILLED per decimal GB (`bytes / 1e9`, platform
42
+ // src/adapters/fly.ts) — a 1024-based "GB/s" would sit ~7% off the invoice. Memory and storage
43
+ // stay binary: those ceilings are provisioned in GiB. Same split as the console.
44
+ if (scale === 'bytes')
45
+ return humanBytes(v, 1024);
46
+ if (scale === 'bytes/s')
47
+ return `${humanBytes(v, 1000)}/s`;
48
+ return String(v);
49
+ }
50
+ function humanBytes(v, base) {
51
+ const units = ['B', 'KB', 'MB', 'GB', 'TB'];
52
+ let value = v;
53
+ let i = 0;
54
+ while (Math.abs(value) >= base && i < units.length - 1) {
55
+ value /= base;
56
+ i += 1;
57
+ }
58
+ // Sub-KB values keep their integer form (`512 B`, not `512.0 B`), but a RATE is fractional —
59
+ // PromQL rate() of a byte counter yields things like 342.857142, which must not print in full.
60
+ const shown = i === 0 && Number.isInteger(value) ? String(value) : value.toFixed(1);
61
+ return `${shown} ${units[i]}`;
62
+ }
11
63
  // insta metrics <db|compute> [group]
12
64
  export async function metrics(component, group, opts) {
13
65
  const api = await ApiClient.load();
@@ -19,10 +71,8 @@ export async function metrics(component, group, opts) {
19
71
  info(`note: ${res.note}`);
20
72
  if (!res.series?.length)
21
73
  return info('(no series)');
22
- for (const s of res.series) {
23
- const last = s.points?.[s.points.length - 1];
24
- info(`${s.name}${s.unit ? ` (${s.unit})` : ''}: ${last ? last[1] : 'n/a'} [${s.points?.length ?? 0} points]`);
25
- }
74
+ for (const s of res.series)
75
+ info(metricLine(s));
26
76
  }
27
77
  // Customer-facing name for each internal billing dimension (the platform stores RAM as `ram`).
28
78
  const DIMENSION_LABEL = { ram: 'memory' };
@@ -23,6 +23,19 @@ export function parseCount(raw) {
23
23
  throw new Error(`count must be a positive integer, got: ${raw}`);
24
24
  return n;
25
25
  }
26
+ // Parse a volume size in whole Gi: "10" or "10Gi" (suffix case-insensitive — unlike the db
27
+ // quantity strings this is not a provider pass-through; the wire value is an integer). Volumes
28
+ // are provisioned block disks, so fractional and Mi values are rejected locally with an example
29
+ // rather than travelling to the server as junk (the parseCpu lesson: NaN serializes to null).
30
+ export function parseVolumeGib(raw) {
31
+ const m = /^\s*(\d+)\s*(gi|gib|g)?\s*$/i.exec(raw);
32
+ if (!m)
33
+ throw new Error(`invalid volume size: ${raw} (whole Gi — try 1 or 10)`);
34
+ const n = Number(m[1]);
35
+ if (n < 1)
36
+ throw new Error(`invalid volume size: ${raw} (whole Gi — try 1 or 10)`);
37
+ return n;
38
+ }
26
39
  // Resolve a service id from a `services list` result by (type, name).
27
40
  export function resolveServiceId(services, type, name) {
28
41
  const svc = services.find((s) => s.type === type && s.name === name);
@@ -54,6 +67,7 @@ export function servicesAddRequestBody(type, name, branch, opts) {
54
67
  ...(opts.image ? { image: opts.image } : {}), ...(opts.port ? { port: Number(opts.port) } : {}),
55
68
  ...(opts.region ? { region: opts.region } : {}),
56
69
  ...(opts.alwaysOn ? { alwaysOn: true } : {}),
70
+ ...(opts.volume !== undefined ? { volumeGib: parseVolumeGib(opts.volume) } : {}),
57
71
  };
58
72
  }
59
73
  export async function servicesAdd(type, name, opts = {}) {
@@ -68,6 +82,11 @@ export async function servicesAdd(type, name, opts = {}) {
68
82
  throw new Error('--port is only valid for compute services');
69
83
  if (opts.alwaysOn && type !== 'compute')
70
84
  throw new Error('--always-on is only valid for compute services (for postgres, use `insta db always-on on` after creation)');
85
+ if (opts.volume !== undefined) {
86
+ if (type !== 'compute')
87
+ throw new Error('--volume is only valid for compute services (postgres has one by default — grow it with `insta db volume --size`)');
88
+ parseVolumeGib(opts.volume); // junk fails here, before any config/network access
89
+ }
71
90
  const api = await ApiClient.load();
72
91
  const p = await requireProject();
73
92
  const branch = opts.branch ?? p.branch;
@@ -77,14 +96,15 @@ export async function servicesAdd(type, name, opts = {}) {
77
96
  const svc = res.body.service;
78
97
  const access = svc.type === 'storage' ? ` [${svc.public ? 'public' : 'private'}]` : '';
79
98
  const img = svc.image ? ` running ${svc.image}${svc.port ? `:${svc.port}` : ''}` : '';
80
- info(`added ${type} service ${name} on ${branch ?? 'default'} (${svc.id})${access}${svc.region ? ` ${svc.region}` : ''}${img}${svc.domain ? ` — ${svc.domain}` : ''}`);
99
+ const vol = svc.volume_gib ? ` vol ${svc.volume_gib}Gi at /data` : '';
100
+ info(`added ${type} service ${name} on ${branch ?? 'default'} (${svc.id})${access}${svc.region ? ` ${svc.region}` : ''}${img}${vol}${svc.domain ? ` — ${svc.domain}` : ''}`);
81
101
  renderNextActions(res.body.nextActions);
82
102
  }
83
103
  // Render one `services list` row. Pure, so it's unit-tested without a network mock (mirrors
84
104
  // billingLines in billing.ts). Compute rows show the running image when the platform reports one.
85
105
  export function serviceListLine(s) {
86
106
  const extra = s.type === 'compute'
87
- ? ` x${s.machine_count}${s.image ? ` running ${s.image}${s.port ? `:${s.port}` : ''}` : ''}`
107
+ ? ` x${s.machine_count}${s.volume_gib ? ` vol ${s.volume_gib}Gi` : ''}${s.image ? ` running ${s.image}${s.port ? `:${s.port}` : ''}` : ''}`
88
108
  : s.type === 'storage' ? ` ${s.public ? 'public' : 'private'}` : '';
89
109
  return `${s.type}/${s.name} [${s.status}]${extra}${s.domain ? ` ${s.domain}` : ''} ${s.id}`;
90
110
  }
package/dist/index.js CHANGED
@@ -54,11 +54,12 @@ function resolveVersion() {
54
54
  }
55
55
  program.name('insta').description('InstaCloud CLI — manage projects, branches, secrets, deploys').version(resolveVersion());
56
56
  // ---- auth ----
57
- program.command('login').description('Log in with email + password, --oauth <github|google> (browser), or --device (headless)')
57
+ program.command('login').description('Log in with email + password, --oauth <github|google> (browser), --device (headless), or --api-key <insta_…> (headless, durable token)')
58
58
  .option('--email <email>', 'account email')
59
59
  .option('--password <password>', 'account password (else $INSTA_PASSWORD or prompt)')
60
60
  .option('--oauth <provider>', 'browser OAuth login: github | google')
61
61
  .option('--device', 'device-code login: approve from a browser on any other machine (VMs, SSH, CI)')
62
+ .option('--api-key <key>', 'non-interactive login with a durable insta_ API token (headless agents / CI)')
62
63
  .option('--api-url <url>', 'control-plane API base URL')
63
64
  .option('--env <name>', `deployment environment: ${ENV_NAMES.join(' | ')}`)
64
65
  .action(guard((o) => auth.login(o)));
@@ -114,6 +115,7 @@ svc.command('add <type> <name>').description('Provision a service on demand (ass
114
115
  .option('--image <url>', 'compute only: run this container image at creation')
115
116
  .option('--port <n>', 'compute only: port the image listens on (default 8080)')
116
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')
117
119
  .action(guard((type, name, o) => services.servicesAdd(type, name, o)));
118
120
  svc.command('list').option('--json').option('--branch <branch>', 'branch (default: current)')
119
121
  .action(guard((o) => services.servicesList(o)));
@@ -169,15 +171,25 @@ compute.command('limits [service]').description("Show or set a compute service's
169
171
  .option('--json').option('--branch <branch>', 'branch (default: current)').action(guard((service, o) => computeCmd.computeLimits(service, o)));
170
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')
171
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")
175
+ .option('--size <gi>', 'new size in whole Gi, e.g. 10 (must be ≥ the current size)')
176
+ .option('--json').option('--branch <branch>', 'branch (default: current)').action(guard((service, o) => computeCmd.computeVolume(service, o)));
172
177
  // ---- db (postgres service controls) ----
173
- const db = program.command('db').description('Postgres service controls (limits / always-on / scale-to-zero)');
178
+ const db = program.command('db').description('Postgres service controls (limits / volume / always-on / scale-to-zero)');
174
179
  db.command('limits').description("Show or set a postgres service's resource ceiling (paid plans; insta-db-backed only). Moves both directions")
175
180
  .option('--cpu <n>', "vCPU ceiling, e.g. 2 or 2500m").option('--memory <size>', "memory ceiling, e.g. 4Gi")
176
181
  .option('--json').option('--branch <branch>', 'branch (default: current)').option('--group <g>', 'postgres service name (default: the sole/default one)')
177
182
  .action(guard((o) => dbCmd.dbLimits(o)));
183
+ db.command('stats').description("Postgres stats snapshot: connections vs the server's max (active count), cache hit rate, database size. insta-db-backed services answer without waking a suspended instance")
184
+ .option('--json').option('--branch <branch>', 'branch (default: current)').option('--group <g>', 'postgres service name (default: the sole/default one)')
185
+ .action(guard((o) => dbCmd.dbStats(o)));
178
186
  db.command('always-on <mode>').description('Set a postgres service always-on (mode: on|off). on = instance stays warm, no cold starts; off = default scale-to-zero (idle instance suspends; first connection cold-starts). insta-db-backed services only')
179
187
  .option('--json').option('--branch <branch>', 'branch (default: current)').option('--group <g>', 'postgres service name (default: the sole/default one)')
180
188
  .action(guard((mode, o) => dbCmd.dbAlwaysOn(mode, o)));
189
+ db.command('volume').description("Show or grow a postgres service's provisioned volume (block disk; insta-db-backed only). No --size: print size and the plan cap (any plan). --size grows it (paid plans; grow-only — a provisioned disk cannot shrink). Billing is actual data stored — the size is a cap, not a price")
190
+ .option('--size <gi>', 'new size in whole Gi, e.g. 10 (must be ≥ the current size)')
191
+ .option('--json').option('--branch <branch>', 'branch (default: current)').option('--group <g>', 'postgres service name (default: the sole/default one)')
192
+ .action(guard((o) => dbCmd.dbVolume(o)));
181
193
  // ---- manifest ----
182
194
  program.command('manifest').description('Print an agent-legible view of the project environments').option('--json').action(guard((o) => manifest(o)));
183
195
  // ---- regions ----
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "insta",
3
- "version": "0.0.27",
3
+ "version": "0.0.29",
4
4
  "type": "module",
5
5
  "description": "InstaCloud CLI — a thin client of the platform control-plane API.",
6
6
  "keywords": [