@@ -197,4 +197,8 @@ import { createServer } from 'node:http'; | ||
| info(`opening browser to authorize with ${provider}…`); | ||
| if (!openUrl(authorizeUrl)) | ||
| info(`open this URL to continue:\n ${authorizeUrl}`); | ||
| // Always print the URL: a launcher that fails to start reports it on spawn's ASYNC error | ||
| // event, so openUrl's return value cannot see it (e.g. powershell.exe blocked by AppLocker | ||
| // on hardened fleets) — and the silent variant of that failure looks exactly like a hang. | ||
| info(`if nothing opens, use this URL:\n ${authorizeUrl}`); | ||
| openUrl(authorizeUrl); | ||
| info('waiting for you to finish in the browser… (times out in 2m; ctrl-c to abort)'); | ||
| timer = setTimeout(() => { server.close(); reject(new Error('timed out waiting for browser login (2m)')); }, 120_000); | ||
@@ -201,0 +205,0 @@ }); |
@@ -70,3 +70,6 @@ import { ApiClient, requireProject } from '../api.js'; | ||
| } | ||
| // Print the URL and, unless --no-open, try to open it in the browser. | ||
| // Print the URL and, unless --no-open, try to open it in the browser. The message says | ||
| // "opening", not "opened": a launcher that starts and then fails reports it asynchronously, | ||
| // so openUrl's true return is an attempt, not a confirmation (see util.ts) — and the URL is | ||
| // already printed above for exactly that case. | ||
| function presentUrl(url, label, open) { | ||
@@ -76,4 +79,4 @@ info(label); | ||
| if (open !== false && openUrl(url)) | ||
| info('(opened in your default browser)'); | ||
| info('(opening in your default browser…)'); | ||
| } | ||
| //# sourceMappingURL=billing.js.map |
+96
-1
@@ -0,4 +1,6 @@ | ||
| import { spawn } from 'node:child_process'; | ||
| import { constants as osConstants } from 'node:os'; | ||
| import { ApiClient, ApiError, requireProject } from '../api.js'; | ||
| import { info, printJson, handleApproval } from '../util.js'; | ||
| import { parseVolumeGib } from './services.js'; | ||
| import { parseVolumeGib, q, resolveSoleService } from './services.js'; | ||
| // Toggle a postgres service between scale-to-zero (the default: instance suspends when idle, | ||
@@ -240,2 +242,95 @@ // cold-starts on the next connection) and always-on (instance stays warm; idle RAM bills at | ||
| } | ||
| // Resolve the postgres service (sole, or --group) and its connection string. Two reads: the | ||
| // branch's services list names the service; GET /services/:id/credentials (gated secrets.read) | ||
| // carries the value. Provider-minted credentials are canonical within their source service | ||
| // (DATABASE_URL) and deliberately absent from the general `insta secrets` bundle, so this is the | ||
| // read that yields the DSN. The credentials call carries no branch param — the service id is | ||
| // already branch-scoped by the list. Returns null when the read parked on an approval | ||
| // (handleApproval already spoke). Takes the client as an argument so tests drive it with a stub, | ||
| // per this repo's pure-seam convention. | ||
| export async function resolveDbUrl(api, projectId, branch, group, json) { | ||
| const { services } = await api.request('GET', `/projects/${projectId}/services${q(branch)}`); | ||
| const svc = resolveSoleService(services, 'postgres', group); | ||
| const res = await api.rawRequest('GET', `/projects/${projectId}/services/${svc.id}/credentials`); | ||
| if (handleApproval(res, json)) | ||
| return null; | ||
| const url = res.body?.credentials?.DATABASE_URL; | ||
| if (typeof url !== 'string' || !url) { | ||
| throw new Error(`postgres ${svc.name} has no DATABASE_URL credential yet — still provisioning? (\`insta services list\` shows status)`); | ||
| } | ||
| return { serviceName: svc.name, url }; | ||
| } | ||
| // Print the postgres connection string: the bare DSN on stdout, nothing else — pipe-friendly | ||
| // (`psql "$(insta db url)"`), like `storage get --json` keeps stdout parseable. | ||
| export async function dbUrl(opts) { | ||
| const api = await ApiClient.load(); | ||
| const p = await requireProject(); | ||
| const branch = opts.branch ?? p.branch; | ||
| const r = await resolveDbUrl(api, p.projectId, branch, opts.group, opts.json); | ||
| if (!r) | ||
| return; | ||
| if (opts.json) | ||
| return printJson({ service: r.serviceName, branch: branch ?? null, url: r.url }); | ||
| process.stdout.write(r.url + '\n'); | ||
| } | ||
| // Decompose a postgres DSN into libpq PG* environment variables. Pure, exported for tests. | ||
| // The credential must NOT ride in psql's argv — process arguments are visible to every local | ||
| // user via `ps`, so a secrets.read-gated value would leak the moment the session starts. Child | ||
| // environment is not (the `insta run` model), and PG* env is a libpq-supported mechanism, so | ||
| // psql runs with an empty argv. `sslmode` is the only query param the platform's DSNs carry; | ||
| // anything else would be a platform-side change this mapping should then learn about. | ||
| export function psqlEnvFromUrl(url) { | ||
| const u = new URL(url); | ||
| const env = {}; | ||
| if (u.hostname) | ||
| env.PGHOST = decodeURIComponent(u.hostname); | ||
| if (u.port) | ||
| env.PGPORT = u.port; | ||
| if (u.username) | ||
| env.PGUSER = decodeURIComponent(u.username); | ||
| if (u.password) | ||
| env.PGPASSWORD = decodeURIComponent(u.password); | ||
| const db = u.pathname.replace(/^\//, ''); | ||
| if (db) | ||
| env.PGDATABASE = decodeURIComponent(db); | ||
| const sslmode = u.searchParams.get('sslmode'); | ||
| if (sslmode) | ||
| env.PGSSLMODE = sslmode; | ||
| return env; | ||
| } | ||
| /** Core, dependency-injected for tests: spawn psql against the DSN (via PG* env, never argv), return its exit code. */ | ||
| export async function connectWithPsql(url, spawnImpl = spawn) { | ||
| // Strip ambient PG* first: parent-env PGHOSTADDR/PGSERVICE/PGOPTIONS/PGSSL* would silently | ||
| // redirect or reshape the connection away from the service this command just resolved. | ||
| const env = { ...process.env }; | ||
| // Case-insensitive: Windows env names are case-insensitive, so ambient `pgservice` redirects | ||
| // psql just as PGSERVICE does. | ||
| for (const k of Object.keys(env)) | ||
| if (k.slice(0, 2).toUpperCase() === 'PG') | ||
| delete env[k]; | ||
| Object.assign(env, psqlEnvFromUrl(url)); | ||
| return await new Promise((resolve, reject) => { | ||
| const child = spawnImpl('psql', [], { stdio: 'inherit', env }); | ||
| child.on('error', (e) => reject(e.code === 'ENOENT' | ||
| ? new Error('psql not found on PATH — install the postgres client, or print the DSN with `insta db url`') | ||
| : e)); | ||
| // Signal death reports code null — map to the conventional 128+signo (full table from | ||
| // os.constants) so the advertised exit-status passthrough holds for Ctrl-C'd/killed sessions. | ||
| child.on('close', (code, signal) => resolve(code ?? (signal ? 128 + (osConstants.signals[signal] ?? 0) : 1))); | ||
| }); | ||
| } | ||
| // Open an interactive psql session on the postgres service. The DSN never touches disk or argv | ||
| // history beyond the child process. Exits with psql's own exit code (agents rely on this, as | ||
| // with `compute exec`). | ||
| export async function dbConnect(opts) { | ||
| const api = await ApiClient.load(); | ||
| const p = await requireProject(); | ||
| const branch = opts.branch ?? p.branch; | ||
| const r = await resolveDbUrl(api, p.projectId, branch, opts.group, opts.json); | ||
| if (!r) | ||
| return; | ||
| // stderr: stdout belongs to psql (the `insta run` rule). | ||
| 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`); | ||
| process.exit(await connectWithPsql(r.url)); | ||
| } | ||
| //# sourceMappingURL=db.js.map |
@@ -123,2 +123,10 @@ // `insta services` — manage a project's opt-in services (postgres | storage | compute | redis | mysql | mongodb). | ||
| info(`added ${type} service ${name} on ${branch ?? 'default'} (${svc.id})${access}${svc.region ? ` ${svc.region}` : ''}${img}${vol}${svc.domain ? ` — ${svc.domain}` : ''}`); | ||
| // Discoverability: the DB is directly dialable, but its DSN is deliberately absent from the | ||
| // general `insta secrets` bundle — without this line nothing in the product says how to reach it. | ||
| if (type === 'postgres') { | ||
| // The hint must be runnable as printed: carry --branch when the service was created on a | ||
| // branch other than the linked one, and --group so it survives multiple postgres services. | ||
| const flags = `${opts.branch ? ` --branch ${opts.branch}` : ''} --group ${name}`; | ||
| info(` connect: \`insta db url${flags}\` prints the connection string, \`insta db connect${flags}\` opens psql (--group optional with a single postgres service)`); | ||
| } | ||
| renderNextActions(res.body.nextActions); | ||
@@ -125,0 +133,0 @@ } |
@@ -354,5 +354,6 @@ // `insta template` — browse the platform template registry and deploy a template (by registry | ||
| info(` ${u}`); | ||
| info('next: run `insta secrets` to refresh .env with the new service credentials'); | ||
| // Provider credentials are not in the `insta secrets` bundle — point at the paths that exist. | ||
| 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'); | ||
| renderNextActions(dep.nextActions); | ||
| } | ||
| //# sourceMappingURL=template.js.map |
+7
-1
@@ -226,3 +226,9 @@ #!/usr/bin/env node | ||
| // ---- db (postgres service controls) ---- | ||
| const db = program.command('db').description('Postgres service controls (limits / volume / always-on / scale-to-zero)'); | ||
| const db = program.command('db').description('Postgres service controls (url / connect / limits / volume / always-on / scale-to-zero)'); | ||
| 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') | ||
| .option('--json').option('--branch <branch>', 'branch (default: current)').option('--group <g>', 'postgres service name (default: the sole/default one)') | ||
| .action(guard((o) => dbCmd.dbUrl(o))); | ||
| 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") | ||
| .option('--branch <branch>', 'branch (default: current)').option('--group <g>', 'postgres service name (default: the sole/default one)') | ||
| .action(guard((o) => dbCmd.dbConnect(o))); | ||
| db.command('limits').description("Show or set a postgres service's resource ceiling (paid plans; insta-db-backed only). Moves both directions") | ||
@@ -229,0 +235,0 @@ .option('--cpu <n>', "vCPU ceiling, e.g. 2 or 2500m").option('--memory <size>', "memory ceiling, e.g. 4Gi") |
+36
-3
| // Output + small pure helpers (env serialization is unit-tested). | ||
| import { createInterface } from 'node:readline'; | ||
| import { spawn } from 'node:child_process'; | ||
| // Best-effort: open a URL in the user's default browser. Returns false if we couldn't launch. | ||
| /** How to launch the default browser for `url` on `platform`. Pure so the Windows encoding is | ||
| * testable. On Windows NO shell may ever parse the URL: cmd.exe splits at bare `&` (which #138 | ||
| * fixed by quoting) but ALSO expands `%…%` sequences even inside quotes, and a percent-encoded | ||
| * OAuth redirect (`http%3A%2F%2F127.0.0.1…`) is nothing but such sequences. So the launch goes | ||
| * through PowerShell's -EncodedCommand: a pure-ASCII script travels as base64(UTF-16LE) — no | ||
| * argument parsing anywhere — and the URL itself rides as a second base64 payload INSIDE that | ||
| * script, decoded by .NET at runtime, so no URL byte ever appears in PowerShell source (see the | ||
| * win32 branch). Start-Process on a URL is ShellExecute, i.e. the default browser. */ | ||
| export function openUrlSpawn(url, platform = process.platform, | ||
| // Absolute path, not bare `powershell`: CreateProcess-style lookup searches the current | ||
| // directory before PATH, so a planted powershell.exe beside the user's shell would win. | ||
| systemRoot = process.env.SYSTEMROOT ?? process.env.windir ?? 'C:\\Windows') { | ||
| if (platform === 'win32') { | ||
| // The URL never appears in PowerShell SOURCE at all: it travels as base64 inside the script | ||
| // and is decoded by .NET at runtime. Interpolating it into a quoted literal is not enough — | ||
| // PowerShell honors smart quotes (U+2018–U+201B) as string delimiters too, so ASCII-only | ||
| // escaping still leaves a breakout. The script below is pure ASCII by construction (the | ||
| // base64 alphabet), so no byte of any URL can terminate anything. | ||
| const urlB64 = Buffer.from(url, 'utf8').toString('base64'); | ||
| const script = `Start-Process ([Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${urlB64}')))`; | ||
| return { | ||
| cmd: `${systemRoot}\\System32\\WindowsPowerShell\\v1.0\\powershell.exe`, | ||
| args: ['-NoProfile', '-NonInteractive', '-EncodedCommand', Buffer.from(script, 'utf16le').toString('base64')], | ||
| }; | ||
| } | ||
| return { cmd: platform === 'darwin' ? 'open' : 'xdg-open', args: [url] }; | ||
| } | ||
| // ShellExecute-family launchers (Start-Process/open/xdg-open) run ANY target they're handed — | ||
| // a UNC path is an execution, not a navigation — so only web URLs may reach them. | ||
| export const isWebUrl = (url) => /^https?:\/\//i.test(url); | ||
| // Best-effort: open a URL in the user's default browser. Returns false if we couldn't launch — | ||
| // but a launcher that starts and THEN fails (ENOENT arrives on the async 'error' event) still | ||
| // reads as true, so callers must not treat true as proof the browser opened. | ||
| export function openUrl(url) { | ||
| const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'cmd' : 'xdg-open'; | ||
| const args = process.platform === 'win32' ? ['/c', 'start', '', url] : [url]; | ||
| if (!isWebUrl(url)) | ||
| return false; | ||
| const { cmd, args } = openUrlSpawn(url); | ||
| try { | ||
@@ -9,0 +42,0 @@ const child = spawn(cmd, args, { stdio: 'ignore', detached: true }); |
+1
-1
| { | ||
| "name": "insta", | ||
| "version": "0.0.45", | ||
| "version": "0.0.47", | ||
| "type": "module", | ||
@@ -5,0 +5,0 @@ "description": "InstaCloud CLI — a thin client of the platform control-plane API.", |
+11
-6
@@ -68,2 +68,3 @@ # insta-cli | ||
| insta services add compute api | ||
| insta secrets bind DATABASE_URL postgres/db --to compute/api | ||
| insta secrets | ||
@@ -74,4 +75,5 @@ insta deploy . | ||
| `project create` makes an empty project and links the current directory. Services are | ||
| opt-in, so you add only what you need. `secrets` writes the current branch's credentials to | ||
| `./.env`. `deploy .` builds the directory remotely and ships it to the branch's compute | ||
| opt-in, so you add only what you need. `secrets` writes the current branch's user-defined | ||
| secrets to `./.env` (the postgres connection string is read with `insta db url`). `deploy .` | ||
| builds the directory remotely and ships it to the branch's compute | ||
| service; it needs a `Dockerfile`, but no local Docker. | ||
@@ -109,6 +111,8 @@ | ||
| `insta secrets` fetches the current branch's bundle and writes `./.env`. `insta run <cmd>` | ||
| does the same without touching disk, injecting the bundle into the child process only. | ||
| Credential names are per service — `DATABASE_URL`, `BUCKET_NAME`, `AWS_ACCESS_KEY_ID` — | ||
| suffixed with the service name when a project has more than one service of a type. | ||
| `insta secrets` fetches the current branch's **user-defined** secrets and writes `./.env`. | ||
| `insta run <cmd>` does the same without touching disk, injecting them into the child process | ||
| only. Provider-minted service credentials (`DATABASE_URL`, `BUCKET_NAME`, | ||
| `AWS_ACCESS_KEY_ID`, …) are not in that bundle — they reach compute through explicit | ||
| `insta secrets bind` rules, and the postgres connection string is read directly with | ||
| `insta db url` (or `insta db connect` for a psql session). | ||
@@ -203,2 +207,3 @@ ### Destructive actions can require approval | ||
| | `insta compute` | `start` · `stop` · `suspend` · `status` · `set-domain` · `check-domain` · `remove-domain` | | ||
| | `insta db` | `url` (print the postgres DSN) · `connect` (psql session) · `limits` · `stats` · `always-on` · `volume` | | ||
| | `insta regions` | Regions available for postgres and compute | | ||
@@ -205,0 +210,0 @@ | `insta manifest` | Agent-legible view of every branch and its URLs | |
Shell access
Supply chain riskThis module accesses the system shell. Accessing the system shell increases the risk of executing arbitrary code.
Environment variable access
Supply chain riskPackage accesses environment variables, which may be a sign of credential stuffing or data theft.
Found 2 instances
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
344081
3.2%5973
2.58%248
2.06%65
4.84%17
6.25%