Sign In

insta

Package Overview
Dependencies
Maintainers
3
Versions
35
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

insta - npm Package Compare versions

Comparing version
0.0.35
to
0.0.36
+256
dist/commands/build.js
// `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
+90
-0

@@ -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) {

+38
-8
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;

+10
-7

@@ -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));

@@ -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' },

{
"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.",