loki-mode 7.71.0 → 7.73.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/autonomy/loki CHANGED
@@ -115,6 +115,16 @@ if [ -f "$_LOKI_SCRIPT_DIR/quickstart.sh" ]; then
115
115
  source "$_LOKI_SCRIPT_DIR/quickstart.sh"
116
116
  fi
117
117
 
118
+ # Shared PRINT-ONLY pull-request advisory (provides print_pr_advice and its
119
+ # origin/compare-URL helpers). Single source of truth sourced by BOTH this CLI
120
+ # (cmd_deploy CI/CD path) and autonomy/run.sh (create_session_pr) so the two
121
+ # surfaces print byte-identical, correct git push + PR commands and cannot drift.
122
+ # Self-guarded against double-source. Existence-guarded matching the pattern above.
123
+ if [ -f "$_LOKI_SCRIPT_DIR/lib/git-pr-advisory.sh" ]; then
124
+ # shellcheck source=autonomy/lib/git-pr-advisory.sh
125
+ source "$_LOKI_SCRIPT_DIR/lib/git-pr-advisory.sh"
126
+ fi
127
+
118
128
  # Resolve the script's real path (handles symlinks)
119
129
  resolve_script_path() {
120
130
  local script="$1"
@@ -725,7 +735,7 @@ show_help() {
725
735
  echo " version Show version"
726
736
  echo " help Show this help ('loki help aliases' for old names)"
727
737
  echo ""
728
- echo "More commands (grill, spec, cleanup, init, watch, demo, web, api,"
738
+ echo "More commands (grill, spec, deploy, cleanup, init, watch, demo, web, api,"
729
739
  echo "logs, github, import, council, proof, audit, compliance, agent, template,"
730
740
  echo "magic, docs, wiki, ci, test, bench, secrets, telemetry, crash, worktree,"
731
741
  echo "failover, monitor, remote, ...) are dispatchable and documented via"
@@ -5207,42 +5217,14 @@ cmd_web_status() {
5207
5217
  echo "Purple Lab is not running."
5208
5218
  }
5209
5219
 
5210
- # Open the running app preview (the app Loki built and started locally).
5211
- # Surfaces the existing app-runner state; does not start or change the app.
5212
- cmd_preview() {
5213
- local open_browser=true
5214
- case "${1:-}" in
5215
- --help|-h|help)
5216
- echo -e "${BOLD}Loki Mode -- open the running app preview${NC}"
5217
- echo ""
5218
- echo "Usage: loki preview [--no-open]"
5219
- echo " loki open (alias)"
5220
- echo ""
5221
- echo "Prints the URL of the app Loki built and started locally, then"
5222
- echo "opens it in your browser. The app runner starts the app after the"
5223
- echo "first successful build iteration. This serves a real local build"
5224
- echo "from localhost on your machine; it is not hosted."
5225
- echo ""
5226
- echo "Options:"
5227
- echo " --no-open Print the URL and status only; do not open a browser"
5228
- echo " --help, -h Show this help and exit"
5229
- return 0
5230
- ;;
5231
- --no-open)
5232
- open_browser=false
5233
- ;;
5234
- esac
5235
-
5236
- local state_file="${LOKI_DIR}/app-runner/state.json"
5237
- if [ ! -f "$state_file" ]; then
5238
- echo "No app running. The app runner starts after the first successful build iteration."
5239
- echo "Run 'loki status' to check the current run."
5240
- return 0
5241
- fi
5242
-
5243
- # Parse url/status/port. Prefer python3 (used throughout); fall back to grep.
5244
- # Pass the path as argv (not inline interpolation) so a path with quotes
5245
- # cannot break the script, and parse all three fields in one invocation.
5220
+ # Read app-runner state.json and echo url, status, port (one per line, in that
5221
+ # order). Shared by the plain preview browser path and the --public tunnel path
5222
+ # so there is no parse drift between them. Path is passed as argv (not inline
5223
+ # interpolation) so a path with quotes cannot break the script. Always exits 0;
5224
+ # callers re-split with sed -n '1p/2p/3p'. Logic is byte-identical to the
5225
+ # original inline parse in cmd_preview (do not "clean up" -- behavior-neutral).
5226
+ _read_app_state() {
5227
+ local state_file="$1"
5246
5228
  local url status port parsed
5247
5229
  if command -v python3 &> /dev/null; then
5248
5230
  parsed=$(python3 -c "import json,sys
@@ -5261,6 +5243,404 @@ except Exception:
5261
5243
  status=$(grep -oE '"status"[[:space:]]*:[[:space:]]*"[^"]*"' "$state_file" 2>/dev/null | head -1 | sed 's/.*"status"[[:space:]]*:[[:space:]]*"//;s/"$//')
5262
5244
  port=$(grep -oE '"port"[[:space:]]*:[[:space:]]*[0-9]+' "$state_file" 2>/dev/null | head -1 | grep -oE '[0-9]+$')
5263
5245
  fi
5246
+ printf '%s\n%s\n%s\n' "$url" "$status" "$port"
5247
+ }
5248
+
5249
+ # Pure extractor: pull the first cloudflared quick-tunnel URL out of a log file.
5250
+ # Reads a FILE (not a live process) so it is unit-testable. Echoes the URL or
5251
+ # nothing. Always returns 0 -- a no-match must not abort the caller under
5252
+ # `set -o pipefail` (grep exits 1 on no match, so the pipeline is `|| true`d).
5253
+ _extract_tunnel_url_cloudflared() {
5254
+ local logfile="${1:-}"
5255
+ [ -f "$logfile" ] || return 0
5256
+ grep -oE 'https://[a-z0-9-]+\.trycloudflare\.com' "$logfile" 2>/dev/null | head -1 || true
5257
+ }
5258
+
5259
+ # Pure extractor: pull the public_url out of an ngrok 4040 API JSON dump.
5260
+ # Reads a FILE so it is unit-testable. Prefers the https tunnel. Uses python3
5261
+ # json (path passed as argv -- no shell interpolation into the heredoc), with a
5262
+ # grep fallback if python3 is absent or the parse fails. Echoes URL or nothing;
5263
+ # always returns 0.
5264
+ _extract_tunnel_url_ngrok() {
5265
+ local json_file="${1:-}"
5266
+ [ -f "$json_file" ] || return 0
5267
+ local out=""
5268
+ if command -v python3 >/dev/null 2>&1; then
5269
+ out=$(python3 - "$json_file" <<'PYEXTRACT' 2>/dev/null || true
5270
+ import json, sys
5271
+ try:
5272
+ with open(sys.argv[1]) as fh:
5273
+ data = json.load(fh)
5274
+ except Exception:
5275
+ sys.exit(0)
5276
+ tunnels = data.get("tunnels", []) if isinstance(data, dict) else []
5277
+ urls = [t.get("public_url", "") for t in tunnels if isinstance(t, dict) and t.get("public_url")]
5278
+ https = [u for u in urls if u.startswith("https://")]
5279
+ chosen = https[0] if https else (urls[0] if urls else "")
5280
+ if chosen:
5281
+ print(chosen)
5282
+ PYEXTRACT
5283
+ )
5284
+ fi
5285
+ if [ -z "$out" ]; then
5286
+ # Grep fallback: take https public_url values first, else any public_url.
5287
+ out=$(grep -oE '"public_url"[[:space:]]*:[[:space:]]*"https://[^"]*"' "$json_file" 2>/dev/null | head -1 | grep -oE 'https://[^"]*' || true)
5288
+ if [ -z "$out" ]; then
5289
+ out=$(grep -oE '"public_url"[[:space:]]*:[[:space:]]*"[^"]*"' "$json_file" 2>/dev/null | head -1 | sed 's/.*"public_url"[[:space:]]*:[[:space:]]*"//;s/"$//' || true)
5290
+ fi
5291
+ fi
5292
+ printf '%s' "$out"
5293
+ }
5294
+
5295
+ # Teardown for a launched tunnel: TERM, brief grace, then KILL, then remove the
5296
+ # log. All steps are `|| true` so the EXIT/INT/TERM trap is set -e safe and never
5297
+ # leaves an orphaned public tunnel. Args: tunnel_pid [logfile].
5298
+ _preview_public_teardown() {
5299
+ local tunnel_pid="${1:-}"
5300
+ local logfile="${2:-}"
5301
+ if [ -n "$tunnel_pid" ]; then
5302
+ kill -TERM "$tunnel_pid" 2>/dev/null || true
5303
+ sleep 1
5304
+ kill -KILL "$tunnel_pid" 2>/dev/null || true
5305
+ fi
5306
+ if [ -n "$logfile" ]; then
5307
+ rm -f "$logfile" 2>/dev/null || true
5308
+ fi
5309
+ }
5310
+
5311
+ # Expose the already-running local app over a PUBLIC URL by wrapping the user's
5312
+ # OWN tunnel CLI (cloudflared or ngrok). Consent-gated, default-OFF. Loki never
5313
+ # proxies traffic and never bundles a binary. Args: provider yes no_host_rewrite.
5314
+ # Returns non-zero on any precondition failure or declined/refused consent
5315
+ # (except an interactive decline, which is a clean exit 0).
5316
+ _preview_public() {
5317
+ local provider="${1:-}"
5318
+ local assume_yes="${2:-false}"
5319
+ local no_host_rewrite="${3:-false}"
5320
+
5321
+ local state_file="${LOKI_DIR}/app-runner/state.json"
5322
+
5323
+ # Precondition 1: state.json must exist.
5324
+ if [ ! -f "$state_file" ]; then
5325
+ echo "No app running. Nothing to expose." >&2
5326
+ echo "Start a build (loki start) or check status (loki status) first." >&2
5327
+ return 1
5328
+ fi
5329
+
5330
+ # Read url/status/port via the shared helper (same parse as plain preview).
5331
+ local _state url status port
5332
+ _state=$(_read_app_state "$state_file")
5333
+ url=$(printf '%s\n' "$_state" | sed -n '1p')
5334
+ status=$(printf '%s\n' "$_state" | sed -n '2p')
5335
+ port=$(printf '%s\n' "$_state" | sed -n '3p')
5336
+
5337
+ # Precondition 2: status must be running.
5338
+ if [ "$status" != "running" ]; then
5339
+ echo "App is not running (status: ${status:-unknown}). Refusing to expose." >&2
5340
+ echo "The app runner starts the app after a successful build iteration." >&2
5341
+ return 1
5342
+ fi
5343
+
5344
+ # Precondition 3: resolve URL/port (fallback mirrors the plain path).
5345
+ if [ -z "$url" ]; then
5346
+ url="http://localhost:${port:-3000}"
5347
+ fi
5348
+ local probe_port="${port:-3000}"
5349
+
5350
+ # Precondition 4: PORT must actually be reachable. Never tunnel a dead port.
5351
+ # Reuses the curl-readiness poll pattern (autonomy/loki:4979).
5352
+ local retries=0
5353
+ local port_ready=false
5354
+ while [ $retries -lt 6 ]; do
5355
+ if curl -s "http://localhost:${probe_port}" > /dev/null 2>&1; then
5356
+ port_ready=true
5357
+ break
5358
+ fi
5359
+ sleep 0.5
5360
+ retries=$((retries + 1))
5361
+ done
5362
+ if [ "$port_ready" != true ]; then
5363
+ echo "Port ${probe_port} is not responding. Refusing to expose a dead port." >&2
5364
+ echo "Confirm the app is up locally (loki preview) before sharing it." >&2
5365
+ return 1
5366
+ fi
5367
+
5368
+ # Consent (default-OFF). Print the full warning every time (even with --yes).
5369
+ echo "WARNING: This makes the app running on THIS machine reachable by ANYONE who has"
5370
+ echo "the URL, over the public internet, using YOUR tunnel account."
5371
+ echo "- The app may have NO authentication. Anyone with the link can use it."
5372
+ echo "- Traffic flows through your own cloudflared/ngrok account, not through Loki."
5373
+ echo "- This stays up until you stop it. Stop it when you are done."
5374
+ echo ""
5375
+
5376
+ if [ "$assume_yes" = true ]; then
5377
+ : # --yes: warning printed above; skip the prompt.
5378
+ elif [ -t 0 ]; then
5379
+ # Interactive TTY. Default-N: only ^[Yy] proceeds. (Deliberately NOT the
5380
+ # default-Y idiom at :1894 -- public exposure is unsafe by default.)
5381
+ local confirm=""
5382
+ echo -e "Expose this app publicly? [y/N] \c"
5383
+ read -r confirm || true
5384
+ if [[ ! "$confirm" =~ ^[Yy] ]]; then
5385
+ echo "Aborted. App was not exposed."
5386
+ return 0
5387
+ fi
5388
+ else
5389
+ # Non-TTY without --yes: never silently expose.
5390
+ echo "Refusing to expose a public tunnel non-interactively without --yes." >&2
5391
+ return 1
5392
+ fi
5393
+
5394
+ # === SEAM (Agent B): provider detection + tunnel launch + URL extraction ===
5395
+
5396
+ # Provider detection (SS5). command -v based so a PATH stub works in tests.
5397
+ # If $provider was requested, require exactly that one; else prefer
5398
+ # cloudflared (no account needed for quick tunnels) then ngrok.
5399
+ local chosen_provider=""
5400
+ if [ -n "$provider" ]; then
5401
+ # Allowlist: only cloudflared/ngrok are supported. Without this guard an
5402
+ # arbitrary on-PATH binary name (e.g. --provider ls) would set
5403
+ # chosen_provider and fall through to the ngrok launch branch below.
5404
+ case "$provider" in
5405
+ cloudflared|ngrok) ;;
5406
+ *)
5407
+ echo -e "${RED}Unsupported tunnel provider: ${provider}${NC}" >&2
5408
+ echo "Supported providers: cloudflared, ngrok" >&2
5409
+ return 1
5410
+ ;;
5411
+ esac
5412
+ if command -v "$provider" >/dev/null 2>&1; then
5413
+ chosen_provider="$provider"
5414
+ fi
5415
+ else
5416
+ if command -v cloudflared >/dev/null 2>&1; then
5417
+ chosen_provider="cloudflared"
5418
+ elif command -v ngrok >/dev/null 2>&1; then
5419
+ chosen_provider="ngrok"
5420
+ fi
5421
+ fi
5422
+
5423
+ if [ -z "$chosen_provider" ]; then
5424
+ # Honest install hint (mirrors the gh-missing block). Never pretend
5425
+ # success, never download a binary. Loki wraps YOUR OWN client.
5426
+ if [ -n "$provider" ]; then
5427
+ echo -e "${RED}Requested tunnel provider not found: ${provider}${NC}" >&2
5428
+ else
5429
+ echo -e "${RED}No tunnel CLI found (cloudflared or ngrok)${NC}" >&2
5430
+ fi
5431
+ echo "" >&2
5432
+ echo "Loki wraps YOUR OWN tunnel client. It never downloads or bundles one." >&2
5433
+ echo "Install one of the following, then re-run:" >&2
5434
+ echo "" >&2
5435
+ echo " cloudflared (no account needed for quick tunnels):" >&2
5436
+ echo " brew install cloudflared # macOS" >&2
5437
+ echo " https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/ # all platforms" >&2
5438
+ echo "" >&2
5439
+ echo " ngrok (needs a free authtoken):" >&2
5440
+ echo " brew install ngrok # macOS" >&2
5441
+ echo " https://ngrok.com/download # all platforms" >&2
5442
+ return 1
5443
+ fi
5444
+
5445
+ # State dir, parallel to app-runner/.
5446
+ mkdir -p "${LOKI_DIR}/preview" 2>/dev/null || true
5447
+ local log_file="${LOKI_DIR}/preview/${chosen_provider}.log"
5448
+
5449
+ # Build the launch command (SS7/SS8). Host-header rewrite is default-ON to
5450
+ # fix the #1 dev-server "Invalid Host header" failure; --no-host-rewrite
5451
+ # opts out.
5452
+ local -a tunnel_cmd=()
5453
+ if [ "$chosen_provider" = "cloudflared" ]; then
5454
+ tunnel_cmd=(cloudflared tunnel --url "http://localhost:${probe_port}")
5455
+ if [ "$no_host_rewrite" != true ]; then
5456
+ tunnel_cmd+=(--http-host-header localhost)
5457
+ fi
5458
+ else
5459
+ # ngrok
5460
+ tunnel_cmd=(ngrok http "${probe_port}")
5461
+ if [ "$no_host_rewrite" != true ]; then
5462
+ tunnel_cmd+=(--host-header=rewrite)
5463
+ fi
5464
+ fi
5465
+
5466
+ echo ""
5467
+ echo "Starting ${chosen_provider} tunnel for http://localhost:${probe_port} ..."
5468
+
5469
+ # Launch redirected to a log, in the background; capture its pid.
5470
+ "${tunnel_cmd[@]}" > "$log_file" 2>&1 &
5471
+ local tunnel_pid=$!
5472
+
5473
+ # Install teardown immediately so no exit path leaves an orphaned tunnel.
5474
+ trap '_preview_public_teardown "$tunnel_pid" "$log_file"' EXIT INT TERM
5475
+
5476
+ # Poll for the public URL. cloudflared writes it to its log; ngrok exposes
5477
+ # it via the local 4040 API. Bounded loop (~20 x sleep 0.5 = ~10s). Also
5478
+ # detect early tunnel-process death so we fail honestly instead of hanging.
5479
+ local public_url=""
5480
+ local tries=0
5481
+ while [ $tries -lt 20 ]; do
5482
+ # Early death detection: if the tunnel process is gone, report and bail.
5483
+ if ! kill -0 "$tunnel_pid" 2>/dev/null; then
5484
+ echo -e "${RED}${chosen_provider} exited; see ${log_file}${NC}" >&2
5485
+ # Surface the tail of the log for context.
5486
+ tail -n 10 "$log_file" 2>/dev/null >&2 || true
5487
+ if [ "$chosen_provider" = "ngrok" ]; then
5488
+ echo "If this is an auth error, add your token: ngrok config add-authtoken <token>" >&2
5489
+ fi
5490
+ # The EXIT trap fires at SHELL exit, not function return, so reap the
5491
+ # process now and clear the trap. Keep the log for inspection (the
5492
+ # message points at it), so omit the log arg to teardown.
5493
+ _preview_public_teardown "$tunnel_pid"
5494
+ trap - EXIT INT TERM
5495
+ return 1
5496
+ fi
5497
+
5498
+ if [ "$chosen_provider" = "cloudflared" ]; then
5499
+ public_url=$(_extract_tunnel_url_cloudflared "$log_file")
5500
+ else
5501
+ local api_json="${LOKI_DIR}/preview/ngrok-api.json"
5502
+ if curl -s "http://127.0.0.1:4040/api/tunnels" -o "$api_json" 2>/dev/null; then
5503
+ public_url=$(_extract_tunnel_url_ngrok "$api_json")
5504
+ fi
5505
+ rm -f "$api_json" 2>/dev/null || true
5506
+ fi
5507
+
5508
+ if [ -n "$public_url" ]; then
5509
+ break
5510
+ fi
5511
+ sleep 0.5
5512
+ tries=$((tries + 1))
5513
+ done
5514
+
5515
+ if [ -z "$public_url" ]; then
5516
+ echo -e "${RED}Timed out waiting for a public URL from ${chosen_provider}.${NC}" >&2
5517
+ tail -n 10 "$log_file" 2>/dev/null >&2 || true
5518
+ if [ "$chosen_provider" = "ngrok" ]; then
5519
+ echo "ngrok may be missing an authtoken: ngrok config add-authtoken <token>" >&2
5520
+ fi
5521
+ # Reap now (the EXIT trap fires only at shell exit, not function return,
5522
+ # which would orphan the tunnel back in cmd_preview/main). Keep the log
5523
+ # for inspection, so omit the log arg.
5524
+ _preview_public_teardown "$tunnel_pid"
5525
+ trap - EXIT INT TERM
5526
+ return 1
5527
+ fi
5528
+
5529
+ # URL captured. Print it clearly and re-print the live warning.
5530
+ echo ""
5531
+ echo -e "${GREEN}Public preview URL:${NC} ${public_url}"
5532
+ echo ""
5533
+ echo "WARNING: This URL is live and reachable by ANYONE who has it, over the"
5534
+ echo "public internet, through YOUR ${chosen_provider} account. The app may have no"
5535
+ echo "authentication. This stays up until you stop it."
5536
+ echo ""
5537
+ echo "Press Ctrl+C to stop sharing."
5538
+
5539
+ # Foreground-block on the tunnel. Ctrl+C delivers INT -> trap -> teardown.
5540
+ # `wait` returns 130 on signal; guard so set -e does not abort the clean exit.
5541
+ wait "$tunnel_pid" 2>/dev/null || true
5542
+
5543
+ # Idempotent final teardown (the INT trap may already have run) + remove the
5544
+ # log on this success/Ctrl+C path. Clear the trap so it does not re-fire at
5545
+ # shell exit with out-of-scope locals.
5546
+ _preview_public_teardown "$tunnel_pid" "$log_file"
5547
+ trap - EXIT INT TERM
5548
+
5549
+ echo ""
5550
+ echo "Tunnel stopped."
5551
+ return 0
5552
+ }
5553
+
5554
+ # Open the running app preview (the app Loki built and started locally).
5555
+ # Surfaces the existing app-runner state; does not start or change the app.
5556
+ cmd_preview() {
5557
+ local open_browser=true
5558
+ local public=false
5559
+ local provider=""
5560
+ local assume_yes=false
5561
+ local no_host_rewrite=false
5562
+
5563
+ # Parse options. Loop so flag combos like `--public --yes` work; preserve
5564
+ # the existing leniency on unknown args (no hard error -> no behavior drift).
5565
+ while [ $# -gt 0 ]; do
5566
+ case "${1:-}" in
5567
+ --help|-h|help)
5568
+ echo -e "${BOLD}Loki Mode -- open the running app preview${NC}"
5569
+ echo ""
5570
+ echo "Usage: loki preview [--no-open]"
5571
+ echo " loki preview --public [--provider cloudflared|ngrok] [--yes] [--no-host-rewrite]"
5572
+ echo " loki open (alias)"
5573
+ echo ""
5574
+ echo "Prints the URL of the app Loki built and started locally, then"
5575
+ echo "opens it in your browser. The app runner starts the app after the"
5576
+ echo "first successful build iteration. This serves a real local build"
5577
+ echo "from localhost on your machine; it is not hosted."
5578
+ echo ""
5579
+ echo "Options:"
5580
+ echo " --no-open Print the URL and status only; do not open a browser"
5581
+ echo " --public Expose the running app over a PUBLIC URL via your own"
5582
+ echo " tunnel CLI (cloudflared or ngrok). Consent-gated,"
5583
+ echo " default-OFF. Loki never proxies traffic or bundles a"
5584
+ echo " binary; it wraps YOUR OWN tunnel client."
5585
+ echo " --provider NAME Tunnel provider for --public: cloudflared or ngrok"
5586
+ echo " (default: auto-detect, cloudflared first)"
5587
+ echo " --yes Skip the --public consent prompt (warning is still"
5588
+ echo " printed). Required to use --public non-interactively"
5589
+ echo " --no-host-rewrite Do not rewrite the Host header on the tunnel"
5590
+ echo " --help, -h Show this help and exit"
5591
+ echo ""
5592
+ echo "WARNING (--public): exposes THIS machine's app to ANYONE with the URL,"
5593
+ echo "over the public internet, through your own tunnel account. The app may"
5594
+ echo "have no authentication. It stays up until you stop it."
5595
+ return 0
5596
+ ;;
5597
+ --no-open)
5598
+ open_browser=false
5599
+ ;;
5600
+ --public)
5601
+ public=true
5602
+ ;;
5603
+ --provider)
5604
+ provider="${2:-}"
5605
+ # Guard the value-consuming shift: if --provider is the LAST arg
5606
+ # (no value), an unguarded shift here plus the loop's trailing
5607
+ # shift would underflow and abort under set -e. Only consume a
5608
+ # value when one is actually present.
5609
+ [ $# -ge 2 ] && shift
5610
+ ;;
5611
+ --yes)
5612
+ assume_yes=true
5613
+ ;;
5614
+ --no-host-rewrite)
5615
+ no_host_rewrite=true
5616
+ ;;
5617
+ esac
5618
+ shift
5619
+ done
5620
+
5621
+ # --public branches into the tunnel path BEFORE the browser-open logic.
5622
+ # Capture the exit code (set -e safe: a bare non-zero return on the call
5623
+ # line would trip set -e before `return` runs).
5624
+ if [ "$public" = true ]; then
5625
+ local rc=0
5626
+ _preview_public "$provider" "$assume_yes" "$no_host_rewrite" || rc=$?
5627
+ return $rc
5628
+ fi
5629
+
5630
+ local state_file="${LOKI_DIR}/app-runner/state.json"
5631
+ if [ ! -f "$state_file" ]; then
5632
+ echo "No app running. The app runner starts after the first successful build iteration."
5633
+ echo "Run 'loki status' to check the current run."
5634
+ return 0
5635
+ fi
5636
+
5637
+ # Parse url/status/port via the shared helper, then re-split (same logic the
5638
+ # inline parse used; behavior-neutral for the plain preview path).
5639
+ local url status port _state
5640
+ _state=$(_read_app_state "$state_file")
5641
+ url=$(printf '%s\n' "$_state" | sed -n '1p')
5642
+ status=$(printf '%s\n' "$_state" | sed -n '2p')
5643
+ port=$(printf '%s\n' "$_state" | sed -n '3p')
5264
5644
 
