insta 0.0.69 → 0.0.70

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/README.md CHANGED
@@ -240,6 +240,7 @@ build never reaches a production installer.
240
240
  |---|---|
241
241
  | `~/.insta/config.json` | API URL, access and refresh tokens, user, auto-update preference |
242
242
  | `./.insta/project.json` | Project id, org id, current branch |
243
+ | `./.insta/link-plane.json` | The control-plane URL this machine linked against. Gitignored and per machine; a link made against a different control plane is refused rather than reused. The home directory is never a project |
243
244
 
244
245
  | Variable | Effect |
245
246
  |---|---|
package/dist/api.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // Thin API client over the platform control-plane. Handles bearer auth + one-shot refresh on 401.
2
2
  // 2xx (including 202 approval_required) returns the parsed body; >=400 throws ApiError.
3
- import { readGlobal, writeGlobal, readProject, writeProject } from './config.js';
3
+ import { readGlobal, writeGlobal, readProject, persistAutoLink, resolveProjectLink, foreignLinkMessage } from './config.js';
4
4
  import { autoResolveProject, promptChoice } from './resolve-project.js';
5
5
  import { die } from './util.js';
6
6
  import { USER_AGENT } from './version.js';
@@ -127,12 +127,20 @@ export class ApiClient {
127
127
  // Resolve the linked project (./.insta/project.json), or null.
128
128
  export async function linkedProject() { return readProject(); }
129
129
  // Resolve the linked project or exit with guidance.
130
- export async function requireProject() {
131
- const p = await readProject();
132
- if (p)
133
- return p;
130
+ export async function requireProject(deps = {}) {
131
+ const r = await resolveProjectLink(deps.cwd);
132
+ // Fail CLOSED on a link made against another control plane. Treating it as "unlinked" sent this
133
+ // into auto-resolve, which with exactly one project on the new plane picks it without a prompt
134
+ // and SAVES — so a read-only command replaced the committed team binding, and pointing back
135
+ // flipped it again. Only an explicit `insta project link` may replace a link.
136
+ if (r?.foreign)
137
+ die(foreignLinkMessage(r.foreign));
138
+ if (r)
139
+ return r.link;
134
140
  if (agentMode())
135
141
  die('agent mode requires a linked project — run `insta setup agent --project <id>`');
142
+ if (deps.autoResolve)
143
+ return deps.autoResolve();
136
144
  // One command, just works: unlinked ≠ error. Resolve the project (auto when there's one,
137
145
  // one-keystroke picker when several) and persist the choice so this happens once per dir.
138
146
  const api = await ApiClient.load();
@@ -143,10 +151,16 @@ export async function requireProject() {
143
151
  listProjects: async () => (await api.request('GET', `/orgs/${orgId}/projects`)).projects,
144
152
  promptChoice,
145
153
  save: async (c) => {
146
- await writeProject(c);
147
154
  // stderr: this is a diagnostic that can precede ANY command's output — under --json,
148
155
  // stdout must stay one parseable document.
149
- process.stderr.write(`auto-linked project ${c.projectId} → ./.insta/project.json\n`);
156
+ if (await persistAutoLink(c, deps.cwd)) {
157
+ process.stderr.write(`auto-linked project ${c.projectId} → ./.insta/project.json\n`);
158
+ }
159
+ else {
160
+ // The home directory never holds a link (~/.insta is the global config): use the choice
161
+ // for this command instead of failing it after the picker has already run.
162
+ process.stderr.write(`using project ${c.projectId} for this command — not saving a link in the home directory; run inside a project directory to remember it\n`);
163
+ }
150
164
  },
151
165
  tty: !!process.stdin.isTTY && !!process.stderr.isTTY,
152
166
  });
@@ -14,17 +14,20 @@ export async function resolveOrgId(opts) {
14
14
  export function billingLines(s, org) {
15
15
  const t = s.totals;
16
16
  const lines = [
17
- `tier: ${s.tier}`,
18
- `status: ${s.billingStatus}`,
17
+ `tier: ${s.tier}`,
18
+ `status: ${s.billingStatus}`,
19
19
  cycleLine(s.window),
20
- `included: $${Number(t.includedUsd).toFixed(2)}`,
21
- `used: $${Number(t.usedUsd).toFixed(4)}`,
22
- `overage: $${Number(t.overageUsd).toFixed(4)}`,
23
- `credits: $${Number(t.creditsUsd).toFixed(2)}`,
24
- `forecast: $${Number(t.forecastUsd).toFixed(4)} (predicted full cycle)`,
20
+ // Two separate figures, never added together: the plan's allowance for this cycle, and the
21
+ // org's wallet. "included usage" is the name the console and the pricing page use for the
22
+ // former; "credits" now means the wallet alone.
23
+ `included usage: $${Number(t.includedUsd).toFixed(2)}`,
24
+ `used: $${Number(t.usedUsd).toFixed(4)}`,
25
+ `overage: $${Number(t.overageUsd).toFixed(4)}`,
26
+ `credits: $${Number(t.creditBalanceUsd).toFixed(2)}`,
27
+ `forecast: $${Number(t.forecastUsd).toFixed(4)} (predicted full cycle)`,
25
28
  ];
26
29
  if (s.subscriptionStatus)
27
- lines.push(`subscription: ${s.subscriptionStatus}`);
30
+ lines.push(`subscription: ${s.subscriptionStatus}`);
28
31
  if (s.billingStatus === 'suspended') {
29
32
  // Four causes, five messages, and every one is a dead end for the others. Tier first: only a
30
33
  // free org can spend a prepaid wallet, and waiting for the next cycle genuinely fixes that one.
@@ -1,5 +1,5 @@
1
1
  import { ApiClient, requireProject } from '../api.js';
2
- import { writeProject } from '../config.js';
2
+ import { isHomeLinkTarget, writeProject } from '../config.js';
3
3
  import { info, die, printJson, handleApproval, renderNextActions } from '../util.js';
4
4
  export async function branchCreate(name, opts) {
5
5
  const api = await ApiClient.load();
@@ -20,6 +20,11 @@ export async function branchList(opts) {
20
20
  info(`${b.is_default ? '*' : ' '} ${b.name} [${b.status}] ${b.id}`);
21
21
  }
22
22
  export async function branchSwitch(name, opts = {}) {
23
+ // A switch is remembered in ./.insta/project.json, which never lives in the home directory. Say so
24
+ // up front, instead of auto-resolving a project "for this command" and then refusing to save.
25
+ if (await isHomeLinkTarget()) {
26
+ die('can\'t switch branches in the home directory — the branch is remembered in ./.insta/project.json, and ~/.insta is the insta CLI\'s global config. Run this inside a project directory');
27
+ }
23
28
  const api = await ApiClient.load();
24
29
  const p = await requireProject();
25
30
  const { branches } = await api.request('GET', `/projects/${p.projectId}/branches`);
@@ -1,7 +1,7 @@
1
1
  import { homedir } from 'node:os';
2
2
  import { agentMode, setupProjectAgentSession } from '../agent.js';
3
3
  import { ApiClient, requireProject } from '../api.js';
4
- import { writeProject } from '../config.js';
4
+ import { HOME_LINK_REFUSAL, isHomeLinkTarget, writeProject } from '../config.js';
5
5
  import { info, die, printJson, handleApproval, renderNextActions } from '../util.js';
6
6
  import { installObserve } from '../observe/install.js';
7
7
  import { installRoot } from './observe.js';
@@ -73,6 +73,10 @@ export async function projectCreate(name, opts) {
73
73
  info(' (or just ask your coding agent — it has the insta skill and will do this for you)');
74
74
  return;
75
75
  }
76
+ // Refuse the home directory BEFORE provisioning: refusing only inside writeProject created the
77
+ // project on the control plane and then failed, with no id printed under --json.
78
+ if (await isHomeLinkTarget())
79
+ die(HOME_LINK_REFUSAL);
76
80
  const api = await ApiClient.load();
77
81
  const orgId = await resolveOrg(api, opts.org);
78
82
  const out = await api.request('POST', `/orgs/${orgId}/projects`, { name: resolved });
@@ -103,6 +107,11 @@ export async function projectList(opts) {
103
107
  info(`${p.id} ${p.name} [${p.status}]`);
104
108
  }
105
109
  export async function projectLink(id, opts = {}) {
110
+ // Refuse the home directory BEFORE anything with side effects. In agent mode the session below is
111
+ // saved, and .gitignore edited, at the link root; refusing only inside writeProject left both
112
+ // behind in ~ after the command had already failed.
113
+ if (await isHomeLinkTarget())
114
+ die(HOME_LINK_REFUSAL);
106
115
  const api = await ApiClient.load();
107
116
  if (agentMode())
108
117
  await setupProjectAgentSession(api, id);
package/dist/config.js CHANGED
@@ -1,13 +1,21 @@
1
1
  // CLI config: global (~/.insta/config.json: api url + tokens) and per-project (./.insta/project.json).
2
2
  import { homedir } from 'node:os';
3
3
  import { dirname, join, resolve } from 'node:path';
4
- import { mkdir, readFile, writeFile } from 'node:fs/promises';
4
+ import { realpathSync } from 'node:fs';
5
+ import { chmod, mkdir, readFile, writeFile } from 'node:fs/promises';
5
6
  import { ensureGitignore } from './gitignore.js';
7
+ import { die } from './util.js';
6
8
  import { DEFAULT_ENV, ENVS, envForApiUrl, envFromEnvVar, normalizeUrl } from './env.js';
7
9
  const GLOBAL_DIR = join(homedir(), '.insta');
8
10
  const GLOBAL_FILE = join(GLOBAL_DIR, 'config.json');
9
11
  const PROJECT_DIR = '.insta';
10
12
  const PROJECT_FILE = 'project.json';
13
+ // Machine-local, gitignored: which project this machine linked here, and on which control plane.
14
+ // It is NOT in project.json because that file is the team's committed binding — a URL chosen by one
15
+ // machine (a staging switch, a local box, a transient INSTA_API_URL) would make every teammate's
16
+ // CLI treat the shared link as foreign. It records the project id too, because project.json is
17
+ // committed and this file is not: a pull or checkout can replace the project underneath it.
18
+ const LINK_PLANE_FILE = 'link-plane.json';
11
19
  // The cloud API default. Uses the instacloud.com brand domain (matches the agents.instacloud.com
12
20
  // onboarding), NOT the legacy beta-api.insta.insforge.dev host — same backend, branded domain.
13
21
  // Only affects fresh installs: a persisted apiUrl (from a prior login) or INSTA_API_URL wins below.
@@ -85,11 +93,46 @@ export async function writeGlobal(c) {
85
93
  await mkdir(GLOBAL_DIR, { recursive: true });
86
94
  await writeFile(GLOBAL_FILE, JSON.stringify(c, null, 2));
87
95
  }
96
+ /** The home directory is never a project root. `~/.insta/` is this CLI's GLOBAL config directory,
97
+ * so a project.json there is not a project link: honouring one made every directory under the home
98
+ * dir inherit it, and `insta project link` run anywhere below home silently overwrote it. */
99
+ function isHomeDir(dir) {
100
+ return canonicalDir(dir) === canonicalDir(homedir());
101
+ }
102
+ /** A directory's filesystem identity rather than its spelling: the native real path, which resolves
103
+ * symlinks and returns the on-disk case on case-insensitive filesystems (macOS, Windows). Comparing
104
+ * spellings let a symlinked or differently-cased path to home through the home check. A path that
105
+ * doesn't exist has no identity to resolve, so it falls back to its resolved spelling. */
106
+ function canonicalDir(dir) {
107
+ try {
108
+ return realpathSync.native(resolve(dir));
109
+ }
110
+ catch {
111
+ return resolve(dir);
112
+ }
113
+ }
114
+ export const HOME_LINK_REFUSAL = 'refusing to link the home directory — ~/.insta is the insta CLI\'s global config, not a project. Run this inside a project directory';
115
+ /** Where a link written from `cwd` lands: the existing project root, or `cwd` itself. */
116
+ async function linkTarget(cwd) {
117
+ return (await findProjectRoot(cwd)) ?? resolve(cwd);
118
+ }
119
+ /** True when a link written from `cwd` would land in the home directory, which never holds one.
120
+ * Lets a caller refuse BEFORE it does anything else with side effects. */
121
+ export async function isHomeLinkTarget(cwd = process.cwd()) {
122
+ return isHomeDir(await linkTarget(cwd));
123
+ }
88
124
  /** Git-style ancestor lookup: the nearest directory at-or-above `cwd` containing
89
- * .insta/project.json — so "link once" works from any subdirectory of the project. */
125
+ * .insta/project.json — so "link once" works from any subdirectory of the project. The search
126
+ * stops at the home directory (see isHomeDir): neither home nor anything above it is a project
127
+ * root for a directory inside home. */
90
128
  export async function findProjectRoot(cwd = process.cwd()) {
91
129
  let dir = resolve(cwd);
92
130
  for (;;) {
131
+ // Stop AT the home directory rather than skipping it: a link above home is not a project for
132
+ // home or anything below it, and climbing past home let `insta project link` run in ~ resolve
133
+ // to, and overwrite, an ancestor's link after the home-directory check had passed.
134
+ if (isHomeDir(dir))
135
+ return null;
93
136
  try {
94
137
  await readFile(join(dir, PROJECT_DIR, PROJECT_FILE), 'utf8');
95
138
  return dir;
@@ -101,32 +144,157 @@ export async function findProjectRoot(cwd = process.cwd()) {
101
144
  dir = parent;
102
145
  }
103
146
  }
104
- export async function readProject(cwd = process.cwd()) {
147
+ /** The link that applies to `cwd`, and whether it was made against a DIFFERENT control plane. A
148
+ * project id means nothing on another control plane (cloud, staging and every insta-oss box each
149
+ * have their own), and the CLI used to reuse a link against whatever API it was pointed at. */
150
+ export async function resolveProjectLink(cwd = process.cwd()) {
105
151
  // Linkless targeting (CI / one-offs / agents): INSTA_PROJECT_ID resolves the project with no
106
152
  // link file, and beats one when both exist — an explicit parameter outranks ambient state.
107
153
  if (process.env.INSTA_PROJECT_ID) {
108
154
  return {
109
- projectId: process.env.INSTA_PROJECT_ID,
110
- orgId: process.env.INSTA_ORG_ID ?? '',
111
- branch: process.env.INSTA_BRANCH ?? 'main',
155
+ link: {
156
+ projectId: process.env.INSTA_PROJECT_ID,
157
+ orgId: process.env.INSTA_ORG_ID ?? '',
158
+ branch: process.env.INSTA_BRANCH ?? 'main',
159
+ },
112
160
  };
113
161
  }
114
162
  const root = await findProjectRoot(cwd);
115
163
  if (!root)
116
164
  return null;
165
+ let link;
166
+ try {
167
+ link = JSON.parse(await readFile(join(root, PROJECT_DIR, PROJECT_FILE), 'utf8'));
168
+ }
169
+ catch {
170
+ return null;
171
+ }
172
+ // No sidecar — a link from before this existed, or a teammate who just cloned — resolves as it
173
+ // always did: there is nothing to say which control plane it belongs to.
174
+ const record = await readLinkPlane(root);
175
+ if (record) {
176
+ const current = safeUrl((await readGlobal()).apiUrl);
177
+ const base = {
178
+ file: join(root, PROJECT_DIR, PROJECT_FILE), projectId: String(link.projectId),
179
+ linkedProjectId: record.projectId, linkedApiUrl: record.apiUrl, currentApiUrl: current,
180
+ };
181
+ // The record vouches for the project it was written for, and only that one. A record for
182
+ // another project says nothing about this one: trusting it would send this project to the
183
+ // control plane of the project it replaced, or refuse it for a reason that is not true. Fail
184
+ // closed with the real reason instead.
185
+ if (record.projectId !== base.projectId)
186
+ return { link, foreign: { reason: 'changed', ...base } };
187
+ if (normalizeUrl(record.apiUrl) !== normalizeUrl(current))
188
+ return { link, foreign: { reason: 'plane', ...base } };
189
+ }
190
+ return { link };
191
+ }
192
+ /** The link for this control plane, or null. A foreign link is NOT returned (it names a project
193
+ * that does not exist here); callers that must act on a project use requireProject, which stops
194
+ * with guidance instead of treating a foreign link as "unlinked". */
195
+ export async function readProject(cwd = process.cwd()) {
196
+ const r = await resolveProjectLink(cwd);
197
+ if (!r)
198
+ return null;
199
+ if (r.foreign) {
200
+ if (!warnedForeignLinks.has(r.foreign.file)) {
201
+ warnedForeignLinks.add(r.foreign.file);
202
+ process.stderr.write(`note: ${foreignLinkMessage(r.foreign)}\n`);
203
+ }
204
+ return null;
205
+ }
206
+ return r.link;
207
+ }
208
+ const warnedForeignLinks = new Set();
209
+ export function foreignLinkMessage(f) {
210
+ // Every field can come from a file (a committed project.json, a copied or hand-edited record),
211
+ // so all of it is stripped of control characters before it reaches a terminal.
212
+ const file = safeText(f.file);
213
+ const id = safeText(f.projectId);
214
+ const linked = safeUrl(f.linkedApiUrl);
215
+ const current = safeUrl(f.currentApiUrl);
216
+ if (f.reason === 'changed') {
217
+ return `${file} now links project ${id}, but this machine linked project ${safeText(f.linkedProjectId)} there, on ${linked}. `
218
+ + `The link changed since (a pull or checkout), so its control plane is unknown. `
219
+ + `Confirm it for ${current} with \`insta project link ${id}\`.`;
220
+ }
221
+ return `${file} links project ${id} on ${linked}, but the CLI is pointed at ${current}. `
222
+ + `Link this directory for ${current} with \`insta project link <id>\`, or point the CLI back at ${linked}.`;
223
+ }
224
+ async function readLinkPlane(root) {
117
225
  try {
118
- return JSON.parse(await readFile(join(root, PROJECT_DIR, PROJECT_FILE), 'utf8'));
226
+ const raw = JSON.parse(await readFile(join(root, PROJECT_DIR, LINK_PLANE_FILE), 'utf8'));
227
+ // Validated, not cast: a malformed record is ignored rather than crashing every command. A
228
+ // record with no project id cannot say which project it vouches for, so it is ignored too.
229
+ if (!raw || typeof raw.projectId !== 'string' || !raw.projectId || typeof raw.apiUrl !== 'string' || !raw.apiUrl)
230
+ return null;
231
+ // Untrusted input (it may have been copied or edited), so sanitized before it is compared or printed.
232
+ return { projectId: raw.projectId, apiUrl: safeUrl(raw.apiUrl) };
119
233
  }
120
234
  catch {
121
235
  return null;
122
236
  }
123
237
  }
238
+ /** Text safe to echo to a terminal: C0 and C1 control characters and DEL removed — ESC and the
239
+ * single-byte C1 introducers (U+009B CSI among them) alike, so no escape sequence survives. */
240
+ function safeText(text) {
241
+ return String(text).replace(/[\u0000-\u001f\u007f-\u009f]/g, '');
242
+ }
243
+ /** A control-plane URL safe to persist and to print: control characters removed, surrounding
244
+ * whitespace trimmed, and any userinfo (INSTA_API_URL may carry credentials) removed.
245
+ *
246
+ * Userinfo is removed with the same WHATWG parser fetch uses, not with a pattern. That parser is
247
+ * lenient in ways a pattern keeps missing: it accepts leading whitespace, `\` for `/`, and a
248
+ * missing `//` on special schemes, and each of those defeated an anchored pattern while fetch
249
+ * would still have sent the credentials. A value the parser rejects is never requested, but it can
250
+ * still be stored or printed, so everything up to its last `@` is dropped. The same goes for a
251
+ * value that parses under any other scheme: `user:token@host` parses as scheme `user:` with the
252
+ * credentials in its path, where clearing username and password removes nothing. */
253
+ export function safeUrl(url) {
254
+ const text = safeText(url).trim();
255
+ const pastLastAt = () => (text.includes('@') ? text.slice(text.lastIndexOf('@') + 1) : text);
256
+ let parsed;
257
+ try {
258
+ parsed = new URL(text);
259
+ }
260
+ catch {
261
+ return pastLastAt();
262
+ }
263
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:')
264
+ return pastLastAt();
265
+ if (!parsed.username && !parsed.password)
266
+ return text;
267
+ parsed.username = '';
268
+ parsed.password = '';
269
+ return parsed.href;
270
+ }
124
271
  /** Writes to the existing project root when inside a linked project (branch switches from a
125
- * subdirectory must not mint a nested link); a fresh `link` in an unlinked tree writes to cwd. */
272
+ * subdirectory must not mint a nested link); a fresh `link` in an unlinked tree writes to cwd.
273
+ * Records the control plane in the machine-local sidecar beside it. Never writes into the home
274
+ * directory: `~/.insta/` is the global config, not a project. */
126
275
  export async function writeProject(c, cwd = process.cwd()) {
127
- const target = (await findProjectRoot(cwd)) ?? cwd;
276
+ const target = await linkTarget(cwd);
277
+ if (isHomeDir(target))
278
+ die(HOME_LINK_REFUSAL);
279
+ const { apiUrl } = await readGlobal();
128
280
  await mkdir(join(target, PROJECT_DIR), { recursive: true });
129
281
  ensureGitignore(target, ['.insta/agent-session.json'], '# Local agent credentials');
282
+ ensureGitignore(target, [`.insta/${LINK_PLANE_FILE}`], '# Local: the control plane this machine linked against');
130
283
  await writeFile(join(target, PROJECT_DIR, PROJECT_FILE), JSON.stringify(c, null, 2));
284
+ // Owner-only, like agent-session.json: it can name a private self-hosted box. writeFile's mode
285
+ // applies only when it creates the file, so an existing record is chmod-ed too.
286
+ const record = join(target, PROJECT_DIR, LINK_PLANE_FILE);
287
+ await writeFile(record, JSON.stringify({ projectId: c.projectId, apiUrl: safeUrl(apiUrl) }, null, 2), { mode: 0o600 });
288
+ await chmod(record, 0o600);
289
+ }
290
+ /** Save a link that auto-resolution chose. Unlike an explicit `insta project link`, the command it
291
+ * was resolved for must still run: in the home directory the choice is used for this command and
292
+ * simply not remembered, rather than showing the picker and then failing the command. Returns
293
+ * whether the link was saved. */
294
+ export async function persistAutoLink(c, cwd = process.cwd()) {
295
+ if (await isHomeLinkTarget(cwd))
296
+ return false;
297
+ await writeProject(c, cwd);
298
+ return true;
131
299
  }
132
300
  //# sourceMappingURL=config.js.map
package/dist/index.js CHANGED
@@ -149,7 +149,7 @@ svc.command('add [type] [name]').description('Provision a service on demand (ass
149
149
  .option('--port <n>', 'compute only: port the image listens on (default 8080)')
150
150
  .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)')
151
151
  .option('--no-always-on', 'compute only: create as scale-to-zero — idle machines suspend and wake on the next request')
152
- .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 10 (the free cap, on every plan); larger sizes are paid and plan-capped). Volume services keep 1 machine and stop (cold wake) instead of suspend when idle')
152
+ .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
153
  .option('--json')
154
154
  .action(guard(async (type, name, o) => {
155
155
  const a = await resolveServiceArgs(type, name, serviceArgsDeps(o.json), o);
@@ -270,7 +270,7 @@ compute.command('watch-paths [service]').description("Show or change which paths
270
270
  .option('--json').option('--branch <branch>', 'branch (default: current)').action(guard((service, o) => githubCmd.computeWatchPaths(service, o)));
271
271
  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')
272
272
  .option('--json').option('--branch <branch>', 'branch (default: current)').action(guard((service, o) => githubCmd.computeDisconnectRepo(service, o)));
273
- 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 10Gi, the free cap; 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")
273
+ 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")
274
274
  .option('--size <gi>', 'new size in whole Gi, e.g. 10 (must be ≥ the current size)')
275
275
  .option('--delete', 'destroy the volume and ALL its data (irreversible; download anything you need first)')
276
276
  .option('--json').option('--branch <branch>', 'branch (default: current)').action(guard((service, o) => computeCmd.computeVolume(service, o)));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "insta",
3
- "version": "0.0.69",
3
+ "version": "0.0.70",
4
4
  "type": "module",
5
5
  "description": "InstaCloud CLI — a thin client of the platform control-plane API.",
6
6
  "keywords": [