loki-mode 7.72.0 → 7.74.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"
@@ -5658,6 +5668,396 @@ cmd_preview() {
5658
5668
  fi
5659
5669
  }
5660
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
+
5661
6061
  # Import GitHub issues
5662
6062
  cmd_import() {
5663
6063
  # v7.6.2 B-13 fix: --help must print help, not start an import.
@@ -6996,10 +7396,10 @@ cmd_watch() {
6996
7396
  return 0
6997
7397
  ;;
6998
7398
  --once) run_once=true; shift ;;
6999
- --interval) poll_interval="${2:-2}"; shift 2 ;;
7399
+ --interval) poll_interval="${2:-2}"; [ $# -ge 2 ] && shift 2 || shift ;;
7000
7400
  --interval=*) poll_interval="${1#*=}"; shift ;;
7001
7401
  --no-auto-start) no_auto_start=true; shift ;;
7002
- --debounce) debounce="${2:-3}"; shift 2 ;;
7402
+ --debounce) debounce="${2:-3}"; [ $# -ge 2 ] && shift 2 || shift ;;
7003
7403
  --debounce=*) debounce="${1#*=}"; shift ;;
7004
7404
  -*)
7005
7405
  echo -e "${RED}Unknown option: $1${NC}"
@@ -14966,6 +15366,9 @@ main() {
14966
15366
  preview)
14967
15367
  cmd_preview "$@"
14968
15368
  ;;
15369
+ deploy)
15370
+ cmd_deploy "$@"
15371
+ ;;
14969
15372
  open)
14970
15373
  # CLI consolidation (Phase A): 'open' is a deprecated alias of 'preview'.
14971
15374
  _deprecated_alias open preview "$@"
@@ -19474,11 +19877,11 @@ else:
19474
19877
  case "$1" in
19475
19878
  --name|-n)
19476
19879
  name="${2:-}"
19477
- shift 2
19880
+ if [ $# -ge 2 ]; then shift 2; else shift; fi
19478
19881
  ;;
19479
19882
  --alias|-a)
19480
19883
  alias="${2:-}"
19481
- shift 2
19884
+ if [ $# -ge 2 ]; then shift 2; else shift; fi
19482
19885
  ;;
19483
19886
  *)
19484
19887
  shift
@@ -19494,10 +19897,21 @@ else:
19494
19897
  exit 1
19495
19898
  fi
19496
19899
 
19497
- _REGISTRY_FILE="$registry_file" _PROJ_PATH="$path" _PROJ_NAME="$name" _PROJ_ALIAS="$alias" python3 -c "
19900
+ # Route the read-modify-write through dashboard/registry.py so the
19901
+ # mutation is serialized under its fcntl lock and written atomically
19902
+ # (tempfile + os.replace), matching the v7.45.1 hardening that
19903
+ # run.sh registration and mark_project_stopped already use. The
19904
+ # inline open(...,'w') path here was unlocked and truncating, which
19905
+ # could lose a concurrent writer's entry or expose a 0-byte file to
19906
+ # a reader. Falls back to an atomic temp-file write if registry.py
19907
+ # cannot be imported at runtime (never a bare truncating open).
19908
+ _REGISTRY_FILE="$registry_file" _PROJ_PATH="$path" _PROJ_NAME="$name" _PROJ_ALIAS="$alias" \
19909
+ _REGISTRY_SKILL="${SKILL_DIR:-$_LOKI_SCRIPT_DIR/..}" python3 -c "
19498
19910
  import json
19499
19911
  import os
19912
+ import sys
19500
19913
  import hashlib
19914
+ import tempfile
19501
19915
  from datetime import datetime, timezone
19502
19916
 
19503
19917
  registry_file = os.environ['_REGISTRY_FILE']
@@ -19505,41 +19919,62 @@ path = os.environ['_PROJ_PATH']
19505
19919
  name = os.environ['_PROJ_NAME'] or os.path.basename(path)
19506
19920
  alias = os.environ['_PROJ_ALIAS'] or None
19507
19921
 
19508
- # Generate project ID
19509
- project_id = hashlib.md5(path.encode()).hexdigest()[:12]
19510
-
19511
- # Load registry
19512
- with open(registry_file, 'r') as f:
19513
- data = json.load(f)
19922
+ sys.path.insert(0, os.environ.get('_REGISTRY_SKILL', '.'))
19923
+ try:
19924
+ from dashboard import registry
19925
+ except Exception:
19926
+ registry = None
19514
19927
 
