Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .claude/skills/developing-insta-cli/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,10 @@ npx tsx src/index.ts --help # run the CLI from source
| `index.ts` | commander program — registers every command |
| `api.ts` | typed platform-API client (auth headers, token refresh, error mapping) |
| `config.ts` | global `~/.insta/config.json` (apiUrl + tokens + user) · project `./.insta/project.json` (projectId / orgId / current branch) |
| `commands/` | one file per command group: `auth` `org` `project` `services` `branch` `secrets` `deploy` `compute` `upgrade` `metrics` (+`logs`) `billing` `govern` (policy/approvals) `manifest` `observe` |
| `commands/` | one file per command group: `auth` `org` `project` `services` `branch` `secrets` `build` `deploy` `compute` `upgrade` `metrics` (+`logs`) `billing` `govern` (policy/approvals) `manifest` `observe` |
| `observe/` | local `insta observe` hook — `scanner.ts` (AWS/GitHub/Stripe/LLM/DB cred detection), `hook.ts`, `install.ts`, `report.ts` (→ platform event ingest) |
| `flyctl-build.ts` | source-directory deploy build glue (Fly build context) |
| `nixpacks.ts` | nixpacks glue for `insta build` — plan detection + Dockerfile generation (no Docker daemon) |
| `ensure-skills.ts` | installs/refreshes the agent skills into the user's project |
| `util.ts` | shared helpers |

Expand Down
267 changes: 267 additions & 0 deletions src/commands/build.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,267 @@
// `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, type NixpacksPlan } from '../nixpacks.js'
import type { BuildRunner } from '../flyctl-build.js'

export type BuildCheck = {
id: string
severity: 'critical' | 'warning' | 'info'
status: 'pass' | 'fail' | 'skip'
title: string
detail?: string
nextAction?: string
}

export type BuildReport = {
dir: string
plan: {
builder: 'dockerfile' | 'nixpacks' | null
providers: string[]
installCommand?: string
buildCommand?: string
startCommand?: string
port?: number
portRationale: string
envKeys: string[]
}
dockerfile: { source: 'user' | 'nixpacks' | null; path?: string; content?: string }
checks: BuildCheck[]
verdict: 'deployable' | 'needs-attention' | 'failed'
}

// A failed critical sinks the build; a failed warning deserves attention; skips are not failures.
export function computeVerdict(checks: BuildCheck[]): BuildReport['verdict'] {
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: string | undefined, dockerfile: string | undefined): { port?: number; rationale: string } {
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: string): string[] {
const keys: string[] = []
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)

export type ContextStats = { totalBytes: number; nodeModulesBytes: number; hasNodeModules: boolean; nodeModulesIgnored: boolean; truncated: boolean }

// 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: string, cap = WALK_CAP): ContextStats {
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: string, inNodeModules: boolean) => {
let entries: string[]
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
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
if (isNm) nodeModulesBytes += st.size
}
}
}
walk(dir, false)
return { totalBytes, nodeModulesBytes, hasNodeModules, nodeModulesIgnored, truncated }
}

const mb = (bytes: number): string => `${(bytes / 1024 / 1024).toFixed(1)} MB`

export function contextCheck(ctx: ContextStats): BuildCheck {
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: string,
opts: { port?: string },
deps: { runner: BuildRunner; nixpacksAvailable: boolean },
): Promise<BuildReport> {
const dir = resolve(process.cwd(), dirArg)
const userDockerfilePath = join(dir, 'Dockerfile')
const hasUserDockerfile = existsSync(userDockerfilePath)

let dockerfile: BuildReport['dockerfile'] = { source: null }
let np: NixpacksPlan | null = 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: BuildReport['plan']['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: BuildCheck[] = []
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: '·' } as const

export function renderReport(r: BuildReport, explain: boolean): string[] {
const lines: string[] = []
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: BuildReport, explain: boolean): BuildReport {
return explain ? report : { ...report, dockerfile: { ...report.dockerfile, content: undefined } }
}

export async function build(dirArg: string | undefined, opts: { explain?: boolean; json?: boolean; port?: string }): Promise<void> {
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
}
8 changes: 8 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { resolveServiceArgs, serviceArgsDeps } from './resolve-service.js'
import * as regions from './commands/regions.js'
import * as secretsCmd from './commands/secrets.js'
import { deploy } from './commands/deploy.js'
import { build } from './commands/build.js'
import * as computeCmd from './commands/compute.js'
import * as dbCmd from './commands/db.js'
import * as storageCmd from './commands/storage.js'
Expand Down Expand Up @@ -165,6 +166,13 @@ sec.command('unset <name>').description('Remove a user secret')
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 ----
program.command('deploy [dir]').description('Deploy a source directory (built remotely on Fly) or a prebuilt --image to a branch compute group')
.option('--image <url>', 'prebuilt container image to deploy (instead of a source dir)').option('--branch <b>').option('--group <g>').option('--port <p>')
Expand Down
78 changes: 78 additions & 0 deletions src/nixpacks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
// 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'
import type { BuildRunner } from './flyctl-build.js'

export type NixpacksPlan = {
providers: string[]
installCommand?: string
buildCommand?: string
startCommand?: string
}

// 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: BuildRunner = (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: string): NixpacksPlan | null {
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: string) => 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: string, run: BuildRunner = quietRunner): Promise<NixpacksPlan | null> {
const { code, output } = await run('nixpacks', ['plan', dir], { cwd: dir, env: process.env as Record<string, string> })
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: string, run: BuildRunner = quietRunner): Promise<string | null> {
const out = mkdtempSync(join(tmpdir(), 'insta-nixpacks-'))
try {
const { code } = await run('nixpacks', ['build', dir, '--out', out], { cwd: dir, env: process.env as Record<string, string> })
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: BuildRunner = quietRunner): Promise<boolean> {
const { code } = await run('nixpacks', ['--version'], { cwd: '.', env: process.env as Record<string, string> })
return code === 0
}
Loading
Loading