insta 0.0.45 → 0.0.47

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
@@ -65,13 +65,15 @@ insta login --oauth github
65
65
  insta project create my-app
66
66
  insta services add postgres db
67
67
  insta services add compute api
68
+ insta secrets bind DATABASE_URL postgres/db --to compute/api
68
69
  insta secrets
69
70
  insta deploy .
70
71
  ```
71
72
 
72
73
  `project create` makes an empty project and links the current directory. Services are
73
- opt-in, so you add only what you need. `secrets` writes the current branch's credentials to
74
- `./.env`. `deploy .` builds the directory remotely and ships it to the branch's compute
74
+ opt-in, so you add only what you need. `secrets` writes the current branch's user-defined
75
+ secrets to `./.env` (the postgres connection string is read with `insta db url`). `deploy .`
76
+ builds the directory remotely and ships it to the branch's compute
75
77
  service; it needs a `Dockerfile`, but no local Docker.
76
78
 
77
79
  ## Authentication
@@ -104,10 +106,12 @@ the two branches diverge independently. A project is capped at 10 branches.
104
106
 
105
107
  ### Credentials come from the secret seam, not a file you maintain
106
108
 
107
- `insta secrets` fetches the current branch's bundle and writes `./.env`. `insta run <cmd>`
108
- does the same without touching disk, injecting the bundle into the child process only.
109
- Credential names are per service — `DATABASE_URL`, `BUCKET_NAME`, `AWS_ACCESS_KEY_ID` —
110
- suffixed with the service name when a project has more than one service of a type.
109
+ `insta secrets` fetches the current branch's **user-defined** secrets and writes `./.env`.
110
+ `insta run <cmd>` does the same without touching disk, injecting them into the child process
111
+ only. Provider-minted service credentials (`DATABASE_URL`, `BUCKET_NAME`,
112
+ `AWS_ACCESS_KEY_ID`, …) are not in that bundle — they reach compute through explicit
113
+ `insta secrets bind` rules, and the postgres connection string is read directly with
114
+ `insta db url` (or `insta db connect` for a psql session).
111
115
 
112
116
  ### Destructive actions can require approval
113
117
 
@@ -197,6 +201,7 @@ build never reaches a production installer.
197
201
  | `insta run <cmd>` | Run a command with the branch bundle injected, nothing written to disk |
198
202
  | `insta deploy [dir]` | Deploy a source directory (built remotely) or `--image <url>` |
199
203
  | `insta compute` | `start` · `stop` · `suspend` · `status` · `set-domain` · `check-domain` · `remove-domain` |
204
+ | `insta db` | `url` (print the postgres DSN) · `connect` (psql session) · `limits` · `stats` · `always-on` · `volume` |
200
205
  | `insta regions` | Regions available for postgres and compute |
201
206
  | `insta manifest` | Agent-legible view of every branch and its URLs |
202
207
  | `insta metrics` · `logs` · `events` | Service metrics; runtime logs (`--deploy` for deploy events); audit timeline |
@@ -194,8 +194,12 @@ function browserOauth(apiUrl, provider) {
194
194
  const redirect = `http://127.0.0.1:${port}/callback`;
195
195
  const authorizeUrl = `${apiUrl}/auth/cli/authorize?provider=${encodeURIComponent(provider)}&redirect=${encodeURIComponent(redirect)}&state=${state}`;
196
196
  info(`opening browser to authorize with ${provider}…`);
197
- if (!openUrl(authorizeUrl))
198
- info(`open this URL to continue:\n ${authorizeUrl}`);
197
+ // Always print the URL: a launcher that fails to start reports it on spawn's ASYNC error
198
+ // event, so openUrl's return value cannot see it (e.g. powershell.exe blocked by AppLocker
199
+ // on hardened fleets) — and the silent variant of that failure looks exactly like a hang.
200
+ info(`if nothing opens, use this URL:\n ${authorizeUrl}`);
201
+ openUrl(authorizeUrl);
202
+ info('waiting for you to finish in the browser… (times out in 2m; ctrl-c to abort)');
199
203
  timer = setTimeout(() => { server.close(); reject(new Error('timed out waiting for browser login (2m)')); }, 120_000);