19515
- projects = data.get('projects', {})
19516
- now = datetime.now(timezone.utc).isoformat()
19517
-
19518
- if project_id in projects:
19519
- # Update existing
19520
- projects[project_id]['name'] = name
19521
- if alias:
19522
- projects[project_id]['alias'] = alias
19523
- projects[project_id]['updated_at'] = now
19524
- print(f'Updated: {name}')
19928
+ if registry is not None:
19929
+ existing = registry.get_project(path)
19930
+ project = registry.register_project(path, name, alias)
19931
+ print('Updated: ' + name if existing else 'Registered: ' + name)
19525
19932
  else:
19526
- # Add new
19527
- projects[project_id] = {
19528
- 'id': project_id,
19529
- 'path': path,
19530
- 'name': name,
19531
- 'alias': alias,
19532
- 'registered_at': now,
19533
- 'updated_at': now,
19534
- 'last_accessed': None,
19535
- 'has_loki_dir': os.path.isdir(os.path.join(path, '.loki')),
19536
- 'status': 'active',
19537
- }
19538
- print(f'Registered: {name}')
19539
-
19540
- data['projects'] = projects
19541
- with open(registry_file, 'w') as f:
19542
- json.dump(data, f, indent=2)
19933
+ # Fallback: atomic temp-file write (no truncating open) if registry.py
19934
+ # is unavailable. Mirrors register_project semantics (sha256 id).
19935
+ project_id = hashlib.sha256(path.encode()).hexdigest()[:12]
19936
+ try:
19937
+ with open(registry_file, 'r') as f:
19938
+ data = json.load(f)
19939
+ except (OSError, json.JSONDecodeError):
19940
+ data = {'version': '1.0', 'projects': {}}
19941
+ projects = data.get('projects', {})
19942
+ now = datetime.now(timezone.utc).isoformat()
19943
+ if project_id in projects:
19944
+ projects[project_id]['name'] = name
19945
+ if alias:
19946
+ projects[project_id]['alias'] = alias
19947
+ projects[project_id]['updated_at'] = now
19948
+ print(f'Updated: {name}')
19949
+ else:
19950
+ projects[project_id] = {
19951
+ 'id': project_id,
19952
+ 'path': path,
19953
+ 'name': name,
19954
+ 'alias': alias,
19955
+ 'registered_at': now,
19956
+ 'updated_at': now,
19957
+ 'last_accessed': None,
19958
+ 'has_loki_dir': os.path.isdir(os.path.join(path, '.loki')),
19959
+ 'status': 'active',
19960
+ }
19961
+ print(f'Registered: {name}')
19962
+ data['projects'] = projects
19963
+ reg_dir = os.path.dirname(registry_file) or '.'
19964
+ os.makedirs(reg_dir, exist_ok=True)
19965
+ fd, tmp = tempfile.mkstemp(dir=reg_dir, prefix='.projects.', suffix='.tmp')
19966
+ try:
19967
+ with os.fdopen(fd, 'w') as f:
19968
+ json.dump(data, f, indent=2)
19969
+ f.flush()
19970
+ os.fsync(f.fileno())
19971
+ os.replace(tmp, registry_file)
19972
+ except BaseException:
19973
+ try:
19974
+ os.unlink(tmp)
19975
+ except OSError:
19976
+ pass
19977
+ raise
19543
19978
 
19544
19979
  print(f' Path: {path}')
19545
19980
  if alias:
@@ -19555,34 +19990,69 @@ if alias:
19555
19990
  exit 1
19556
19991
  fi
19557
19992
 
19558
- _REGISTRY_FILE="$registry_file" _IDENTIFIER="$identifier" python3 -c "
19993
+ # Route through registry.unregister_project so the delete is
19994
+ # serialized + atomic. Look the name up first to preserve the
19995
+ # "Removed: {name}" output. Falls back to an atomic temp-file write
19996
+ # if registry.py cannot be imported (never a truncating open).
19997
+ _REGISTRY_FILE="$registry_file" _IDENTIFIER="$identifier" \
19998
+ _REGISTRY_SKILL="${SKILL_DIR:-$_LOKI_SCRIPT_DIR/..}" python3 -c "
19559
19999
  import json
19560
20000
  import os
20001
+ import sys
20002
+ import tempfile
19561
20003
 
