insta 0.1.15 → 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
  });
@@ -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);
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
package/dist/index.js CHANGED
@@ -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)));
@@ -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)')
@@ -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/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.15",
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": [