insta 0.1.13 → 0.1.16
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/commands/auth.js +3 -3
- package/dist/commands/deploy.js +1 -4
- package/dist/commands/mcp.js +15 -1
- package/dist/commands/postgres.js +23 -0
- package/dist/commands/services.js +11 -11
- package/dist/config.js +6 -4
- package/dist/deploy-archive.js +1 -1
- package/dist/index.js +10 -7
- package/dist/observe/hook.js +9 -3
- package/dist/observe/scanner.js +24 -14
- package/dist/pack.js +5 -26
- package/dist/resolve-service.js +3 -3
- package/dist/util.js +34 -11
- package/package.json +1 -1
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/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/deploy.js
CHANGED
|
@@ -147,12 +147,9 @@ export async function deploy(dir, opts) {
|
|
|
147
147
|
const p = await requireProject();
|
|
148
148
|
const branch = opts.branch ?? p.branch;
|
|
149
149
|
const log = note(opts);
|
|
150
|
-
// Junk fails here, before a directory is packed and uploaded for a body the platform would only
|
|
151
|
-
// refuse: the same parser every other --port in this CLI runs. `Number()` alone sent NaN as
|
|
152
|
-
// `null` and let 0 or 70000 travel to the server.
|
|
153
150
|
let port;
|
|
154
151
|
try {
|
|
155
|
-
port = opts.port === undefined ? undefined : parsePort(opts.port);
|
|
152
|
+
port = opts.port === undefined ? undefined : parsePort(opts.port, { allowZero: true });
|
|
156
153
|
}
|
|
157
154
|
catch (e) {
|
|
158
155
|
die(`--${e.message}`);
|
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);
|
|
@@ -47,14 +47,13 @@ export function parseCount(raw) {
|
|
|
47
47
|
}
|
|
48
48
|
return n;
|
|
49
49
|
}
|
|
50
|
-
//
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
export function parsePort(raw) {
|
|
50
|
+
// Keep TCP-only callers strict; port 0 is reserved for explicitly opted-in compute workers.
|
|
51
|
+
export function parsePort(raw, opts = {}) {
|
|
52
|
+
const min = opts.allowZero ? 0 : 1;
|
|
54
53
|
const m = /^\s*(\d+)\s*$/.exec(raw);
|
|
55
54
|
const n = m ? Number(m[1]) : NaN;
|
|
56
|
-
if (!Number.isInteger(n) || n <
|
|
57
|
-
throw new Error(`port must be an integer between
|
|
55
|
+
if (!Number.isInteger(n) || n < min || n > 65535)
|
|
56
|
+
throw new Error(`port must be an integer between ${min} and 65535, got: ${raw}`);
|
|
58
57
|
return n;
|
|
59
58
|
}
|
|
60
59
|
// Parse a volume size in whole Gi: "10" or "10Gi" (suffix case-insensitive — unlike the db
|
|
@@ -112,10 +111,11 @@ function defaultDatabasePort(type) {
|
|
|
112
111
|
export function servicesAddRequestBody(type, name, branch, opts) {
|
|
113
112
|
return {
|
|
114
113
|
type, name, ...(branch ? { branch } : {}), public: !!opts.public,
|
|
115
|
-
...(opts.image ? { image: opts.image } : {}), ...(opts.port ? { port: parsePort(opts.port) } : {}),
|
|
114
|
+
...(opts.image ? { image: opts.image } : {}), ...(opts.port !== undefined ? { port: parsePort(opts.port, { allowZero: type === 'compute' }) } : {}),
|
|
116
115
|
...(opts.region ? { region: opts.region } : {}),
|
|
117
|
-
// Sent whenever the flag was given, false included:
|
|
118
|
-
//
|
|
116
|
+
// Sent whenever the flag was given, false included: the birth default is platform config
|
|
117
|
+
// (INSTA_COMPUTE_ALWAYS_ON_DEFAULT, scale-to-zero unless set), so an explicit --no-always-on
|
|
118
|
+
// must reach the API as false to hold whatever that config says. Omitted means the platform default.
|
|
119
119
|
...(opts.alwaysOn !== undefined ? { alwaysOn: opts.alwaysOn } : {}),
|
|
120
120
|
...(opts.volume !== undefined ? { volumeGib: parseVolumeGib(opts.volume) } : {}),
|
|
121
121
|
...(opts.mountPath !== undefined ? { volumeMountPath: opts.mountPath } : {}),
|
|
@@ -130,10 +130,10 @@ export async function servicesAdd(type, name, opts = {}) {
|
|
|
130
130
|
throw new Error('--region is not valid for storage services');
|
|
131
131
|
if (opts.image && type !== 'compute')
|
|
132
132
|
throw new Error('--image is only valid for compute services');
|
|
133
|
-
if (opts.port) {
|
|
133
|
+
if (opts.port !== undefined) {
|
|
134
134
|
if (type !== 'compute')
|
|
135
135
|
throw new Error('--port is only valid for compute services');
|
|
136
|
-
parsePort(opts.port
|
|
136
|
+
parsePort(opts.port, { allowZero: true });
|
|
137
137
|
}
|
|
138
138
|
// Presence, not truthiness: `--no-always-on` is an explicit false and is just as compute-only.
|
|
139
139
|
if (opts.alwaysOn !== undefined && type !== 'compute')
|
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
|
package/dist/deploy-archive.js
CHANGED
|
@@ -121,7 +121,7 @@ export async function deployArchive(api, projectId, ref, branch, opts, now = Dat
|
|
|
121
121
|
if (state === 'live') {
|
|
122
122
|
const image = res.body?.imageRef;
|
|
123
123
|
const url = res.body?.url;
|
|
124
|
-
if (typeof image !== 'string' || !image || typeof url !== 'string' || !url) {
|
|
124
|
+
if (typeof image !== 'string' || !image || typeof url !== 'string' || (!url && opts.port !== '0')) {
|
|
125
125
|
throw new Error('the deploy finished but the platform returned no image or URL for it — check `insta status`');
|
|
126
126
|
}
|
|
127
127
|
const optionalString = (field, v) => {
|
package/dist/index.js
CHANGED
|
@@ -153,9 +153,9 @@ svc.command('add [type] [name]').description('Provision a service on demand (com
|
|
|
153
153
|
.option('--region <region>', 'region for postgres/compute/managed databases, e.g. us-east (see `insta config regions`)')
|
|
154
154
|
.option('--public', 'storage only: serve the bucket with anonymous public-read (default private)')
|
|
155
155
|
.option('--image <url>', 'compute only: run this container image at creation')
|
|
156
|
-
.option('--port <n>', 'compute only: port the image listens on (default 8080)')
|
|
157
|
-
.option('--always-on', 'compute only: create as always-on — never scales to zero (
|
|
158
|
-
.option('--no-always-on', 'compute only: create as scale-to-zero — idle machines suspend and wake on the next request')
|
|
156
|
+
.option('--port <n>', 'compute only: port the image listens on (default 8080; 0 for a worker without a routed port)')
|
|
157
|
+
.option('--always-on', 'compute only: create as always-on — never scales to zero (all plans; billing is actual usage either way). A worker (--port 0) is always-on regardless')
|
|
158
|
+
.option('--no-always-on', 'compute only: create as scale-to-zero — idle machines suspend and wake on the next request (the default for new compute services; refused with --port 0, a worker cannot scale to zero)')
|
|
159
159
|
.option('--mount-path <path>', 'compute only: container mount path for a new volume (requires --volume; default /data)')
|
|
160
160
|
.option('--volume <gi>', 'compute only: attach a persistent volume of this many whole Gi (also attachable later: `insta compute volume <name> --size <gi>`). Any plan may attach up to its own plan cap (10Gi free, 50Gi paid by default; the bare `insta compute volume <name>` read prints it as plan max); a size above the free cap is paid. Volume services keep 1 machine and stop (cold wake) instead of suspend when idle')
|
|
161
161
|
.option('--pg-version <major>', 'postgres only: the Postgres major version to create the database on — 16, 17 or 18 (default 16). Fixed for the database\'s lifetime; branches keep it')
|
|
@@ -365,7 +365,7 @@ compute.command('start [service]').description('Bring a compute service online (
|
|
|
365
365
|
.option('--json').option('--branch <branch>', 'branch (default: current)').action(guard((service, o) => computeCmd.computeStart(service, o)));
|
|
366
366
|
compute.command('stop [service]').description('Take a compute service offline; traffic will NOT wake it until `start`')
|
|
367
367
|
.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);
|
|
368
|
+
compute.command('suspend [service]').description('Suspend a compute service (RAM snapshot); traffic can wake it unless already stopped; `start` clears the stop')
|
|
369
369
|
.option('--json').option('--branch <branch>', 'branch (default: current)').action(guard((service, o) => computeCmd.computeSuspend(service, o)));
|
|
370
370
|
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
371
|
.option('--json').option('--branch <branch>', 'branch (default: current)').action(guard((service, o) => computeCmd.computeRestart(service, o)));
|
|
@@ -379,7 +379,7 @@ compute.command('scale [count] [service]').description('Set a compute service sa
|
|
|
379
379
|
compute.command('limits [service]').description("Show or set a compute service's resource ceiling (any plan within the free cap; raising above it needs a paid plan). --memory is the dial; cpu derives from it unless --cpu is given. Billing is actual usage — the ceiling caps what the app may burn, it is not a price")
|
|
380
380
|
.option('--memory <size>', 'memory ceiling, e.g. 512mb or 1gb').option('--cpu <n>', 'vCPU ceiling override (provider sizes: 1, 2, 4, 6, 8)')
|
|
381
381
|
.option('--json').option('--branch <branch>', 'branch (default: current)').action(guard((service, o) => computeCmd.computeLimits(service, o)));
|
|
382
|
-
compute.command('always-on <mode> [service]').description('Set a compute service always-on (mode: on|off). on = machines never scale to zero (the default for new compute services
|
|
382
|
+
compute.command('always-on <mode> [service]').description('Set a compute service always-on (mode: on|off). on = machines never scale to zero; off = scale-to-zero (the default for new compute services; refused on a worker, port 0). All plans; billing is actual usage either way')
|
|
383
383
|
.option('--json').option('--branch <branch>', 'branch (default: current)').action(guard((mode, service, o) => computeCmd.computeAlwaysOn(mode, service, o)));
|
|
384
384
|
compute.command('ssh [service]')
|
|
385
385
|
.description("Issue a short-lived SSH certificate for a compute service and print the command that uses it -- this command does NOT open the session itself, it makes `ssh` work. `--setup` does the one-time work: it generates a dedicated key under ~/.insta/ssh (your existing keys are never touched), has the platform sign a SHORT-LIVED certificate for it, adds one @cert-authority line to ~/.ssh/known_hosts so every region is trusted without per-node fingerprint prompts, and writes an ssh_config block AT THE TOP of ~/.ssh/config giving each compute service the alias `<service>.insta`. After that it is plain `ssh api.insta`, scp and -L: the block renews that alias's certificate for you while OpenSSH parses the config. Needs an interactive login -- API keys are refused; use `insta compute exec` for one-shot commands from CI")
|
|
@@ -423,7 +423,7 @@ compute.command('volume [service]').description("Show, attach, grow, remount, or
|
|
|
423
423
|
.option('--json').option('--branch <branch>', 'branch (default: current)').action(guard((service, o) => computeCmd.computeVolume(service, o)));
|
|
424
424
|
addObservability(compute, 'compute', 'compute');
|
|
425
425
|
// ---- postgres ----
|
|
426
|
-
const pg = program.command('postgres').description('Postgres services: connection string, psql, stats, resource ceiling, volume, always-on, network access, logs, metrics');
|
|
426
|
+
const pg = program.command('postgres').description('Postgres services: connection string, psql, stats, resource ceiling, restart, volume, always-on, network access, logs, metrics');
|
|
427
427
|
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
428
|
.option('--json').option('--branch <branch>', 'branch (default: current)')
|
|
429
429
|
.action(guard((service, o) => pgCmd.dbUrl(service, o)));
|
|
@@ -437,6 +437,9 @@ pg.command('limits [service]').description("Show or set a postgres service's res
|
|
|
437
437
|
.option('--cpu <n>', 'vCPU ceiling, e.g. 2 or 2500m').option('--memory <size>', 'memory ceiling, e.g. 4Gi')
|
|
438
438
|
.option('--json').option('--branch <branch>', 'branch (default: current)')
|
|
439
439
|
.action(guard((service, o) => pgCmd.dbLimits(service, o)));
|
|
440
|
+
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)")
|
|
441
|
+
.option('--json').option('--branch <branch>', 'branch (default: current)')
|
|
442
|
+
.action(guard((service, o) => pgCmd.dbRestart(service, o)));
|
|
440
443
|
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
444
|
.option('--size <gi>', 'new size in whole Gi, e.g. 10 (must be ≥ the current size)')
|
|
442
445
|
.option('--json').option('--branch <branch>', 'branch (default: current)')
|
|
@@ -515,7 +518,7 @@ buildCmd.command('logs <build-id>').description('Read source-build output for a
|
|
|
515
518
|
.action(guard((id, opts) => buildLogs(id, opts)));
|
|
516
519
|
// ---- deploy ----
|
|
517
520
|
program.command('deploy [dir]').description('Deploy a source directory (built remotely; on insta-compute a Dockerfile is optional and nixpacks detects the runtime) or a prebuilt --image to a branch compute group')
|
|
518
|
-
.option('--image <url>', 'prebuilt container image to deploy (instead of a source dir)').option('--branch <b>').option('--group <g>').option('--port <p>')
|
|
521
|
+
.option('--image <url>', 'prebuilt container image to deploy (instead of a source dir)').option('--branch <b>').option('--group <g>').option('--port <p>', 'port the app listens on (0 for a worker without a routed port)')
|
|
519
522
|
.option('--websocket', 'run a WebSocket app (larger guest + connection-based concurrency)')
|
|
520
523
|
.option('--replace-source', 'the service deploys from a connected GitHub repo: switch it to this image and remove the repo connection (admin); without it such a deploy is refused')
|
|
521
524
|
.option('--json', 'print the deploy result as JSON (build progress goes to stderr)')
|
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;
|
package/dist/pack.js
CHANGED
|
@@ -109,31 +109,8 @@ function walk(root, rel, out, links, ig, files, flavour, cap = Infinity) {
|
|
|
109
109
|
}
|
|
110
110
|
}
|
|
111
111
|
}
|
|
112
|
-
//
|
|
113
|
-
|
|
114
|
-
// into an archive that promises none. Two guards, and neither is a full one on its own:
|
|
115
|
-
//
|
|
116
|
-
// O_NOFOLLOW refuses when the final component is a symlink AT OPEN TIME, closing the swap the
|
|
117
|
-
// walk cannot see. Undefined on Windows, where it degrades to the check below.
|
|
118
|
-
//
|
|
119
|
-
// fstat on the OPEN HANDLE must still describe the file the walk measured: same inode, same
|
|
120
|
-
// device, same size. Its job is the tar's own consistency -- a file rewritten to a different
|
|
121
|
-
// length mid-pack would otherwise produce a header whose count disagrees with its payload.
|
|
122
|
-
//
|
|
123
|
-
// Two things neither closes, and both are stated rather than implied away:
|
|
124
|
-
//
|
|
125
|
-
// An ANCESTOR directory swapped for a symlink. Node exposes no openat, so resolving each
|
|
126
|
-
// component against a directory handle is not available here.
|
|
127
|
-
//
|
|
128
|
-
// A same-size plain file deleted and recreated. Measured on linux rather than assumed: the
|
|
129
|
-
// inode is REUSED and mtimeNs/ctimeNs are byte-identical for a delete+create inside one
|
|
130
|
-
// timestamp tick, so no stat-based identity can see it. It is also the least interesting case
|
|
131
|
-
// -- the symlink promise still holds, the tar stays well formed because the length did not
|
|
132
|
-
// move, and the archive simply carries a slightly newer copy of a file the caller owns.
|
|
133
|
-
//
|
|
134
|
-
// The residual on both is narrow: someone able to rewrite files and directories inside the tree
|
|
135
|
-
// being packed can already put any bytes they like into it by writing them.
|
|
136
|
-
export function readEntry(abs, e) {
|
|
112
|
+
// O_NOFOLLOW closes final-component symlink swaps only on platforms that expose it.
|
|
113
|
+
export function readEntry(abs, e, platform = process.platform) {
|
|
137
114
|
const noFollow = constants.O_NOFOLLOW ?? 0;
|
|
138
115
|
let fd;
|
|
139
116
|
try {
|
|
@@ -147,8 +124,10 @@ export function readEntry(abs, e) {
|
|
|
147
124
|
}
|
|
148
125
|
try {
|
|
149
126
|
const st = fstatSync(fd);
|
|
127
|
+
// Some Windows libuv versions omit lstat's device ID; inode and size must still match.
|
|
128
|
+
const missingDevice = platform === 'win32' && (e.dev === 0 || e.dev === 0n);
|
|
150
129
|
const same = st.isFile() && st.size === e.size
|
|
151
|
-
&& (e.ino === undefined || st.ino === e.ino) && (e.dev === undefined || st.dev === e.dev);
|
|
130
|
+
&& (e.ino === undefined || st.ino === e.ino) && (e.dev === undefined || missingDevice || st.dev === e.dev);
|
|
152
131
|
if (!same)
|
|
153
132
|
throw new Error(`${e.path} changed while packing — re-run the deploy`);
|
|
154
133
|
return readFileSync(fd);
|
package/dist/resolve-service.js
CHANGED
|
@@ -70,7 +70,7 @@ export async function resolveServiceArgs(type, name, deps, given = {}) {
|
|
|
70
70
|
throw new Error(missingArgsMessage(type));
|
|
71
71
|
// A bad --port is a typo in the command, not an answer: fail before asking anything.
|
|
72
72
|
if (given.port !== undefined)
|
|
73
|
-
parsePort(given.port);
|
|
73
|
+
parsePort(given.port, { allowZero: !type || type === 'compute' });
|
|
74
74
|
const kind = type
|
|
75
75
|
? SERVICE_KINDS.find((k) => k.type === type && !k.needsImage)
|
|
76
76
|
: await deps.selectKind(SERVICE_KINDS);
|
|
@@ -133,12 +133,12 @@ export async function promptServiceName(kind, suggested) {
|
|
|
133
133
|
}
|
|
134
134
|
export async function promptPort(fallback) {
|
|
135
135
|
const answer = await clack.text({
|
|
136
|
-
message: 'Port the image listens on:',
|
|
136
|
+
message: 'Port the image listens on (0 for a worker):',
|
|
137
137
|
initialValue: fallback,
|
|
138
138
|
// The rule the command enforces, so the prompt and a --port can never disagree.
|
|
139
139
|
validate: (v) => {
|
|
140
140
|
try {
|
|
141
|
-
parsePort(v.trim());
|
|
141
|
+
parsePort(v.trim(), { allowZero: true });
|
|
142
142
|
return undefined;
|
|
143
143
|
}
|
|
144
144
|
catch (e) {
|
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';
|
|
@@ -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
|