loki-mode

DEPLOY-PLAN.md – loki deploy (original design: advisory print-only; --execute now exists)

Current behavior (v11.7.0). loki deploy still prints the deploy command by default and runs nothing. Loki deploys only behind --execute plus a VERIFIED, HEAD-anchored receipt, a clean tree, a destructive-op refusal and a policy permission (.loki/policies.json autonomy: operate, or an explicit actions.deploy allow). With --health-url URL it probes the deployed URL after the command. A non-2xx result is recorded as FAILED_HEALTH and exits non-zero; with --auto-rollback it also runs the provider’s documented rollback verb (vercel rollback, fly releases rollback <captured version>; Netlify and Cloudflare print a manual note). With no URL, health is recorded as “unchecked”, never healthy. The deploy record in .loki/deploys is a signed loki.deploy/1 attestation bound to the receipt that authorized it (fields: receipt_sha256, head_sha, target, command, health_status, health_url, rollback_attempted, rollback_exit, started_at, finished_at). It is signed with the existing receipt signer (Ed25519, no new crypto) and the receipt itself is unchanged. loki verify <record.json> re-checks it offline (also with --pubkey FILE; --receipt FILE names the cited receipt when it is not beside the record). It fails closed: a flipped byte is TAMPERED, an unsigned record or an unknown key is UNCHECKED, and a cited receipt that is not VERIFIED is never reported as VERIFIED. loki deploy --execute --canary is a staged rollout using only documented provider verbs: Vercel deploys without --prod, health-checks the preview URL, then runs vercel promote <url> (a failed probe never promotes); Fly runs fly deploy --strategy canary; Netlify and Cloudflare document no staged path and refuse --canary with a sentence saying so. loki start --deploy [--health-url U] [--auto-rollback] chains the same gated command after a start.

Everything below is the original print-only design plan and is kept for history.

Status: Architecture plan. No implementation code here. Pattern family: the just-shipped loki preview --public (_preview_public at autonomy/loki:5306, helpers from _read_app_state at :5216, arg parsing in cmd_preview at :5546-:5609, dispatch arm preview) at :14966).

Why the original design was print-only. The README then stated that deploy commands were run by a human and that Loki did not access cloud accounts. The first version was an advisory printer: it ran command -v <cli> ONLY (never the CLI itself, not even --version), printed the exact command, and the human ran it. Fully reversible; touched no cloud account.


These are the decisions the Product Owner / integrator should lock before build. Each has a clear recommended default.

LOCK 1 – Command surface: NEW top-level command loki deploy (not a flag)

LOCK 2 – Disambiguation: print ALL viable options, most-idiomatic first

LOCK 3 – Per-provider canonical commands (DOCUMENTED, not invented)

Confidence column: “task” = exactly the form specified in the approved task brief (authoritative for this build); placeholders are <...> the user fills. No flag is invented.

Provider CLI binary (command -v) Command printed Notes / placeholders Confidence
Vercel vercel vercel --prod none task / high
Netlify netlify netlify deploy --prod for static, append --dir=<build-output> (e.g. dist or build); print the placeholder, do not guess task / high
Fly.io flyctl fly deploy Detect flyctl, print fly deploy – the binary is flyctl but the canonical subcommand verb is fly deploy; this is intentional, not a typo. If no fly.toml, append a one-line note: “run fly launch once first to create fly.toml” (note text only, never executed) task / high
Cloudflare Pages (static) wrangler wrangler pages deploy <build-output> placeholder dir task / high
Cloudflare Workers/containers wrangler wrangler deploy none task / high

LOCK 4 – Clipboard: best-effort, cross-platform, never fatal, copy the idiomatic one

LOCK 5 – NON-EXECUTION is a tested invariant

LOCK 6 – Filesystem-only; NOT gated on a running app

LOCK 7 – Bun parity: bash-only is acceptable


1. Project-type detection (filesystem signals)

Reuse the signals already proven in autonomy/app-runner.sh. All checks are read-only file/dir existence + grep on package.json. Resolve the directory as local dir="${TARGET_DIR:-.}" (same idiom as app_runner_init at app-runner.sh:739 and _detect_nextjs_standalone at :594).

Detection cascade (first match wins for the primary type label; multiple provider options can still be offered per LOCK 2):

# Type Signal (exact checks) Source in repo
1 Next.js grep -q '"next"' "$dir/package.json" OR [ -f "$dir/next.config.js" ] OR next.config.mjs/next.config.ts present mirrors next detection near app-runner.sh:800-813
2 Static / SPA ([ -d "$dir/dist" ] OR [ -d "$dir/build" ]) AND a built index.html in that dir; OR grep -q '"vite"' package.json / CRA (react-scripts) vite signal at app-runner.sh:697
3 Dockerfile / container [ -f "$dir/Dockerfile" ] (and/or docker-compose.yml/compose.yml) app-runner.sh:759, :774
4 Generic Node server [ -f "$dir/package.json" ] with a "start"/"dev" script and no static build dir app-runner.sh:798, :814, :821
5 Python [ -f "$dir/requirements.txt" ] OR [ -f "$dir/pyproject.toml" ] app-runner.sh:953

2. Command surface + dispatch wiring

2.1 Dispatch arm (new, contiguous block placement)

Add a deploy) arm to the dispatch case next to preview) (autonomy/loki:14966):

        deploy)
            cmd_deploy "$@"
            ;;

No deprecated alias (brand-new verb; nothing to alias).

2.2 Function placement – CONTIGUOUS, for test extractability