19562
20004
  registry_file = os.environ['_REGISTRY_FILE']
19563
20005
  identifier = os.environ['_IDENTIFIER']
19564
20006
 
19565
- with open(registry_file, 'r') as f:
19566
- data = json.load(f)
19567
-
19568
- projects = data.get('projects', {})
19569
- found_id = None
19570
-
19571
- for pid, project in projects.items():
19572
- if pid == identifier or project['path'] == identifier or project.get('alias') == identifier:
19573
- found_id = pid
19574
- break
20007
+ sys.path.insert(0, os.environ.get('_REGISTRY_SKILL', '.'))
20008
+ try:
20009
+ from dashboard import registry
20010
+ except Exception:
20011
+ registry = None
19575
20012
 
19576
- if found_id:
19577
- name = projects[found_id]['name']
19578
- del projects[found_id]
19579
- data['projects'] = projects
19580
- with open(registry_file, 'w') as f:
19581
- json.dump(data, f, indent=2)
19582
- print(f'Removed: {name}')
20013
+ if registry is not None:
20014
+ existing = registry.get_project(identifier)
20015
+ if existing and registry.unregister_project(identifier):
20016
+ print('Removed: ' + existing['name'])
20017
+ else:
20018
+ print(f'Not found: {identifier}')
20019
+ sys.exit(1)
19583
20020
  else:
19584
- print(f'Not found: {identifier}')
19585
- exit(1)
20021
+ # Fallback: atomic temp-file write if registry.py is unavailable.
20022
+ try:
20023
+ with open(registry_file, 'r') as f:
20024
+ data = json.load(f)
20025
+ except (OSError, json.JSONDecodeError):
20026
+ data = {'version': '1.0', 'projects': {}}
20027
+ projects = data.get('projects', {})
20028
+ found_id = None
20029
+ for pid, project in projects.items():
20030
+ if pid == identifier or project['path'] == identifier or project.get('alias') == identifier:
20031
+ found_id = pid
20032
+ break
20033
+ if found_id:
20034
+ name = projects[found_id]['name']
20035
+ del projects[found_id]
20036
+ data['projects'] = projects
20037
+ reg_dir = os.path.dirname(registry_file) or '.'
20038
+ os.makedirs(reg_dir, exist_ok=True)
20039
+ fd, tmp = tempfile.mkstemp(dir=reg_dir, prefix='.projects.', suffix='.tmp')
20040
+ try:
20041
+ with os.fdopen(fd, 'w') as f:
20042
+ json.dump(data, f, indent=2)
20043
+ f.flush()
20044
+ os.fsync(f.fileno())
20045
+ os.replace(tmp, registry_file)
20046
+ except BaseException:
20047
+ try:
20048
+ os.unlink(tmp)
20049
+ except OSError:
20050
+ pass
20051
+ raise
20052
+ print(f'Removed: {name}')
20053
+ else:
20054
+ print(f'Not found: {identifier}')
20055
+ sys.exit(1)
19586
20056
  "
19587
20057
  ;;
19588
20058
 
@@ -19648,87 +20118,119 @@ else:
19648
20118
  echo -e "${BOLD}Syncing Project Registry${NC}"
19649
20119
  echo ""
19650
20120
 
19651
- python3 -c "
20121
+ # Route through registry.sync_registry_with_discovery so the
20122
+ # discover->merge->save is serialized + atomic and uses the same
20123
+ # sha256 ids as the rest of the registry. Reproduce the prior
20124
+ # stdout (per-project "Added:" lines + summary) from its return
20125
+ # value. Falls back to an atomic temp-file write if registry.py
20126
+ # cannot be imported (never a truncating open).
20127
+ _REGISTRY_FILE="$registry_file" \
20128
+ _REGISTRY_SKILL="${SKILL_DIR:-$_LOKI_SCRIPT_DIR/..}" python3 -c "
19652
20129
  import json
19653
20130
  import os
20131
+ import sys
19654
20132
  import hashlib
20133
+ import tempfile
19655
20134
  from pathlib import Path
19656
20135
  from datetime import datetime, timezone
19657
20136
 