200
204
  });
201
205
  });
@@ -67,11 +67,14 @@ export async function billingPortal(opts) {
67
67
  return printJson({ url });
68
68
  presentUrl(url, 'Manage billing in your browser:', opts.open);
69
69
  }
70
- // Print the URL and, unless --no-open, try to open it in the browser.
70
+ // Print the URL and, unless --no-open, try to open it in the browser. The message says
71
+ // "opening", not "opened": a launcher that starts and then fails reports it asynchronously,
72
+ // so openUrl's true return is an attempt, not a confirmation (see util.ts) — and the URL is
73
+ // already printed above for exactly that case.
71
74
  function presentUrl(url, label, open) {
72
75
  info(label);
73
76
  info(` ${url}`);
74
77
  if (open !== false && openUrl(url))
75
- info('(opened in your default browser)');
78
+ info('(opening in your default browser…)');
76
79
  }
77
80
  //# sourceMappingURL=billing.js.map
@@ -1,6 +1,8 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { constants as osConstants } from 'node:os';
1
3
  import { ApiClient, ApiError, requireProject } from '../api.js';
2
4
  import { info, printJson, handleApproval } from '../util.js';
3
- import { parseVolumeGib } from './services.js';
5
+ import { parseVolumeGib, q, resolveSoleService } from './services.js';
4
6
  // Toggle a postgres service between scale-to-zero (the default: instance suspends when idle,
5
7
  // cold-starts on the next connection) and always-on (instance stays warm; idle RAM bills at
6
8
  // actual usage). Thin wrapper over PATCH /database/settings {scaleToZero} — insta-db-backed
@@ -237,4 +239,97 @@ export async function dbVolume(opts) {
237
239
  const vg = res.body?.volumeGib;
238
240
  info(`postgres ${opts.group ?? 'default'}: volume ${typeof vg === 'number' ? `grown to ${vg}Gi` : `set to ${sizeGib}Gi`}`);
239
241
  }