5265
5645
  if [ "$status" != "running" ]; then
5266
5646
  echo "App is not running (status: ${status:-unknown})."
@@ -5288,6 +5668,396 @@ except Exception:
5288
5668
  fi
5289
5669
  }
5290
5670
 
5671
+ # =============================================================================
5672
+ # loki deploy -- ADVISORY / PRINT-ONLY deploy command (FEAT-DEPLOY).
5673
+ #
5674
+ # WHY ITS OWN COMMAND (not a preview flag): `loki preview` is "show me the local
5675
+ # app I already built and started" and is GATED on a running app (state.json
5676
+ # status=running, a live reachable port). `loki deploy` is conceptually distinct:
5677
+ # it is a STATIC, FILESYSTEM-ONLY advisory about the project's type and the user's
5678
+ # installed cloud CLI / CI-CD pipeline. It must work with nothing running and no
5679
+ # build started, so it has NO running-app precondition. Folding it into preview
5680
+ # would force preview's running-app gate onto a feature that must not have one.
5681
+ #
5682
+ # HARD INVARIANT (DEPLOY-PLAN LOCK 5 + BRANCH-LIFECYCLE LOCK B4): PRINT-ONLY.
5683
+ # cmd_deploy NEVER runs a cloud CLI (vercel/netlify/flyctl/wrangler) -- not even
5684
+ # `--version` -- and NEVER runs `git push` or `gh pr create`. Tool detection is
5685
+ # `command -v` ONLY. Loki advises; the human runs the printed command. This keeps
5686
+ # the README promise ("Does not deploy -- human runs deploy commands") literally
5687
+ # true. Only the clipboard tools (pbcopy/wl-copy/...) and `command -v` may run.
5688
+ #
5689
+ # This whole block is contiguous (helpers + cmd_deploy) so a test can extract it
5690
+ # by name anchor, mirroring tests/test-preview-public.sh.
5691
+ # =============================================================================
5692
+
5693
+ # _deploy_detect_type <dir>
5694
+ # Echoes the PRIMARY project-type label (one of: nextjs, static, docker, node,
5695
+ # python) or empty if none detected. Read-only file/dir existence + grep on
5696
+ # package.json. Always returns 0 (caller treats empty as "no project"). First
5697
+ # match wins for the primary label; cmd_deploy still offers multiple provider
5698
+ # options per type. set -e safe: every grep is `|| true`-guarded.
5699
+ _deploy_detect_type() {
5700
+ local dir="${1:-.}"
5701
+ local pkg="$dir/package.json"
5702
+
5703
+ # 1. Next.js -- source signal ("next" dep or a next.config.*), NOT the build
5704
+ # artifact (.next/standalone), since the build may not have run yet.
5705
+ if [ -f "$pkg" ] && grep -q '"next"' "$pkg" 2>/dev/null; then
5706
+ printf '%s' "nextjs"; return 0
5707
+ fi
5708
+ if [ -f "$dir/next.config.js" ] || [ -f "$dir/next.config.mjs" ] || [ -f "$dir/next.config.ts" ]; then
5709
+ printf '%s' "nextjs"; return 0
5710
+ fi
5711
+
5712
+ # 2. Static / SPA -- a built dist/ or build/ dir containing index.html, OR a
5713
+ # Vite / CRA source signal in package.json.
5714
+ if { [ -d "$dir/dist" ] && [ -f "$dir/dist/index.html" ]; } || \
5715
+ { [ -d "$dir/build" ] && [ -f "$dir/build/index.html" ]; }; then
5716
+ printf '%s' "static"; return 0
5717
+ fi
5718
+ if [ -f "$pkg" ] && { grep -q '"vite"' "$pkg" 2>/dev/null || grep -q '"react-scripts"' "$pkg" 2>/dev/null; }; then
5719
+ printf '%s' "static"; return 0
5720
+ fi
5721
+
5722
+ # 3. Dockerfile / containerized server.
5723
+ if [ -f "$dir/Dockerfile" ]; then
5724
+ printf '%s' "docker"; return 0
5725
+ fi
5726
+
5727
+ # 4. Generic Node server -- package.json with a start (or dev) script.
5728
+ if [ -f "$pkg" ] && { grep -q '"start"' "$pkg" 2>/dev/null || grep -q '"dev"' "$pkg" 2>/dev/null; }; then
5729
+ printf '%s' "node"; return 0
5730
+ fi
5731
+
5732
+ # 5. Python.
5733
+ if [ -f "$dir/requirements.txt" ] || [ -f "$dir/pyproject.toml" ]; then
5734
+ printf '%s' "python"; return 0
5735
+ fi
5736
+
5737
+ printf '%s' ""
5738
+ return 0
5739
+ }
5740
+
5741
+ # _deploy_options_for_type <type>
5742
+ # Pure, DIR-BLIND helper: echoes the ordered candidate rows for a project type,
5743
+ # one per line, as `provider|cli|command|docs`. Idiomatic provider first per
5744
+ # DEPLOY-PLAN LOCK 2. Lists the FULL ordered candidate set for the type
5745
+ # UNCONDITIONALLY (no dir awareness, no CLI-installed filtering -- cmd_deploy does
5746
+ # the `command -v` filtering). The command strings are the EXACT canonical forms
5747
+ # from DEPLOY-PLAN LOCK 3 (a wrong flag is worse than no feature; do not
5748
+ # paraphrase). Project-dependent dirs stay as `<...>` placeholders the user fills.
5749
+ # Note: the Fly CLI binary is `flyctl` but the canonical deploy verb is
5750
+ # `fly deploy` -- "fly" not "flyctl" here is CORRECT, intentional, not a typo.
5751
+ _deploy_options_for_type() {
5752
+ local type="${1:-}"
5753
+ case "$type" in
5754
+ nextjs)
5755
+ printf '%s\n' "Vercel|vercel|vercel --prod|https://vercel.com/docs/cli"
5756
+ printf '%s\n' "Netlify|netlify|netlify deploy --prod|https://docs.netlify.com/cli/get-started/"
5757
+ # fly not flyctl - correct: binary is flyctl, deploy verb is `fly deploy`.
5758
+ printf '%s\n' "Fly.io|flyctl|fly deploy|https://fly.io/docs/flyctl/install/"
5759
+ ;;
5760
+ static)
5761
+ printf '%s\n' "Netlify|netlify|netlify deploy --prod --dir=<build-output>|https://docs.netlify.com/cli/get-started/"
5762
+ printf '%s\n' "Cloudflare Pages|wrangler|wrangler pages deploy <build-output>|https://developers.cloudflare.com/workers/wrangler/install-and-update/"
5763
+ printf '%s\n' "Vercel|vercel|vercel --prod|https://vercel.com/docs/cli"
5764
+ ;;
5765
+ docker)
5766
+ # fly not flyctl - correct (see note above).
5767
+ printf '%s\n' "Fly.io|flyctl|fly deploy|https://fly.io/docs/flyctl/install/"
5768
+ printf '%s\n' "Cloudflare|wrangler|wrangler deploy|https://developers.cloudflare.com/workers/wrangler/install-and-update/"
5769
+ ;;
5770
+ node)
5771
+ # fly not flyctl - correct (see note above).
5772
+ printf '%s\n' "Fly.io|flyctl|fly deploy|https://fly.io/docs/flyctl/install/"
5773
+ printf '%s\n' "Vercel|vercel|vercel --prod|https://vercel.com/docs/cli"
5774
+ ;;
5775
+ python)
5776
+ # fly not flyctl - correct (see note above).
5777
+ printf '%s\n' "Fly.io|flyctl|fly deploy|https://fly.io/docs/flyctl/install/"
5778
+ ;;
5779
+ esac
5780
+ return 0
5781
+ }
5782
+
5783
+ # _deploy_detect_cicd <dir>
5784
+ # Returns 0 + echoes the detected CI/CD system name(s) (space-separated) if ANY
5785
+ # pipeline config exists (BRANCH-LIFECYCLE LOCK B1); returns 1 + echoes nothing
5786
+ # if none. Read-only file existence only. The `[ -e "$f" ]` guard inside the glob
5787
+ # loop makes the no-match literal-glob case set -e safe (an unmatched glob expands
5788
+ # to the literal pattern, which `[ -e ]` then rejects without aborting).
5789
+ _deploy_detect_cicd() {
5790
+ local dir="${1:-.}"
5791
+ local found=""
5792
+ local systems=""
5793
+
5794
+ # GitHub Actions: any .yml or .yaml under .github/workflows/.
5795
+ local f
5796
+ for f in "$dir"/.github/workflows/*.yml "$dir"/.github/workflows/*.yaml; do
5797
+ if [ -e "$f" ]; then found=1; systems="${systems} GitHub-Actions"; break; fi
5798
+ done
5799
+
5800
+ [ -f "$dir/.gitlab-ci.yml" ] && { found=1; systems="${systems} GitLab-CI"; }
5801
+ [ -f "$dir/Jenkinsfile" ] && { found=1; systems="${systems} Jenkins"; }
5802
+ [ -f "$dir/.circleci/config.yml" ] && { found=1; systems="${systems} CircleCI"; }
5803
+ [ -f "$dir/azure-pipelines.yml" ] && { found=1; systems="${systems} Azure-Pipelines"; }
5804
+ [ -f "$dir/bitbucket-pipelines.yml" ] && { found=1; systems="${systems} Bitbucket-Pipelines"; }
5805
+
5806
+ if [ -n "$found" ]; then
5807
+ # Trim the leading space and echo the names.
5808
+ printf '%s' "${systems# }"
5809
+ return 0
5810
+ fi
5811
+ printf '%s' ""
5812
+ return 1
5813
+ }
5814
+
5815
+ # _deploy_copy_clipboard <cmd>
5816
+ # Best-effort clipboard copy of a SINGLE command (DEPLOY-PLAN LOCK 4). TTY-gated,
5817
+ # command-v guarded, ALWAYS returns 0 and never fatal. Echoes a confirmation note
5818
+ # only if a copy tool actually ran. These clipboard tools ARE allowed to run; only
5819
+ # the four cloud CLIs are forbidden.
5820
+ _deploy_copy_clipboard() {
5821
+ local cmd="${1:-}"
5822
+ [ -n "$cmd" ] || return 0
5823
+ [ -t 1 ] || return 0
5824
+ local copied=""
5825
+ if command -v pbcopy >/dev/null 2>&1; then
5826
+ printf '%s' "$cmd" | pbcopy >/dev/null 2>&1 && copied="1" || true
5827
+ elif command -v wl-copy >/dev/null 2>&1; then
5828
+ printf '%s' "$cmd" | wl-copy >/dev/null 2>&1 && copied="1" || true
5829
+ elif command -v xclip >/dev/null 2>&1; then
5830
+ printf '%s' "$cmd" | xclip -selection clipboard >/dev/null 2>&1 && copied="1" || true
5831
+ elif command -v xsel >/dev/null 2>&1; then
5832
+ printf '%s' "$cmd" | xsel --clipboard --input >/dev/null 2>&1 && copied="1" || true
5833
+ elif command -v clip >/dev/null 2>&1; then
5834
+ printf '%s' "$cmd" | clip >/dev/null 2>&1 && copied="1" || true
5835
+ fi
5836
+ if [ -n "$copied" ]; then
5837
+ printf '%s\n' " (copied to clipboard: ${cmd})"
5838
+ fi
5839
+ return 0
5840
+ }
5841
+
5842
+ # _deploy_print_install_hint <type>
5843
+ # Honest install hints (brew + official URL) for the candidate providers of a
5844
+ # type. NEVER fabricates success, NEVER downloads a binary. Mirrors the
5845
+ # tunnel-missing / gh-missing block. Caller redirects to stderr.
5846
+ _deploy_print_install_hint() {
5847
+ local type="${1:-}"
5848
+ echo "No deploy CLI found for this ${type} project."
5849
+ echo "Loki never accesses your cloud account or runs deploy for you -- you run"
5850
+ echo "the printed command. Install one of the following, then re-run 'loki deploy':"
5851
+ echo ""
5852
+ local options provider cli command docs
5853
+ options="$(_deploy_options_for_type "$type")"
5854
+ while IFS='|' read -r provider cli command docs; do
5855
+ [ -n "$provider" ] || continue
5856
+ case "$cli" in
5857
+ vercel) echo " Vercel: brew install vercel | ${docs}" ;;
5858
+ netlify) echo " Netlify: brew install netlify-cli | ${docs}" ;;
5859
+ flyctl) echo " Fly.io: brew install flyctl | ${docs}" ;;
5860
+ wrangler) echo " Cloudflare: npm i -g wrangler | ${docs}" ;;
5861
+ *) echo " ${provider}: ${docs}" ;;
5862
+ esac
5863
+ done <<EOF
5864
+ $options
5865
+ EOF
5866
+ return 0
5867
+ }
5868
+
5869
+ # _deploy_print_cloud_options <dir> <type> <do_clip> <hint_on_none>
5870
+ # Prints every installed (project-type x CLI) option block, idiomatic first.
5871
+ # Best-effort copies the FIRST installed command when do_clip=true. Returns 0 if
5872
+ # at least one CLI was printed. If NONE installed: when hint_on_none=true, prints
5873
+ # the honest install hint (to stderr) and returns 1; when false (pipeline path,
5874
+ # where cloud is secondary), prints nothing and returns 1 silently.
5875
+ _deploy_print_cloud_options() {
5876
+ local dir="${1:-.}"
5877
+ local type="${2:-}"
5878
+ local do_clip="${3:-true}"
5879
+ local hint_on_none="${4:-true}"
5880
+
5881
+ local options=""
5882
+ options="$(_deploy_options_for_type "$type")"
5883
+
5884
+ local printed=false
5885
+ local first_cmd=""
5886
+ local provider cli command docs
5887
+ while IFS='|' read -r provider cli command docs; do
5888
+ [ -n "$cli" ] || continue
5889
+ if command -v "$cli" >/dev/null 2>&1; then
5890
+ if [ "$printed" != true ]; then
5891
+ echo -e "${BOLD}Detected ${type} project. Deploy options (run one yourself):${NC}"
5892
+ echo ""
5893
+ printed=true
5894
+ fi
5895
+ echo " ${provider}:"
5896
+ echo " ${command}"
5897
+ echo " docs: ${docs}"
5898
+ echo ""
5899
+ if [ -z "$first_cmd" ]; then
5900
+ first_cmd="$command"
5901
+ fi
5902
+ fi
5903
+ done <<EOF
5904
+ $options
5905
+ EOF
5906
+
5907
+ if [ "$printed" = true ]; then
5908
+ echo "Loki does not deploy for you. Review, then run the command yourself."
5909
+ if [ "$do_clip" = true ] && [ -n "$first_cmd" ]; then
5910
+ _deploy_copy_clipboard "$first_cmd"
5911
+ fi
5912
+ return 0
5913
+ fi
5914
+
5915
+ # No installed CLI for this type.
5916
+ if [ "$hint_on_none" = true ]; then
5917
+ _deploy_print_install_hint "$type" >&2
5918
+ return 1
5919
+ fi
5920
+ return 1
5921
+ }
5922
+
5923
+ # cmd_deploy [--dir <path>] [--no-clip] [--help]
5924
+ # Advisory orchestration: detect project type + CI/CD pipeline + installed cloud
5925
+ # CLIs, then PRINT the canonical deploy command(s). PRINT-ONLY (see block header).
5926
+ # Placed LAST in the contiguous deploy block (all helpers above it) so the SDET
5927
+ # can extract from the first helper def to the close of cmd_deploy and capture the
5928
+ # whole self-contained unit (mirrors test-preview-public.sh, where cmd_preview is
5929
+ # the terminal function).
5930
+ cmd_deploy() {
5931
+ local dir="${TARGET_DIR:-.}"
5932
+ local do_clip=true
5933
+
5934
+ # Arg parse (mirror cmd_preview: lenient on unknown args -> no behavior drift).
5935
+ while [ $# -gt 0 ]; do
5936
+ case "${1:-}" in
5937
+ --help|-h|help)
5938
+ echo -e "${BOLD}Loki Mode -- advisory deploy command (print-only)${NC}"
5939
+ echo ""
5940
+ echo "Usage: loki deploy [--dir <path>] [--no-clip]"
5941
+ echo ""
5942
+ echo "Detects your project type and your installed cloud CLI (and any"
5943
+ echo "CI/CD pipeline), then PRINTS the exact deploy command for YOU to run."
5944
+ echo "It is advisory only: it NEVER deploys, NEVER runs a cloud CLI (not"
5945
+ echo "even --version), and NEVER runs 'git push'. Loki does not access your"
5946
+ echo "cloud account. You run the printed command. Detection is read-only."
5947
+ echo ""
5948
+ echo "If a CI/CD pipeline (GitHub Actions, GitLab CI, Jenkins, CircleCI,"
5949
+ echo "Azure Pipelines, Bitbucket) is detected, the primary advice is the"
5950
+ echo "git push + pull-request path, because your pipeline deploys on merge."
5951
+ echo ""
5952
+ echo "Options:"
5953
+ echo " --dir <path> Project directory to scan (default: current dir)"
5954
+ echo " --no-clip Do not copy the idiomatic command to the clipboard"
5955
+ echo " --help, -h Show this help and exit"
5956
+ return 0
5957
+ ;;
5958
+ --dir)
5959
+ dir="${2:-.}"
5960
+ # Guard the value-consuming shift: if --dir is the LAST arg (no
5961
+ # value), an unguarded shift plus the loop's trailing shift would
5962
+ # underflow and abort under set -e. Consume a value only if present.
5963
+ [ $# -ge 2 ] && shift
5964
+ ;;
5965
+ --no-clip)
5966
+ do_clip=false
5967
+ ;;
5968
+ esac
5969
+ shift
5970
+ done
5971
+
5972
+ # Resolve to '.' if the chosen dir does not exist (honest, no crash).
5973
+ [ -d "$dir" ] || dir="."
5974
+
5975
+ # Detect CI/CD pipeline FIRST (BRANCH-LIFECYCLE LOCK B3 precedence).
5976
+ local cicd=""
5977
+ cicd="$(_deploy_detect_cicd "$dir" || true)"
5978
+ local has_pipeline=false
5979
+ [ -n "$cicd" ] && has_pipeline=true
5980
+
5981
+ # Detect project type (filesystem-only; LOCK 6 -- not gated on a running app).
5982
+ local type=""
5983
+ type="$(_deploy_detect_type "$dir")"
5984
+
5985
+ # ---- Pipeline path (LOCK B3): git push + PR advised FIRST, cloud secondary.
5986
+ if [ "$has_pipeline" = true ]; then
5987
+ echo -e "${BOLD}CI/CD pipeline detected (${cicd})${NC}"
5988
+ echo "Your pipeline deploys on push/merge, so the primary deploy path is"
5989
+ echo "commit + push + pull request:"
5990
+ echo ""
5991
+
5992
+ # Derive base/head for the PR advice. set -e safe: every git call is
5993
+ # 2>/dev/null with a fallback so a non-zero git never aborts the shell.
5994
+ local head="" base=""
5995
+ head="$(git -C "$dir" rev-parse --abbrev-ref HEAD 2>/dev/null || echo "")"
5996
+ [ -n "$head" ] || head="HEAD"
5997
+ case "$head" in
5998
+ loki/*)
5999
+ # On a loki branch: prefer the persisted base, else origin default.
6000
+ if [ -s "$dir/.loki/state/base-branch.txt" ]; then
6001
+ base="$(head -n1 "$dir/.loki/state/base-branch.txt" 2>/dev/null || echo "")"
6002
+ fi
6003
+ ;;
6004
+ esac
6005
+ if [ -z "$base" ]; then
6006
+ # Best-effort: origin's default branch (e.g. origin/main -> main).
6007
+ local origin_head=""
6008
+ origin_head="$(git -C "$dir" symbolic-ref refs/remotes/origin/HEAD 2>/dev/null || echo "")"
6009
+ if [ -n "$origin_head" ]; then
6010
+ base="${origin_head##*/}"
6011
+ fi
6012
+ fi
6013
+
6014
+ if [ -n "$base" ] && declare -F print_pr_advice >/dev/null 2>&1; then
6015
+ print_pr_advice "$base" "$head" "$dir"
6016
+ elif declare -F print_pr_advice >/dev/null 2>&1; then
6017
+ echo " Could not determine the PR base branch automatically."
6018
+ echo " Set your PR base manually when opening the pull request for: ${head}"
6019
+ print_pr_advice "manually-set-base" "$head" "$dir"
6020
+ else
6021
+ echo " git push -u origin ${head}"
6022
+ echo " Open a pull request for ${head} (set the base branch manually)."
6023
+ fi
6024
+ echo ""
6025
+
6026
+ # Cloud CLI options are SECONDARY when a pipeline exists. Print them if a
6027
+ # project type + installed CLI match, but never fail the command on their
6028
+ # absence (the git/PR advice above is the real deliverable here).
6029
+ if [ -n "$type" ]; then
6030
+ # Pass do_clip=false here: in the pipeline path the git push line is
6031
+ # the PRIMARY advice (LOCK B3) and print_pr_advice already copied it to
6032
+ # the clipboard, so the secondary cloud command must NOT overwrite it.
6033
+ local printed_any=false
6034
+ _deploy_print_cloud_options "$dir" "$type" "false" "false" && printed_any=true || true
6035
+ if [ "$printed_any" != true ]; then
6036
+ : # No installed cloud CLI; the pipeline path already advised. Fine.
6037
+ fi
6038
+ fi
6039
+ return 0
6040
+ fi
6041
+
6042
+ # ---- No-pipeline path: cloud-CLI advisory exactly per DEPLOY-PLAN.md.
6043
+ # Honest failure: no project type detected.
6044
+ if [ -z "$type" ]; then
6045
+ {
6046
+ echo "No deployable project detected in: ${dir}"
6047
+ echo "Looked for: package.json (Next.js/Vite/CRA/Node), Dockerfile,"
6048
+ echo "dist/ or build/ with index.html, requirements.txt, pyproject.toml."
6049
+ echo "Run 'loki deploy --dir <path>' to point at your project directory."
6050
+ } >&2
6051
+ return 1
6052
+ fi
6053
+
6054
+ # Print cloud options; clipboard the idiomatic one. Returns non-zero (with an
6055
+ # honest install hint) when NO matching CLI is installed.
6056
+ local rc=0
6057
+ _deploy_print_cloud_options "$dir" "$type" "$do_clip" "true" || rc=$?
6058
+ return $rc
6059
+ }
6060
+
5291
6061
  # Import GitHub issues
5292
6062
  cmd_import() {
5293
6063
  # v7.6.2 B-13 fix: --help must print help, not start an import.
@@ -14596,6 +15366,9 @@ main() {
14596
15366
  preview)
14597
15367
  cmd_preview "$@"
14598
15368
  ;;
15369
+ deploy)
15370
+ cmd_deploy "$@"
15371
+ ;;
14599
15372
  open)
14600
15373
  # CLI consolidation (Phase A): 'open' is a deprecated alias of 'preview'.
14601
15374
  _deprecated_alias open preview "$@"