| // `insta build` — pre-push verification of a source directory: the detection plan (what would | ||
| // build), the Dockerfile that would be used (yours, or nixpacks-generated), and static checks. | ||
| // Entirely local and offline: no login, no project link, nothing pushed or deployed. Phase 1 is | ||
| // static-only — no Docker daemon involved. | ||
| import { resolve, join } from 'node:path'; | ||
| import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'; | ||
| import { info, printJson, die } from '../util.js'; | ||
| import { dockerfileExposedPort } from './deploy.js'; | ||
| import { nixpacksPlan, nixpacksGeneratedDockerfile, nixpacksAvailable, quietRunner } from '../nixpacks.js'; | ||
| // A failed critical sinks the build; a failed warning deserves attention; skips are not failures. | ||
| export function computeVerdict(checks) { | ||
| const failed = checks.filter((c) => c.status === 'fail'); | ||
| if (failed.some((c) => c.severity === 'critical')) | ||
| return 'failed'; | ||
| if (failed.length > 0) | ||
| return 'needs-attention'; | ||
| return 'deployable'; | ||
| } | ||
| // Port resolution mirrors deploy.ts: an explicit --port wins, else the Dockerfile's EXPOSE. The | ||
| // rationale string is part of the output — every plan line says why (the `fly launch` pattern). | ||
| export function inferPort(flag, dockerfile) { | ||
| if (flag !== undefined) { | ||
| const port = /^\d+$/.test(flag.trim()) ? Number(flag.trim()) : NaN; | ||
| if (!Number.isInteger(port) || port < 1 || port > 65535) | ||
| throw new Error(`--port must be an integer between 1 and 65535, got: ${flag}`); | ||
| return { port, rationale: '--port flag' }; | ||
| } | ||
| const exposed = dockerfile ? dockerfileExposedPort(dockerfile) : undefined; | ||
| if (exposed) | ||
| return { port: exposed, rationale: `Dockerfile EXPOSE ${exposed}` }; | ||
| return { port: undefined, rationale: 'not detected — deploy defaults to 8080' }; | ||
| } | ||
| // Keys the app expects, from .env.example — surfaced so an agent can `insta secrets set` them | ||
| // before the first deploy instead of discovering missing config from runtime crashes. | ||
| export function envKeysFromDotEnvExample(content) { | ||
| const keys = []; | ||
| for (const line of content.split('\n')) { | ||
| if (line.trim().startsWith('#')) | ||
| continue; | ||
| const key = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=/.exec(line)?.[1]; | ||
| if (key) | ||
| keys.push(key); | ||
| } | ||
| return keys; | ||
| } | ||
| const CONTEXT_WARN_BYTES = 100 * 1024 * 1024; | ||
| const WALK_CAP = 50_000; // entries; hitting it marks the stats truncated (size becomes a floor) | ||
| // Sizes what would actually ship: a dockerignored node_modules is skipped, not counted. (Only the | ||
| // node_modules pattern is honored — full .dockerignore glob semantics aren't reimplemented here.) | ||
| export function contextStats(dir, cap = WALK_CAP) { | ||
| const ignoreFile = join(dir, '.dockerignore'); | ||
| const ignoreLines = existsSync(ignoreFile) | ||
| ? readFileSync(ignoreFile, 'utf8').split('\n').map((l) => l.trim()).filter((l) => l && !l.startsWith('#')) | ||
| : []; | ||
| const nodeModulesIgnored = ignoreLines.some((l) => ['node_modules', 'node_modules/', '/node_modules', '**/node_modules'].includes(l)); | ||
| let totalBytes = 0; | ||
| let nodeModulesBytes = 0; | ||
| let hasNodeModules = false; | ||
| let truncated = false; | ||
| let seen = 0; | ||
| const walk = (d, inNodeModules) => { | ||
| let entries; | ||
| try { | ||
| entries = readdirSync(d); | ||
| } | ||
| catch { | ||
| return; | ||
| } | ||
| for (const name of entries) { | ||
| if (seen++ >= cap) { | ||
| truncated = true; | ||
| return; | ||
| } | ||
| if (name === '.git') | ||
| continue; | ||
| const p = join(d, name); | ||
| let st; | ||
| try { | ||
| st = statSync(p); | ||
| } | ||
| catch { | ||
| continue; | ||
| } | ||
| if (name === 'node_modules' && st.isDirectory()) { | ||
| hasNodeModules = true; | ||
| if (nodeModulesIgnored) | ||
| continue; // excluded from the context — don't count it | ||
| } | ||
| const isNm = inNodeModules || name === 'node_modules'; | ||
| if (st.isDirectory()) | ||
| walk(p, isNm); | ||
| else { | ||
| totalBytes += st.size; | ||
| if (isNm) | ||
| nodeModulesBytes += st.size; | ||
| } | ||
| } | ||
| }; | ||
| walk(dir, false); | ||
| return { totalBytes, nodeModulesBytes, hasNodeModules, nodeModulesIgnored, truncated }; | ||
| } | ||
| const mb = (bytes) => `${(bytes / 1024 / 1024).toFixed(1)} MB`; | ||
| export function contextCheck(ctx) { | ||
| const shipsNodeModules = ctx.hasNodeModules && !ctx.nodeModulesIgnored; | ||
| const tooBig = ctx.totalBytes > CONTEXT_WARN_BYTES; | ||
| const size = `${mb(ctx.totalBytes)}${ctx.truncated ? '+' : ''}`; | ||
| const detail = shipsNodeModules | ||
| ? `node_modules (${mb(ctx.nodeModulesBytes)}) would ship in the ${size} build context` | ||
| : ctx.truncated | ||
| ? `over ${WALK_CAP.toLocaleString('en-US')} entries — scan truncated, ${size} is a floor` | ||
| : `${size}${tooBig ? ' — large contexts make remote builds slow' : ''}`; | ||
| const bad = shipsNodeModules || tooBig || ctx.truncated; | ||
| return { | ||
| id: 'context', | ||
| severity: 'warning', | ||
| status: bad ? 'fail' : 'pass', | ||
| title: 'build context', | ||
| detail, | ||
| ...(bad ? { nextAction: 'add a .dockerignore (node_modules, build artifacts, secrets)' } : {}), | ||
| }; | ||
| } | ||
| export async function buildReport(dirArg, opts, deps) { | ||
| const dir = resolve(process.cwd(), dirArg); | ||
| const userDockerfilePath = join(dir, 'Dockerfile'); | ||
| const hasUserDockerfile = existsSync(userDockerfilePath); | ||
| let dockerfile = { source: null }; | ||
| let np = null; | ||
| let dockerfileDetail = ''; | ||
| if (hasUserDockerfile) { | ||
| dockerfile = { source: 'user', path: userDockerfilePath, content: readFileSync(userDockerfilePath, 'utf8') }; | ||
| dockerfileDetail = 'using the Dockerfile in the directory'; | ||
| } | ||
| else if (!deps.nixpacksAvailable) { | ||
| dockerfileDetail = 'no Dockerfile in the directory, and nixpacks is not installed to generate one'; | ||
| } | ||
| else { | ||
| np = await nixpacksPlan(dir, deps.runner); | ||
| if (!np) { | ||
| dockerfileDetail = 'no Dockerfile, and nixpacks matched no provider for this directory'; | ||
| } | ||
| else { | ||
| const generated = await nixpacksGeneratedDockerfile(dir, deps.runner); | ||
| if (generated) { | ||
| dockerfile = { source: 'nixpacks', content: generated }; | ||
| dockerfileDetail = `generated by nixpacks (providers: ${np.providers.join(', ') || 'none'})`; | ||
| } | ||
| else { | ||
| dockerfileDetail = 'nixpacks detected the app but could not generate a Dockerfile'; | ||
| } | ||
| } | ||
| } | ||
| const builder = hasUserDockerfile ? 'dockerfile' : np ? 'nixpacks' : null; | ||
| const { port, rationale } = inferPort(opts.port, dockerfile.content); | ||
| const envExample = join(dir, '.env.example'); | ||
| const envKeys = existsSync(envExample) ? envKeysFromDotEnvExample(readFileSync(envExample, 'utf8')) : []; | ||
| const checks = []; | ||
| checks.push({ | ||
| id: 'dockerfile', | ||
| severity: 'critical', | ||
| status: dockerfile.source ? 'pass' : 'fail', | ||
| title: 'Dockerfile', | ||
| detail: dockerfileDetail, | ||
| ...(dockerfile.source ? {} : { nextAction: `add a Dockerfile at ${userDockerfilePath}, or install nixpacks (https://nixpacks.com/docs/install) so insta can generate one` }), | ||
| }); | ||
| if (builder === 'dockerfile') { | ||
| const hasCmd = /^\s*(CMD|ENTRYPOINT)\s/im.test(dockerfile.content ?? ''); | ||
| checks.push({ | ||
| id: 'start-command', | ||
| severity: 'warning', | ||
| status: hasCmd ? 'pass' : 'fail', | ||
| title: 'start command', | ||
| detail: hasCmd ? 'Dockerfile has a CMD/ENTRYPOINT' : 'no CMD or ENTRYPOINT in the Dockerfile — the base image must supply one', | ||
| ...(hasCmd ? {} : { nextAction: 'add a CMD (or ENTRYPOINT) so the image starts your app' }), | ||
| }); | ||
| } | ||
| else if (builder === 'nixpacks') { | ||
| checks.push({ | ||
| id: 'start-command', | ||
| severity: 'critical', | ||
| status: np?.startCommand ? 'pass' : 'fail', | ||
| title: 'start command', | ||
| detail: np?.startCommand ?? 'nixpacks found no start command — the built image would not run', | ||
| ...(np?.startCommand ? {} : { nextAction: 'define one (e.g. a package.json "start" script, or a Procfile)' }), | ||
| }); | ||
| } | ||
| else { | ||
| checks.push({ id: 'start-command', severity: 'critical', status: 'skip', title: 'start command', detail: 'skipped — no builder' }); | ||
| } | ||
| checks.push({ | ||
| id: 'port', | ||
| severity: 'warning', | ||
| status: port !== undefined ? 'pass' : 'fail', | ||
| title: 'port', | ||
| detail: port !== undefined ? `${port} (${rationale})` : rationale, | ||
| ...(port !== undefined ? {} : { nextAction: 'pass --port <n> (or add EXPOSE <n> to the Dockerfile) — a port mismatch is the #1 deploy mistake' }), | ||
| }); | ||
| checks.push(contextCheck(contextStats(dir))); | ||
| return { | ||
| dir, | ||
| plan: { builder, providers: np?.providers ?? [], installCommand: np?.installCommand, buildCommand: np?.buildCommand, startCommand: np?.startCommand, port, portRationale: rationale, envKeys }, | ||
| dockerfile, | ||
| checks, | ||
| verdict: computeVerdict(checks), | ||
| }; | ||
| } | ||
| const MARK = { pass: '✓', fail: '✗', skip: '·' }; | ||
| export function renderReport(r, explain) { | ||
| const lines = []; | ||
| lines.push(`plan for ${r.dir}:`); | ||
| lines.push(` builder: ${r.plan.builder ?? 'none'}${r.plan.providers.length ? ` (providers: ${r.plan.providers.join(', ')})` : ''}`); | ||
| if (r.plan.installCommand) | ||
| lines.push(` install: ${r.plan.installCommand}`); | ||
| if (r.plan.buildCommand) | ||
| lines.push(` build: ${r.plan.buildCommand}`); | ||
| if (r.plan.startCommand) | ||
| lines.push(` start: ${r.plan.startCommand}`); | ||
| lines.push(` port: ${r.plan.port ?? '?'} (${r.plan.portRationale})`); | ||
| if (r.plan.envKeys.length) | ||
| lines.push(` env keys (.env.example): ${r.plan.envKeys.join(', ')}`); | ||
| lines.push('checks:'); | ||
| for (const c of r.checks) { | ||
| const mark = c.status === 'fail' && c.severity !== 'critical' ? '⚠' : MARK[c.status]; | ||
| lines.push(` ${mark} ${c.title}${c.detail ? ` — ${c.detail}` : ''}`); | ||
| if (c.status === 'fail' && c.nextAction) | ||
| lines.push(` → ${c.nextAction}`); | ||
| } | ||
| if (explain && r.dockerfile.content) { | ||
| lines.push(`dockerfile (${r.dockerfile.source}):`); | ||
| for (const l of r.dockerfile.content.trimEnd().split('\n')) | ||
| lines.push(` ${l}`); | ||
| } | ||
| lines.push(`verdict: ${r.verdict}`); | ||
| return lines; | ||
| } | ||
| // Dockerfile content is included with --explain; without it the report stays small. | ||
| export function jsonReport(report, explain) { | ||
| return explain ? report : { ...report, dockerfile: { ...report.dockerfile, content: undefined } }; | ||
| } | ||
| export async function build(dirArg, opts) { | ||
| const dir = dirArg ?? '.'; | ||
| const abs = resolve(process.cwd(), dir); | ||
| if (!existsSync(abs) || !statSync(abs).isDirectory()) | ||
| die(`no such directory: ${abs}`); | ||
| // Only probe for nixpacks when there is no Dockerfile to verify. The probe is silent and never | ||
| // installs anything — stdout must stay pure for --json, and a verifier must stay offline. | ||
| const available = existsSync(join(abs, 'Dockerfile')) ? false : await nixpacksAvailable(); | ||
| const report = await buildReport(dir, opts, { runner: quietRunner, nixpacksAvailable: available }); | ||
| if (opts.json) | ||
| printJson(jsonReport(report, !!opts.explain)); | ||
| else | ||
| for (const line of renderReport(report, !!opts.explain)) | ||
| info(line); | ||
| if (report.verdict === 'failed') | ||
| process.exitCode = 1; | ||
| } | ||
| //# sourceMappingURL=build.js.map |
| // nixpacks glue for `insta build`: framework detection (`nixpacks plan`) and Dockerfile | ||
| // generation (`nixpacks build --out`) — both static, neither touches a Docker daemon. | ||
| // Same injectable-runner pattern as flyctl-build.ts. | ||
| import { spawn } from 'node:child_process'; | ||
| import { mkdtempSync, readFileSync, rmSync, existsSync } from 'node:fs'; | ||
| import { tmpdir } from 'node:os'; | ||
| import { join } from 'node:path'; | ||
| // Capture-only runner: plan output is parsed (not shown), and a wedged binary must not hang the | ||
| // command — kill after 30s and let the caller degrade. | ||
| export const quietRunner = (cmd, args, opts) => new Promise((resolve) => { | ||
| const child = spawn(cmd, args, { cwd: opts.cwd, env: opts.env, stdio: ['ignore', 'pipe', 'pipe'] }); | ||
| let output = ''; | ||
| const timer = setTimeout(() => child.kill('SIGKILL'), 30_000); | ||
| child.stdout?.on('data', (b) => { output += b.toString(); }); | ||
| child.stderr?.on('data', (b) => { output += b.toString(); }); | ||
| child.on('error', (err) => { clearTimeout(timer); resolve({ code: -1, output: `${output}\n${err.message}` }); }); | ||
| child.on('close', (code) => { clearTimeout(timer); resolve({ code: code ?? -1, output }); }); | ||
| }); | ||
| export function parseNixpacksPlan(text) { | ||
| try { | ||
| const j = JSON.parse(text); | ||
| // Real plans (nixpacks ≥1.x) leave `providers` empty and name the matched provider(s) in the | ||
| // NIXPACKS_METADATA build variable instead. | ||
| const listed = Array.isArray(j.providers) ? j.providers : []; | ||
| const meta = typeof j.variables?.NIXPACKS_METADATA === 'string' | ||
| ? j.variables.NIXPACKS_METADATA.split(',').map((s) => s.trim()).filter(Boolean) | ||
| : []; | ||
| return { | ||
| providers: listed.length ? listed : meta, | ||
| installCommand: j.phases?.install?.cmds?.join(' && ') || undefined, | ||
| buildCommand: j.phases?.build?.cmds?.join(' && ') || undefined, | ||
| startCommand: j.start?.cmd || undefined, | ||
| }; | ||
| } | ||
| catch { | ||
| return null; | ||
| } | ||
| } | ||
| export async function nixpacksPlan(dir, run = quietRunner) { | ||
| const { code, output } = await run('nixpacks', ['plan', dir], { cwd: dir, env: process.env }); | ||
| if (code !== 0) | ||
| return null; | ||
| return parseNixpacksPlan(output); | ||
| } | ||
| // `nixpacks build --out <dir>` generates .nixpacks/Dockerfile and skips Docker entirely. The out | ||
| // dir is a temp dir so the user's source tree stays clean (the platform writes into the source | ||
| // dir because it builds from a scratch clone — a local verify must not). | ||
| export async function nixpacksGeneratedDockerfile(dir, run = quietRunner) { | ||
| const out = mkdtempSync(join(tmpdir(), 'insta-nixpacks-')); | ||
| try { | ||
| const { code } = await run('nixpacks', ['build', dir, '--out', out], { cwd: dir, env: process.env }); | ||
| if (code !== 0) | ||
| return null; | ||
| const generated = join(out, '.nixpacks', 'Dockerfile'); | ||
| return existsSync(generated) ? readFileSync(generated, 'utf8') : null; | ||
| } | ||
| finally { | ||
| rmSync(out, { recursive: true, force: true }); | ||
| } | ||
| } | ||
| // Quiet probe — no install, no output. `insta build` advertises itself as local and offline, so | ||
| // unlike deploy's ensureFlyctl it must never download anything or write to stdout (which would | ||
| // corrupt --json); when nixpacks is missing the command degrades to Dockerfile-only checks and | ||
| // the report's nextAction says how to install it. | ||
| export async function nixpacksAvailable(run = quietRunner) { | ||
| const { code } = await run('nixpacks', ['--version'], { cwd: '.', env: process.env }); | ||
| return code === 0; | ||
| } | ||
| //# sourceMappingURL=nixpacks.js.map |
@@ -73,2 +73,92 @@ import { ApiClient, ApiError, requireProject } from '../api.js'; | ||
| } | ||
| // ---- exec (one-shot command; no interactive shell/PTY) ---- | ||
| // `insta compute exec [service] -- <command> [args…]`: the command must reach the platform | ||
| // byte-for-byte and can itself contain dashes or another `--`, so it can't be a normal commander | ||
| // positional — with `service` optional, commander flattens everything past the literal `--` into | ||
| // one operand list and has no way to tell "no service, command starts here" apart from "service IS | ||
| // the first command token". Splitting argv on the first literal `--` after `compute exec` | ||
| // ourselves, before commander ever parses it, removes the ambiguity; this is the only place in the | ||
| // whole CLI a bare `--` has this meaning, so nothing else is affected. Exported for a direct, | ||
| // network-free unit test — this split is the seam most likely to regress. | ||
| export function splitExecArgs(argv) { | ||
| const i = argv.findIndex((a, idx) => a === 'compute' && argv[idx + 1] === 'exec'); | ||
| if (i === -1) | ||
| return { argv }; | ||
| const dash = argv.indexOf('--', i + 2); | ||
| if (dash === -1) | ||
| return { argv }; | ||
| return { argv: argv.slice(0, dash), command: argv.slice(dash + 1) }; | ||
| } | ||
| // The --timeout override, through a throwing parser like every other user-typed number in this | ||
| // repo (parseCpu, parseCount, parsePort): junk must fail locally instead of reaching the server as | ||
| // NaN, and the bounds mirror what the platform enforces (1-180s; server default 30 when omitted). | ||
| export function parseTimeoutSec(raw) { | ||
| const n = Number(raw); | ||
| if (!Number.isInteger(n) || n < 1 || n > 180) | ||
| throw new Error(`invalid timeout: ${raw} (1-180 seconds)`); | ||
| return n; | ||
| } | ||
| // Map exec inputs to the platform POST body. Pure, unit-tested without a network mock (mirrors | ||
| // deployRequestBody / servicesAddRequestBody). timeoutSec is omitted when not given so the server | ||
| // applies its own default (30s) rather than the client picking one on the wire. | ||
| export function execRequestBody(command, timeoutSec) { | ||
| return { command, ...(timeoutSec !== undefined ? { timeoutSec } : {}) }; | ||
| } | ||
| // Renders the exec response and sets process.exitCode — split out of computeExec as a pure function | ||
| // of (res, json) so it's unit-testable without a network mock, same as handleApproval's own | ||
| // {status, body} shape. | ||
| // | ||
| // A 202 means the command has NOT run: unlike every other gated command (where "nothing happened" | ||
| // is the safe default), a caller chaining `insta compute exec … && next` must not see exit 0 here, | ||
| // or `next` runs believing the command succeeded. --json prints the raw envelope (so a scripted | ||
| // caller can inspect approvalId/action) instead of the human hint; either way exit 1. | ||
| export function applyExecResult(res, json) { | ||
| if (res.status === 202 && res.body?.status === 'approval_required') { | ||
| if (json) | ||
| printJson(res.body); | ||
| else | ||
| handleApproval(res); | ||
| process.exitCode = 1; | ||
| return; | ||
| } | ||
| const { exitCode, stdout, stderr, truncated } = res.body; | ||
| if (json) { | ||
| printJson(res.body); | ||
| } | ||
| else { | ||
| process.stdout.write(stdout); | ||
| process.stderr.write(stderr); | ||
| if (truncated) | ||
| process.stderr.write('note: output truncated — the platform caps stdout/stderr at 1 MiB each\n'); | ||
| } | ||
| // The platform sends -1 as an "unknown exit" sentinel, and nothing outside 0-255 is a valid POSIX | ||
| // exit code. Assigning it straight to process.exitCode risks Node's own DEP0164 (a negative code | ||
| // silently exits 255) — clamp out-of-range codes to 1 instead, with a one-line note so the cause is | ||
| // visible. Normal codes pass through untouched. | ||
| if (exitCode < 0 || exitCode > 255) { | ||
| process.stderr.write(`note: remote exit code ${exitCode} out of range — exiting 1\n`); | ||
| process.exitCode = 1; | ||
| } | ||
| else { | ||
| process.exitCode = exitCode; | ||
| } | ||
| } | ||
| // One HTTP round trip, not a shell session: no PTY, no interactivity, stdout/stderr come back as | ||
| // two whole strings (each capped at 1 MiB server-side) rather than a stream. They're written to | ||
| // this process's own stdout/stderr verbatim — no prefixes, no added newline — and the remote exit | ||
| // code becomes this process's own exit code (--json still passes it through, it just skips the | ||
| // split-stream output), since agents scripting this rely on it. Waking a scaled-to-zero machine is | ||
| // expected — it adds latency and bills as uptime, it is not an error. | ||
| export async function computeExec(serviceName, command, opts) { | ||
| if (!command || command.length === 0) | ||
| throw new Error('usage: insta compute exec [service] -- <command> [args…] (see --help)'); | ||
| const timeoutSec = opts.timeout !== undefined ? parseTimeoutSec(opts.timeout) : undefined; | ||
| const api = await ApiClient.load(); | ||
| const p = await requireProject(); | ||
| const branch = opts.branch ?? p.branch; | ||
| const { services } = await api.request('GET', `/projects/${p.projectId}/services${q(branch)}`); | ||
| const id = resolveComputeServiceId(services, serviceName); | ||
| const res = await api.rawRequest('POST', `/projects/${p.projectId}/services/${id}/exec`, execRequestBody(command, timeoutSec)); | ||
| applyExecResult(res, opts.json); | ||
| } | ||
| // ---- always-on (opt out of scale-to-zero; all plans; billing is actual usage either way) ---- | ||
@@ -75,0 +165,0 @@ export async function computeAlwaysOn(mode, serviceName, opts) { |
| import { resolve, join } from 'node:path'; | ||
| import { existsSync, readFileSync } from 'node:fs'; | ||
| import { ApiClient, requireProject } from '../api.js'; | ||
| import { ApiClient, ApiError, requireProject } from '../api.js'; | ||
| import { info, die, handleApproval, renderNextActions } from '../util.js'; | ||
| import { flyctlBuildAndPush, ensureFlyctl } from '../flyctl-build.js'; | ||
| import { flyctlBuildAndPush, ensureFlyctl, defaultBuildRunner } from '../flyctl-build.js'; | ||
| // Map CLI options to the platform deploy request body. Pure, so it's unit-tested. --websocket is only | ||
@@ -56,16 +56,46 @@ // sent when set (plain deploys unchanged). | ||
| } | ||
| // The local image tag a daemon-side deploy runs: unique per build so a redeploy replaces, and | ||
| // legible in `docker images`. Pure, so it's unit-tested. | ||
| export function localImageTag(projectId, group, now = Date.now()) { | ||
| return `insta-src-${projectId.slice(0, 8)}-${group ?? 'default'}:${now}`; | ||
| } | ||
| // Local build for a local daemon (insta-oss): the CLI and the daemon share ONE docker, so a | ||
| // locally-built tag is directly runnable — no registry, no push. Same injectable-runner pattern | ||
| // as flyctl-build.ts. | ||
| export async function dockerBuildLocal(absDir, tag, run = defaultBuildRunner) { | ||
| const { code } = await run('docker', ['build', '-t', tag, '.'], { cwd: absDir, env: process.env }); | ||
| if (code !== 0) | ||
| throw new Error(`docker build failed (exit ${code}). See output above.`); | ||
| return tag; | ||
| } | ||
| // Source mode: mint a scoped Fly deploy token from the platform, then build+push <dir> (needs a | ||
| // Dockerfile) with flyctl's remote builder, returning the pushed image ref to deploy. | ||
| async function buildFromSource(api, projectId, dir, branch, opts) { | ||
| // Dockerfile) with flyctl's remote builder, returning the pushed image ref to deploy. Against a | ||
| // local daemon (insta-oss) the token mint answers 501 — build with docker instead, same contract. | ||
| // Exported with injectable pieces for tests (the repo's DI pattern; no global mocks). | ||
| export async function buildFromSource(api, projectId, dir, branch, opts, run = defaultBuildRunner) { | ||
| const absDir = resolve(process.cwd(), dir); | ||
| if (!existsSync(join(absDir, 'Dockerfile'))) | ||
| die(`no Dockerfile at ${join(absDir, 'Dockerfile')} — add one, or use --image <url>`); | ||
| await ensureFlyctl(); | ||
| const port = opts.port ? Number(opts.port) : 8080; | ||
| const tok = await api.rawRequest('POST', `/projects/${projectId}/deploy-token`, { branch, group: opts.group }); | ||
| let tok; | ||
| try { | ||
| tok = await api.rawRequest('POST', `/projects/${projectId}/deploy-token`, { branch, group: opts.group }); | ||
| } | ||
| catch (e) { | ||
| // 501 = no remote builder here (insta-oss is the only deployment that answers it) — the | ||
| // daemon deploys from the SAME docker this shell uses, so build locally and hand it the tag. | ||
| if (!(e instanceof ApiError) || e.status !== 501) | ||
| throw e; | ||
| const tag = localImageTag(projectId, opts.group); | ||
| info(`no remote builder on this daemon — building ${dir} locally with docker…`); | ||
| const built = await dockerBuildLocal(absDir, tag, run); | ||
| info(` built ${built}`); | ||
| return built; | ||
| } | ||
| if (handleApproval(tok)) | ||
| die('deploy requires approval — get it approved, then re-run'); | ||
| const { token, flyApp } = tok.body; | ||
| await ensureFlyctl(); // cloud path only — the local path needs docker, which the daemon requires anyway | ||
| const port = opts.port ? Number(opts.port) : 8080; | ||
| info(`building ${dir} for ${flyApp} (remote builder)…`); | ||
| const { imageRef } = await flyctlBuildAndPush({ dir: absDir, flyApp, imageLabel: `insta-${Date.now()}`, token, port }); | ||
| const { imageRef } = await flyctlBuildAndPush({ dir: absDir, flyApp, imageLabel: `insta-${Date.now()}`, token, port }, run); | ||
| info(` pushed ${imageRef}`); | ||
@@ -72,0 +102,0 @@ return imageRef; |
@@ -63,3 +63,3 @@ import { ApiClient, requireProject } from '../api.js'; | ||
| } | ||
| // insta metrics <db|compute> [group] | ||
| // insta metrics <db|compute|redis|mysql|mongodb> [group] | ||
| export async function metrics(component, group, opts) { | ||
@@ -133,5 +133,7 @@ const api = await ApiClient.load(); | ||
| } | ||
| // pure: platform path for a compute deploy-events request (used by `insta logs --deploy`). | ||
| // pure: platform path for a deploy-events request (used by `insta logs --deploy`). Any Fly-backed | ||
| // component (compute or a managed database) has machine lifecycle events; omitted → the platform | ||
| // defaults to compute. | ||
| export function deployEventsPath(projectId, opts) { | ||
| return `/projects/${projectId}/deploy-events${qs({ group: opts.group, branch: opts.branch, limit: opts.limit, instance: opts.instance })}`; | ||
| return `/projects/${projectId}/deploy-events${qs({ component: opts.component, group: opts.group, branch: opts.branch, limit: opts.limit, instance: opts.instance })}`; | ||
| } | ||
@@ -143,3 +145,3 @@ // pure: render one deploy event as a log-style line. | ||
| } | ||
| // insta logs <db|compute> [group] | ||
| // insta logs <db|compute|redis|mysql|mongodb> [group] | ||
| export async function logs(component, group, opts) { | ||
@@ -149,5 +151,6 @@ const api = await ApiClient.load(); | ||
| if (opts.deploy) { | ||
| if (component !== 'compute') | ||
| return info('deploy events are only available for compute'); | ||
| const res = await api.request('GET', deployEventsPath(p.projectId, { group, branch: opts.branch ?? p.branch, limit: opts.limit, instance: opts.instance })); | ||
| // Machine lifecycle events exist for every Fly-backed component; 'db' (postgres) has no machines. | ||
| if (component === 'db') | ||
| return info('deploy events are not available for db — use compute, redis, mysql or mongodb'); | ||
| const res = await api.request('GET', deployEventsPath(p.projectId, { component, group, branch: opts.branch ?? p.branch, limit: opts.limit, instance: opts.instance })); | ||
| if (opts.json) | ||
@@ -154,0 +157,0 @@ return printJson(res); |
@@ -118,2 +118,65 @@ import { writeFile } from 'node:fs/promises'; | ||
| } | ||
| export async function secretsBind(envName, source, opts) { | ||
| if (!opts.to) | ||
| die('--to <compute/name> is required'); | ||
| const api = await ApiClient.load(); | ||
| const p = await requireProject(); | ||
| const branch = opts.branch ?? p.branch; | ||
| const res = await api.rawRequest('PUT', `/projects/${p.projectId}/secret-bindings/${encodeURIComponent(envName)}`, { | ||
| branch, | ||
| target: opts.to, | ||
| source, | ||
| ...(opts.sourceName ? { sourceName: opts.sourceName } : {}), | ||
| }); | ||
| if (handleApproval(res)) | ||
| return; | ||
| if (opts.json) | ||
| return printJson({ ok: true }); | ||
| info(`bound ${envName} on ${opts.to} to ${source}${opts.sourceName ? `.${opts.sourceName}` : ''} (branch ${branch})`); | ||
| } | ||
| export async function secretsUnbind(envName, opts) { | ||
| if (!opts.from) | ||
| die('--from <compute/name> is required'); | ||
| const api = await ApiClient.load(); | ||
| const p = await requireProject(); | ||
| const branch = opts.branch ?? p.branch; | ||
| const res = await api.rawRequest('DELETE', `/projects/${p.projectId}/secret-bindings/${encodeURIComponent(envName)}?branch=${encodeURIComponent(branch)}&target=${encodeURIComponent(opts.from)}`); | ||
| if (handleApproval(res)) | ||
| return; | ||
| if (opts.json) | ||
| return printJson({ ok: true }); | ||
| info(`unbound ${envName} from ${opts.from} (branch ${branch})`); | ||
| } | ||
| export async function secretsBindings(opts) { | ||
| if (!opts.target) | ||
| die('--target <compute/name> is required'); | ||
| const api = await ApiClient.load(); | ||
| const p = await requireProject(); | ||
| const branch = opts.branch ?? p.branch; | ||
| const res = await api.rawRequest('GET', `/projects/${p.projectId}/secret-bindings?branch=${encodeURIComponent(branch)}&target=${encodeURIComponent(opts.target)}`); | ||
| if (handleApproval(res)) | ||
| return; | ||
| const bindings = res.body.bindings ?? []; | ||
| if (opts.json) | ||
| return printJson(bindings); | ||
| if (!bindings.length) | ||
| return info(`(no secret bindings for ${opts.target} on ${branch})`); | ||
| for (const b of bindings) | ||
| info(`${b.envName} <- ${b.source.type}/${b.source.name}.${b.sourceName}`); | ||
| } | ||
| export async function secretsSources(opts) { | ||
| const api = await ApiClient.load(); | ||
| const p = await requireProject(); | ||
| const branch = opts.branch ?? p.branch; | ||
| const res = await api.rawRequest('GET', `/projects/${p.projectId}/secret-sources?branch=${encodeURIComponent(branch)}`); | ||
| if (handleApproval(res)) | ||
| return; | ||
| const sources = res.body.sources ?? []; | ||
| if (opts.json) | ||
| return printJson(sources); | ||
| if (!sources.length) | ||
| return info(`(no credential sources on ${branch})`); | ||
| for (const s of sources) | ||
| info(`${s.service.type}/${s.service.name}: ${s.secrets.join(', ')}`); | ||
| } | ||
| /** Gitignore the env file we just wrote (git repos only; idempotent). Returns true if added. */ | ||
@@ -120,0 +183,0 @@ export function ensureIgnored(cwd, name) { |
@@ -1,5 +0,5 @@ | ||
| // `insta services` — manage a project's opt-in services (postgres | storage | compute | redis). | ||
| // `insta services` — manage a project's opt-in services (postgres | storage | compute | redis | mysql | mongodb). | ||
| import { ApiClient, requireProject } from '../api.js'; | ||
| import { info, printJson, handleApproval, renderNextActions } from '../util.js'; | ||
| export const SERVICE_TYPES = ['postgres', 'storage', 'compute', 'redis']; | ||
| export const SERVICE_TYPES = ['postgres', 'storage', 'compute', 'redis', 'mysql', 'mongodb']; | ||
| const SERVICE_NAME_RE = /^[a-z0-9][a-z0-9-]{0,38}$/; | ||
@@ -75,2 +75,5 @@ export function q(branch) { | ||
| } | ||
| function defaultDatabasePort(type) { | ||
| return type === 'mysql' ? 3306 : type === 'mongodb' ? 27017 : 6379; | ||
| } | ||
| // Map service-add options to the platform POST body. Pure, so it's unit-tested without a network | ||
@@ -128,3 +131,3 @@ // mock (mirrors deployRequestBody in deploy.ts). Validation (which options are valid for which | ||
| ? ` x${s.machine_count}${s.volume_gib ? ` vol ${s.volume_gib}Gi` : ''}${s.image ? ` running ${s.image}${s.port ? `:${s.port}` : ''}` : ''}` | ||
| : s.type === 'redis' ? ` tcp/${s.port ?? 6379}${s.volume_gib ? ` vol ${s.volume_gib}Gi` : ''}` | ||
| : ['redis', 'mysql', 'mongodb'].includes(s.type) ? ` tcp/${s.port ?? defaultDatabasePort(s.type)}${s.volume_gib ? ` vol ${s.volume_gib}Gi` : ''}` | ||
| : s.type === 'storage' ? ` ${s.public ? 'public' : 'private'}` : ''; | ||
@@ -141,3 +144,3 @@ return `${s.type}/${s.name} [${s.status}]${extra}${s.domain ? ` ${s.domain}` : ''} ${s.id}`; | ||
| if (!services.length) | ||
| return info(`(no services on ${branch ?? 'default'} — add one with \`insta services add <postgres|storage|compute|redis> <name>\`)`); | ||
| return info(`(no services on ${branch ?? 'default'} — add one with \`insta services add <postgres|storage|compute|redis|mysql|mongodb> <name>\`)`); | ||
| for (const s of services) | ||
@@ -144,0 +147,0 @@ info(serviceListLine(s)); |
+41
-7
@@ -20,2 +20,3 @@ #!/usr/bin/env node | ||
| import { deploy } from './commands/deploy.js'; | ||
| import { build } from './commands/build.js'; | ||
| import * as computeCmd from './commands/compute.js'; | ||
@@ -113,4 +114,4 @@ import * as dbCmd from './commands/db.js'; | ||
| .option('--into <branch>', 'target branch (default: current)').action(guard((source, o) => branch.branchMerge(source, o))); | ||
| // ---- services (opt-in postgres/storage/compute/redis) ---- | ||
| const svc = program.command('services').alias('svc').description('Manage project services (postgres|storage|compute|redis)'); | ||
| // ---- services (opt-in postgres/storage/compute/redis/mysql/mongodb) ---- | ||
| const svc = program.command('services').alias('svc').description('Manage project services (postgres|storage|compute|redis|mysql|mongodb)'); | ||
| // [type] [name] are optional so the command can answer "what can I add?" — a terminal is walked | ||
@@ -121,3 +122,3 @@ // through the dashboard's Add Service kinds, anything else gets that list back as an error | ||
| .option('--branch <branch>', 'target branch (default: current)') | ||
| .option('--region <region>', 'region for postgres/compute/redis, e.g. us-east (see `insta regions`)') | ||
| .option('--region <region>', 'region for postgres/compute/managed databases, e.g. us-east (see `insta regions`)') | ||
| .option('--public', 'storage only: serve the bucket with anonymous public-read (default private)') | ||
@@ -159,4 +160,30 @@ .option('--image <url>', 'compute only: run this container image at creation') | ||
| .option('--branch <branch>', 'scope to one branch').action(guard((n, o) => secretsCmd.secretsUnset(n, o))); | ||
| sec.command('bind <env-name> <source>').description('Bind a service credential into a compute env var') | ||
| .option('--branch <branch>', 'branch (default: current)') | ||
| .option('--to <compute-service>', 'target compute service, e.g. compute/api') | ||
| .option('--source-name <name>', 'source credential name when the source exposes more than one') | ||
| .option('--json') | ||
| .action(guard((n, source, o) => secretsCmd.secretsBind(n, source, o))); | ||
| sec.command('unbind <env-name>').description('Remove a service credential binding from a compute env var') | ||
| .option('--branch <branch>', 'branch (default: current)') | ||
| .option('--from <compute-service>', 'target compute service, e.g. compute/api') | ||
| .option('--json') | ||
| .action(guard((n, o) => secretsCmd.secretsUnbind(n, o))); | ||
| sec.command('bindings').description('List service credential bindings for a compute service') | ||
| .option('--branch <branch>', 'branch (default: current)') | ||
| .option('--target <compute-service>', 'target compute service, e.g. compute/api') | ||
| .option('--json') | ||
| .action(guard((o) => secretsCmd.secretsBindings(o))); | ||
| sec.command('sources').description('List service credential sources available for binding') | ||
| .option('--branch <branch>', 'branch (default: current)') | ||
| .option('--json') | ||
| .action(guard((o) => secretsCmd.secretsSources(o))); | ||
| sec.command('tree').description('Show secrets as project → branch → service → secrets').option('--json') | ||
| .action(guard((o) => secretsCmd.secretsTree(o))); | ||
| // ---- build (pre-push verification — local, offline, deploys nothing) ---- | ||
| program.command('build [dir]').description('Verify a source directory would build before deploying: detection plan + the Dockerfile that would be used (yours, or nixpacks-generated) + static checks. Local and offline — no login needed, nothing pushed. Exit 1 when the verdict is failed') | ||
| .option('--explain', 'include the Dockerfile content in the output') | ||
| .option('--port <p>', 'port the app listens on (else the Dockerfile EXPOSE)') | ||
| .option('--json') | ||
| .action(guard((dir, o) => build(dir, o))); | ||
| // ---- deploy ---- | ||
@@ -167,2 +194,6 @@ program.command('deploy [dir]').description('Deploy a source directory (built remotely on Fly) or a prebuilt --image to a branch compute group') | ||
| .action(guard((dir, o) => deploy(dir, o))); | ||
| // `insta compute exec` needs the command verbatim after a literal `--`; split it out of argv here, | ||
| // before commander parses anything (see splitExecArgs's own comment for why `service` being | ||
| // optional makes commander unable to hold that boundary itself). | ||
| const { argv: computeArgv, command: execCommand } = computeCmd.splitExecArgs(process.argv); | ||
| // ---- compute (lifecycle control + custom domains) ---- | ||
@@ -189,2 +220,5 @@ const compute = program.command('compute').description('Control compute lifecycle (start/stop/suspend/status) + custom domains'); | ||
| .option('--json').option('--branch <branch>', 'branch (default: current)').action(guard((mode, service, o) => computeCmd.computeAlwaysOn(mode, service, o))); | ||
| compute.command('exec [service]').description("Run a one-shot command inside a compute service's machine (`insta compute exec [service] -- <command> [args…]`) — no interactive shell/PTY: `command` is argv, no shell is invoked (use [\"sh\", \"-c\", \"...\"] for shell features). Wakes the machine first if it's scaled to zero — expect a few seconds of latency, billed as uptime, not an error. Exits with the remote command's own exit code (agents rely on this)") | ||
| .option('--branch <b>').option('--timeout <sec>', 'command timeout in seconds, 1-180 (platform default: 30)').option('--json') | ||
| .action(guard((service, o) => computeCmd.computeExec(service, execCommand, o))); | ||
| compute.command('volume [service]').description("Show, attach, grow, or delete a compute service's persistent /data volume. No flag: print size, mount path, and the plan cap (any plan). --size on a volumeless service ATTACHES one (any plan at the default 1Gi; larger is paid and plan-capped; the disk mounts at /data on the next deploy); on a volume-bearing one it grows (paid plans; grow-only — a provisioned disk cannot shrink). --delete DESTROYS the disk and ALL its data immediately (no detach, no undo; billing stops now, and suspend fast-wake + scale-out return). Billing is actual data stored — the size is a cap, not a price") | ||
@@ -234,7 +268,7 @@ .option('--size <gi>', 'new size in whole Gi, e.g. 10 (must be ≥ the current size)') | ||
| // ---- observability ---- | ||
| program.command('metrics <target> [group]').description('Service metrics (target: db|compute)') | ||
| program.command('metrics <target> [group]').description('Service metrics (target: db|compute|redis|mysql|mongodb)') | ||
| .option('--branch <b>').option('--from <unix>').option('--to <unix>').option('--step <s>').option('--json') | ||
| .action(guard((target, group, o) => obs.metrics(target, group, o))); | ||
| program.command('logs <target> [group]').description('Service logs (runtime by default; --deploy = compute deploy events; target: db|compute)') | ||
| .option('--branch <b>').option('--limit <n>').option('--region <r>').option('--instance <i>').option('--deploy', 'show compute deploy events (machine lifecycle) instead of runtime logs').option('--json') | ||
| program.command('logs <target> [group]').description('Service logs (runtime by default; --deploy = machine lifecycle events; target: db|compute|redis|mysql|mongodb)') | ||
| .option('--branch <b>').option('--limit <n>').option('--region <r>').option('--instance <i>').option('--deploy', 'show deploy events (machine lifecycle) instead of runtime logs — Fly-backed targets only, not db').option('--json') | ||
| .action(guard((target, group, o) => obs.logs(target, group, o))); | ||
@@ -294,3 +328,3 @@ program.command('usage').description('Usage for the current billing cycle by billing dimension (org by default; --proj for one project)') | ||
| selfUpdate.maybeUpdate(resolveVersion(), process.argv); | ||
| program.parseAsync(process.argv); | ||
| program.parseAsync(computeArgv); | ||
| //# sourceMappingURL=index.js.map |
| // `insta services add` with no type (or no name): the kinds are otherwise only discoverable by | ||
| // guessing wrong and reading `type must be postgres|storage|compute|redis`, so missing arguments answer | ||
| // guessing wrong and reading `type must be postgres|storage|compute|redis|mysql|mongodb`, so missing arguments answer | ||
| // "what can I add?" instead. The list mirrors the dashboard's Add Service menu (frontend | ||
@@ -15,2 +15,4 @@ // `add-service-button.tsx`) — Docker Image sits BESIDE Empty Service, not under it, because | ||
| { id: 'redis', label: 'Redis', type: 'redis', hint: 'private Redis-compatible cache', defaultName: 'cache' }, | ||
| { id: 'mysql', label: 'MySQL', type: 'mysql', hint: 'private MySQL database', defaultName: 'mysql-db' }, | ||
| { id: 'mongodb', label: 'MongoDB', type: 'mongodb', hint: 'private MongoDB database', defaultName: 'mongo-db' }, | ||
| { id: 'storage', label: 'Storage', type: 'storage', hint: 'S3-compatible bucket, private by default', defaultName: 'assets' }, | ||
@@ -17,0 +19,0 @@ { id: 'compute', label: 'Empty Service', type: 'compute', hint: 'an app to deploy code to (empty until `insta deploy`)', defaultName: 'compute' }, |
+1
-1
| { | ||
| "name": "insta", | ||
| "version": "0.0.35", | ||
| "version": "0.0.36", | ||
| "type": "module", | ||
@@ -5,0 +5,0 @@ "description": "InstaCloud CLI — a thin client of the platform control-plane API.", |
Filesystem access
Supply chain riskAccesses the file system, and could potentially read sensitive data.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
273958
11.93%41
5.13%4804
12.9%53
12.77%16
6.67%