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 +4 -1
- package/dist/commands/billing.js +28 -11
- package/dist/commands/compute.js +53 -20
- package/dist/commands/domain.js +23 -1
- package/dist/commands/ssh-config.js +2 -4
- package/dist/commands/template.js +2 -1
- package/dist/config.js +4 -3
- package/dist/index.js +4 -1
- package/dist/template-manifest.js +11 -1
- package/dist/util.js +1 -1
- package/package.json +2 -2
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
|
|
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
|
package/dist/commands/billing.js
CHANGED
|
@@ -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.
|
|
36
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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 —
|
|
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);
|
package/dist/commands/compute.js
CHANGED
|
@@ -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 === '
|
|
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
|
|
1479
|
-
*
|
|
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(
|
|
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
|
-
|
|
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
|
package/dist/commands/domain.js
CHANGED
|
@@ -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 =
|
|
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
|
-
//
|
|
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
|
|
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 ?
|
|
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
|
-
|
|
262
|
-
|
|
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
|
|
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>'}
|
|
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.
|
|
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": "^
|
|
55
|
+
"vitest": "^4.1.11"
|
|
56
56
|
}
|
|
57
57
|
}
|