insta 0.0.77 → 0.0.79
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/api.js +4 -5
- package/dist/commands/auth.js +1 -1
- package/dist/commands/billing.js +2 -4
- package/dist/commands/compute.js +105 -49
- package/dist/commands/db.js +6 -9
- package/dist/commands/deploy.js +4 -4
- package/dist/commands/feedback.js +2 -2
- package/dist/commands/manifest.js +1 -2
- package/dist/commands/metrics.js +1 -2
- package/dist/commands/secrets.js +59 -15
- package/dist/commands/services.js +2 -3
- package/dist/commands/setup.js +4 -4
- package/dist/commands/ssh-config.js +61 -31
- package/dist/commands/template.js +1 -1
- package/dist/commands/upgrade.js +3 -4
- package/dist/config.js +5 -6
- package/dist/flyctl-build.js +2 -2
- package/dist/github-source.js +5 -6
- package/dist/index.js +2 -6
- package/dist/observe/hook.js +1 -1
- package/dist/observe/install.js +1 -1
- package/dist/pack-ignore.js +2 -2
- package/dist/resolve-project.js +2 -2
- package/dist/template-manifest.js +1 -1
- package/dist/util.js +7 -9
- package/package.json +1 -1
package/dist/api.js
CHANGED
|
@@ -67,11 +67,9 @@ export class ApiClient {
|
|
|
67
67
|
}
|
|
68
68
|
// Returns parsed body for status < 400 (incl. 202); throws ApiError otherwise.
|
|
69
69
|
async request(method, path, body, opts = {}) {
|
|
70
|
-
const res = await this.
|
|
70
|
+
const res = await this.rawRequest(method, path, body, opts);
|
|
71
71
|
if (agentMode() && res.status === 202 && res.body?.status === 'approval_required')
|
|
72
72
|
throw new AgentApprovalRequired(res.body);
|
|
73
|
-
if (res.status >= 400)
|
|
74
|
-
throw new ApiError(res.status, res.body?.error ?? `HTTP ${res.status}`, res.body);
|
|
75
73
|
return res.body;
|
|
76
74
|
}
|
|
77
75
|
// Like request but returns {status, body} so callers can branch on 202 (approval_required).
|
|
@@ -93,12 +91,13 @@ export class ApiClient {
|
|
|
93
91
|
const headers = { 'Content-Type': 'application/json', 'Insta-Hints': '1', 'User-Agent': USER_AGENT };
|
|
94
92
|
if (auth && this.cfg.accessToken)
|
|
95
93
|
headers.Authorization = `Bearer ${this.cfg.accessToken}`;
|
|
94
|
+
const payload = body === undefined ? undefined : JSON.stringify(body);
|
|
96
95
|
if (auth && scope.evidence !== false)
|
|
97
|
-
Object.assign(headers, await agentHeaders(this, method, path,
|
|
96
|
+
Object.assign(headers, await agentHeaders(this, method, path, payload ?? '', scope));
|
|
98
97
|
const res = await this.fetchImpl(this.apiUrl + path, {
|
|
99
98
|
method,
|
|
100
99
|
headers,
|
|
101
|
-
body:
|
|
100
|
+
body: payload,
|
|
102
101
|
signal: scope.signal,
|
|
103
102
|
});
|
|
104
103
|
const text = await res.text();
|
package/dist/commands/auth.js
CHANGED
|
@@ -193,7 +193,7 @@ export async function deviceGrant(post, wait = sleepSeconds, open) {
|
|
|
193
193
|
continue;
|
|
194
194
|
} // RFC 8628 §3.5: back off by 5s
|
|
195
195
|
// The platform's per-IP limiter answers a bare HTTP 429 (no OAuth error code) when a poll
|
|
196
|
-
// trips it
|
|
196
|
+
// trips it. That is the same
|
|
197
197
|
// instruction as slow_down: the code is still pending, so back off and keep waiting rather
|
|
198
198
|
// than abort a login the human may be one click away from approving.
|
|
199
199
|
if (e.status === 429) {
|
package/dist/commands/billing.js
CHANGED
|
@@ -85,10 +85,8 @@ export async function billing(opts) {
|
|
|
85
85
|
}
|
|
86
86
|
// insta billing upgrade <tier> — start a Stripe Checkout to subscribe the org to a paid tier.
|
|
87
87
|
export async function billingUpgrade(tier, opts) {
|
|
88
|
-
// pro|team, matching what POST /orgs/:orgId/billing/checkout
|
|
89
|
-
//
|
|
90
|
-
// here, and `enterprise` is per-deal and 400s at the server. The suspension hint above now names
|
|
91
|
-
// the org's own tier, so a Team org was being sent to a command that rejected it.
|
|
88
|
+
// pro|team, matching what POST /orgs/:orgId/billing/checkout accepts: `team` is a real
|
|
89
|
+
// self-serve tier, and `enterprise` is per-deal and 400s at the server.
|
|
92
90
|
if (tier !== 'pro' && tier !== 'team')
|
|
93
91
|
die('tier must be pro|team');
|
|
94
92
|
const api = await ApiClient.load();
|
package/dist/commands/compute.js
CHANGED
|
@@ -64,8 +64,7 @@ export function domainGuidanceLines(r, ctx = {}) {
|
|
|
64
64
|
// Whether this provider reports an edge routing target AT ALL. The compute plane's domain view
|
|
65
65
|
// always carries an `ssl` status, so a plane answer without `origin` is a daemon too old to report
|
|
66
66
|
// where the hostname resolves — we cannot confirm routing, and must not call it serving. A Fly
|
|
67
|
-
// answer carries no `ssl` and has no per-hostname origin concept, so its own verdict stands
|
|
68
|
-
// (r2d2 round 1 Critical: "no target reported" was previously treated as ready for both).
|
|
67
|
+
// answer carries no `ssl` and has no per-hostname origin concept, so its own verdict stands.
|
|
69
68
|
const reportsOrigin = (r) => r.ssl !== undefined;
|
|
70
69
|
// Where the hostname actually resolves — the region-specific origin the plane requested vs what
|
|
71
70
|
// Cloudflare holds. Each shape carries its action; absent fields are reported as absent.
|
|
@@ -116,9 +115,8 @@ export function domainStatusLines(r, ctx = {}) {
|
|
|
116
115
|
// A record is settled only when the platform says `ok` — or, for a provider that reports no
|
|
117
116
|
// per-record status at all (Fly), when it vouched for the whole set with `configured`. missing,
|
|
118
117
|
// mismatch and never-checked are each outstanding and each add a blocker. Applying this to only
|
|
119
|
-
//
|
|
120
|
-
//
|
|
121
|
-
// still-pending validation record, could ride under a `serving https://…` line.
|
|
118
|
+
// some records lets an apex whose AAAA is missing, or a still-pending validation record, ride
|
|
119
|
+
// under a `serving https://…` line.
|
|
122
120
|
const verdictOf = (d) => d.status ?? (r.configured ? 'ok' : 'unchecked');
|
|
123
121
|
if (txt) {
|
|
124
122
|
const st = verdictOf(txt);
|
|
@@ -139,7 +137,7 @@ export function domainStatusLines(r, ctx = {}) {
|
|
|
139
137
|
}
|
|
140
138
|
else if (reportsOrigin(r)) {
|
|
141
139
|
// The stage is drawn even with no record to draw it from: an omitted stage reads as "not
|
|
142
|
-
// required", when in fact the platform told us nothing to publish
|
|
140
|
+
// required", when in fact the platform told us nothing to publish. Only the plane
|
|
143
141
|
// proves ownership by TXT, so only a plane answer missing one is a problem.
|
|
144
142
|
stage('ownership', 'unknown', 'the platform returned no ownership TXT for this domain — nothing to publish yet; ask an operator');
|
|
145
143
|
blockers.push('no ownership TXT from the platform');
|
|
@@ -148,10 +146,10 @@ export function domainStatusLines(r, ctx = {}) {
|
|
|
148
146
|
stage('ownership', 'n/a', '(this provider does not use an ownership TXT)');
|
|
149
147
|
}
|
|
150
148
|
if (routing.length === 0) {
|
|
151
|
-
// No routing record for the hostname — whatever ELSE came back.
|
|
152
|
-
//
|
|
153
|
-
//
|
|
154
|
-
//
|
|
149
|
+
// No routing record for the hostname — whatever ELSE came back. Keyed on an entirely empty
|
|
150
|
+
// record set, a payload carrying only the ownership TXT skips the stage and adds no blocker,
|
|
151
|
+
// so a `configured: true` answer with a live cert and a confirmed origin prints `serving`
|
|
152
|
+
// for a hostname with nothing pointing at us.
|
|
155
153
|
stage('cname', 'unknown', 'the platform returned no routing record for this domain — nothing to publish yet; ask an operator');
|
|
156
154
|
blockers.push('no routing record from the platform');
|
|
157
155
|
}
|
|
@@ -213,7 +211,7 @@ export function domainStatusLines(r, ctx = {}) {
|
|
|
213
211
|
}
|
|
214
212
|
const resolve = domainResolveLine(r);
|
|
215
213
|
out.push(resolve.line);
|
|
216
|
-
// An error STATE is a blocker whether or not the plane sent a reason with it
|
|
214
|
+
// An error STATE is a blocker whether or not the plane sent a reason with it: a row
|
|
217
215
|
// that says `error` has not been observed serving, and saying otherwise is the blackhole lie.
|
|
218
216
|
if (r.status === 'error') {
|
|
219
217
|
stage('error', r.status, r.errorReason || '(the plane reported an error state with no reason)');
|
|
@@ -230,7 +228,7 @@ export function domainStatusLines(r, ctx = {}) {
|
|
|
230
228
|
// `serving` is claimed only when every stage above agreed: the provider says configured, the
|
|
231
229
|
// routing target is confirmed, and NOTHING is outstanding. A blocker beside a `configured: true`
|
|
232
230
|
// answer means the record set and the verdict disagree — report the disagreement, never paper
|
|
233
|
-
// over it with a URL the user would then trust
|
|
231
|
+
// over it with a URL the user would then trust.
|
|
234
232
|
if (r.configured && blockers.length === 0)
|
|
235
233
|
stage('serving', `https://${r.hostname}`, '');
|
|
236
234
|
else
|
|
@@ -249,7 +247,7 @@ export function domainConflictMessage(host, e, services, ctx = {}) {
|
|
|
249
247
|
const owner = m?.[1] && m[1] !== 'another' ? m[1] : undefined;
|
|
250
248
|
const region = m?.[2];
|
|
251
249
|
// The release command must name the OWNER's group, and the branch the user is working on — a
|
|
252
|
-
// command that defaults back to the linked branch would release nothing
|
|
250
|
+
// command that defaults back to the linked branch would release nothing.
|
|
253
251
|
const release = (group) => `insta compute remove-domain ${host}${flags({ group, branch: ctx.branch })}`;
|
|
254
252
|
if (owner) {
|
|
255
253
|
const here = services.find((s) => s.type === 'compute' && s.name === owner);
|
|
@@ -379,14 +377,6 @@ export async function computeStatus(serviceName, opts) {
|
|
|
379
377
|
info(`compute ${serviceName ?? id}: desired=${r.desiredState} live=${r.state}`);
|
|
380
378
|
}
|
|
381
379
|
// ---- exec (one-shot command; no interactive shell/PTY) ----
|
|
382
|
-
// `insta compute exec [service] -- <command> [args…]`: the command must reach the platform
|
|
383
|
-
// byte-for-byte and can itself contain dashes or another `--`, so it can't be a normal commander
|
|
384
|
-
// positional — with `service` optional, commander flattens everything past the literal `--` into
|
|
385
|
-
// one operand list and has no way to tell "no service, command starts here" apart from "service IS
|
|
386
|
-
// the first command token". Splitting argv on the first literal `--` after `compute exec`
|
|
387
|
-
// ourselves, before commander ever parses it, removes the ambiguity; this is the only place in the
|
|
388
|
-
// whole CLI a bare `--` has this meaning, so nothing else is affected. Exported for a direct,
|
|
389
|
-
// network-free unit test — this split is the seam most likely to regress.
|
|
390
380
|
/** The options `insta compute exec` declares — the one source of truth. index.ts builds the
|
|
391
381
|
* commander command from this list, and the payload scan below uses it to know where the CLI's
|
|
392
382
|
* own arguments stop. Adding an option here reaches both. */
|
|
@@ -650,9 +640,9 @@ export function volumeWriteLine(name, body) {
|
|
|
650
640
|
export function volumeDeleteLine(name) {
|
|
651
641
|
return `compute ${name}: volume deleted — the disk and its data are gone; suspend fast-wake and scale-out are back`;
|
|
652
642
|
}
|
|
653
|
-
// Map a DELETE .../volume failure. Pure, exported for tests
|
|
654
|
-
//
|
|
655
|
-
//
|
|
643
|
+
// Map a DELETE .../volume failure. Pure, exported for tests. An older backend has no DELETE
|
|
644
|
+
// route, and what its 404 looks like depends on who answered: the real platform (Fastify, no
|
|
645
|
+
// custom notFound handler) sends its
|
|
656
646
|
// default body {"message":"Route DELETE:/… not found","error":"Not Found"} → ApiError message
|
|
657
647
|
// "Not Found"; a proxy or bodyless 404 leaves ApiError's own "HTTP 404" fallback. BOTH are the
|
|
658
648
|
// generic route-miss shape and mean version skew, not a bug — parroting them would send the user
|
|
@@ -750,7 +740,7 @@ import { homedir } from 'node:os';
|
|
|
750
740
|
import { dirname, join } from 'node:path';
|
|
751
741
|
import { execFileSync } from 'node:child_process';
|
|
752
742
|
import { createHash, randomUUID } from 'node:crypto';
|
|
753
|
-
import { aliasFor, certifiesPublicKey, hasOwnedBlock, isSafeAlias, isSafeConfigValue, isSafeSSHHost, isSafeSSHUsername, isSafeTimestamp, isSSHCertificateRecord, mayWidenCAHost, parseCAPublicKey, planCertAuthority, renderConfigBlock, revertCertAuthority, upsertConfigBlock, } from './ssh-config.js';
|
|
743
|
+
import { aliasFor, certifiesPublicKey, hasOwnedBlock, isSafeAlias, isSafeConfigValue, isSafeSSHHost, isSafeSSHUsername, isSafeTimestamp, isSSHCertificateRecord, mayWidenCAHost, parseCAPublicKey, planCertAuthority, renderConfigBlock, revertCertAuthority, upsertConfigBlock, ownedBlock, ownedBlockIsFirst } from './ssh-config.js';
|
|
754
744
|
/** Where this CLI keeps its own SSH material. Deliberately NOT ~/.ssh: we never
|
|
755
745
|
* touch a key the user already had, and a dedicated key pairs with
|
|
756
746
|
* IdentitiesOnly to avoid being identified by the wrong one. */
|
|
@@ -769,6 +759,15 @@ export const instaAliasStorePath = () => join(instaSSHDir(), 'aliases.json');
|
|
|
769
759
|
export const userKnownHostsPath = () => join(homedir(), '.ssh', 'known_hosts');
|
|
770
760
|
/** The renewal-hook command prefix. ssh-config.ts appends the validated alias. */
|
|
771
761
|
export const ENSURE_CERT_COMMAND = 'insta __ssh-ensure-cert';
|
|
762
|
+
/** The gateway's public port when the plane does not say: :2222, the port the
|
|
763
|
+
* lane shipped on (:22 waits for a compliance exception). The plane's answer
|
|
764
|
+
* in a mint response ALWAYS wins over this; it exists for stores and planes
|
|
765
|
+
* that predate the `port` field, so a working alias does not stop working. */
|
|
766
|
+
export const DEFAULT_SSH_PORT = 2222;
|
|
767
|
+
/** A TCP port as the plane returns it: an integer, 1..65535. */
|
|
768
|
+
export function isValidPort(v) {
|
|
769
|
+
return typeof v === 'number' && Number.isInteger(v) && v >= 1 && v <= 65535;
|
|
770
|
+
}
|
|
772
771
|
/** Never throws: a missing or hand-mangled store must degrade to "nothing is
|
|
773
772
|
* set up", not break `ssh` for every alias. */
|
|
774
773
|
export function readAliasStore(path = instaAliasStorePath()) {
|
|
@@ -800,6 +799,7 @@ export function isValidAliasRecord(r) {
|
|
|
800
799
|
return typeof v.projectId === 'string' && v.projectId !== ''
|
|
801
800
|
&& typeof v.serviceId === 'string' && v.serviceId !== ''
|
|
802
801
|
&& (v.branch === undefined || typeof v.branch === 'string')
|
|
802
|
+
&& (v.port === undefined || isValidPort(v.port))
|
|
803
803
|
&& isSafeConfigValue(v.host) && isSafeConfigValue(v.username);
|
|
804
804
|
}
|
|
805
805
|
/** Refuse to point an existing alias at a different service.
|
|
@@ -839,7 +839,7 @@ export function hostEntries(store) {
|
|
|
839
839
|
// sit where the rendering does.
|
|
840
840
|
.filter(([alias, r]) => isSafeAlias(alias) && isValidAliasRecord(r))
|
|
841
841
|
.sort(([a], [b]) => a.localeCompare(b))
|
|
842
|
-
.map(([alias, r]) => ({ alias, hostName: r.host, user: r.username, certificateFile: instaCertPath(alias) }));
|
|
842
|
+
.map(([alias, r]) => ({ alias, hostName: r.host, user: r.username, port: r.port ?? DEFAULT_SSH_PORT, certificateFile: instaCertPath(alias) }));
|
|
843
843
|
}
|
|
844
844
|
/**
|
|
845
845
|
* When `ssh-keygen -L` output says the certificate stops being valid, or
|
|
@@ -969,6 +969,11 @@ export function validateCertResponse(body) {
|
|
|
969
969
|
// are refused rather than rendered.
|
|
970
970
|
if (!isSafeTimestamp(b.expiresAt))
|
|
971
971
|
throw new Error('the platform returned an unusable certificate expiry');
|
|
972
|
+
// Optional only because planes predating the field exist; PRESENT and wrong
|
|
973
|
+
// is refused, never coerced -- a port we made up is a connection that hangs.
|
|
974
|
+
if (b.port !== undefined && !isValidPort(b.port)) {
|
|
975
|
+
throw new Error(`the platform returned an unusable ssh port: ${JSON.stringify(String(b.port).slice(0, 16))}`);
|
|
976
|
+
}
|
|
972
977
|
// Parsed here rather than at install time so a malformed key fails before
|
|
973
978
|
// anything is written, instead of after the alias is already recorded.
|
|
974
979
|
if (b.caPublicKey !== undefined)
|
|
@@ -992,9 +997,7 @@ const sshKeygenVerifyCert = (certPath) => {
|
|
|
992
997
|
* So the authority is `ssh-keygen -L`, run against a temporary file, and the
|
|
993
998
|
* real file is only replaced once it passes. Spawning it here costs nothing
|
|
994
999
|
* new: certNeedsRenewal already runs the same binary on this same path, every
|
|
995
|
-
* time a certificate exists.
|
|
996
|
-
* that a subprocess did not belong on the renewal path -- that reasoning was
|
|
997
|
-
* simply wrong about what the path already does.)
|
|
1000
|
+
* time a certificate exists.
|
|
998
1001
|
*
|
|
999
1002
|
* A missing ssh-keygen is a REFUSAL, not a pass: it means we cannot confirm,
|
|
1000
1003
|
* and an unconfirmable certificate must not displace one that works. Nothing
|
|
@@ -1235,6 +1238,40 @@ function withLockedFile(name, fn, { waitMs, staleMs, sleep, busy }) {
|
|
|
1235
1238
|
sleep(LOCK_POLL_MS);
|
|
1236
1239
|
}
|
|
1237
1240
|
}
|
|
1241
|
+
/** The block ssh_config SHOULD carry for this store, rendered the one way. */
|
|
1242
|
+
function renderInstalledBlock(store) {
|
|
1243
|
+
return renderConfigBlock({
|
|
1244
|
+
entries: hostEntries(store),
|
|
1245
|
+
identityFile: instaKeyPath(),
|
|
1246
|
+
knownHostsFile: userKnownHostsPath(),
|
|
1247
|
+
ensureCertCommand: ENSURE_CERT_COMMAND,
|
|
1248
|
+
});
|
|
1249
|
+
}
|
|
1250
|
+
/** Whether the installed block differs from what the store renders -- the ONE
|
|
1251
|
+
* predicate for "rewrite ssh_config". A host move, a port the plane changed,
|
|
1252
|
+
* and a stanza written before the Port line existed all show up here; a
|
|
1253
|
+
* renewal whose response changed nothing does not, which matters because
|
|
1254
|
+
* OpenSSH is reading the file while the hook runs and a rename over an open
|
|
1255
|
+
* file fails on Windows. No installed block = nothing to repair (setup owns
|
|
1256
|
+
* the first write). */
|
|
1257
|
+
function configBlockStale(store) {
|
|
1258
|
+
let existing;
|
|
1259
|
+
try {
|
|
1260
|
+
existing = readFileSync(join(homedir(), '.ssh', 'config'), 'utf8');
|
|
1261
|
+
}
|
|
1262
|
+
catch {
|
|
1263
|
+
return false;
|
|
1264
|
+
}
|
|
1265
|
+
const installed = ownedBlock(existing);
|
|
1266
|
+
if (installed === undefined)
|
|
1267
|
+
return false;
|
|
1268
|
+
// Position is content too: a block that has slid below other configuration
|
|
1269
|
+
// is shadowed keyword by keyword (first obtained value wins), whatever its
|
|
1270
|
+
// text says. The rewrite puts it back where setup wrote it, at the top.
|
|
1271
|
+
if (!ownedBlockIsFirst(existing))
|
|
1272
|
+
return true;
|
|
1273
|
+
return installed.replace(/\n+$/, '') !== renderInstalledBlock(store).replace(/\n+$/, '');
|
|
1274
|
+
}
|
|
1238
1275
|
function installConfigBlock(store) {
|
|
1239
1276
|
const cfg = join(homedir(), '.ssh', 'config');
|
|
1240
1277
|
mkdirSync(dirname(cfg), { recursive: true, mode: 0o700 });
|
|
@@ -1246,12 +1283,7 @@ function installConfigBlock(store) {
|
|
|
1246
1283
|
// target says.
|
|
1247
1284
|
const existedBefore = pathExists(cfg);
|
|
1248
1285
|
const existing = readUserText(cfg);
|
|
1249
|
-
const block =
|
|
1250
|
-
entries: hostEntries(store),
|
|
1251
|
-
identityFile: instaKeyPath(),
|
|
1252
|
-
knownHostsFile: userKnownHostsPath(),
|
|
1253
|
-
ensureCertCommand: ENSURE_CERT_COMMAND,
|
|
1254
|
-
});
|
|
1286
|
+
const block = renderInstalledBlock(store);
|
|
1255
1287
|
// Backed up: this is the file that decides whether the user can ssh anywhere
|
|
1256
1288
|
// at all, and our block goes at the TOP of it.
|
|
1257
1289
|
writeFileAtomicSync(cfg, upsertConfigBlock(existing, block), { mode: 0o600, backup: true });
|
|
@@ -1370,6 +1402,20 @@ export async function ensureCertForAlias(alias, timeoutMs = RENEWAL_REQUEST_TIME
|
|
|
1370
1402
|
try {
|
|
1371
1403
|
if (!isSafeAlias(alias))
|
|
1372
1404
|
return;
|
|
1405
|
+
// Repair BEFORE the renewal gate, because the stanza can lag the store
|
|
1406
|
+
// with no certificate due: an alias set up before the Port line existed
|
|
1407
|
+
// dials :22 on every connection, and waiting for its certificate to age
|
|
1408
|
+
// would leave it failing for up to the certificate's lifetime. The
|
|
1409
|
+
// predicate is content drift, so the common case -- an up-to-date stanza --
|
|
1410
|
+
// is one file read and a string compare, with no lock taken; only a stale
|
|
1411
|
+
// stanza takes the alias-store lock, re-checks under it and rewrites.
|
|
1412
|
+
if (configBlockStale(readAliasStore())) {
|
|
1413
|
+
withAliasStoreLock(() => {
|
|
1414
|
+
const store = readAliasStore();
|
|
1415
|
+
if (store[alias] && configBlockStale(store))
|
|
1416
|
+
installConfigBlock(store);
|
|
1417
|
+
}, { waitMs: KNOWN_HOSTS_LOCK_WAIT_MS });
|
|
1418
|
+
}
|
|
1373
1419
|
if (!certNeedsRenewal(instaCertPath(alias)))
|
|
1374
1420
|
return;
|
|
1375
1421
|
// An IDE opens several connections at once and `scp` adds more, so the
|
|
@@ -1433,9 +1479,16 @@ export async function ensureCertForAlias(alias, timeoutMs = RENEWAL_REQUEST_TIME
|
|
|
1433
1479
|
return;
|
|
1434
1480
|
if (!certNeedsRenewal(instaCertPath(alias)))
|
|
1435
1481
|
return;
|
|
1482
|
+
// The plane's answer wins, on every field it answers. `moved` is the
|
|
1483
|
+
// host/user half and keeps its CA requirement below (a new host needs
|
|
1484
|
+
// an anchor); the port has no such requirement but is part of the
|
|
1485
|
+
// record all the same, and a legacy record without one gains it here
|
|
1486
|
+
// so the stanza can carry the line.
|
|
1487
|
+
const port = out.port ?? held.port ?? DEFAULT_SSH_PORT;
|
|
1436
1488
|
const moved = held.host !== out.host || held.username !== out.username;
|
|
1437
|
-
const
|
|
1438
|
-
|
|
1489
|
+
const recordChanged = moved || held.port !== port;
|
|
1490
|
+
const store = recordChanged
|
|
1491
|
+
? { ...before, [alias]: { ...held, host: out.host, username: out.username, port } }
|
|
1439
1492
|
: before;
|
|
1440
1493
|
const installed = configBlockInstalled();
|
|
1441
1494
|
const ca = out.caPublicKey;
|
|
@@ -1463,11 +1516,13 @@ export async function ensureCertForAlias(alias, timeoutMs = RENEWAL_REQUEST_TIME
|
|
|
1463
1516
|
// host into a renewal that gives up -- one failed renewal and a
|
|
1464
1517
|
// `--setup` to repair it, never a half-moved alias.
|
|
1465
1518
|
const steps = [];
|
|
1466
|
-
if (
|
|
1519
|
+
if (recordChanged)
|
|
1467
1520
|
steps.push(() => { const back = snapshotForUndo(instaAliasStorePath()); writeAliasStore(store); return back; });
|
|
1468
1521
|
if (ca !== undefined)
|
|
1469
1522
|
steps.push(() => installCertAuthority(hostPatternFor(out.host), ca));
|
|
1470
|
-
|
|
1523
|
+
// Drift, not "moved": a changed port rewrites the stanza; an unchanged
|
|
1524
|
+
// response leaves the file alone (see configBlockStale).
|
|
1525
|
+
if (installed && configBlockStale(store))
|
|
1471
1526
|
steps.push(() => installConfigBlock(store));
|
|
1472
1527
|
steps.push(() => out.staged.commit());
|
|
1473
1528
|
// ONE section from reading the anchor to committing the certificate
|
|
@@ -1653,11 +1708,10 @@ function processAlive(pid) {
|
|
|
1653
1708
|
* move or remove the lock, so there is no window in which the file is
|
|
1654
1709
|
* missing for something to slip in through.
|
|
1655
1710
|
*
|
|
1656
|
-
* And the takeover NEVER UNLINKS THE PATH
|
|
1657
|
-
*
|
|
1658
|
-
*
|
|
1659
|
-
*
|
|
1660
|
-
* Here the breaker writes its token INTO the inode the two names share.
|
|
1711
|
+
* And the takeover NEVER UNLINKS THE PATH: an unlink-then-create is not tied
|
|
1712
|
+
* to the inode that was verified, so a holder releasing in between lets a
|
|
1713
|
+
* third process create a fresh lock at the path, which the breaker then
|
|
1714
|
+
* deletes. Here the breaker writes its token INTO the inode the two names share.
|
|
1661
1715
|
* `path` keeps its inode throughout, so there is no moment at which the lock
|
|
1662
1716
|
* at `path` can have become somebody else's between the check and the act:
|
|
1663
1717
|
* the holder judged dead cannot release, every other breaker is behind the
|
|
@@ -1752,7 +1806,7 @@ export async function computeSSH(serviceName, opts, deps = {}) {
|
|
|
1752
1806
|
assertAliasFree(before, alias, { projectId: p.projectId, serviceId: svc.id, branch });
|
|
1753
1807
|
const store = {
|
|
1754
1808
|
...before,
|
|
1755
|
-
[alias]: { projectId: p.projectId, ...(branch ? { branch } : {}), serviceId: svc.id, host: out.host, username: out.username },
|
|
1809
|
+
[alias]: { projectId: p.projectId, ...(branch ? { branch } : {}), serviceId: svc.id, host: out.host, username: out.username, port: out.port ?? DEFAULT_SSH_PORT },
|
|
1756
1810
|
};
|
|
1757
1811
|
// Whether ~/.ssh is BACKING this store, not merely whether --setup was
|
|
1758
1812
|
// passed. The store and the certificate are rewritten by every issuance,
|
|
@@ -1811,9 +1865,9 @@ export async function computeSSH(serviceName, opts, deps = {}) {
|
|
|
1811
1865
|
out.staged.discard();
|
|
1812
1866
|
}
|
|
1813
1867
|
if (opts.json)
|
|
1814
|
-
return printJson({ alias, host: out.host, username: out.username, expiresAt: out.expiresAt, configured: installed });
|
|
1868
|
+
return printJson({ alias, host: out.host, username: out.username, port: out.port ?? DEFAULT_SSH_PORT, expiresAt: out.expiresAt, configured: installed });
|
|
1815
1869
|
for (const line of sshAdvice({
|
|
1816
|
-
alias, host: out.host, username: out.username, expiresAt: out.expiresAt, serviceName: svc.name,
|
|
1870
|
+
alias, host: out.host, username: out.username, port: out.port ?? DEFAULT_SSH_PORT, expiresAt: out.expiresAt, serviceName: svc.name,
|
|
1817
1871
|
configured: installed, identityFile: instaKeyPath(), certificateFile: instaCertPath(alias),
|
|
1818
1872
|
}))
|
|
1819
1873
|
emit(line);
|
|
@@ -1830,7 +1884,7 @@ export async function computeSSH(serviceName, opts, deps = {}) {
|
|
|
1830
1884
|
*/
|
|
1831
1885
|
export function sshAdvice(r) {
|
|
1832
1886
|
const head = r.configured
|
|
1833
|
-
? [`ssh ${r.alias} → ${r.username}@${r.host}`]
|
|
1887
|
+
? [`ssh ${r.alias} → ${r.username}@${r.host} (port ${r.port})`]
|
|
1834
1888
|
: [
|
|
1835
1889
|
// Every option here is load-bearing. The key lives at
|
|
1836
1890
|
// ~/.insta/ssh/id_ed25519 and the certificate at <alias>-cert.pub;
|
|
@@ -1839,7 +1893,9 @@ export function sshAdvice(r) {
|
|
|
1839
1893
|
// this command just issued -- it fails, having printed success.
|
|
1840
1894
|
// IdentitiesOnly stops a loaded agent from spending the server's
|
|
1841
1895
|
// MaxAuthTries on unrelated keys before ours is ever tried.
|
|
1842
|
-
|
|
1896
|
+
// -p is not optional: the gateway is on :2222 and :22 is closed, so a
|
|
1897
|
+
// pasted command without it fails before the credential is ever tried.
|
|
1898
|
+
`ssh -p ${r.port} -i ${shQuote(r.identityFile)} -o CertificateFile=${shQuote(r.certificateFile)} -o IdentitiesOnly=yes ${shQuote(`${r.username}@${r.host}`)}`,
|
|
1843
1899
|
` run \`insta compute ssh ${r.serviceName} --setup\` once for the shorter \`ssh ${r.alias}\`, automatic renewal, and scp/-L support`,
|
|
1844
1900
|
];
|
|
1845
1901
|
return [...head, ` certificate valid until ${r.expiresAt}`];
|
package/dist/commands/db.js
CHANGED
|
@@ -6,9 +6,7 @@ import { parseVolumeGib, q, resolveSoleService } from './services.js';
|
|
|
6
6
|
// Toggle a postgres service between scale-to-zero (the default: instance suspends when idle,
|
|
7
7
|
// cold-starts on the next connection) and always-on (instance stays warm; idle RAM bills at
|
|
8
8
|
// actual usage). Thin wrapper over PATCH /database/settings {scaleToZero} — insta-db-backed
|
|
9
|
-
// postgres only.
|
|
10
|
-
// insta-db) and this code is retained, not live — Neon-backed services managed their own
|
|
11
|
-
// autosuspend and the platform returned an error for them.
|
|
9
|
+
// postgres only.
|
|
12
10
|
export async function dbAlwaysOn(mode, opts) {
|
|
13
11
|
if (mode !== 'on' && mode !== 'off')
|
|
14
12
|
throw new Error('mode must be on|off');
|
|
@@ -55,9 +53,8 @@ export async function fetchDbInstance(api, projectId, suffix) {
|
|
|
55
53
|
return { kind: 'ok', body: res.body };
|
|
56
54
|
}
|
|
57
55
|
catch (e) {
|
|
58
|
-
// The platform answers a provider-shaped 502 for services with no manageable instance
|
|
59
|
-
//
|
|
60
|
-
// not live): a soft case, not a failure. Everything else stays an error — an expired
|
|
56
|
+
// The platform answers a provider-shaped 502 for services with no manageable instance:
|
|
57
|
+
// a soft case, not a failure. Everything else stays an error — an expired
|
|
61
58
|
// token must not render as "no ceiling set" — but wrapped so the user sees what failed.
|
|
62
59
|
if (e instanceof ApiError && e.status === 502)
|
|
63
60
|
return { kind: 'no-instance' };
|
|
@@ -121,9 +118,6 @@ export async function dbLimits(opts) {
|
|
|
121
118
|
const mem = typeof res.body?.memoryMib === 'number' ? fmtMib(res.body.memoryMib) : (opts.memory ?? 'unchanged');
|
|
122
119
|
info(`postgres ${opts.group ?? 'default'}: ceiling set to ${cpu} / ${mem}`);
|
|
123
120
|
}
|
|
124
|
-
// Render the instance's volume from a database/instance read. Pure, exported for tests. Reads the
|
|
125
|
-
// CANONICAL volume* names only — storageSize/storageGiB are deprecated aliases the platform drops
|
|
126
|
-
// next release, so depending on them here would be a scheduled breakage.
|
|
127
121
|
// Bytes → human units, one decimal above KiB. Local because the metrics payload is the only
|
|
128
122
|
// bytes-denominated read in this file (fmtMib serves the MiB-denominated resize path).
|
|
129
123
|
export function fmtBytes(n) {
|
|
@@ -185,6 +179,9 @@ export async function dbStats(opts) {
|
|
|
185
179
|
for (const line of dbStatsLines(opts.group ?? 'default', res.body))
|
|
186
180
|
info(line);
|
|
187
181
|
}
|
|
182
|
+
// Render the instance's volume from a database/instance read. Pure, exported for tests. Reads the
|
|
183
|
+
// CANONICAL volume* names only — storageSize/storageGiB are deprecated aliases the platform drops
|
|
184
|
+
// next release, so depending on them here would be a scheduled breakage.
|
|
188
185
|
export function dbVolumeLines(group, body) {
|
|
189
186
|
const gib = typeof body?.volumeGib === 'number' ? `${body.volumeGib}Gi` : (typeof body?.volumeSize === 'string' ? body.volumeSize : undefined);
|
|
190
187
|
if (gib === undefined)
|
package/dist/commands/deploy.js
CHANGED
|
@@ -38,9 +38,9 @@ async function discoverLane(api, projectId, branch, opts) {
|
|
|
38
38
|
if (!known.includes(tag)) {
|
|
39
39
|
die(`this platform answered with a source-build lane this CLI does not know (${JSON.stringify(tag)}) — upgrade with \`insta upgrade\``);
|
|
40
40
|
}
|
|
41
|
-
// The tag alone is not the contract: each branch carries a payload this code then trusts
|
|
42
|
-
//
|
|
43
|
-
//
|
|
41
|
+
// The tag alone is not the contract: each branch carries a payload this code then trusts, so
|
|
42
|
+
// it is validated too — an `archive` with malformed limits must not fall back to local
|
|
43
|
+
// defaults the SERVER does not enforce, and a `none` must carry its reason.
|
|
44
44
|
if (tag === 'archive') {
|
|
45
45
|
const l = lane.limits;
|
|
46
46
|
const positive = (v) => typeof v === 'number' && Number.isSafeInteger(v) && v > 0;
|
|
@@ -112,7 +112,7 @@ export function dockerfileExposedPort(dockerfile) {
|
|
|
112
112
|
// This message is for the target that still REQUIRES a Dockerfile: a Fly-backed service, where a
|
|
113
113
|
// directory deploy builds the Dockerfile in the directory and dies without one. On insta-compute
|
|
114
114
|
// the archive lane carries the directory to the gateway and nixpacks builds it, so this dead end is
|
|
115
|
-
//
|
|
115
|
+
// not universal. It names every way forward instead of the bare "add one".
|
|
116
116
|
//
|
|
117
117
|
// It deliberately does NOT say "save the Dockerfile `insta build --explain` prints": that file is
|
|
118
118
|
// not standalone — it COPYs `.nixpacks/nixpkgs-<hash>.nix` support files nixpacks writes beside it,
|
|
@@ -39,7 +39,7 @@ const FEEDBACK_ENDPOINT = process.env.INSTA_FEEDBACK_URL ||
|
|
|
39
39
|
'https://feedback.instacloud.com/v1/feedback';
|
|
40
40
|
const FEEDBACK_INGEST_TOKEN = process.env.INSTA_FEEDBACK_TOKEN || 'insta-feedback-public-v1';
|
|
41
41
|
// 15s gives the backend's scale-to-zero cold start room to answer (the ingest service waits out
|
|
42
|
-
// the DB wake and persists, so a report can land after
|
|
42
|
+
// the DB wake and persists, so a report can land after a shorter deadline gave up on it).
|
|
43
43
|
// An expired deadline is reported as UNCONFIRMED, not failed — the report may well be stored.
|
|
44
44
|
const FEEDBACK_TIMEOUT_MS = 15_000;
|
|
45
45
|
const MAX_FILE_BYTES = 256 * 1024;
|
|
@@ -153,7 +153,7 @@ export async function buildPayload(opts, ctx) {
|
|
|
153
153
|
branch: project?.branch,
|
|
154
154
|
};
|
|
155
155
|
}
|
|
156
|
-
/** One POST,
|
|
156
|
+
/** One POST, one bounded attempt (FEEDBACK_TIMEOUT_MS), zero retries — feedback is a side quest and must never hang the CLI.
|
|
157
157
|
* Transport and server failures come back as a result, not an exception: the caller downgrades
|
|
158
158
|
* them to a warning so a broken feedback backend can't fail the user's actual task. */
|
|
159
159
|
export async function submit(payload, fetchImpl) {
|
|
@@ -6,8 +6,7 @@ import { info, printJson } from '../util.js';
|
|
|
6
6
|
*
|
|
7
7
|
* `kind` is the platform's internal resource kind, and for compute it is always 'fly' — 'fly' is
|
|
8
8
|
* the compute SEAT, occupied by the microvm plane on any environment that has cut over. Printing
|
|
9
|
-
* it
|
|
10
|
-
* Fly (staging, 2026-08-25: `fly(api)` for a row serving from warm pod insta-warm-00178a-16).
|
|
9
|
+
* it tells users and agents that a microvm-backed service runs on Fly.
|
|
11
10
|
*
|
|
12
11
|
* So for compute rows the label is the platform's explicit `provider`, and when that is absent --
|
|
13
12
|
* an older platform, or a row whose provider the platform itself could not determine -- we fall
|
package/dist/commands/metrics.js
CHANGED
|
@@ -98,8 +98,7 @@ function printDimensions(dims) {
|
|
|
98
98
|
// insta usage — usage across the 5 billing dimensions (cpu/memory/volume/egress/storage) for the
|
|
99
99
|
// current billing cycle. Shows the whole ORG by default (with a per-project breakdown); pass --proj
|
|
100
100
|
// [id] for a single project (the linked one, or a given id). Billed dimensions, not raw provider
|
|
101
|
-
// meters.
|
|
102
|
-
// though the adapter code is retained, not live.)
|
|
101
|
+
// meters.
|
|
103
102
|
export async function usage(opts) {
|
|
104
103
|
const api = await ApiClient.load();
|
|
105
104
|
const p = await requireProject();
|
package/dist/commands/secrets.js
CHANGED
|
@@ -162,27 +162,66 @@ async function readStdin() {
|
|
|
162
162
|
data += chunk;
|
|
163
163
|
return data.trim();
|
|
164
164
|
}
|
|
165
|
+
// Every platform SkipReason (applyTargets.ts) except 'unknown-service', which means the plan
|
|
166
|
+
// could not resolve the service, not that it correctly chose to skip it.
|
|
167
|
+
const BENIGN_SKIP_REASONS = new Set(['no-image', 'other-branch', 'caller-deploying', 'skip-deploy']);
|
|
168
|
+
// Three outcomes: an unwritten entry is a hard failure; a written entry with a
|
|
169
|
+
// failed or unexplained-skip service is durable but not fully live; anything else is success.
|
|
170
|
+
export function applyVerdict(entries, services) {
|
|
171
|
+
if (entries.some((e) => !e.written))
|
|
172
|
+
return 'not-written';
|
|
173
|
+
const degraded = services.some((s) => s.result === 'failed' || (s.result === 'skipped' && !BENIGN_SKIP_REASONS.has(s.reason ?? '')));
|
|
174
|
+
return degraded ? 'degraded' : 'ok';
|
|
175
|
+
}
|
|
176
|
+
// util.ts already claims 1 (plain failure) and 2 (nothing ran, re-run is safe); this state fits
|
|
177
|
+
// neither, so it takes the next code rather than overload one of theirs.
|
|
178
|
+
export function applyExitCode(verdict) {
|
|
179
|
+
return verdict === 'ok' ? 0 : verdict === 'not-written' ? 1 : 3;
|
|
180
|
+
}
|
|
181
|
+
// Pure — one line per service, extending `branch.ts`'s create/skip convention; `started` matters most, it means new billing.
|
|
182
|
+
export function applyServiceLines(services) {
|
|
183
|
+
return services.map((s) => {
|
|
184
|
+
if (s.result === 'started')
|
|
185
|
+
return ` + ${s.serviceId} started (was stopped — this now bills)`;
|
|
186
|
+
if (s.result === 'deployed')
|
|
187
|
+
return ` ~ ${s.serviceId} redeployed`;
|
|
188
|
+
if (s.result === 'failed')
|
|
189
|
+
return ` ! ${s.serviceId} failed${s.reason ? `: ${s.reason}` : ''}`;
|
|
190
|
+
return ` = ${s.serviceId}${s.reason ? ` (${s.reason})` : ''}`;
|
|
191
|
+
});
|
|
192
|
+
}
|
|
165
193
|
// Set a user secret. Project-wide by default; --branch scopes it to one branch. --service binds
|
|
166
194
|
// it to a branch service instead, which implies the current branch (binding requires one). Value
|
|
167
195
|
// comes from the argument, or stdin when omitted (keeps secret values out of shell history).
|
|
168
|
-
export async function secretsSet(name, value, opts) {
|
|
196
|
+
export async function secretsSet(name, value, opts, deps) {
|
|
169
197
|
// An empty --service must not fall through to a project-wide WRITE. The scoping test below is a
|
|
170
198
|
// truthiness check, so `--service ''` (a client interpolating an absent variable) would have put
|
|
171
199
|
// the secret at a WIDER scope than the caller asked for, visible to every service on the branch.
|
|
172
200
|
assertServiceRef(opts.service);
|
|
173
|
-
const
|
|
174
|
-
const p = await requireProject();
|
|
201
|
+
const d = deps ?? (await loadDeps());
|
|
175
202
|
const v = value ?? (await readStdin());
|
|
176
203
|
if (!v)
|
|
177
204
|
die('value is required (pass as an argument or on stdin)');
|
|
178
|
-
const branch = opts.service ? (opts.branch ??
|
|
179
|
-
|
|
180
|
-
const
|
|
205
|
+
const branch = opts.service ? (opts.branch ?? d.linkedBranch) : opts.branch;
|
|
206
|
+
// The batch always deploys on ONE branch, so the top level falls back to the linked one even when the entry itself is project-wide.
|
|
207
|
+
const entry = { kind: 'set', name, value: v, ...(branch ? { branch } : {}), ...(opts.service ? { service: opts.service } : {}) };
|
|
208
|
+
const res = await d.api.rawRequest('POST', `/projects/${d.projectId}/apply`, { branch: branch ?? d.linkedBranch, entries: [entry] });
|
|
181
209
|
if (handleApproval(res, opts.json))
|
|
182
210
|
return;
|
|
211
|
+
const entries = res.body.entries ?? [];
|
|
212
|
+
const services = res.body.services ?? [];
|
|
213
|
+
const verdict = applyVerdict(entries, services);
|
|
214
|
+
process.exitCode = applyExitCode(verdict);
|
|
183
215
|
if (opts.json)
|
|
184
|
-
return printJson({ ok:
|
|
216
|
+
return printJson({ ok: verdict === 'ok', verdict, name, branch: branch ?? null, service: opts.service ?? null, entries, services });
|
|
185
217
|
info(`set ${name}${opts.service ? ` → ${opts.service}` : ''} (${branch ? `branch ${branch}` : 'project-wide'})`);
|
|
218
|
+
for (const line of applyServiceLines(services))
|
|
219
|
+
info(line);
|
|
220
|
+
// Verdict note on stderr, after the per-service lines: not success, and not the same failure either.
|
|
221
|
+
if (verdict === 'not-written')
|
|
222
|
+
process.stderr.write(`error: ${name} was not written\n`);
|
|
223
|
+
else if (verdict === 'degraded')
|
|
224
|
+
process.stderr.write(`warning: ${name} is saved but not applied everywhere — retry the deploy for the services marked failed above, do not resend this value\n`);
|
|
186
225
|
}
|
|
187
226
|
// Remove a user secret. --service removes only THAT service's copy (the platform has always
|
|
188
227
|
// honoured ?service= here; without the flag a name several services define stays defined).
|
|
@@ -192,21 +231,26 @@ export async function secretsUnset(name, opts, deps) {
|
|
|
192
231
|
// Service scoping REQUIRES a branch (a service exists on a branch, so the platform rejects the
|
|
193
232
|
// pair without one) — so --service defaults to the linked branch, exactly as `secrets set` does.
|
|
194
233
|
const branch = opts.service ? (opts.branch ?? d.linkedBranch) : opts.branch;
|
|
195
|
-
const
|
|
196
|
-
|
|
197
|
-
parts.push(`branch=${encodeURIComponent(branch)}`);
|
|
198
|
-
if (opts.service)
|
|
199
|
-
parts.push(`service=${encodeURIComponent(opts.service)}`);
|
|
200
|
-
const qs = parts.length ? `?${parts.join('&')}` : '';
|
|
201
|
-
const res = await d.api.rawRequest('DELETE', `/projects/${d.projectId}/secrets/${encodeURIComponent(name)}${qs}`);
|
|
234
|
+
const entry = { kind: 'delete', name, ...(branch ? { branch } : {}), ...(opts.service ? { service: opts.service } : {}) };
|
|
235
|
+
const res = await d.api.rawRequest('POST', `/projects/${d.projectId}/apply`, { branch: branch ?? d.linkedBranch, entries: [entry] });
|
|
202
236
|
if (handleApproval(res, opts.json))
|
|
203
237
|
return;
|
|
238
|
+
const entries = res.body.entries ?? [];
|
|
239
|
+
const services = res.body.services ?? [];
|
|
240
|
+
const verdict = applyVerdict(entries, services);
|
|
241
|
+
process.exitCode = applyExitCode(verdict);
|
|
204
242
|
// The EFFECTIVE branch, not the flag: with --service and no --branch the scope that was deleted
|
|
205
243
|
// is the linked branch's, and the output has to say which scope it actually touched.
|
|
206
244
|
if (opts.json)
|
|
207
|
-
return printJson({ ok:
|
|
245
|
+
return printJson({ ok: verdict === 'ok', verdict, name, branch: branch ?? null, service: opts.service ?? null, entries, services });
|
|
208
246
|
const scope = opts.service ? `${opts.service}, branch ${branch}` : branch ? `branch ${branch}` : 'project-wide';
|
|
209
247
|
info(`unset ${name} (${scope})`);
|
|
248
|
+
for (const line of applyServiceLines(services))
|
|
249
|
+
info(line);
|
|
250
|
+
if (verdict === 'not-written')
|
|
251
|
+
process.stderr.write(`error: ${name} was not removed\n`);
|
|
252
|
+
else if (verdict === 'degraded')
|
|
253
|
+
process.stderr.write(`warning: ${name} is removed but not applied everywhere — retry the deploy for the services marked failed above\n`);
|
|
210
254
|
}
|
|
211
255
|
export async function secretsBind(envName, source, opts) {
|
|
212
256
|
if (!opts.to)
|
|
@@ -85,9 +85,8 @@ export function servicesAddRequestBody(type, name, branch, opts) {
|
|
|
85
85
|
type, name, ...(branch ? { branch } : {}), public: !!opts.public,
|
|
86
86
|
...(opts.image ? { image: opts.image } : {}), ...(opts.port ? { port: parsePort(opts.port) } : {}),
|
|
87
87
|
...(opts.region ? { region: opts.region } : {}),
|
|
88
|
-
// Sent whenever the flag was given, false included: compute is born always-on by default
|
|
89
|
-
//
|
|
90
|
-
// false. Omitted means the platform default.
|
|
88
|
+
// Sent whenever the flag was given, false included: compute is born always-on by default,
|
|
89
|
+
// so `--no-always-on` must reach the API as an explicit false. Omitted means the platform default.
|
|
91
90
|
...(opts.alwaysOn !== undefined ? { alwaysOn: opts.alwaysOn } : {}),
|
|
92
91
|
...(opts.volume !== undefined ? { volumeGib: parseVolumeGib(opts.volume) } : {}),
|
|
93
92
|
...(opts.mountPath !== undefined ? { volumeMountPath: opts.mountPath } : {}),
|
package/dist/commands/setup.js
CHANGED
|
@@ -21,7 +21,7 @@ import { projectCreate, projectLink, slugifyName } from './project.js';
|
|
|
21
21
|
import { envUse } from './env.js';
|
|
22
22
|
import { installAgentConfigs } from './mcp.js';
|
|
23
23
|
import { detectChannel } from './upgrade.js';
|
|
24
|
-
export { resolveSpawnable
|
|
24
|
+
export { resolveSpawnable } from '../spawn.js';
|
|
25
25
|
// The `skills` tool we shell out to prints a clack UI: a frame-by-frame clone spinner, an
|
|
26
26
|
// "Installing to all N agents" banner, a full N-line install-path box, and a third-party
|
|
27
27
|
// "Security Risk Assessment" that flags our OWN first-party skill as "Critical Risk". Streamed
|
|
@@ -65,7 +65,7 @@ export function parseInstalledAgents(output) {
|
|
|
65
65
|
// The tool boxes each line ("│ → ~/.claude/skills/insta │"), so don't anchor to EOL.
|
|
66
66
|
// Separator-agnostic: on Windows the tool prints C:\Users\…\.claude\skills\insta — a
|
|
67
67
|
// forward-slash-only match found nothing there, collapsing the summary to a nameless
|
|
68
|
-
// "Agents set up"
|
|
68
|
+
// "Agents set up".
|
|
69
69
|
const m = plain.match(/→\s*(\S+)[\\/]skills[\\/][A-Za-z0-9_-]+/);
|
|
70
70
|
if (m && m[1])
|
|
71
71
|
paths.add(m[1]);
|
|
@@ -256,7 +256,7 @@ export function requireMcpRegistration(status) {
|
|
|
256
256
|
/** The environment `setup agent` should target, and whether the machine must be switched to it
|
|
257
257
|
* first. Pure — decides only; the caller performs the switch.
|
|
258
258
|
*
|
|
259
|
-
* The contract
|
|
259
|
+
* The contract: the public one-liner `npx -y insta setup agent` means PRODUCTION,
|
|
260
260
|
* full stop — a leftover `insta env use staging` from last month must not silently give a new
|
|
261
261
|
* onboarding run staging skills. Staging is an explicit ask: `--env staging` (or $INSTA_ENV).
|
|
262
262
|
* Two deliberate exceptions leave the machine alone:
|
|
@@ -442,7 +442,7 @@ export async function setupAgent(opts, run = defaultRunner, mint, installConfigs
|
|
|
442
442
|
// --project / --create: bind this directory to a project inside the SAME process. Never split
|
|
443
443
|
// this back into `setup agent && insta project <cmd>` as one paste: no shell joiner survives
|
|
444
444
|
// every Windows shell, and in shells without bracketed paste the queued second line is eaten
|
|
445
|
-
// as the answer to the login prompt above
|
|
445
|
+
// as the answer to the login prompt above. Both need the session: without
|
|
446
446
|
// one the manual command is the hint, never a hang; a failure (bad id, no access, name taken)
|
|
447
447
|
// is a REAL error — binding a project is the entire point of the flag — so it sets the exit
|
|
448
448
|
// code instead of pretending setup succeeded.
|
|
@@ -115,10 +115,12 @@ export function renderConfigBlock(o) {
|
|
|
115
115
|
throw new Error(`refusing to write an unsafe ssh HostName into ssh_config: ${JSON.stringify(e.hostName)}`);
|
|
116
116
|
if (!isSafeConfigValue(e.user))
|
|
117
117
|
throw new Error(`refusing to write an unsafe ssh User into ssh_config: ${JSON.stringify(e.user)}`);
|
|
118
|
+
if (!Number.isInteger(e.port) || e.port < 1 || e.port > 65535)
|
|
119
|
+
throw new Error(`refusing to write an unusable ssh Port into ssh_config: ${JSON.stringify(e.port)}`);
|
|
118
120
|
lines.push(`Host ${e.alias}`,
|
|
119
121
|
// Without HostName and User the alias is not routing at all: ssh resolves
|
|
120
122
|
// `api.insta` in DNS and logs in as the local OS username.
|
|
121
|
-
` HostName ${e.hostName}`, ` User ${e.user}`, ` IdentityFile ${quoteConfigPath(o.identityFile)}`, ` CertificateFile ${quoteConfigPath(e.certificateFile)}`,
|
|
123
|
+
` HostName ${e.hostName}`, ` User ${e.user}`, ` Port ${e.port}`, ` IdentityFile ${quoteConfigPath(o.identityFile)}`, ` CertificateFile ${quoteConfigPath(e.certificateFile)}`,
|
|
122
124
|
// WRITTEN, not left to the default, and this is the one keyword where
|
|
123
125
|
// being first in the file does not save us. First-wins settles a keyword
|
|
124
126
|
// two blocks both set; a keyword we never set at all goes on being filled
|
|
@@ -137,24 +139,29 @@ export function renderConfigBlock(o) {
|
|
|
137
139
|
// IdentitiesOnly is not tidiness. SSH offers public keys ONE AT A TIME,
|
|
138
140
|
// so a user with several keys is identified non-deterministically -- the
|
|
139
141
|
// server sees whichever key happened to be offered first, which may not be
|
|
140
|
-
// the one carrying our certificate.
|
|
141
|
-
//
|
|
142
|
-
// unexplainable auth failures.
|
|
142
|
+
// the one carrying our certificate. Without this line a developer with a
|
|
143
|
+
// full ssh-agent gets intermittent, unexplainable auth failures.
|
|
143
144
|
' IdentitiesOnly yes');
|
|
144
145
|
// Connection multiplexing collapses scp, an IDE's several connections and a
|
|
145
146
|
// second terminal onto ONE connection; without it a single developer can
|
|
146
147
|
// reach the per-service session cap in an afternoon.
|
|
147
148
|
//
|
|
148
|
-
//
|
|
149
|
+
// The socket is keyed on %C -- a hash of (local host, remote host, port,
|
|
150
|
+
// user) -- not %r@%h:%p. A ControlPath is a Unix-domain socket, whose path
|
|
151
|
+
// is capped at 104 bytes on macOS (108 on Linux), and the route-key user
|
|
152
|
+
// plus the regional gateway hostname sailed past that: a real prod alias
|
|
153
|
+
// rendered a 109-byte path and every `ssh` died on `ControlPath too long`.
|
|
154
|
+
// %C is a fixed 40 hex chars however long the host and user grow, so the
|
|
155
|
+
// path can no longer overflow; it also drops the `:` the old token carried.
|
|
156
|
+
//
|
|
157
|
+
// OMITTED ON WINDOWS, where it is not an optimisation but a broken config:
|
|
149
158
|
// Win32-OpenSSH does not implement ControlMaster (PowerShell/Win32-OpenSSH
|
|
150
|
-
// #1328, #405) and
|
|
151
|
-
//
|
|
152
|
-
//
|
|
153
|
-
//
|
|
154
|
-
// `ssh` and skip on Windows, so this branch is asserted on the rendered
|
|
155
|
-
// text instead.
|
|
159
|
+
// #1328, #405) and FAILS the connection rather than ignoring the directive,
|
|
160
|
+
// so every alias would be unusable on a platform this repo runs CI for. The
|
|
161
|
+
// effective-config tests need a real `ssh` and skip on Windows, so this
|
|
162
|
+
// branch is asserted on the rendered text instead.
|
|
156
163
|
if ((o.platform ?? process.platform) !== 'win32') {
|
|
157
|
-
lines.push(' ControlMaster auto', ' ControlPath ~/.insta/ssh/cm-%
|
|
164
|
+
lines.push(' ControlMaster auto', ' ControlPath ~/.insta/ssh/cm-%C', ' ControlPersist 10m');
|
|
158
165
|
}
|
|
159
166
|
if (o.ensureCertCommand) {
|
|
160
167
|
// Renewal happens while OpenSSH PARSES the config, before it connects, so
|
|
@@ -210,6 +217,35 @@ export function upsertConfigBlock(existing, block) {
|
|
|
210
217
|
export function hasOwnedBlock(existing) {
|
|
211
218
|
return existing.split('\n').some(isMarkerLine(BLOCK_BEGIN));
|
|
212
219
|
}
|
|
220
|
+
/** Whether nothing that OpenSSH would read precedes the owned block. OpenSSH
|
|
221
|
+
* takes the FIRST obtained value for each keyword, so a `Host *` stanza -- or a
|
|
222
|
+
* bare global `Port 22` -- above our block silently overrides the block's
|
|
223
|
+
* Port, HostName, User and credential; that is why upsertConfigBlock writes
|
|
224
|
+
* the block at the top. Comments and blank lines above it are harmless and do
|
|
225
|
+
* not count. false when there is no owned block at all. */
|
|
226
|
+
export function ownedBlockIsFirst(existing) {
|
|
227
|
+
const lines = existing.split('\n');
|
|
228
|
+
const begin = lines.findIndex(isMarkerLine(BLOCK_BEGIN));
|
|
229
|
+
if (begin === -1)
|
|
230
|
+
return false;
|
|
231
|
+
return lines.slice(0, begin).every((l) => /^\s*(#.*)?$/.test(l));
|
|
232
|
+
}
|
|
233
|
+
/** The installed owned block, BEGIN through END marker inclusive, exactly as it
|
|
234
|
+
* sits in the file; undefined when there is none (or an unterminated one). It
|
|
235
|
+
* exists so a writer can compare what IS installed with what it WOULD render
|
|
236
|
+
* and touch the file only when the two differ -- a stanza written before a
|
|
237
|
+
* keyword existed (the Port line) is the case, and "nothing changed in the
|
|
238
|
+
* response" is not the same question as "nothing would change in the file". */
|
|
239
|
+
export function ownedBlock(existing) {
|
|
240
|
+
const lines = existing.split('\n');
|
|
241
|
+
const begin = lines.findIndex(isMarkerLine(BLOCK_BEGIN));
|
|
242
|
+
if (begin === -1)
|
|
243
|
+
return undefined;
|
|
244
|
+
const end = lines.findIndex((l, n) => n > begin && isMarkerLine(BLOCK_END)(l));
|
|
245
|
+
if (end === -1)
|
|
246
|
+
return undefined;
|
|
247
|
+
return lines.slice(begin, end + 1).join('\n');
|
|
248
|
+
}
|
|
213
249
|
/** A marker is a WHOLE LINE, never a substring of one.
|
|
214
250
|
*
|
|
215
251
|
* Matching the marker text wherever it occurred made a user's comment that
|
|
@@ -297,11 +333,10 @@ function isEcdsaBody(curve, point, wantCurve, pointLen) {
|
|
|
297
333
|
/** Exactly ONE OpenSSH public-key record: `<type> <base64>` with an optional
|
|
298
334
|
* comment, and nothing else -- no second line, no leading directive.
|
|
299
335
|
*
|
|
300
|
-
*
|
|
301
|
-
*
|
|
302
|
-
*
|
|
303
|
-
*
|
|
304
|
-
* part of it is honoured. */
|
|
336
|
+
* Parsing to the three fields we will actually write, and rebuilding the line
|
|
337
|
+
* from THOSE, means a value either is one key record or is refused; there is
|
|
338
|
+
* no third outcome where part of it is honoured (an embedded newline would
|
|
339
|
+
* otherwise smuggle additional known_hosts lines through). */
|
|
305
340
|
export function parseCAPublicKey(value) {
|
|
306
341
|
if (typeof value !== 'string')
|
|
307
342
|
throw new Error('the platform returned no ssh certificate authority key');
|
|
@@ -318,22 +353,18 @@ export function parseCAPublicKey(value) {
|
|
|
318
353
|
if (!/^[A-Za-z0-9+/]+={0,3}$/.test(blob) || blob.length < 32) {
|
|
319
354
|
throw new Error('refusing a certificate authority key whose body is not base64');
|
|
320
355
|
}
|
|
321
|
-
// The blob's OWN type must agree with the text field
|
|
322
|
-
//
|
|
323
|
-
//
|
|
324
|
-
//
|
|
325
|
-
//
|
|
326
|
-
//
|
|
327
|
-
// and an anchor built from that installs silently, then fails at connect
|
|
328
|
-
// time, where the message points at known_hosts rather than at the response
|
|
329
|
-
// that produced it.
|
|
356
|
+
// The blob's OWN type must agree with the text field, and the WHOLE blob is
|
|
357
|
+
// checked, not just its first field: a first-field check rejects arbitrary
|
|
358
|
+
// base64 and still accepts a correct type name followed by noise -- and an
|
|
359
|
+
// anchor built from that installs silently, then fails at connect time,
|
|
360
|
+
// where the message points at known_hosts rather than at the response that
|
|
361
|
+
// produced it.
|
|
330
362
|
const fields = sshBlobFields(blob);
|
|
331
363
|
if (!fields || fields.length < 2 || fields[0].toString('utf8') !== type) {
|
|
332
364
|
throw new Error(`refusing a certificate authority key whose body does not match its type ${JSON.stringify(type.slice(0, 32))}`);
|
|
333
365
|
}
|
|
334
|
-
// And the fields must be the ones THIS type has
|
|
335
|
-
//
|
|
336
|
-
// any well-formed fields passed -- see CA_KEY_SHAPES.
|
|
366
|
+
// And the fields must be the ones THIS type has (see CA_KEY_SHAPES): a
|
|
367
|
+
// correct RSA or ECDSA type name followed by any well-formed fields must not pass.
|
|
337
368
|
if (!shape(fields)) {
|
|
338
369
|
throw new Error(`refusing a certificate authority key whose body is not the shape of ${JSON.stringify(type.slice(0, 32))}`);
|
|
339
370
|
}
|
|
@@ -631,8 +662,7 @@ export function planCertAuthority(existing, hostPattern, caKey) {
|
|
|
631
662
|
export function revertCertAuthority(current, plan) {
|
|
632
663
|
const kept = current.split('\n').filter((l) => l.trimEnd() !== plan.line);
|
|
633
664
|
// Only the ONE trailing blank the split leaves behind a final newline. A
|
|
634
|
-
// blank line before that is the user's -- trailing blanks included
|
|
635
|
-
// loop popping every empty tail line was deleting on the failure path.
|
|
665
|
+
// blank line before that is the user's -- trailing blanks included.
|
|
636
666
|
if (kept.length > 0 && kept[kept.length - 1] === '')
|
|
637
667
|
kept.pop();
|
|
638
668
|
// Only the anchors that are genuinely gone: a concurrent install may already
|
|
@@ -329,7 +329,7 @@ export async function templateDeploy(target, opts = {}, deps = {}) {
|
|
|
329
329
|
manifest = fetched.manifest;
|
|
330
330
|
source = fetched.source;
|
|
331
331
|
vars = collectManifestVariables(manifest);
|
|
332
|
-
//
|
|
332
|
+
// Exactly ONE line in front of today's output. A second "deploying template …" line
|
|
333
333
|
// would read as a duplicate of the "deploying template <code> to branch <branch>" line below,
|
|
334
334
|
// so the manifest's code@version rides on this one instead.
|
|
335
335
|
if (!quiet) {
|
package/dist/commands/upgrade.js
CHANGED
|
@@ -9,9 +9,8 @@
|
|
|
9
9
|
// `resolveLatest()`, reading npm's `latest` dist-tag, the same thing `npx insta@latest` and
|
|
10
10
|
// `npm i -g insta` resolve. Both the background check and `insta upgrade` go through it, and the
|
|
11
11
|
// binary channel then installs THAT version by tag (INSTA_VERSION=v<latest>) rather than asking
|
|
12
|
-
// GitHub independently for /releases/latest. Two independent resolvers is how the two paths
|
|
13
|
-
//
|
|
14
|
-
// neither npm nor GitHub).
|
|
12
|
+
// GitHub independently for /releases/latest. Two independent resolvers is how the two paths
|
|
13
|
+
// drift apart: a version can be merged but never tagged, so it exists on neither npm nor GitHub.
|
|
15
14
|
//
|
|
16
15
|
// A CACHE MUST NOT LIE. ~/.insta/update-check.json is a cache of that one answer, never a second
|
|
17
16
|
// source. Every path that learns the real latest rewrites it (including a successful upgrade),
|
|
@@ -348,7 +347,7 @@ export async function autoupdate(mode) {
|
|
|
348
347
|
* `upgrade`/`autoupdate` are the update machinery itself. Every `__` command
|
|
349
348
|
* is internal machinery rather than a user at a prompt, and the rule is
|
|
350
349
|
* written as a PREFIX so the next such command inherits it instead of
|
|
351
|
-
* rediscovering it
|
|
350
|
+
* rediscovering it:
|
|
352
351
|
* `__ssh-ensure-cert` is run by OpenSSH while it PARSES ssh_config, on every
|
|
353
352
|
* ssh, scp, `ssh -G` and IDE connection, so a nudge here is written straight
|
|
354
353
|
* into the ssh session's stderr and an auto-upgrade spawns a detached process
|
package/dist/config.js
CHANGED
|
@@ -16,9 +16,8 @@ const PROJECT_FILE = 'project.json';
|
|
|
16
16
|
// CLI treat the shared link as foreign. It records the project id too, because project.json is
|
|
17
17
|
// committed and this file is not: a pull or checkout can replace the project underneath it.
|
|
18
18
|
const LINK_PLANE_FILE = 'link-plane.json';
|
|
19
|
-
// The cloud API default.
|
|
20
|
-
//
|
|
21
|
-
// Only affects fresh installs: a persisted apiUrl (from a prior login) or INSTA_API_URL wins below.
|
|
19
|
+
// The cloud API default. Only affects fresh installs: a persisted apiUrl (from a prior login) or
|
|
20
|
+
// INSTA_API_URL wins below.
|
|
22
21
|
const DEFAULT_API = ENVS[DEFAULT_ENV].api;
|
|
23
22
|
export async function readGlobal() {
|
|
24
23
|
// Precedence, most explicit first:
|
|
@@ -147,7 +146,7 @@ export async function findProjectRoot(cwd = process.cwd()) {
|
|
|
147
146
|
}
|
|
148
147
|
/** The link that applies to `cwd`, and whether it was made against a DIFFERENT control plane. A
|
|
149
148
|
* project id means nothing on another control plane (cloud, staging and every insta-oss box each
|
|
150
|
-
* have their own)
|
|
149
|
+
* have their own). */
|
|
151
150
|
export async function resolveProjectLink(cwd = process.cwd()) {
|
|
152
151
|
// Linkless targeting (CI / one-offs / agents): INSTA_PROJECT_ID resolves the project with no
|
|
153
152
|
// link file, and beats one when both exist — an explicit parameter outranks ambient state.
|
|
@@ -170,8 +169,8 @@ export async function resolveProjectLink(cwd = process.cwd()) {
|
|
|
170
169
|
catch {
|
|
171
170
|
return null;
|
|
172
171
|
}
|
|
173
|
-
// No sidecar
|
|
174
|
-
//
|
|
172
|
+
// No sidecar (a teammate who just cloned): nothing says which control plane the link belongs
|
|
173
|
+
// to, so it resolves unchecked.
|
|
175
174
|
const record = await readLinkPlane(root);
|
|
176
175
|
if (record) {
|
|
177
176
|
const current = safeUrl((await readGlobal()).apiUrl);
|
package/dist/flyctl-build.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Build a source directory into an image and push it to Fly's registry, using a short-lived,
|
|
2
2
|
// app-scoped deploy token minted by the platform (the CLI never holds a standing Fly credential).
|
|
3
|
-
// Shells out to `flyctl deploy --build-only --push` (remote builder).
|
|
3
|
+
// Shells out to `flyctl deploy --build-only --push` (remote builder).
|
|
4
4
|
import { spawn } from 'node:child_process';
|
|
5
5
|
import { existsSync, writeFileSync, unlinkSync } from 'node:fs';
|
|
6
6
|
import { join } from 'node:path';
|
|
@@ -80,7 +80,7 @@ export async function ensureFlyctl() {
|
|
|
80
80
|
return;
|
|
81
81
|
}
|
|
82
82
|
if (process.platform === 'linux') {
|
|
83
|
-
// A fresh Linux machine (CI containers included
|
|
83
|
+
// A fresh Linux machine (CI containers included) has no flyctl
|
|
84
84
|
// and no brew; without this branch `insta deploy <dir>` dead-ends on a hand-install of a
|
|
85
85
|
// third-party CLI. Official installer, pinned into ~/.fly; the current process extends its
|
|
86
86
|
// own PATH because the installer's shell-profile edit can't reach an already-running process.
|
package/dist/github-source.js
CHANGED
|
@@ -7,7 +7,6 @@ import { tmpdir } from 'node:os';
|
|
|
7
7
|
import { join, sep } from 'node:path';
|
|
8
8
|
import { parseManifestYaml, MANIFEST_FILE } from './template-manifest.js';
|
|
9
9
|
const GITHUB_HOST = /^(?:https?:\/\/)?(?:www\.)?github\.com\//i;
|
|
10
|
-
// An explicit address: a scheme, or an scp-style user@host:path. Never a local path.
|
|
11
10
|
// A scheme (with or without its slashes) or an scp-style user@host:path. The slashes are optional
|
|
12
11
|
// because `https:/github.com/o/r`, a URL that lost one, must be named as a bad address rather than
|
|
13
12
|
// resolved as a directory called `https:`. Two or more scheme characters are required so a Windows
|
|
@@ -49,7 +48,7 @@ function decodedSegments(parts, target) {
|
|
|
49
48
|
}
|
|
50
49
|
/** Parse a github.com URL into owner, repo and the still-unsplit ref+path tail.
|
|
51
50
|
* null = not URL-shaped, so the caller's local-directory and registry modes still get a look.
|
|
52
|
-
* A URL-shaped target that is not a github.com repository URL throws
|
|
51
|
+
* A URL-shaped target that is not a github.com repository URL throws. */
|
|
53
52
|
export function parseGitHubTemplateUrl(target) {
|
|
54
53
|
if (!GITHUB_HOST.test(target)) {
|
|
55
54
|
// Name a non-GitHub address for what it is; let everything else reach local/registry mode.
|
|
@@ -140,7 +139,7 @@ function releaseChild(child) {
|
|
|
140
139
|
}
|
|
141
140
|
catch { /* already gone */ }
|
|
142
141
|
}
|
|
143
|
-
/** spawnFn is injected so the timeout path is testable without a network
|
|
142
|
+
/** spawnFn is injected so the timeout path is testable without a network. */
|
|
144
143
|
export function makeGitRunner(spawnFn = nodeSpawn) {
|
|
145
144
|
return (args, opts) => new Promise((resolve) => {
|
|
146
145
|
// Spawned directly, NOT through resolveSpawnable: that wrapper exists for npm-installed
|
|
@@ -232,7 +231,7 @@ export function splitRefAndPath(refAndPath, refs) {
|
|
|
232
231
|
}
|
|
233
232
|
/** One round trip answers two questions: the default branch, and where the ref ends. The commit
|
|
234
233
|
* is NOT taken from here — an annotated tag lists its tag object, and a branch can move before
|
|
235
|
-
* the clone. fetchGitHubTemplate reads it from the checkout instead
|
|
234
|
+
* the clone. fetchGitHubTemplate reads it from the checkout instead. */
|
|
236
235
|
export async function resolveGitHubRef(t, run) {
|
|
237
236
|
// HEAD is listed EXPLICITLY: adding refspecs otherwise drops the symref line that names the
|
|
238
237
|
// default branch (measured on this repo: 375 refs unfiltered, 188 with the filter, and no
|
|
@@ -285,7 +284,7 @@ export async function fetchGitHubTemplate(target, run = defaultGitRunner, parse
|
|
|
285
284
|
const dir = mkdtempSync(join(tmpdir(), 'insta-tpl-gh-'));
|
|
286
285
|
try {
|
|
287
286
|
// The short name, not resolved.qualifiedRef: `--branch` rejects a fully-qualified ref, and
|
|
288
|
-
// git's own short-name tie-break already agrees with splitRefAndPath
|
|
287
|
+
// git's own short-name tie-break already agrees with splitRefAndPath.
|
|
289
288
|
const cloned = await run(['clone', '--depth', '1', '--quiet', '--branch', resolved.ref, repoUrl(target), dir], { timeoutMs: CLONE_TIMEOUT_MS });
|
|
290
289
|
if (cloned.timedOut)
|
|
291
290
|
throw new Error(`timed out after ${CLONE_TIMEOUT_MS / 1000}s cloning https://github.com/${target.owner}/${target.repo}`);
|
|
@@ -294,7 +293,7 @@ export async function fetchGitHubTemplate(target, run = defaultGitRunner, parse
|
|
|
294
293
|
if (cloned.code !== 0)
|
|
295
294
|
throw new Error(unreadableRepoMessage(target, cloned.stderr));
|
|
296
295
|
// The deployed commit is the one in the checkout: an annotated tag's listing entry is its tag
|
|
297
|
-
// object, and a branch can move between ls-remote and here
|
|
296
|
+
// object, and a branch can move between ls-remote and here.
|
|
298
297
|
const head = await run(['-C', dir, 'rev-parse', 'HEAD'], { timeoutMs: LS_REMOTE_TIMEOUT_MS });
|
|
299
298
|
// Same three outcomes the other two calls distinguish, so a timeout or a missing git here is
|
|
300
299
|
// not reported as an unreadable repository.
|
package/dist/index.js
CHANGED
|
@@ -448,12 +448,8 @@ program.command('autoupdate [mode]').description('Show or set auto-update: on |
|
|
|
448
448
|
program.command('__update-check', { hidden: true }).action(guard(() => selfUpdate.backgroundCheck(cliVersion())));
|
|
449
449
|
// The ssh_config renewal hook. Hidden, and named with the `__` prefix that
|
|
450
450
|
// trackCommand skips, because OpenSSH runs it while PARSING the config on EVERY
|
|
451
|
-
// ssh/scp/`ssh -G`/IDE connection
|
|
452
|
-
// path
|
|
453
|
-
// early -- reading config, possibly creating ~/.insta/telemetry.json, and
|
|
454
|
-
// making a PostHog request with a timeout of up to 1.5s. That is a network
|
|
455
|
-
// round trip on the critical path of every ordinary ssh, which is exactly what
|
|
456
|
-
// the hook was specified not to do.
|
|
451
|
+
// ssh/scp/`ssh -G`/IDE connection: a telemetry round trip here would sit on the
|
|
452
|
+
// critical path of every ordinary ssh.
|
|
457
453
|
program.command('__ssh-ensure-cert <alias>', { hidden: true })
|
|
458
454
|
.action(guard((alias) => computeCmd.ensureCertForAlias(alias)));
|
|
459
455
|
selfUpdate.maybeUpdate(cliVersion(), process.argv);
|
package/dist/observe/hook.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// PostToolUse hook: reads a tool-use event on stdin (Claude Code / Codex), scans every string
|
|
2
|
-
// surface for credential exposure, and appends findings to ./.insta/audit.jsonl.
|
|
2
|
+
// surface for credential exposure, and appends findings to ./.insta/audit.jsonl.
|
|
3
3
|
import { appendFileSync, mkdirSync } from 'node:fs';
|
|
4
4
|
import { basename, dirname, join, resolve } from 'node:path';
|
|
5
5
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
package/dist/observe/install.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Install the observe hook into a project's agent harness (Claude Code / Codex) and materialize
|
|
2
|
-
// the standalone hook + scanner into ./.insta/observe.
|
|
2
|
+
// the standalone hook + scanner into ./.insta/observe.
|
|
3
3
|
import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
4
4
|
import { dirname, join } from 'node:path';
|
|
5
5
|
import { fileURLToPath } from 'node:url';
|
package/dist/pack-ignore.js
CHANGED
|
@@ -139,8 +139,8 @@ function posixClass(name, negated) {
|
|
|
139
139
|
// literal. docker hands the class to Go's regexp: a `]` right after the opening `[` (or after
|
|
140
140
|
// the `^`) is a MEMBER, not the close, `\` quotes the next character, and only `^` negates, so a
|
|
141
141
|
// `!` is an ordinary member. Unlike `*` and `?`, these Go regexp classes CAN match a separator.
|
|
142
|
-
//
|
|
143
|
-
//
|
|
142
|
+
// moby/patternmatcher compile() preserves bracket expressions without adding a separator
|
|
143
|
+
// exclusion (private[^x]token matches private/token).
|
|
144
144
|
function bracket(p, start) {
|
|
145
145
|
let j = start + 1;
|
|
146
146
|
let negated = false;
|
package/dist/resolve-project.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
// "One command, just works": when a command needs a project and the directory isn't linked
|
|
2
2
|
// (and no INSTA_PROJECT_ID is set), resolve it instead of lecturing about `project link` —
|
|
3
3
|
// one project auto-selects silently, several get a one-keystroke picker, and either way the
|
|
4
|
-
// choice is SAVED so this happens at most once per directory
|
|
5
|
-
//
|
|
4
|
+
// choice is SAVED so this happens at most once per directory, and the committed link file shares
|
|
5
|
+
// it with the team.
|
|
6
6
|
import { createInterface } from 'node:readline/promises';
|
|
7
7
|
export async function autoResolveProject(orgId, deps) {
|
|
8
8
|
const projects = await deps.listProjects();
|
|
@@ -125,7 +125,7 @@ export function validateManifest(m) {
|
|
|
125
125
|
problems.push(`${where}: web services must declare a healthcheck path`);
|
|
126
126
|
if (svc.healthcheck && !String(svc.healthcheck).startsWith('/'))
|
|
127
127
|
problems.push(`${where}: healthcheck must be an absolute path (start with /)`);
|
|
128
|
-
// Sizing is the platform's, capped for the org's plan
|
|
128
|
+
// Sizing is the platform's, capped for the org's plan. Same answers the
|
|
129
129
|
// publish endpoint gives, said here so an author does not upload to find out. Read as unknown:
|
|
130
130
|
// the type above admits only what is SUPPORTED, and the document is a cast over YAML.parse, so
|
|
131
131
|
// a refused shape arrives as a value that type does not describe.
|
package/dist/util.js
CHANGED
|
@@ -101,8 +101,8 @@ export function resolveThroughSymlink(path) {
|
|
|
101
101
|
/** Linux allows 40; the exact number does not matter, only that the walk ends. */
|
|
102
102
|
const MAX_SYMLINK_HOPS = 40;
|
|
103
103
|
/** How to launch the default browser for `url` on `platform`. Pure so the Windows encoding is
|
|
104
|
-
* testable. On Windows NO shell may ever parse the URL: cmd.exe splits at bare `&` (
|
|
105
|
-
*
|
|
104
|
+
* testable. On Windows NO shell may ever parse the URL: cmd.exe splits at bare `&` (quoting
|
|
105
|
+
* fixes that) but ALSO expands `%…%` sequences even inside quotes, and a percent-encoded
|
|
106
106
|
* OAuth redirect (`http%3A%2F%2F127.0.0.1…`) is nothing but such sequences. So the launch goes
|
|
107
107
|
* through PowerShell's -EncodedCommand: a pure-ASCII script travels as base64(UTF-16LE) — no
|
|
108
108
|
* argument parsing anywhere — and the URL itself rides as a second base64 payload INSIDE that
|
|
@@ -113,11 +113,10 @@ export function openUrlSpawn(url, platform = process.platform,
|
|
|
113
113
|
// directory before PATH, so a planted powershell.exe beside the user's shell would win.
|
|
114
114
|
systemRoot = process.env.SYSTEMROOT ?? process.env.windir ?? 'C:\\Windows') {
|
|
115
115
|
if (platform === 'win32') {
|
|
116
|
-
//
|
|
117
|
-
//
|
|
118
|
-
//
|
|
119
|
-
//
|
|
120
|
-
// base64 alphabet), so no byte of any URL can terminate anything.
|
|
116
|
+
// Interpolating the URL into a quoted literal is not enough — PowerShell honors smart quotes
|
|
117
|
+
// (U+2018–U+201B) as string delimiters too, so ASCII-only escaping still leaves a breakout.
|
|
118
|
+
// The script below is pure ASCII by construction (the base64 alphabet), so no byte of any
|
|
119
|
+
// URL can terminate anything.
|
|
121
120
|
const urlB64 = Buffer.from(url, 'utf8').toString('base64');
|
|
122
121
|
const script = `Start-Process ([Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${urlB64}')))`;
|
|
123
122
|
return {
|
|
@@ -149,8 +148,7 @@ export function openUrl(url) {
|
|
|
149
148
|
}
|
|
150
149
|
export class CliExit extends Error {
|
|
151
150
|
constructor() {
|
|
152
|
-
//
|
|
153
|
-
// process.exit(1) by throwing `Error('exit 1')`.
|
|
151
|
+
// Command-unit tests assert this exact message.
|
|
154
152
|
super('exit 1');
|
|
155
153
|
this.name = 'CliExit';
|
|
156
154
|
}
|