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 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 |
@@ -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('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;')})` : '')}</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
- openUrl(authorizeUrl);
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
  });
@@ -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}`);
@@ -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 = "${url}"\n`;
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
- // Parse a TCP port. Junk fails here rather than reaching the API as NaN (the parseCpu lesson).
51
- // Decimal digits only, as parseVolumeGib: `Number()` alone would quietly read 0x1f90 as 8080 and
52
- // 1e3 as 1000, and a port written in hex is a typo worth reporting, not one worth honouring.
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 < 1 || n > 65535)
57
- throw new Error(`port must be an integer between 1 and 65535, got: ${raw}`);
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: compute is born always-on by default,
118
- // so `--no-always-on` must reach the API as an explicit false. Omitted means the platform default.
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); // junk fails here, before any config/network access
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
- await writeFile(GLOBAL_FILE, JSON.stringify(c, null, 2));
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
@@ -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 (the default for new compute services; all plans; billing is actual usage either way)')
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); stays down until `start`')
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); off = scale-to-zero. All plans; billing is actual usage either way')
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)')
@@ -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
- appendFileSync(join(instaDir, 'audit.jsonl'), lines, 'utf8');
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() {
@@ -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-----/gd, group: false },
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, span, fp) {
45
- const [s, e] = span;
46
- const redacted = text.slice(0, s) + '«' + fp + '»' + text.slice(e);
47
- const marker = '«' + fp + '»';
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 < redacted.length)
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
- if (kept.some((k) => !(r[1] <= k[0] || r[0] >= k[1])))
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
- for (const [start, end, detector, secret] of scanText(text)) {
182
- const fp = fingerprint(secret, detector);
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(text, [start, end], fp), note);
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
- // The walk classifies with lstat and the read happens later, so a plain readFileSync would
113
- // FOLLOW a symlink that replaced the file in between and put a file from outside the directory
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);
@@ -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
- // Hidden password prompt (best-effort: mutes echo on a TTY).
257
- export function promptPassword(label = 'Password: ') {
258
- return new Promise((resolve) => {
259
- const rl = createInterface({ input: process.stdin, output: process.stdout });
260
- const stdout = process.stdout;
261
- process.stdout.write(label);
262
- let captured = '';
263
- stdout._writeToOutput = (s) => { if (s.includes('\n'))
264
- process.stdout.write('\n'); };
265
- rl.on('line', (line) => { captured = line; rl.close(); });
266
- rl.on('close', () => resolve(captured));
257
+ export function mutePasswordOutput(real) {
258
+ return new Writable({
259
+ write(chunk, _encoding, callback) {
260
+ const text = Buffer.isBuffer(chunk) ? chunk.toString('utf8') : String(chunk);
261
+ const endings = text.replace(/[^\r\n]/g, '');
262
+ if (endings.length)
263
+ real.write(endings);
264
+ callback();
265
+ },
266
+ });
267
+ }
268
+ export function promptPasswordFrom(input, output, label = 'Password: ') {
269
+ return new Promise((resolve, reject) => {
270
+ let value = '';
271
+ const rl = createInterface({
272
+ input,
273
+ output: mutePasswordOutput(output),
274
+ // TTY stdin needs raw mode even when stdout is redirected, or the terminal echoes secrets.
275
+ terminal: Boolean(input.isTTY),
276
+ });
277
+ output.write(label);
278
+ rl.on('SIGINT', () => {
279
+ reject(new CliCancel());
280
+ rl.close();
281
+ });
282
+ rl.on('line', (line) => {
283
+ value = line;
284
+ rl.close();
285
+ });
286
+ rl.on('close', () => resolve(value));
267
287
  });
268
288
  }
289
+ export function promptPassword(label = 'Password: ') {
290
+ return promptPasswordFrom(process.stdin, process.stdout, label);
291
+ }
269
292
  //# sourceMappingURL=util.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "insta",
3
- "version": "0.1.13",
3
+ "version": "0.1.16",
4
4
  "type": "module",
5
5
  "description": "InstaCloud CLI — a thin client of the platform control-plane API.",
6
6
  "keywords": [