insta 0.1.16 → 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/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
@@ -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)
@@ -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
@@ -257,9 +257,10 @@ async function readLinkPlane(root) {
257
257
  }
258
258
  }
259
259
  /** Text safe to echo to a terminal: C0 and C1 control characters and DEL removed — ESC and the
260
- * single-byte C1 introducers (U+009B CSI among them) alike, so no escape sequence survives. */
261
- function safeText(text) {
262
- 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, '');
263
264
  }
264
265
  /** A control-plane URL safe to persist and to print: control characters removed, surrounding
265
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)));
@@ -555,7 +558,7 @@ bill.command('subscribe <tier>').description('Subscribe the org to a paid tier (
555
558
  bill.command('redeem <code>').description("Redeem a one-time credit code into the org's wallet")
556
559
  .option('--org <id>').option('--json')
557
560
  .action(guard((code, o) => billingRedeem(code, o)));
558
- 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)')
559
562
  .option('--org <id>').option('--no-open', 'print the URL instead of opening a browser').option('--json')
560
563
  .action(guard((o) => billingPortal(o)));
561
564
  bill.command('usage').description('Usage for the current billing cycle by billing dimension (org by default; --proj for one project)')
@@ -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
@@ -226,7 +226,7 @@ const targetGroup = (a) => (a.target === 'db' ? 'postgres' : String(a.target ??
226
226
  const OP_COMMAND = {
227
227
  'service.add': (a) => `insta service add ${a.type ?? '<type>'} ${a.name ?? '<name>'}`,
228
228
  deploy: (a) => `insta deploy${a.branch ? ` --branch ${a.branch}` : ''}`,
229
- 'secrets.set': (a) => `insta secrets set ${a.name ?? '<NAME>'} ${a.value ?? '<value>'}`,
229
+ 'secrets.set': (a) => `insta secrets set ${a.name ?? '<NAME>'} <value>`,
230
230
  metrics: (a) => `insta ${targetGroup(a)} metrics`,
231
231
  logs: (a) => `insta ${targetGroup(a)} logs`,
232
232
  'approvals.approve': (a) => `insta agent approvals approve ${a.approvalId ?? '<id>'}`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "insta",
3
- "version": "0.1.16",
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
  }