242
+ // Resolve the postgres service (sole, or --group) and its connection string. Two reads: the
243
+ // branch's services list names the service; GET /services/:id/credentials (gated secrets.read)
244
+ // carries the value. Provider-minted credentials are canonical within their source service
245
+ // (DATABASE_URL) and deliberately absent from the general `insta secrets` bundle, so this is the
246
+ // read that yields the DSN. The credentials call carries no branch param — the service id is
247
+ // already branch-scoped by the list. Returns null when the read parked on an approval
248
+ // (handleApproval already spoke). Takes the client as an argument so tests drive it with a stub,
249
+ // per this repo's pure-seam convention.
250
+ export async function resolveDbUrl(api, projectId, branch, group, json) {
251
+ const { services } = await api.request('GET', `/projects/${projectId}/services${q(branch)}`);
252
+ const svc = resolveSoleService(services, 'postgres', group);
253
+ const res = await api.rawRequest('GET', `/projects/${projectId}/services/${svc.id}/credentials`);
254
+ if (handleApproval(res, json))
255
+ return null;
256
+ const url = res.body?.credentials?.DATABASE_URL;
257
+ if (typeof url !== 'string' || !url) {
258
+ throw new Error(`postgres ${svc.name} has no DATABASE_URL credential yet — still provisioning? (\`insta services list\` shows status)`);
259
+ }
260
+ return { serviceName: svc.name, url };
261
+ }
262
+ // Print the postgres connection string: the bare DSN on stdout, nothing else — pipe-friendly
263
+ // (`psql "$(insta db url)"`), like `storage get --json` keeps stdout parseable.
264
+ export async function dbUrl(opts) {
265
+ const api = await ApiClient.load();
266
+ const p = await requireProject();
267
+ const branch = opts.branch ?? p.branch;
268
+ const r = await resolveDbUrl(api, p.projectId, branch, opts.group, opts.json);
269
+ if (!r)
270
+ return;
271
+ if (opts.json)
272
+ return printJson({ service: r.serviceName, branch: branch ?? null, url: r.url });
273
+ process.stdout.write(r.url + '\n');
274
+ }
275
+ // Decompose a postgres DSN into libpq PG* environment variables. Pure, exported for tests.
276
+ // The credential must NOT ride in psql's argv — process arguments are visible to every local
277
+ // user via `ps`, so a secrets.read-gated value would leak the moment the session starts. Child
278
+ // environment is not (the `insta run` model), and PG* env is a libpq-supported mechanism, so
279
+ // psql runs with an empty argv. `sslmode` is the only query param the platform's DSNs carry;
280
+ // anything else would be a platform-side change this mapping should then learn about.
281
+ export function psqlEnvFromUrl(url) {
282
+ const u = new URL(url);
283
+ const env = {};
284
+ if (u.hostname)
285
+ env.PGHOST = decodeURIComponent(u.hostname);
286
+ if (u.port)
287
+ env.PGPORT = u.port;
288
+ if (u.username)
289
+ env.PGUSER = decodeURIComponent(u.username);
290
+ if (u.password)
291
+ env.PGPASSWORD = decodeURIComponent(u.password);
292
+ const db = u.pathname.replace(/^\//, '');
293
+ if (db)
294
+ env.PGDATABASE = decodeURIComponent(db);
295
+ const sslmode = u.searchParams.get('sslmode');
296
+ if (sslmode)
297
+ env.PGSSLMODE = sslmode;
298
+ return env;
299
+ }
300
+ /** Core, dependency-injected for tests: spawn psql against the DSN (via PG* env, never argv), return its exit code. */
301
+ export async function connectWithPsql(url, spawnImpl = spawn) {
302
+ // Strip ambient PG* first: parent-env PGHOSTADDR/PGSERVICE/PGOPTIONS/PGSSL* would silently
303
+ // redirect or reshape the connection away from the service this command just resolved.
304
+ const env = { ...process.env };
305
+ // Case-insensitive: Windows env names are case-insensitive, so ambient `pgservice` redirects
306
+ // psql just as PGSERVICE does.
307
+ for (const k of Object.keys(env))
308
+ if (k.slice(0, 2).toUpperCase() === 'PG')
309
+ delete env[k];
310
+ Object.assign(env, psqlEnvFromUrl(url));
311
+ return await new Promise((resolve, reject) => {
312
+ const child = spawnImpl('psql', [], { stdio: 'inherit', env });
313
+ child.on('error', (e) => reject(e.code === 'ENOENT'
314
+ ? new Error('psql not found on PATH — install the postgres client, or print the DSN with `insta db url`')
315
+ : e));
316
+ // Signal death reports code null — map to the conventional 128+signo (full table from
317
+ // os.constants) so the advertised exit-status passthrough holds for Ctrl-C'd/killed sessions.
318
+ child.on('close', (code, signal) => resolve(code ?? (signal ? 128 + (osConstants.signals[signal] ?? 0) : 1)));
319
+ });
320
+ }
321
+ // Open an interactive psql session on the postgres service. The DSN never touches disk or argv
322
+ // history beyond the child process. Exits with psql's own exit code (agents rely on this, as
323
+ // with `compute exec`).
324
+ export async function dbConnect(opts) {
325
+ const api = await ApiClient.load();
326
+ const p = await requireProject();
327
+ const branch = opts.branch ?? p.branch;
328
+ const r = await resolveDbUrl(api, p.projectId, branch, opts.group, opts.json);
329
+ if (!r)
330
+ return;
331
+ // stderr: stdout belongs to psql (the `insta run` rule).
332
+ process.stderr.write(`psql → postgres/${r.serviceName}${branch ? ` (branch ${branch})` : ''} — a suspended instance wakes on connect, so the first prompt can take a few seconds\n`);
333
+ process.exit(await connectWithPsql(r.url));
334
+ }
240
335
  //# sourceMappingURL=db.js.map
@@ -120,6 +120,14 @@ export async function servicesAdd(type, name, opts = {}) {
120
120
  const img = svc.image ? ` running ${svc.image}${svc.port ? `:${svc.port}` : ''}` : '';
121
121
  const vol = svc.volume_gib ? ` vol ${svc.volume_gib}Gi at /data` : '';
122
122
  info(`added ${type} service ${name} on ${branch ?? 'default'} (${svc.id})${access}${svc.region ? ` ${svc.region}` : ''}${img}${vol}${svc.domain ? ` — ${svc.domain}` : ''}`);
123
+ // Discoverability: the DB is directly dialable, but its DSN is deliberately absent from the
124
+ // general `insta secrets` bundle — without this line nothing in the product says how to reach it.
125
+ if (type === 'postgres') {
126
+ // The hint must be runnable as printed: carry --branch when the service was created on a
127
+ // branch other than the linked one, and --group so it survives multiple postgres services.
128
+ const flags = `${opts.branch ? ` --branch ${opts.branch}` : ''} --group ${name}`;
129
+ info(` connect: \`insta db url${flags}\` prints the connection string, \`insta db connect${flags}\` opens psql (--group optional with a single postgres service)`);
130
+ }
123
131
  renderNextActions(res.body.nextActions);
124
132
  }
125
133
  // Render one `services list` row. Pure, so it's unit-tested without a network mock (mirrors
@@ -351,7 +351,8 @@ export async function templateDeploy(target, opts = {}, deps = {}) {
351
351
  info(`template ${codeLabel} deployed to branch ${branchName}`);
352
352
  for (const u of deploymentUrls(dep))
353
353
  info(` ${u}`);
354
- info('next: run `insta secrets` to refresh .env with the new service credentials');
354
+ // Provider credentials are not in the `insta secrets` bundle — point at the paths that exist.
355
+ info('next: `insta db url` prints the postgres DSN; bind service credentials into compute with `insta secrets bind`; `insta secrets` refreshes user-defined secrets in .env');
355
356
  renderNextActions(dep.nextActions);
356
357
  }
357
358
  //# sourceMappingURL=template.js.map
package/dist/index.js CHANGED
@@ -223,7 +223,13 @@ compute.command('volume [service]').description("Show, attach, grow, or delete a
223
223
  .option('--delete', 'destroy the volume and ALL its data (irreversible; download anything you need first)')
224
224
  .option('--json').option('--branch <branch>', 'branch (default: current)').action(guard((service, o) => computeCmd.computeVolume(service, o)));
225
225
  // ---- db (postgres service controls) ----
226
- const db = program.command('db').description('Postgres service controls (limits / volume / always-on / scale-to-zero)');
226
+ const db = program.command('db').description('Postgres service controls (url / connect / limits / volume / always-on / scale-to-zero)');
227
+ db.command('url').description('Print the postgres connection string (DSN) — bare on stdout for piping, e.g. `psql "$(insta db url)"` (gated: secrets.read). Provider credentials are not in `insta secrets` — this is the command that yields the DSN')
228
+ .option('--json').option('--branch <branch>', 'branch (default: current)').option('--group <g>', 'postgres service name (default: the sole/default one)')
229
+ .action(guard((o) => dbCmd.dbUrl(o)));
230
+ db.command('connect').description("Open an interactive psql session on the postgres service (needs psql on PATH; gated: secrets.read). A suspended instance wakes on connect — the first prompt can take a few seconds. Exits with psql's own exit code")
231
+ .option('--branch <branch>', 'branch (default: current)').option('--group <g>', 'postgres service name (default: the sole/default one)')
232
+ .action(guard((o) => dbCmd.dbConnect(o)));
227
233
  db.command('limits').description("Show or set a postgres service's resource ceiling (paid plans; insta-db-backed only). Moves both directions")
228
234
  .option('--cpu <n>', "vCPU ceiling, e.g. 2 or 2500m").option('--memory <size>', "memory ceiling, e.g. 4Gi")
229
235
  .option('--json').option('--branch <branch>', 'branch (default: current)').option('--group <g>', 'postgres service name (default: the sole/default one)')
package/dist/util.js CHANGED
@@ -1,10 +1,43 @@
1
1
  // Output + small pure helpers (env serialization is unit-tested).
2
2
  import { createInterface } from 'node:readline';
3
3
  import { spawn } from 'node:child_process';
4
- // Best-effort: open a URL in the user's default browser. Returns false if we couldn't launch.
4
+ /** How to launch the default browser for `url` on `platform`. Pure so the Windows encoding is
5
+ * testable. On Windows NO shell may ever parse the URL: cmd.exe splits at bare `&` (which #138
6
+ * fixed by quoting) but ALSO expands `%…%` sequences even inside quotes, and a percent-encoded
7
+ * OAuth redirect (`http%3A%2F%2F127.0.0.1…`) is nothing but such sequences. So the launch goes
8
+ * through PowerShell's -EncodedCommand: a pure-ASCII script travels as base64(UTF-16LE) — no
9
+ * argument parsing anywhere — and the URL itself rides as a second base64 payload INSIDE that
10
+ * script, decoded by .NET at runtime, so no URL byte ever appears in PowerShell source (see the
11
+ * win32 branch). Start-Process on a URL is ShellExecute, i.e. the default browser. */
12
+ export function openUrlSpawn(url, platform = process.platform,
13
+ // Absolute path, not bare `powershell`: CreateProcess-style lookup searches the current
14
+ // directory before PATH, so a planted powershell.exe beside the user's shell would win.
15
+ systemRoot = process.env.SYSTEMROOT ?? process.env.windir ?? 'C:\\Windows') {
16
+ if (platform === 'win32') {
17
+ // The URL never appears in PowerShell SOURCE at all: it travels as base64 inside the script
18
+ // and is decoded by .NET at runtime. Interpolating it into a quoted literal is not enough —
19
+ // PowerShell honors smart quotes (U+2018–U+201B) as string delimiters too, so ASCII-only
20
+ // escaping still leaves a breakout. The script below is pure ASCII by construction (the
21
+ // base64 alphabet), so no byte of any URL can terminate anything.
22
+ const urlB64 = Buffer.from(url, 'utf8').toString('base64');
23
+ const script = `Start-Process ([Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${urlB64}')))`;
24
+ return {
25
+ cmd: `${systemRoot}\\System32\\WindowsPowerShell\\v1.0\\powershell.exe`,
26
+ args: ['-NoProfile', '-NonInteractive', '-EncodedCommand', Buffer.from(script, 'utf16le').toString('base64')],
27
+ };
28
+ }
29
+ return { cmd: platform === 'darwin' ? 'open' : 'xdg-open', args: [url] };
30
+ }
31
+ // ShellExecute-family launchers (Start-Process/open/xdg-open) run ANY target they're handed —
32
+ // a UNC path is an execution, not a navigation — so only web URLs may reach them.
33
+ export const isWebUrl = (url) => /^https?:\/\//i.test(url);
34
+ // Best-effort: open a URL in the user's default browser. Returns false if we couldn't launch —
35
+ // but a launcher that starts and THEN fails (ENOENT arrives on the async 'error' event) still
36
+ // reads as true, so callers must not treat true as proof the browser opened.
5
37
  export function openUrl(url) {
6
- const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'cmd' : 'xdg-open';
7
- const args = process.platform === 'win32' ? ['/c', 'start', '', url] : [url];
38
+ if (!isWebUrl(url))
39
+ return false;
40
+ const { cmd, args } = openUrlSpawn(url);
8
41
  try {
9
42
  const child = spawn(cmd, args, { stdio: 'ignore', detached: true });
10
43
  child.on('error', () => { });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "insta",
3
- "version": "0.0.45",
3
+ "version": "0.0.47",
4
4
  "type": "module",
5
5
  "description": "InstaCloud CLI — a thin client of the platform control-plane API.",
6
6
  "keywords": [