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/README.md +1 -1
- package/SKILL.md +2 -2
- package/VERSION +1 -1
- package/autonomy/completion-council.sh +26 -0
- package/autonomy/lib/git-pr-advisory.sh +112 -0
- package/autonomy/lib/voter-agents.sh +43 -2
- package/autonomy/loki +641 -132
- package/autonomy/run.sh +416 -28
- package/autonomy/verify.sh +7 -1
- package/dashboard/__init__.py +1 -1
- package/docs/BRANCH-LIFECYCLE-PLAN.md +354 -0
- package/docs/DEPLOY-PLAN.md +302 -0
- package/docs/INSTALLATION.md +2 -2
- package/loki-ts/data/finding-schema.json +1 -0
- package/loki-ts/dist/loki.js +189 -189
- package/mcp/__init__.py +1 -1
- package/package.json +1 -1
- package/plugins/loki-mode/.claude-plugin/plugin.json +1 -1
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
|
-
|
|
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
|
-
|
|
19509
|
-
|
|
19510
|
-
|
|
19511
|
-
|
|
19512
|
-
|
|
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
|
-
|
|
19516
|
-
|
|
19517
|
-
|
|
19518
|
-
if
|
|
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
|
-
#
|
|
19527
|
-
|
|
19528
|
-
|
|
19529
|
-
|
|
19530
|
-
'
|
|
19531
|
-
|
|
19532
|
-
|
|
19533
|
-
'
|
|
19534
|
-
|
|
19535
|
-
|
|
19536
|
-
|
|
19537
|
-
|
|
19538
|
-
|
|
19539
|
-
|
|
19540
|
-
|
|
19541
|
-
|
|
19542
|
-
|
|
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
|
-
|
|
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
|
-
|
|
19566
|
-
|
|
19567
|
-
|
|
19568
|
-
|
|
19569
|
-
|
|
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
|
|
19577
|
-
|
|
19578
|
-
|
|
19579
|
-
|
|
19580
|
-
|
|
19581
|
-
|
|
19582
|
-
|
|
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
|
-
|
|
19585
|
-
|
|
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
|
-
|
|
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 = '
|
|
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
|
-
|
|
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
|
-
|
|
19679
|
-
|
|
19680
|
-
|
|
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
|
-
|
|
19683
|
-
|
|
19684
|
-
|
|
19685
|
-
|
|
19686
|
-
|
|
19687
|
-
|
|
19688
|
-
|
|
19689
|
-
|
|
19690
|
-
|
|
19691
|
-
|
|
19692
|
-
|
|
19693
|
-
|
|
19694
|
-
|
|
19695
|
-
|
|
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
|
-
|
|
19699
|
-
|
|
19700
|
-
|
|
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
|
|
19703
|
-
|
|
19704
|
-
|
|
19705
|
-
|
|
19706
|
-
|
|
19707
|
-
|
|
19708
|
-
|
|
19709
|
-
|
|
19710
|
-
|
|
19711
|
-
|
|
19712
|
-
|
|
19713
|
-
|
|
19714
|
-
|
|
19715
|
-
|
|
19716
|
-
|
|
19717
|
-
|
|
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
|
-
|
|
19720
|
-
|
|
19721
|
-
|
|
19722
|
-
|
|
19723
|
-
|
|
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
|
-
|
|
19728
|
-
|
|
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
|
-
|
|
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 = '
|
|
22149
|
-
tokens =
|
|
22150
|
-
ctx_file = '
|
|
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':
|
|
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="$
|
|
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 ;;
|