Repository navigation
feat: insta build — pre-push verification (plan + Dockerfile + static checks) #104
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
| 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 | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.