fix: stop promising a nixpacks lane `insta deploy <dir>` does not have

Merged
TO
3mo ago
+194-14

The contradiction

Three surfaces disagreed about deploying a Dockerfile-less source directory. Reproduced on 0.0.47 with a minimal Node app (package.json with a start script + server.js, no Dockerfile):

$ insta build .srcapp
    builder: nixpacks (providers: node)
  checks:
    ✓ Dockerfile — generated by nixpacks (providers: node)     <-- passes
$ insta build .srcapp --port 3000
  verdict: deployable                                          <-- blessed

# installer next-steps banner (also the upgrade banner: the binary channel
# re-runs `curl install.sh | sh` with inherited stdio)
  insta deploy . --port 3000     # ship your app and get a live URL

$ insta deploy .srcapp --group src --port 8080
  error: no Dockerfile at .../Dockerfile — add one, or use --image <url>   <-- refuses

Which one is right

deploy is. A directory deploy mints a scoped Fly token (POST /projects/:id/deploy-token) and runs flyctl deploy --remote-only --build-only --push over the directory's own Dockerfile.

The nixpacks lane is real, but it is server-side and git-sourced: the build gateway's {source: {type: git, repo_url, commit_sha}, build: {type: nixpacks, context_path}} (insta-compute#55 / insta-platform#279), reachable only from a GitHub-connected repo. Nothing on the deploy <dir> path can enter it — the gateway's archive source type exists but, per docs/build-gateway.md, "There is no upload endpoint yet", and "CLI dir-deploy archive lane" is a recorded, not-started follow-up. So this is a known gap, not a regression.

Scope call: wiring the CLI archive path into that lane needs an object store + upload endpoint, a new platform route, and build-token plumbing across three repos. This PR does not build that. It makes the two lying surfaces truthful instead.

