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 +1 -1
- package/dist/agent.js +4 -1
- package/dist/commands/auth.js +3 -3
- package/dist/commands/billing.js +28 -11
- package/dist/commands/compute.js +53 -20
- package/dist/commands/domain.js +23 -1
- package/dist/commands/mcp.js +15 -1
- package/dist/commands/postgres.js +23 -0
- package/dist/commands/ssh-config.js +2 -4
- package/dist/commands/template.js +2 -1
- package/dist/config.js +10 -7
- package/dist/index.js +9 -3
- package/dist/observe/hook.js +9 -3
- package/dist/observe/scanner.js +24 -14
- package/dist/template-manifest.js +11 -1
- package/dist/util.js +35 -12
- package/package.json +2 -2
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
|
|
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/auth.js
CHANGED
|
@@ -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('&', '&').replaceAll('<', '<').replaceAll('>', '>')})` : '')}</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
|
-
|
|
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
|
});
|
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)
|
package/dist/commands/mcp.js
CHANGED
|
@@ -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 =
|
|
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
|
-
//
|
|
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
|
@@ -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
|
-
|
|
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
|
-
|
|
260
|
-
|
|
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);
|
|
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
|
|
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)')
|
package/dist/observe/hook.js
CHANGED
|
@@ -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
|
-
|
|
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() {
|
package/dist/observe/scanner.js
CHANGED
|
@@ -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
|
|
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,
|
|
45
|
-
const
|
|
46
|
-
const
|
|
47
|
-
|
|
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 <
|
|
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
|
-
|
|
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
|
-
|
|
182
|
-
|
|
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(
|
|
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>'}
|
|
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
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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.
|
|
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
|
}
|