19658
- registry_file = '$registry_file'
19659
- home = Path.home()
19660
-
19661
- # Load registry
19662
- with open(registry_file, 'r') as f:
19663
- data = json.load(f)
19664
- projects = data.get('projects', {})
19665
-
19666
- # Discover projects
19667
- search_paths = [
19668
- home / 'git',
19669
- home / 'projects',
19670
- home / 'code',
19671
- home / 'dev',
19672
- home / 'workspace',
19673
- home / 'src',
19674
- ]
20137
+ registry_file = os.environ['_REGISTRY_FILE']
19675
20138
 
19676
- discovered = []
20139
+ sys.path.insert(0, os.environ.get('_REGISTRY_SKILL', '.'))
20140
+ try:
20141
+ from dashboard import registry
20142
+ except Exception:
20143
+ registry = None
19677
20144
 
19678
- def search_dir(path, depth=0, max_depth=3):
19679
- if depth > max_depth:
19680
- return
20145
+ if registry is not None:
20146
+ result = registry.sync_registry_with_discovery()
20147
+ for project in result['details']['added']:
20148
+ print(f\" Added: {project['name']}\")
20149
+ print('')
20150
+ print(f\"Added: {result['added']}, Missing: {result['missing']}, Total: {result['total']}\")
20151
+ else:
20152
+ # Fallback: atomic temp-file write if registry.py is unavailable.
20153
+ home = Path.home()
19681
20154
  try:
19682
- if not path.is_dir():
19683
- return
19684
- loki_dir = path / '.loki'
19685
- if loki_dir.is_dir():
19686
- discovered.append(str(path))
19687
- return
19688
- for child in path.iterdir():
19689
- if child.is_dir() and not child.name.startswith('.'):
19690
- search_dir(child, depth + 1, max_depth)
19691
- except (PermissionError, OSError):
19692
- pass
19693
-
19694
- for search_path in search_paths:
19695
- if search_path.exists():
19696
- search_dir(search_path)
20155
+ with open(registry_file, 'r') as f:
20156
+ data = json.load(f)
20157
+ except (OSError, json.JSONDecodeError):
20158
+ data = {'version': '1.0', 'projects': {}}
20159
+ projects = data.get('projects', {})
20160
+ search_paths = [
20161
+ home / 'git',
20162
+ home / 'projects',
20163
+ home / 'code',
20164
+ home / 'dev',
20165
+ home / 'workspace',
20166
+ home / 'src',
20167
+ ]
20168
+ discovered = []
19697
20169
 
19698
- # Add new projects
19699
- added = 0
19700
- now = datetime.now(timezone.utc).isoformat()
20170
+ def search_dir(path, depth=0, max_depth=3):
20171
+ if depth > max_depth:
20172
+ return
20173
+ try:
20174
+ if not path.is_dir():
20175
+ return
20176
+ loki_dir = path / '.loki'
20177
+ if loki_dir.is_dir():
20178
+ discovered.append(str(path))
20179
+ return
20180
+ for child in path.iterdir():
20181
+ if child.is_dir() and not child.name.startswith('.'):
20182
+ search_dir(child, depth + 1, max_depth)
20183
+ except (PermissionError, OSError):
20184
+ pass
19701
20185
 
19702
- for path in discovered:
19703
- project_id = hashlib.md5(path.encode()).hexdigest()[:12]
19704
- if project_id not in projects:
19705
- projects[project_id] = {
19706
- 'id': project_id,
19707
- 'path': path,
19708
- 'name': os.path.basename(path),
19709
- 'alias': None,
19710
- 'registered_at': now,
19711
- 'updated_at': now,
19712
- 'last_accessed': None,
19713
- 'has_loki_dir': True,
19714
- 'status': 'active',
19715
- }
19716
- added += 1
19717
- print(f' Added: {os.path.basename(path)}')
20186
+ for search_path in search_paths:
20187
+ if search_path.exists():
20188
+ search_dir(search_path)
20189
+
20190
+ added = 0
20191
+ now = datetime.now(timezone.utc).isoformat()
20192
+ for path in discovered:
20193
+ project_id = hashlib.sha256(path.encode()).hexdigest()[:12]
20194
+ if project_id not in projects:
20195
+ projects[project_id] = {
20196
+ 'id': project_id,
20197
+ 'path': path,
20198
+ 'name': os.path.basename(path),
20199
+ 'alias': None,
20200
+ 'registered_at': now,
20201
+ 'updated_at': now,
20202
+ 'last_accessed': None,
20203
+ 'has_loki_dir': True,
20204
+ 'status': 'active',
20205
+ }
20206
+ added += 1
20207
+ print(f' Added: {os.path.basename(path)}')
19718
20208
 
19719
- # Check for missing
19720
- missing = 0
19721
- for pid, project in list(projects.items()):
19722
- if not os.path.isdir(project['path']):
19723
- project['status'] = 'missing'
19724
- missing += 1
20209
+ missing = 0
20210
+ for pid, project in list(projects.items()):
20211
+ if not os.path.isdir(project['path']):
20212
+ project['status'] = 'missing'
20213
+ missing += 1
19725
20214
 
19726
- data['projects'] = projects
19727
- with open(registry_file, 'w') as f:
19728
- json.dump(data, f, indent=2)
20215
+ data['projects'] = projects
20216
+ reg_dir = os.path.dirname(registry_file) or '.'
20217
+ os.makedirs(reg_dir, exist_ok=True)
20218
+ fd, tmp = tempfile.mkstemp(dir=reg_dir, prefix='.projects.', suffix='.tmp')
20219
+ try:
20220
+ with os.fdopen(fd, 'w') as f:
20221
+ json.dump(data, f, indent=2)
20222
+ f.flush()
20223
+ os.fsync(f.fileno())
20224
+ os.replace(tmp, registry_file)
20225
+ except BaseException:
20226
+ try:
20227
+ os.unlink(tmp)
20228
+ except OSError:
20229
+ pass
20230
+ raise
19729
20231
 
19730
- print('')
19731
- print(f'Added: {added}, Missing: {missing}, Total: {len(projects)}')
20232
+ print('')
20233
+ print(f'Added: {added}, Missing: {missing}, Total: {len(projects)}')
19732
20234
  " 2>/dev/null
19733
20235
  ;;
19734
20236
 
@@ -22143,11 +22645,18 @@ _context_add() {
22143
22645
  local loki_dir="${LOKI_DIR:-.loki}"
22144
22646
  mkdir -p "$loki_dir/state"
22145
22647
  local ctx_files="$loki_dir/state/context-files.json"
22146
- python3 -c "
22648
+ # Pass values via env vars (not string interpolation) so a filename
22649
+ # with an apostrophe or other special char cannot break the python
22650
+ # source (was a swallowed SyntaxError -> set -e abort -> file silently
22651
+ # not added). Matches the safe pattern used by the registry commands.
22652
+ _CTX_PATH="$file_path" _CTX_TOKENS="$est_tokens" _CTX_SIZE="$size" \
22653
+ _CTX_LINES="$lines" _CTX_FILE="$ctx_files" python3 -c "
22147
22654
  import json, os
22148
- path = '$file_path'
22149
- tokens = $est_tokens
22150
- ctx_file = '$ctx_files'
22655
+ path = os.environ['_CTX_PATH']
22656
+ tokens = int(os.environ['_CTX_TOKENS'])
22657
+ ctx_file = os.environ['_CTX_FILE']
22658
+ size = int(os.environ['_CTX_SIZE'])
22659
+ lines = int(os.environ['_CTX_LINES'])
22151
22660
  try:
22152
22661
  with open(ctx_file) as f:
22153
22662
  files = json.load(f)
@@ -22155,7 +22664,7 @@ except:
22155
22664
  files = []
22156
22665
  # Avoid duplicates
22157
22666
  files = [f for f in files if f.get('path') != path]
22158
- files.append({'path': path, 'estimated_tokens': tokens, 'size': $size, 'lines': $lines})
22667
+ files.append({'path': path, 'estimated_tokens': tokens, 'size': size, 'lines': lines})
22159
22668
  with open(ctx_file, 'w') as f:
22160
22669
  json.dump(files, f, indent=2)
22161
22670
  " 2>/dev/null
@@ -29350,7 +29859,7 @@ cmd_docker() {
29350
29859
  local -a fwd=()
29351
29860
  while [ $# -gt 0 ]; do
29352
29861
  case "$1" in
29353
- --image) shift; export LOKI_DOCKER_IMAGE="${1:-}"; shift ;;
29862
+ --image) [ $# -ge 2 ] && { shift; export LOKI_DOCKER_IMAGE="$1"; } || { echo "loki docker --image requires a value" >&2; return 1; }; shift ;;
29354
29863
  --dry-run) dry_run=1; shift ;;
29355
29864
  --api) with_api=1; fwd+=("$1"); shift ;;
29356
29865
  *) fwd+=("$1"); shift ;;