insta 0.1.15 → 0.1.17

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
@@ -231,7 +231,7 @@ build never reaches a production installer.
231
231
  | `insta secrets` | Write `.env`, plus `list` · `set` · `unset` · `bind` · `unbind` · `bindings` · `sources` · `tree` |
232
232
  | `insta domain` | Bought or bring-your-own: `attach` · `check` · `detach`; buy through InstaCloud: `search` · `buy` · `list` · `status` · `records …` |
233
233
  | `insta compute` | `start` · `stop` · `suspend` · `restart` · `status` · `scale` · `limits` · `volume` · `always-on` · `exec` · `ssh` · `repo` · `connect-repo` · `watch-paths` · `disconnect-repo` · `logs` · `metrics` |
234
- | `insta postgres` | `url` (print the DSN) · `connect` (psql) · `stats` · `limits` · `volume` · `always-on` · `private-access` · `public-access` · `logs` · `metrics` — every verb takes `[service]` |
234
+ | `insta postgres` | `url` (print the DSN) · `connect` (psql) · `stats` · `limits` · `restart` · `volume` · `always-on` · `private-access` · `public-access` · `logs` · `metrics` — every verb takes `[service]` |
235
235
  | `insta redis` · `mysql` · `mongodb` | `query` · `status` · `limits` · `volume` · `always-on` · `logs` · `metrics` |
236
236
  | `insta storage` | `list` · `get` · `delete` · `set-access` |
237
237
  | `insta build [dir]` · `deploy [dir]` | Verify a source dir would build; deploy a source directory (built remotely) or `--image <url>`. `insta build logs <id>` reads source-build output: `--source archive` (default) uses the deploy operation ID, `--source github` uses a GitHub build ID; `--follow` watches output, `--json` returns one snapshot |
package/dist/agent.js CHANGED
@@ -18,6 +18,9 @@ export function canonicalTarget(path) {
18
18
  return url.pathname + url.search;
19
19
  }
20
20
  const guidance = 'agent session missing, expired, or for another project/environment — run `insta agent setup`';
21
+ /** No usable agent session for the project, looked up from the current directory. */
22
+ export class AgentSessionMissing extends Error {
23
+ }
21
24
  export async function issueAgentSession(api, projectId) {
22
25
  const pair = generateKeyPairSync('ed25519');
23
26
  const client = mode?.client ?? detectAgent(false)?.client ?? 'unknown';
@@ -60,7 +63,7 @@ export async function loadAgentSession(apiUrl, projectId, cwd = process.cwd()) {
60
63
  return session;
61
64
  }
62
65
  catch {
63
- throw new Error(guidance);
66
+ throw new AgentSessionMissing(guidance);
64
67
  }
65
68
  }
66
69
  // Routes the CLI may call on an account-level (bootstrap) session, by first path segment. Every
@@ -325,7 +325,7 @@ export async function claimGrant(email, client, post, wait = sleepSeconds, open,
325
325
  throw expired();
326
326
  }
327
327
  // Start a loopback server, open the browser at the platform bridge, and await the token.