Place ALL deploy code as one contiguous block so the SDET test can extract it by name anchor the same way test-preview-public.sh extracts the preview block (awk from the first helper def to the closing } of cmd_deploy). Recommended location: immediately AFTER cmd_preview() ends (after autonomy/loki:5649-area close), so the two advisory features sit together. The block:

Keep helpers pure where possible (take dir / type as args, echo to stdout, return 0) so unit tests can call them directly without a process – mirrors the pure extractors _extract_tunnel_url_*.

2.3 cmd_deploy() flow

  1. Parse args in a while [ $# -gt 0 ] loop (mirror cmd_preview :5555):
    • --help|-h|help -> print help block (Section 2.4), return 0.
    • --dir <path> -> override the project dir to scan (default ${TARGET_DIR:-.} then .).
    • --no-clip -> disable clipboard copy.
    • --json -> machine-readable output (optional; see 2.5). Lenient on unknown args (no hard error -> no behavior drift), matching cmd_preview. Guard value-consuming shifts exactly as --provider does at :5595-:5599 (avoid set -e underflow when a flag is the last arg).
  2. type=$(_deploy_detect_type "$dir").
  3. If empty -> no-project path: honest message + return 1.
  4. options=$(_deploy_options_for_type "$type"); filter to rows whose CLI is installed (command -v "$cli" >/dev/null 2>&1).
  5. If zero installed CLIs -> no-CLI path: honest install hints for each candidate provider (brew + official URL), return 1 (Section 3).
  6. Else: print a header (detected type), then each installed option block (label, command, docs URL), idiomatic first. Best-effort copy the first command (LOCK 4). return 0.
  7. Never call any cloud CLI. Print only.

2.4 --help

Mirror the cmd_preview help block (:5557-:5585). Must state: advisory/print-only; it does NOT deploy and does NOT run any cloud CLI; it detects project type + your installed CLI and prints the command for YOU to run; clipboard is best-effort. List options --dir, --no-clip, --json, --help.

Emit {"type": "...", "options": [{"provider","cli","command","docs"}], "copied": "<cmd|>"}. Honest: empty options when no CLI installed. Suppress clipboard note under --json.


3. No-CLI-installed path (honest install hints)

Mirror the gh-missing / tunnel-missing block at autonomy/loki:5413-:5433. For each candidate provider for the detected type, print to stderr, then return 1:

No deploy CLI found for this <type> project.
Loki never accesses your cloud account or runs deploy for you -- you run the printed command.
Install one of the following, then re-run 'loki deploy':

  Vercel:      brew install vercel       |  https://vercel.com/docs/cli
  Netlify:     brew install netlify-cli  |  https://docs.netlify.com/cli/get-started/
  Fly.io:      brew install flyctl       |  https://fly.io/docs/flyctl/install/
  Cloudflare:  npm i -g wrangler         |  https://developers.cloudflare.com/workers/wrangler/install-and-update/

3b. No-detected-project path

Honest message to stderr naming the signals checked (Section 1), return 1.


4. set -e / shellcheck safety (file runs under set -euo pipefail)


5. SDET test plan – tests/test-deploy.sh (NEW)

Structure mirrors tests/test-preview-public.sh: extract the contiguous deploy block by name anchor into a temp lib, source it, drive cmd_deploy and the pure helpers. pass/fail counters, mktemp -d WORKROOT, trap cleanup EXIT INT TERM. Color globals exported empty. Every un-runnable case emits a visible FAIL/SKIP – never a silent pass. Fixtures live under temp dirs (fake package.json, Dockerfile, dist/index.html, requirements.txt).

Extraction sanity (non-vacuity gate, like preview lines 113-126): assert all 4 deploy function defs are present in the extracted lib, else fail loudly and abort.

Test 1 (HEADLINE) – NON-EXECUTION invariant

Test 2 – Project-type detection (pure helper, per type)

For each fixture (Next.js / static dist+index.html / Dockerfile / generic Node / Python), assert _deploy_detect_type "$dir" returns the expected label. Include a “none” fixture (empty dir) -> empty label.

Test 3 – Option ordering / idiomatic-first (pure helper)

With all four stubs on PATH, assert _deploy_options_for_type next lists Vercel before Netlify before Fly; static lists Netlify/Cloudflare before Vercel; Dockerfile lists Fly first. Assert the printed command strings are EXACTLY the LOCK 3 canonical forms (mutation-proof: a wrong flag fails).

Test 4 – No-CLI-installed path

Curated clean PATH with NONE of the four CLIs (build_clean_bin idiom, preview lines 191-201; symlink only coreutils). Next.js fixture. Assert: honest install hint printed (contains “Install one of the following” and at least the Vercel brew + URL) AND non-zero exit AND no sentinel.

Test 5 – No-detected-project path

Empty dir fixture. Assert honest “no deployable project” message naming signals + non-zero exit.

Test 6 – Clipboard best-effort, never fatal

Test 7 – --json honesty (if implemented)

Assert valid JSON; options empty when no CLI installed; non-empty with correct commands when stubs present. Suppresses the clipboard note.

Test 8 – set -e safety / no-abort

Run a representative cmd_deploy invocation inside a set -e -o pipefail subshell and assert it reaches a sentinel “ALIVE” echo after the call (proves no spurious abort), mirroring preview 1b (lines 304-314).

Wiring into tests/run-all-tests.sh

Add next to the preview registration (tests/run-all-tests.sh:204):

run_test "Deploy Advisory (print-only, non-execution invariant)" "$SCRIPT_DIR/test-deploy.sh"

Also covered transitively by tests/run-shellcheck.sh and the mutation/mock detectors (gates #8/#9).


6. Docs to update on release


7. Critical files for implementation