What changed

  • insta build — a nixpacks-generated Dockerfile no longer passes the dockerfile check. It fails as a warning, not a critical: the plan is real (the GitHub lane does build it), so the command must not exit 1 — it must only stop short of deployable. Verdict for a Dockerfile-less dir is now needs-attention, exit 0. The check detail and nextAction name the lane split, and the builder: line carries the caveat too, since that line is what gets scraped.
  • Neither message says "save the Dockerfile --explain prints". That file is not standalone — it COPYs the .nixpacks/nixpkgs-<hash>.nix support files nixpacks writes beside it into the --out dir, which the source dir does not have (the platform's own lane handles this by generating with --out <dir> and passing --dockerfile <dir>/.nixpacks/Dockerfile). Advising it would swap one false promise for another. --explain now labels the dump # for inspection — not standalone. The detected install/start commands are reusable, and that is what both messages offer.
  • insta deploy <dir> — the dead-end error names all three real options: write a Dockerfile (with insta build as the starting point), --image <url>, or the GitHub flow for the nixpacks lane. Extracted as the pure noDockerfileMessage() so it is unit-tested.
  • install.sh next-steps banner — the deploy line says it needs a Dockerfile, and an insta build . line precedes it so the plan is seen before the dead end. (One line of install.sh, coordinated with #141.)
  • insta build --help — no longer says nixpacks generates "the Dockerfile that would be used".

Unchanged: a directory with a Dockerfile plans, verdicts deployable, and deploys exactly as before — pinned by tests, and verified end-to-end (the guard passes and the run reaches the API).

Before / after

$ insta deploy .srcapp --group src --port 8080
- error: no Dockerfile at /tmp/srcapp-repro/Dockerfile — add one, or use --image <url>
+ error: no Dockerfile at /tmp/srcapp-repro/Dockerfile — a directory deploy builds the Dockerfile in the directory.
+ Options:
+   - add a Dockerfile to /tmp/srcapp-repro (`insta build /tmp/srcapp-repro` prints the install/start commands nixpacks detected, as a starting point)
+   - deploy a prebuilt image instead: `insta deploy --image <url>`
+   - connect the app's GitHub repo in the console — that lane builds Dockerfile-less repos with nixpacks server-side

$ insta build .srcapp --port 3000
-   builder: nixpacks (providers: node)
-   ✓ Dockerfile — generated by nixpacks (providers: node)
- verdict: deployable
+   builder: nixpacks (providers: node) — GitHub lane only; `insta deploy <dir>` needs a Dockerfile
+   ⚠ Dockerfile — generated by nixpacks (providers: node) — but `insta deploy <dir>` builds the directory's own Dockerfile; the nixpacks lane runs server-side for GitHub-connected repos only
+       → to deploy this directory, write a Dockerfile at /tmp/srcapp-repro/Dockerfile — the detected install/start commands above are the starting point; or connect the repo on GitHub to use the nixpacks lane
+ verdict: needs-attention

Tests — every one negative-controlled

13 tests added/updated. Each was run against unmodified src/ + install.sh (test files only applied) and fails there:

 × installer next-steps banner > says a directory deploy needs a Dockerfile — it must not promise what deploy rejects
     AssertionError: expected 'echo "  insta deploy . --port 3000 …' to contain 'dockerfile'
 × installer next-steps banner > points at `insta build` first, so the user sees the plan before the deploy can dead-end
     AssertionError: expected 'echo "Next steps:"\necho "  insta log…' to contain 'insta build .'
 × buildReport > no Dockerfile: nixpacks detects, generates, and the report carries the plan + content
     AssertionError: expected 'deployable' to be 'needs-attention'
 × buildReport > a nixpacks-only dir is never blessed as deployable — the dockerfile check names the lane split
     AssertionError: expected 'pass' to be 'fail'
 × renderReport > prints plan lines with rationale, check marks, and the verdict
     AssertionError: expected 'plan for /var/folders/…' to contain 'verdict: needs-attention'
 × renderReport > the builder line itself carries the lane caveat — "builder: nixpacks" alone reads as a promise
     AssertionError: expected '  builder: nixpacks (providers: node)' to contain 'needs a Dockerfile'
 FAIL test/deploy.test.ts [ test/deploy.test.ts ]     <-- noDockerfileMessage does not exist on main

 Test Files  3 failed (3)
      Tests  6 failed | 24 passed (30)

And passes on this branch:

 Test Files  3 passed (3)
      Tests  39 passed (39)

Coverage includes the negative shapes, not just the success one: a user Dockerfile still passes outright and keeps a clean builder line; the no-Dockerfile-and-no-nixpacks case keeps its critical failure and exit 1; and two assertions forbid the "save the generated Dockerfile" advice from creeping back into either message.

Mirror PR (AGENTS.md non-negotiable #4)

The agent-facing reference lives in the separate InsForge/insta-skills repo, not here. Its insta build row carried the exact old promise this PR retires ("the Dockerfile that would be used (yours, or nixpacks-generated …)"), and references/deploy.md said "no buildpacks yet". Both are updated to the new truth in InsForge/insta-skills#58 — insta build verdict semantics (nixpacks-only → needs-attention, not deployable), insta deploy <dir> requires a Dockerfile in the dir with the three options named, the GitHub lane as the home of nixpacks, and the do-not-save-the---explain-Dockerfile warning. No other commands touched there. Merge the two together; neither should land without the other.

Gates (re-run after rebasing onto main @ a5c918a, post-#144)

GateResult
npm run typecheckclean
npm run buildclean
sh -n install.shclean
npm test517 passed, 0 failed (40 files)

(The 5 setup-agent/ensure-skills failures present at the first push were pre-existing on the old main baseline and are gone after the rebase — not touched by this PR.)

Files touched

src/commands/build.ts, src/commands/deploy.ts, src/index.ts (one line: the build command description), install.sh (the banner block), test/build.test.ts, test/deploy.test.ts, test/install-banner.test.ts (new).

🤖 Generated with Claude Code