328
- function browserOauth(apiUrl, provider) {
328
+ export function browserOauth(apiUrl, provider, open = openUrl) {
329
329
  return new Promise((resolve, reject) => {
330
330
  const state = randomBytes(16).toString('hex');
331
331
  let timer;
@@ -340,7 +340,7 @@ function browserOauth(apiUrl, provider) {
340
340
  const err = url.searchParams.get('error');
341
341
  const ok = !!token && !err && url.searchParams.get('state') === state;
342
342
  res.writeHead(ok ? 200 : 400, { 'content-type': 'text/html' });
343
- res.end(`<!doctype html><meta charset=utf-8><body style="font-family:system-ui;text-align:center;margin-top:4rem"><h2>InstaCloud</h2><p>${ok ? '✓ Login complete — you can close this tab.' : '✗ Login failed' + (err ? ` (${err})` : '')}</p></body>`);
343
+ res.end(`<!doctype html><meta charset=utf-8><body style="font-family:system-ui;text-align:center;margin-top:4rem"><h2>InstaCloud</h2><p>${ok ? '✓ Login complete — you can close this tab.' : '✗ Login failed' + (err ? ` (${err.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;')})` : '')}</p></body>`);
344
344
  clearTimeout(timer);
345
345
  server.close();
346
346
  if (err)
@@ -362,7 +362,7 @@ function browserOauth(apiUrl, provider) {
362
362
  // event, so openUrl's return value cannot see it (e.g. powershell.exe blocked by AppLocker
363
363
  // on hardened fleets) — and the silent variant of that failure looks exactly like a hang.
364
364
  info(`if nothing opens, use this URL:\n ${authorizeUrl}`);
365
- openUrl(authorizeUrl);
365
+ open(authorizeUrl);
366
366
  info('waiting for you to finish in the browser… (times out in 2m; ctrl-c to abort)');
367
367
  timer = setTimeout(() => { server.close(); reject(new Error('timed out waiting for browser login (2m)')); }, 120_000);
368
368
  });
@@ -32,15 +32,36 @@ export function billingLines(s, org) {
32
32
  ];
33
33
  if (s.subscriptionStatus)
34
34
  lines.push(`subscription: ${s.subscriptionStatus}`);
35
- if (s.billingStatus === 'suspended') {
36
- // Four causes, five messages, and every one is a dead end for the others. Tier first: only a
35
+ if (s.pendingPlanChange) {
36
+ const pending = s.pendingPlanChange;
37
+ const boundary = new Date(pending.effectiveAt).toISOString().replace('T', ' ').replace('.000Z', ' UTC');
38
+ lines.push(pending.status === 'scheduled'
39
+ ? `next plan: ${pending.toTier} at ${boundary}`
40
+ : `plan change: preparing ${pending.toTier}; requested boundary ${boundary}`);
41
+ if (pending.changeKind === 'upgrade') {
42
+ lines.push(`full-cycle allowance: $${Number(pending.includedUsd).toFixed(2)} (next renewal)`);
43
+ lines.push('Current cycle: proportional allowance increase; existing usage and renewal date are preserved.');
44
+ }
45
+ else
46
+ lines.push(`next allowance: $${Number(pending.includedUsd).toFixed(2)} (available after cutover)`);
47
+ lines.push(pending.changeKind === 'downgrade'
48
+ ? 'Current paid plan remains active until period end. Withdraw this downgrade in Console Plans before it takes effect.'
49
+ : 'A plan change is pending; contact support for billing changes.');
50
+ }
51
+ if (s.tier === 'enterprise') {
52
+ lines.push('Enterprise billing is managed by staff; payment failure does not change the plan.');
53
+ if (s.billingStatus === 'suspended')
54
+ lines.push('Contact support for billing or resource recovery.');
55
+ }
56
+ else if (s.billingStatus === 'suspended' && !s.pendingPlanChange) {
57
+ // Ordinary-plan recovery advice depends on the cause. Tier first: only a
37
58
  // free org can spend a prepaid wallet, and waiting for the next cycle genuinely fixes that one.
38
59
  // (Tier, not subscriptionStatus, because rows written before non-payment suspended carry
39
60
  // `unpaid` beside tier 'free' and survive with no migration.) Then the status splits the paid
40
61
  // branch three ways: an invoice to settle, a subscription to replace, or — when it reads
41
62
  // healthy — a suspension that outlived its cause, which is what a recovery whose compute failed
42
63
  // to restart looks like, and where telling them to pay means re-settling a paid invoice. The
43
- // replace case is the one that splits again, because enterprise has no self-serve checkout.
64
+ // Enterprise and pending changes were handled above: neither offers self-serve checkout.
44
65
  //
45
66
  // EVERY command here carries the caller's --org. `billing` and the command being suggested
46
67
  // resolve the target independently, so a hint that drops the flag acts on a different org than
@@ -53,13 +74,9 @@ export function billingLines(s, org) {
53
74
  : lapsed
54
75
  ? `⚠ org suspended — subscription payment did not go through; settle it in \`insta billing portal${flag}\``
55
76
  : ended
56
- ? s.tier === 'enterprise'
57
- // Per-deal, and `billing subscribe` cannot create one: naming a self-serve tier here
58
- // would move them off the plan they negotiated.
59
- ? '⚠ org suspended — the subscription ended; contact support to restore this plan'
60
- // Their OWN tier, not a hardcoded one: suggesting `subscribe pro` to a Team org
61
- // resubscribes it onto the wrong plan.
62
- : `⚠ org suspended — the subscription ended; resubscribe with \`insta billing subscribe ${s.tier}${flag}\``
77
+ // Their OWN tier, not a hardcoded one: suggesting `subscribe pro` to a Team org
78
+ // resubscribes it onto the wrong plan.
79
+ ? `⚠ org suspended — the subscription ended; resubscribe with \`insta billing subscribe ${s.tier}${flag}\``
63
80
  // Deliberately claims nothing about the subscription: `incomplete` reaches here too,
64
81
  // and that one is neither current nor failed. All this branch knows is that the
65
82
  // suspension has no billing cause it can name.
@@ -157,7 +174,7 @@ export async function billingRedeem(code, opts) {
157
174
  for (const l of redeemLines(res))
158
175
  info(l);
159
176
  }
160
- // insta billing portal — open the Stripe Customer Portal (change plan / card / cancel).
177
+ // insta billing portal — manage payment methods. UTC-managed plans cannot change/cancel here.
161
178
  export async function billingPortal(opts) {
162
179
  const api = await ApiClient.load();
163
180
  const orgId = await resolveOrgId(opts);
@@ -1,4 +1,6 @@
1
- import { ApiClient, ApiError, requireProject } from '../api.js';
1
+ import { AgentApprovalRequired, ApiClient, ApiError, requireProject } from '../api.js';
2
+ import { AgentSessionMissing } from '../agent.js';
3
+ import { safeText } from '../config.js';
2
4
  import { info, printJson, handleApproval, relayExitCode, writeFileAtomicSync, resolveThroughSymlink } from '../util.js';
3
5
  import { resolveComputeServiceId, resolveSoleService, q, parseVolumeGib, parseCount } from './services.js';
4
6
  export const isWorker = (s) => s.port === 0;
@@ -103,7 +105,7 @@ export function domainStatusLines(r, ctx = {}) {
103
105
  }
104
106
  const records = recordsOf(r);
105
107
  const out = [`${r.hostname} -> ${targetOf(r)}`];
106
- const txt = records.find((d) => d.type === 'TXT');
108
+ const txt = records.find((d) => d.type === 'TXT' && d.purpose !== 'edge_ownership');
107
109
  // The ROUTING records for the hostname, whatever type they take: a CNAME for a subdomain, or the
108
110
  // A/AAAA PAIR an apex needs (Fly's apex path emits both). All of them, not the first one —
109
111
  // a correct A beside a missing AAAA is not "routing is fine".
@@ -111,12 +113,6 @@ export function domainStatusLines(r, ctx = {}) {
111
113
  const routing = records.filter(isRouting);
112
114
  const blockers = [];
113
115
  const stage = (label, state, detail) => out.push(` ${pad(label, 12)}${pad(state, 10)}${detail ? ` ${detail}` : ''}`);
114
- // The ONE verdict rule, applied to EVERY record the platform returned regardless of its role.
115
- // A record is settled only when the platform says `ok` — or, for a provider that reports no
116
- // per-record status at all (Fly), when it vouched for the whole set with `configured`. missing,
117
- // mismatch and never-checked are each outstanding and each add a blocker. Applying this to only
118
- // some records lets an apex whose AAAA is missing, or a still-pending validation record, ride
119
- // under a `serving https://…` line.
120
116
  const verdictOf = (d) => d.status ?? (r.configured ? 'ok' : 'unchecked');
121
117
  if (txt) {
122
118
  const st = verdictOf(txt);
@@ -173,15 +169,16 @@ export function domainStatusLines(r, ctx = {}) {
173
169
  blockers.push(`${d.type} unchecked`);
174
170
  }
175
171
  }
176
- // Everything else the platform returned — a Let's Encrypt validation CNAME, any extra record.
177
- // Same rule, no exemption: an outstanding record is outstanding whatever its role.
178
172
  for (const d of records) {
179
173
  if (d === txt || isRouting(d))
180
174
  continue;
181
175
  const st = verdictOf(d);
182
176
  const lbl = d.type.toLowerCase();
183
177
  const where = `${d.name} -> ${d.value}${d.note ? ` (${d.note})` : ''}`;
184
- if (st === 'ok')
178
+ if (d.type === 'TXT' && d.purpose === 'edge_ownership' && st === 'unchecked') {
179
+ stage('edge TXT', 'unchecked', `${where} (Cloudflare validates this record; see certificate status below)`);
180
+ }
181
+ else if (st === 'ok')
185
182
  stage(lbl, 'ok', where);
186
183
  else if (st === 'mismatch') {
187
184
  stage(lbl, 'mismatch', `${d.type} ${d.name} must point at ${d.value}`);
@@ -822,7 +819,7 @@ import { homedir } from 'node:os';
822
819
  import { dirname, join } from 'node:path';
823
820
  import { execFileSync } from 'node:child_process';
824
821
  import { createHash, randomUUID } from 'node:crypto';
825
- import { aliasFor, certifiesPublicKey, hasOwnedBlock, isSafeAlias, isSafeConfigValue, isSafeSSHHost, isSafeSSHUsername, isSafeTimestamp, isSSHCertificateRecord, mayWidenCAHost, parseCAPublicKey, planCertAuthority, renderConfigBlock, revertCertAuthority, upsertConfigBlock, ownedBlock, ownedBlockIsFirst } from './ssh-config.js';
822
+ import { ALIAS_SUFFIX, aliasFor, certifiesPublicKey, hasOwnedBlock, isSafeAlias, isSafeConfigValue, isSafeSSHHost, isSafeSSHUsername, isSafeTimestamp, isSSHCertificateRecord, mayWidenCAHost, parseCAPublicKey, planCertAuthority, renderConfigBlock, revertCertAuthority, upsertConfigBlock, ownedBlock, ownedBlockIsFirst } from './ssh-config.js';
826
823
  /** Where this CLI keeps its own SSH material. Deliberately NOT ~/.ssh: we never
827
824
  * touch a key the user already had, and a dedicated key pairs with
828
825
  * IdentitiesOnly to avoid being identified by the wrong one. */
@@ -1017,6 +1014,8 @@ function ensureKeyPair() {
1017
1014
  const keyMaterial = (record) => record.split(/\s+/).slice(0, 2).join(' ');
1018
1015
  async function mintCert(api, projectId, serviceId, publicKey, alias, signal) {
1019
1016
  const res = await api.rawRequest('POST', `/projects/${projectId}/services/${serviceId}/ssh-cert`, { publicKey }, { signal });
1017
+ if (res.status === 202 && res.body?.status === 'approval_required')
1018
+ throw new AgentApprovalRequired(res.body);
1020
1019
  if (res.status < 200 || res.status >= 300) {
1021
1020
  throw new ApiError(res.status, res.body?.error ?? 'could not issue an ssh certificate');
1022
1021
  }
@@ -1458,6 +1457,27 @@ function readUserText(path) {
1458
1457
  }
1459
1458
  return text;
1460
1459
  }
1460
+ /** Another process holds this alias's renewal lock and is renewing it now. */
1461
+ class RenewalInProgress extends Error {
1462
+ }
1463
+ /** The one line a missing or expired certificate that could not be renewed gets: the cause and the fix. */
1464
+ export function renewalFailureNotice(alias, err) {
1465
+ const head = `insta: the SSH certificate for ${alias} is missing or expired`;
1466
+ const fix = `insta compute ssh ${alias.slice(0, -ALIAS_SUFFIX.length)}`;
1467
+ if (err instanceof RenewalInProgress) {
1468
+ return `${head} and another ssh may be renewing it right now. Retry in a moment, or run "${fix}".`;
1469
+ }
1470
+ if (err instanceof AgentSessionMissing) {
1471
+ return `${head}. Agent mode found no agent session for its project in this directory. Run ssh from the project directory, or ask a person to run "${fix}".`;
1472
+ }
1473
+ if (err instanceof AgentApprovalRequired) {
1474
+ return `${head} and renewing it needs approval. ${safeText(err.message.replace(/\s+/g, ' ')).trim()} Retry after it is approved.`;
1475
+ }
1476
+ if (err instanceof ApiError && err.status === 403 && err.message.endsWith('denied by agent policy')) {
1477
+ return `${head} and this project's agent policy does not allow renewing it.`;
1478
+ }
1479
+ return `${head} and could not be renewed. Run "${fix}" to renew it.`;
1480
+ }
1461
1481
  /**
1462
1482
  * The renewal hook OpenSSH runs while PARSING the config, before it connects.
1463
1483
  *
@@ -1475,15 +1495,20 @@ function readUserText(path) {
1475
1495
  * rule __update-check relies on), so the fast path is genuinely local only
1476
1496
  * when the hook enters through that name.
1477
1497
  * - Silent and fail-safe. An unlinked directory, an expired login or a network
1478
- * outage must not print anything or fail the parse: the existing certificate
1479
- * stays in place and the login then fails with SSH's own message, not a CLI
1480
- * error spliced into the middle of an ssh session.
1498
+ * outage must not fail the parse: the existing certificate stays in place.
1499
+ * Only an already expired one earns a single stderr line saying why.
1481
1500
  */
1482
1501
  export async function ensureCertForAlias(alias, timeoutMs = RENEWAL_REQUEST_TIMEOUT_MS) {
1483
1502
  let release;
1503
+ let explainOnExit = false;
1504
+ let cause;
1484
1505
  try {
1485
1506
  if (!isSafeAlias(alias))
1486
1507
  return;
1508
+ const managed = readAliasStore();
1509
+ if (!managed[alias])
1510
+ return; // not an alias this CLI manages: stay silent
1511
+ explainOnExit = true; // set before the repair, which can throw too
1487
1512
  // Repair BEFORE the renewal gate, because the stanza can lag the store
1488
1513
  // with no certificate due: an alias set up before the Port line existed
1489
1514
  // dials :22 on every connection, and waiting for its certificate to age
@@ -1491,15 +1516,17 @@ export async function ensureCertForAlias(alias, timeoutMs = RENEWAL_REQUEST_TIME
1491
1516
  // predicate is content drift, so the common case -- an up-to-date stanza --
1492
1517
  // is one file read and a string compare, with no lock taken; only a stale
1493
1518
  // stanza takes the alias-store lock, re-checks under it and rewrites.
1494
- if (configBlockStale(readAliasStore())) {
1519
+ if (configBlockStale(managed)) {
1495
1520
  withAliasStoreLock(() => {
1496
1521
  const store = readAliasStore();
1497
1522
  if (store[alias] && configBlockStale(store))
1498
1523
  installConfigBlock(store);
1499
1524
  }, { waitMs: KNOWN_HOSTS_LOCK_WAIT_MS });
1500
1525
  }
1501
- if (!certNeedsRenewal(instaCertPath(alias)))
1526
+ if (!certNeedsRenewal(instaCertPath(alias))) {
1527
+ explainOnExit = false; // the certificate works, so no second check on the way out
1502
1528
  return;
1529
+ }
1503
1530
  // An IDE opens several connections at once and `scp` adds more, so the
1504
1531
  // near-expiry certificate is observed by every one of them simultaneously
1505
1532
  // and each would mint its own replacement -- redundant requests against a
@@ -1510,8 +1537,10 @@ export async function ensureCertForAlias(alias, timeoutMs = RENEWAL_REQUEST_TIME
1510
1537
  // a lock that can hang `ssh` itself -- strictly worse than the duplicate
1511
1538
  // request it would prevent. The loser simply lets the winner renew.
1512
1539
  release = acquireRenewalLock(alias);
1513
- if (!release)
1540
+ if (!release) {
1541
+ cause = new RenewalInProgress();
1514
1542
  return;
1543
+ }
1515
1544
  // Re-checked after the lock. Without this the second process through the
1516
1545
  // door renews again over the certificate the first just wrote -- the lock
1517
1546
  // would serialise the stampede instead of collapsing it.
@@ -1619,11 +1648,15 @@ export async function ensureCertForAlias(alias, timeoutMs = RENEWAL_REQUEST_TIME
1619
1648
  out.staged.discard();
1620
1649
  }
1621
1650
  }
1622
- catch {
1623
- // Deliberately swallowed. See above.
1651
+ catch (err) {
1652
+ cause = err;
1624
1653
  }
1625
1654
  finally {
1626
1655
  release?.();
1656
+ // Silent unless this ssh is about to fail: any exit that left the certificate expired says why.
1657
+ if (explainOnExit && certNeedsRenewal(instaCertPath(alias), { marginMs: 0 })) {
1658
+ process.stderr.write(renewalFailureNotice(alias, cause) + '\n');
1659
+ }
1627
1660
  }
1628
1661
  }
1629
1662
  /** A renewal lock older than this is broken even if its holder is alive. That
@@ -208,6 +208,15 @@ export async function domainNameserversReset(domainName, opts, deps) {
208
208
  for (const line of domainLines(r, !opts.org))
209
209
  info(line);
210
210
  }
211
+ export async function domainRenew(domainName, mode, opts, deps) {
212
+ if (mode !== 'on' && mode !== 'off')
213
+ die('mode must be on or off');
214
+ const { api, orgId } = await orgDeps(opts, deps);
215
+ const r = await api.request('PATCH', domainPath(orgId, domainName), { autorenew: mode === 'on' });
216
+ if (opts.json)
217
+ return printJson(r);
218
+ info(`${r.domainName} auto-renew ${r.autorenew ? 'on' : 'off'}`);
219
+ }
211
220
  export async function domainTransferLock(domainName, mode, opts, deps) {
212
221
  if (mode !== 'on' && mode !== 'off')
213
222
  die('mode must be on or off');
@@ -386,7 +395,20 @@ export async function zoneDelegate(domainName, opts, deps) {
386
395
  }
387
396
  export async function zoneList(opts, deps) {
388
397
  const { api, orgId } = await orgDeps(opts, deps);
389
- const r = await api.request('GET', zonePath(orgId));
398
+ const r = { items: [] };
399
+ let cursor;
400
+ const seen = new Set();
401
+ do {
402
+ const path = `${zonePath(orgId)}?limit=100${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''}`;
403
+ const page = await api.request('GET', path);
404
+ r.items.push(...page.items);
405
+ cursor = page.nextCursor;
406
+ if (cursor) {
407
+ if (seen.has(cursor))
408
+ throw new Error('zone listing returned a repeated pagination cursor');
409
+ seen.add(cursor);
410
+ }
411
+ } while (cursor);
390
412
  if (opts.json)
391
413
  return printJson(r);
392
414
  if (!r.items.length)
@@ -51,6 +51,20 @@ export function renderJsonConfig(slug, existing, url, name = MCP_SERVER_NAME) {
51
51
  }
52
52
  return JSON.stringify(root, null, 2) + '\n';
53
53
  }
54
+ export function tomlBasicString(value) {
55
+ const escaped = value.replace(/[\u0000-\u0008\u000A-\u001F\u007F\\"]/g, (ch) => {
56
+ switch (ch) {
57
+ case '\\': return '\\\\';
58
+ case '"': return '\\"';
59
+ case '\n': return '\\n';
60
+ case '\r': return '\\r';
61
+ case '\b': return '\\b';
62
+ case '\f': return '\\f';
63
+ default: return `\\u${ch.charCodeAt(0).toString(16).padStart(4, '0')}`;
64
+ }
65
+ });
66
+ return `"${escaped}"`;
67
+ }
54
68
  // Codex config is TOML. Appending a complete `[mcp_servers.<name>]` table is always valid at
55
69
  // EOF, so we avoid a TOML parser: string-detect for idempotency, append for install.
56
70
  export function renderCodexConfig(existing, url, name = MCP_SERVER_NAME) {
@@ -58,7 +72,7 @@ export function renderCodexConfig(existing, url, name = MCP_SERVER_NAME) {
58
72
  if (base.includes(`[mcp_servers.${name}]`))
59
73
  return null; // already configured
60
74
  const sep = base.length && !base.endsWith('\n') ? '\n' : '';
61
- return `${base}${sep}\n[mcp_servers.${name}]\nurl = "${url}"\n`;
75
+ return `${base}${sep}\n[mcp_servers.${name}]\nurl = ${tomlBasicString(url)}\n`;
62
76
  }
63
77
  // Install for one agent. Returns 'installed' | 'already' | 'skipped' (unparseable config).
64
78
  export async function installFor(slug, home, url, name = MCP_SERVER_NAME) {
@@ -282,6 +282,29 @@ export async function dbVolume(service, opts) {
282
282
  const vg = res.body?.volumeGib;
283
283
  info(`postgres ${service ?? 'default'}: volume ${typeof vg === 'number' ? `grown to ${vg}Gi` : `set to ${sizeGib}Gi`}`);
284
284
  }
285
+ export async function dbRestart(service, opts) {
286
+ const api = await ApiClient.load();
287
+ const p = await requireProject();
288
+ const qs = new URLSearchParams();
289
+ const branch = opts.branch ?? p.branch;
290
+ if (branch)
291
+ qs.set('branch', branch);
292
+ if (service)
293
+ qs.set('group', service);
294
+ const res = await api.rawRequest('POST', `/projects/${p.projectId}/database/restart${qs.toString() ? `?${qs}` : ''}`);
295
+ if (handleApproval(res, opts.json))
296
+ return;
297
+ if (opts.json)
298
+ return printJson(res.body);
299
+ info(restartLine(service ?? 'default', res.body));
300
+ }
301
+ export function restartLine(group, body) {
302
+ if (body?.pending)
303
+ return `postgres ${group}: restart accepted and still in progress — settings apply once it is back up`;
304
+ if (body?.state === 'suspended')
305
+ return `postgres ${group}: suspended, not restarted — pending settings apply when the next connection starts it`;
306
+ return `postgres ${group}: restarted — ALTER SYSTEM settings that need a restart are now in effect`;
307
+ }
285
308
  export async function resolveDbUrl(api, projectId, branch, service, json) {
286
309
  const { services } = await api.request('GET', `/projects/${projectId}/services${q(branch)}`);
287
310
  const svc = resolveSoleService(services, 'postgres', service);
@@ -142,9 +142,7 @@ export function renderConfigBlock(o) {
142
142
  // the one carrying our certificate. Without this line a developer with a
143
143
  // full ssh-agent gets intermittent, unexplainable auth failures.
144
144
  ' IdentitiesOnly yes');
145
- // Connection multiplexing collapses scp, an IDE's several connections and a
146
- // second terminal onto ONE connection; without it a single developer can
147
- // reach the per-service session cap in an afternoon.
145
+ // Multiplexing spares a handshake per ssh, and 60s keeps a master from outliving a pod that scaled to zero.
148
146
  //
149
147
  // The socket is keyed on %C -- a hash of (local host, remote host, port,
150
148
  // user) -- not %r@%h:%p. A ControlPath is a Unix-domain socket, whose path
@@ -161,7 +159,7 @@ export function renderConfigBlock(o) {
161
159
  // effective-config tests need a real `ssh` and skip on Windows, so this
162
160
  // branch is asserted on the rendered text instead.
163
161
  if ((o.platform ?? process.platform) !== 'win32') {
164
- lines.push(' ControlMaster auto', ' ControlPath ~/.insta/ssh/cm-%C', ' ControlPersist 10m');
162
+ lines.push(' ControlMaster auto', ' ControlPath ~/.insta/ssh/cm-%C', ' ControlPersist 60s');
165
163
  }
166
164
  if (o.ensureCertCommand) {
167
165
  // Renewal happens while OpenSSH PARSES the config, before it connects, so
@@ -34,6 +34,7 @@ export function normalizeInfoServices(raw) {
34
34
  name, type: s?.type, port: s?.port,
35
35
  volumeGib: s?.volumeGib ?? s?.volume?.size,
36
36
  volume: s?.volume === true || s?.volumeGib != null || s?.volume?.size != null,
37
+ mountPath: typeof s?.mountPath === 'string' ? s.mountPath : undefined,
37
38
  });
38
39
  if (Array.isArray(raw))
39
40
  return raw.map((s) => one(s?.name ?? '?', s));
@@ -73,7 +74,7 @@ export function templateInfoLines(t, bold = (s) => s) {
73
74
  const summary = services.map((s) => {
74
75
  // A size only when the registry still carries one: a manifest names no size any more, so
75
76
  // "persistent /data" is all there is to say until the service exists.
76
- const disk = s.volumeGib ? `${s.volumeGib}Gi volume` : s.volume ? 'persistent /data' : undefined;
77
+ const disk = s.volumeGib ? `${s.volumeGib}Gi volume` : s.volume ? `persistent ${s.mountPath ?? '/data'}` : undefined;
77
78
  const bits = [s.type && s.type !== 'compute' ? s.type : undefined, s.port ? `port ${s.port}` : undefined, disk].filter(Boolean);
78
79
  return `${s.name}${bits.length ? ` (${bits.join(', ')})` : ''}`;
79
80
  });
package/dist/config.js CHANGED
@@ -2,9 +2,9 @@
2
2
  import { homedir } from 'node:os';
3
3
  import { dirname, join, resolve } from 'node:path';
4
4
  import { realpathSync } from 'node:fs';
5
- import { chmod, mkdir, readFile, writeFile } from 'node:fs/promises';
5
+ import { chmod, lstat, mkdir, readFile, writeFile } from 'node:fs/promises';
6
6
  import { ensureGitignore } from './gitignore.js';
7
- import { die } from './util.js';
7
+ import { die, writeFileAtomicSync } from './util.js';
8
8
  import { DEFAULT_ENV, ENVS, envForApiUrl, envFromEnvVar, normalizeUrl } from './env.js';
9
9
  const GLOBAL_DIR = join(homedir(), '.insta');
10
10
  const GLOBAL_FILE = join(GLOBAL_DIR, 'config.json');
@@ -109,8 +109,10 @@ export async function readPersistedGlobal() {
109
109
  }
110
110
  }
111
111
  export async function writeGlobal(c) {
112
- await mkdir(GLOBAL_DIR, { recursive: true });
113
- await writeFile(GLOBAL_FILE, JSON.stringify(c, null, 2));
112
+ await mkdir(GLOBAL_DIR, { recursive: true, mode: 0o700 });
113
+ if (process.platform !== 'win32' && !(await lstat(GLOBAL_DIR)).isSymbolicLink())
114
+ await chmod(GLOBAL_DIR, 0o700);
115
+ writeFileAtomicSync(GLOBAL_FILE, JSON.stringify(c, null, 2));
114
116
  }
115
117
  /** The home directory is never a project root. `~/.insta/` is this CLI's GLOBAL config directory,
116
118
  * so a project.json there is not a project link: honouring one made every directory under the home
@@ -255,9 +257,10 @@ async function readLinkPlane(root) {
255
257
  }
256
258
  }
257
259
  /** Text safe to echo to a terminal: C0 and C1 control characters and DEL removed — ESC and the
258
- * single-byte C1 introducers (U+009B CSI among them) alike, so no escape sequence survives. */
259
- function safeText(text) {
260
- return String(text).replace(/[\u0000-\u001f\u007f-\u009f]/g, '');
260
+ * single-byte C1 introducers (U+009B CSI among them) alike, so no escape sequence survives.
261
+ * Bidi marks, embeddings, overrides and isolates go too, so nothing can reorder what is shown. */
262
+ export function safeText(text) {
263
+ return String(text).replace(/[\u0000-\u001f\u007f-\u009f‎‏‪-‮⁦-⁩]/g, '');
261
264
  }
262
265
  /** A control-plane URL safe to persist and to print: control characters removed, surrounding
263
266
  * whitespace trimmed, and any userinfo (INSTA_API_URL may carry credentials) removed.
package/dist/index.js CHANGED
@@ -293,6 +293,9 @@ dom.command('list').description("Domains bought through InstaCloud in this org
293
293
  dom.command('status <name>').description("A bought domain's order and attach state")
294
294
  .option('--org <id>', "target org (default: linked project's org)").option('--json')
295
295
  .action(guard((name, o) => domainCmd.domainStatus(name, o)));
296
+ dom.command('renew <domain> <mode>').description('Turn automatic renewal on or off for a bought domain (mode: on|off). Does not renew immediately. Agent credentials are refused; run with a human login')
297
+ .option('--org <id>', "target org (default: linked project's org)").option('--json')
298
+ .action(guard((domain, mode, o) => domainCmd.domainRenew(domain, mode, o)));
296
299
  dom.command('delegate <domain>').description("Move a bought domain's DNS onto an InstaCloud-managed zone — the way an apex hostname gets a certificate and serves. Every record is copied first and the nameservers switch after, so serving hostnames stay up and ones that failed by delegating away revive on their own. While managed, `domain records` answers 409 for every verb (managed-zone record editing is not covered yet); `nameservers reset` is the way back (org admin; gated: domain.delegate — agent mode gates from a linked project, an unlinked --org call falls under org administration instead)")
297
300
  .option('--org <id>', "target org (default: linked project's org)").option('--json')
298
301
  .action(guard((domain, o) => domainCmd.domainDelegate(domain, o)));
@@ -365,7 +368,7 @@ compute.command('start [service]').description('Bring a compute service online (
365
368
  .option('--json').option('--branch <branch>', 'branch (default: current)').action(guard((service, o) => computeCmd.computeStart(service, o)));
366
369
  compute.command('stop [service]').description('Take a compute service offline; traffic will NOT wake it until `start`')
367
370
  .option('--json').option('--branch <branch>', 'branch (default: current)').action(guard((service, o) => computeCmd.computeStop(service, o)));
368
- compute.command('suspend [service]').description('Suspend a compute service (RAM snapshot); stays down until `start`')
371
+ compute.command('suspend [service]').description('Suspend a compute service (RAM snapshot); traffic can wake it unless already stopped; `start` clears the stop')
369
372
  .option('--json').option('--branch <branch>', 'branch (default: current)').action(guard((service, o) => computeCmd.computeSuspend(service, o)));
370
373
  compute.command('restart [service]').description("Restart a compute service by re-running the image it already runs against a freshly resolved env bundle — this is how a changed secret or binding reaches a running machine (env is baked into the machine at deploy time), and how a machine that is up but wedged gets cycled (`start` no-ops on one that is already started). No new image, no new spec. The service must be running: a stopped or suspended one comes back with `insta compute start`. All plans; gated: deploy — it lands configuration the same way a deploy does, so a policy denying deploys denies this too (`start`/`stop` stay ungated, and cycle a wedged machine without one). A service whose app fails to answer on its port coming back up reports that failure, and the machines are rolled back, best-effort, to the config they were serving")
371
374
  .option('--json').option('--branch <branch>', 'branch (default: current)').action(guard((service, o) => computeCmd.computeRestart(service, o)));
@@ -423,7 +426,7 @@ compute.command('volume [service]').description("Show, attach, grow, remount, or
423
426
  .option('--json').option('--branch <branch>', 'branch (default: current)').action(guard((service, o) => computeCmd.computeVolume(service, o)));
424
427
  addObservability(compute, 'compute', 'compute');
425
428
  // ---- postgres ----
426
- const pg = program.command('postgres').description('Postgres services: connection string, psql, stats, resource ceiling, volume, always-on, network access, logs, metrics');
429
+ const pg = program.command('postgres').description('Postgres services: connection string, psql, stats, resource ceiling, restart, volume, always-on, network access, logs, metrics');
427
430
  pg.command('url [service]').description('Print the postgres connection string (DSN) — bare on stdout for piping, e.g. `psql "$(insta postgres url)"` (gated: secrets.read). `insta secrets` includes the primary postgres service\'s DATABASE_URL; use this command to select a specific postgres service')
428
431
  .option('--json').option('--branch <branch>', 'branch (default: current)')
429
432
  .action(guard((service, o) => pgCmd.dbUrl(service, o)));
@@ -437,6 +440,9 @@ pg.command('limits [service]').description("Show or set a postgres service's res
437
440
  .option('--cpu <n>', 'vCPU ceiling, e.g. 2 or 2500m').option('--memory <size>', 'memory ceiling, e.g. 4Gi')
438
441
  .option('--json').option('--branch <branch>', 'branch (default: current)')
439
442
  .action(guard((service, o) => pgCmd.dbLimits(service, o)));
443
+ pg.command('restart [service]').description("Restart the postgres server so settings written with ALTER SYSTEM that need a restart (wal_level, max_wal_senders, max_replication_slots, …) take effect. Connections drop while it restarts; waits for it to finish. A suspended instance stays suspended and picks the settings up on its next connection. insta-db-backed only (gated: deploy)")
444
+ .option('--json').option('--branch <branch>', 'branch (default: current)')
445
+ .action(guard((service, o) => pgCmd.dbRestart(service, o)));
440
446
  pg.command('volume [service]').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")
441
447
  .option('--size <gi>', 'new size in whole Gi, e.g. 10 (must be ≥ the current size)')
442
448
  .option('--json').option('--branch <branch>', 'branch (default: current)')
@@ -552,7 +558,7 @@ bill.command('subscribe <tier>').description('Subscribe the org to a paid tier (
552
558
  bill.command('redeem <code>').description("Redeem a one-time credit code into the org's wallet")
553
559
  .option('--org <id>').option('--json')
554
560
  .action(guard((code, o) => billingRedeem(code, o)));
555
- bill.command('portal').description('Open the Stripe Customer Portal (change plan / card / cancel)')
561
+ bill.command('portal').description('Open Stripe payment-method management (owner/admin)')
556
562
  .option('--org <id>').option('--no-open', 'print the URL instead of opening a browser').option('--json')
557
563
  .action(guard((o) => billingPortal(o)));
558
564
  bill.command('usage').description('Usage for the current billing cycle by billing dimension (org by default; --proj for one project)')
@@ -1,6 +1,6 @@
1
1
  // PostToolUse hook: reads a tool-use event on stdin (Claude Code / Codex), scans every string
2
2
  // surface for credential exposure, and appends findings to ./.insta/audit.jsonl.
3
- import { appendFileSync, mkdirSync } from 'node:fs';
3
+ import { appendFileSync, chmodSync, mkdirSync } from 'node:fs';
4
4
  import { basename, dirname, join, resolve } from 'node:path';
5
5
  import { fileURLToPath, pathToFileURL } from 'node:url';
6
6
  import { scanEvent } from './scanner.js';
@@ -27,9 +27,15 @@ export function recordFindings(event, baseDir) {
27
27
  tool: event.tool_name ?? null,
28
28
  cwd: event.cwd ?? null,
29
29
  };
30
- mkdirSync(instaDir, { recursive: true });
30
+ mkdirSync(instaDir, { recursive: true, mode: 0o700 });
31
+ // Windows ACLs do not store these modes. On POSIX a failure must surface: main() prints it.
32
+ if (process.platform !== 'win32')
33
+ chmodSync(instaDir, 0o700);
31
34
  const lines = findings.map((f) => JSON.stringify({ ...common, ...f })).join('\n') + '\n';
32
- appendFileSync(join(instaDir, 'audit.jsonl'), lines, 'utf8');
35
+ const auditPath = join(instaDir, 'audit.jsonl');
36
+ appendFileSync(auditPath, lines, { encoding: 'utf8', mode: 0o600 });
37
+ if (process.platform !== 'win32')
38
+ chmodSync(auditPath, 0o600);
33
39
  return findings.length;
34
40
  }
35
41
  async function readStdin() {
@@ -8,7 +8,7 @@ const DETECTORS = [
8
8
  { name: 'stripe_secret_key', rx: /\b[rs]k_(?:live|test)_[A-Za-z0-9]{16,}\b/gd, group: false },
9
9
  { name: 'llm_api_key', rx: /\bsk-(?:ant-)?[A-Za-z0-9_-]{20,}\b/gd, group: false },
10
10
  { name: 'google_api_key', rx: /\bAIza[0-9A-Za-z_-]{35}\b/gd, group: false },
11
- { name: 'private_key_block', rx: /-----BEGIN (?:[A-Z ]+ )?PRIVATE KEY-----/gd, group: false },
11
+ { name: 'private_key_block', rx: /-----BEGIN ((?:[A-Z ]+ )?PRIVATE KEY)-----(?:(?:[ \t\r\n]|\\[rn])*(?:Proc-Type|DEK-Info):[^\r\n\\]*(?:\r?\n|\\[rn]|$))*(?:[A-Za-z0-9+/=\r\n \t]|\\[rn])*(?:-----END \1-----)?/gd, group: false },
12
12
  { name: 'jwt', rx: /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/gd, group: false },
13
13
  { name: 'db_conn_string', rx: /\b(?:postgres(?:ql)?|mysql|mongodb(?:\+srv)?|redis|amqp):\/\/[^:\s/@]+:[^@\s/]+@[^\s'"]+/gd, group: false },
14
14
  { name: 'bearer_token', rx: /\bbearer\s+([A-Za-z0-9._-]{20,})/gid, group: true },
@@ -41,17 +41,13 @@ function fingerprint(secret, detector) {
41
41
  const tail = last4 ? '••••' + last4 : '••••';
42
42
  return `${detector}:${tail}:#${h}`;
43
43
  }
44
- function snippet(text, span, fp) {
45
- const [s, e] = span;
46
- const redacted = text.slice(0, s) + '«' + fp + '»' + text.slice(e);
47
- const marker = '«' + fp + '»';
48
- const i = redacted.indexOf(marker);
49
- const start = Math.max(0, i - 36);
50
- const end = Math.min(redacted.length, i + marker.length + 36);
51
- let out = redacted.slice(start, end).replace(/\s+/g, ' ').trim();
44
+ function snippet(text, offset, fp) {
45
+ const start = Math.max(0, offset - 36);
46
+ const end = Math.min(text.length, offset + fp.length + 2 + 36);
47
+ let out = text.slice(start, end).replace(/\s+/g, ' ').trim();
52
48
  if (start > 0)
53
49
  out = '…' + out;
54
- if (end < redacted.length)
50
+ if (end < text.length)
55
51
  out = out + '…';
56
52
  return out.slice(0, 160);
57
53
  }
@@ -104,8 +100,12 @@ function scanText(text) {
104
100
  hits.sort((a, b) => a[0] - b[0] || a[1] - b[1]); // stable: earlier detector wins an equal span
105
101
  const kept = [];
106
102
  for (const r of hits) {
107
- if (kept.some((k) => !(r[1] <= k[0] || r[0] >= k[1])))
103
+ const previous = kept.at(-1);
104
+ if (previous && r[0] < previous[1]) {
105
+ previous[1] = Math.max(previous[1], r[1]);
106
+ previous[3] = text.slice(previous[0], previous[1]);
108
107
  continue;
108
+ }
109
109
  kept.push(r);
110
110
  }
111
111
  return kept;
@@ -178,10 +178,20 @@ export function scanEvent(event, opts = {}) {
178
178
  if (!text || text.length > 1_000_000)
179
179
  continue;
180
180
  const side = surface.startsWith('input') ? 'input' : 'output';
181
- for (const [start, end, detector, secret] of scanText(text)) {
182
- const fp = fingerprint(secret, detector);
181
+ let redacted = '';
182
+ let cursor = 0;
183
+ const hits = scanText(text).map(([start, end, detector, secret]) => {
184
+ const fp = fingerprint(secret.trimEnd(), detector);
185
+ redacted += text.slice(cursor, start);
186
+ const offset = redacted.length;
187
+ redacted += `«${fp}»`;
188
+ cursor = end;
189
+ return { detector, fp, offset };
190
+ });
191
+ redacted += text.slice(cursor);
192
+ for (const { detector, fp, offset } of hits) {
183
193
  const [kind, sev, sink, note] = classify(tool, side, command, filePath, secretFileTarget);
184
- add(kind, sev, detector, `${tool}:${surface}`, sink, fp, snippet(text, [start, end], fp), note);
194
+ add(kind, sev, detector, `${tool}:${surface}`, sink, fp, snippet(redacted, offset, fp), note);
185
195
  }
186
196
  }
187
197
  return findings;
@@ -91,7 +91,7 @@ export function validateManifest(m) {
91
91
  // platform's own check (provisioning/templateManifest.ts) so an author hears it here.
92
92
  if (type && MANAGED_TYPES.includes(type)) {
93
93
  const bare = svc;
94
- for (const field of ['image', 'build', 'port', 'healthcheck', 'volume', 'volumeGib', 'spec', 'alwaysOn']) {
94
+ for (const field of ['image', 'build', 'port', 'healthcheck', 'volume', 'volumeGib', 'spec', 'alwaysOn', 'command', 'mountPath']) {
95
95
  if (bare[field] !== undefined) {
96
96
  problems.push(`${where}.${field}: a ${type} service is platform-managed and carries no ${field} — declare it bare ({ type: ${type} })`);
97
97
  }
@@ -151,6 +151,16 @@ export function validateManifest(m) {
151
151
  if (authored.spec !== undefined) {
152
152
  problems.push(`${where}: compute size is the platform's to choose — remove spec`);
153
153
  }
154
+ // Validate runtime fields: command and mountPath
155
+ if (svc.command !== undefined && (typeof svc.command !== 'string' || !svc.command.trim())) {
156
+ problems.push(`${where}.command must be a non-empty string`);
157
+ }
158
+ if (svc.mountPath !== undefined) {
159
+ if (svc.volume !== true)
160
+ problems.push(`${where}.mountPath requires volume: true`);
161
+ else if (typeof svc.mountPath !== 'string' || !svc.mountPath.startsWith('/'))
162
+ problems.push(`${where}.mountPath must be an absolute path`);
163
+ }
154
164
  const env = svc.env ?? {};
155
165
  for (const group of ['fixed', 'generated', 'required', 'optional']) {
156
166
  for (const varName of Object.keys(env[group] ?? {})) {
package/dist/util.js CHANGED
@@ -1,5 +1,6 @@
1
1
  // Output + small pure helpers (env serialization is unit-tested).
2
2
  import { createInterface } from 'node:readline';
3
+ import { Writable } from 'node:stream';
3
4
  import { spawn } from 'node:child_process';
4
5
  import { chmodSync, copyFileSync, existsSync, lstatSync, readlinkSync, realpathSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
5
6
  import { basename, dirname, join, resolve } from 'node:path';
@@ -225,7 +226,7 @@ const targetGroup = (a) => (a.target === 'db' ? 'postgres' : String(a.target ??
225
226
  const OP_COMMAND = {
226
227
  'service.add': (a) => `insta service add ${a.type ?? '<type>'} ${a.name ?? '<name>'}`,
227
228
  deploy: (a) => `insta deploy${a.branch ? ` --branch ${a.branch}` : ''}`,
228
- 'secrets.set': (a) => `insta secrets set ${a.name ?? '<NAME>'} ${a.value ?? '<value>'}`,
229
+ 'secrets.set': (a) => `insta secrets set ${a.name ?? '<NAME>'} <value>`,
229
230
  metrics: (a) => `insta ${targetGroup(a)} metrics`,
230
231
  logs: (a) => `insta ${targetGroup(a)} logs`,
231
232
  'approvals.approve': (a) => `insta agent approvals approve ${a.approvalId ?? '<id>'}`,
@@ -253,17 +254,39 @@ export function serializeEnv(bundle) {
253
254
  .map(([k, v]) => `${k}="${v.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`)
254
255
  .join('\n') + '\n');
255
256
  }
256
- // Hidden password prompt (best-effort: mutes echo on a TTY).
257
- export function promptPassword(label = 'Password: ') {
258
- return new Promise((resolve) => {
259
- const rl = createInterface({ input: process.stdin, output: process.stdout });
260
- const stdout = process.stdout;
261
- process.stdout.write(label);
262
- let captured = '';
263
- stdout._writeToOutput = (s) => { if (s.includes('\n'))
264
- process.stdout.write('\n'); };
265
- rl.on('line', (line) => { captured = line; rl.close(); });
266
- rl.on('close', () => resolve(captured));
257
+ export function mutePasswordOutput(real) {
258
+ return new Writable({
259
+ write(chunk, _encoding, callback) {
260
+ const text = Buffer.isBuffer(chunk) ? chunk.toString('utf8') : String(chunk);
261
+ const endings = text.replace(/[^\r\n]/g, '');
262
+ if (endings.length)
263
+ real.write(endings);
264
+ callback();
265
+ },
266
+ });
267
+ }
268
+ export function promptPasswordFrom(input, output, label = 'Password: ') {
269
+ return new Promise((resolve, reject) => {
270
+ let value = '';
271
+ const rl = createInterface({
272
+ input,
273
+ output: mutePasswordOutput(output),
274
+ // TTY stdin needs raw mode even when stdout is redirected, or the terminal echoes secrets.
275
+ terminal: Boolean(input.isTTY),
276
+ });
277
+ output.write(label);
278
+ rl.on('SIGINT', () => {
279
+ reject(new CliCancel());
280
+ rl.close();
281
+ });
282
+ rl.on('line', (line) => {
283
+ value = line;
284
+ rl.close();
285
+ });
286
+ rl.on('close', () => resolve(value));
267
287
  });
268
288
  }
289
+ export function promptPassword(label = 'Password: ') {
290
+ return promptPasswordFrom(process.stdin, process.stdout, label);
291
+ }
269
292
  //# sourceMappingURL=util.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "insta",
3
- "version": "0.1.15",
3
+ "version": "0.1.17",
4
4
  "type": "module",
5
5
  "description": "InstaCloud CLI — a thin client of the platform control-plane API.",
6
6
  "keywords": [
@@ -52,6 +52,6 @@
52
52
  "@types/node": "^20.14.0",
53
53
  "tsx": "^4.16.0",
54
54
  "typescript": "^5.5.0",
55
- "vitest": "^2.0.0"
55
+ "vitest": "^4.1.11"
56
56
  }
57
57
  }