diff --git a/.agents/notes/implemented/process/2026-07-22-installer-in-repo-skip-clone.i18n.yaml b/.agents/notes/implemented/process/2026-07-22-installer-in-repo-skip-clone.i18n.yaml new file mode 100644 index 0000000000..a6becde554 --- /dev/null +++ b/.agents/notes/implemented/process/2026-07-22-installer-in-repo-skip-clone.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-22-installer-in-repo-skip-clone.md: f63c438205f7bd6aeb8dd78941bbe0880a8e31a1 +2026-07-22-installer-in-repo-skip-clone.zh.md: f9fe4865ad1090211c094fc8fba843b623512cc9 diff --git a/.agents/notes/implemented/process/2026-07-22-installer-in-repo-skip-clone.md b/.agents/notes/implemented/process/2026-07-22-installer-in-repo-skip-clone.md new file mode 100644 index 0000000000..f63c438205 --- /dev/null +++ b/.agents/notes/implemented/process/2026-07-22-installer-in-repo-skip-clone.md @@ -0,0 +1,27 @@ +# Agent Note: installer skips the clone when run from inside a checkout + +Status: implemented + +English | [中文](2026-07-22-installer-in-repo-skip-clone.zh.md) + +## Problem + +`scripts/install.sh` is written for the `curl ... | sh` path: it clones the harness into `~/.dsh/source`, then installs, links, and launches. Contributors who already have a checkout and run the same script directly (`sh scripts/install.sh`) got a second, unrelated clone at `~/.dsh/source` — installing and linking a different tree than the one they were working in, with no way to exercise the local script against the local source. + +## Decision + +The script detects when it is executing from inside a real checkout and, in that mode, reuses that checkout and skips the clone/update step entirely, leaving the working tree untouched. + +Detection keys on `$0`: under `curl ... | sh` the script text arrives on stdin, so `$0` is the shell name and no file path resolves; running a checked-out copy makes `$0` the script file. When `$0` is a readable file whose parent is a `scripts/` directory inside a tree that carries both the `bin/dsh` launcher and `scripts/install.sh`, the script sets `IN_REPO=1` and repoints `DSH_SOURCE` at that repo root. Step 2 then prints a "using existing checkout" line and does nothing else — no `git fetch`, no `git checkout -B`, so the user's working tree and branch are never mutated. `DSH_REF` is advisory and ignored in this mode. + +Explicit `DSH_SOURCE` wins over detection: the value is captured before defaulting, and in-repo detection only repoints an unset `DSH_SOURCE` (or one already equal to the detected repo root). Setting `DSH_SOURCE` to a different directory opts back into the normal clone/update path, so the escape hatch to install a separate tree from within a checkout still exists. + +## Alternatives considered + +**Detect via `git rev-parse --show-toplevel` on the current directory.** Rejected: `curl ... | sh` frequently runs from inside some unrelated git repo (the user's `cwd`), which would false-positive and skip the clone against a tree that is not dsh. Anchoring on `$0`'s own location ties the decision to where the script physically lives, and the `bin/dsh` + `scripts/install.sh` markers confirm it is actually a dsh checkout. + +**Always skip the clone whenever run from a file, ignoring `DSH_SOURCE`.** Rejected: a contributor may legitimately run the in-repo script to provision a separate `~/.dsh/source` install; honoring an explicit `DSH_SOURCE` that differs from the checkout preserves that path. + +## Consequences + +Running `sh scripts/install.sh` from a checkout now installs, links, and launches that checkout instead of cloning a parallel one, which also makes the local script testable against local source. The cost is a detection block that couples to the repo layout (`scripts/` beside `bin/dsh`); if the launcher or script ever moves, the markers must move with it. The behavior is documented in the script header and both README files, and verified by running the four paths (in-repo skip, curl-style clone, explicit `DSH_SOURCE` elsewhere opting back in, explicit `DSH_SOURCE` equal to repo root still skipping). diff --git a/.agents/notes/implemented/process/2026-07-22-installer-in-repo-skip-clone.zh.md b/.agents/notes/implemented/process/2026-07-22-installer-in-repo-skip-clone.zh.md new file mode 100644 index 0000000000..f9fe4865ad --- /dev/null +++ b/.agents/notes/implemented/process/2026-07-22-installer-in-repo-skip-clone.zh.md @@ -0,0 +1,27 @@ +# Agent Note: 在检出目录内运行时安装脚本跳过克隆 + +Status: implemented + +[English](2026-07-22-installer-in-repo-skip-clone.md) | 中文 + +## 问题 + +`scripts/install.sh`是为`curl ... | sh`路径编写的:它把 harness 克隆到`~/.dsh/source`,然后安装、软链接并启动。已经有检出的贡献者若直接运行同一脚本(`sh scripts/install.sh`),会在`~/.dsh/source`得到第二份无关的克隆——安装并软链接的是与他们正在工作的树不同的另一棵树,且无从用本地脚本验证本地源码。 + +## 决策 + +脚本会检测自身是否在真实检出内执行;在该模式下,它复用该检出并完全跳过克隆/更新步骤,保持工作树不受影响。 + +检测依据是`$0`:在`curl ... | sh`下脚本文本经由 stdin 到达,因此`$0`是 shell 名称、无路径可解析;运行已检出的副本会使`$0`成为脚本文件本身。当`$0`是一个可读文件、其父目录是一个`scripts/`目录、且该树同时带有`bin/dsh`启动器和`scripts/install.sh`时,脚本会设置`IN_REPO=1`并把`DSH_SOURCE`重新指向该仓库根。步骤 2 随后打印一行"using existing checkout"并不做其他事——不执行`git fetch`、不执行`git checkout -B`,因此用户的工作树和分支绝不会被改动。`DSH_REF`在该模式下仅供参考、被忽略。 + +显式的`DSH_SOURCE`优先于检测:该值在默认化之前就被捕获,检测只会重新指向未设置的`DSH_SOURCE`(或已经等于检测到的仓库根的那个)。把`DSH_SOURCE`设为其他目录会重新回到正常的克隆/更新路径,因此在检出目录内安装另一棵独立树的退路依然存在。 + +## 备选方案 + +**通过对当前目录执行`git rev-parse --show-toplevel`来检测。** 已否决:`curl ... | sh`常常在某个无关的 git 仓库(用户的`cwd`)内运行,这会误判并对一棵并非 dsh 的树跳过克隆。把决策锚定在`$0`自身的位置,使其绑定到脚本实际所在之处,而`bin/dsh` + `scripts/install.sh`标记则确认它确实是一个 dsh 检出。 + +**只要从文件运行就总是跳过克隆,忽略`DSH_SOURCE`。** 已否决:贡献者可能合理地运行检出内脚本来配置一份独立的`~/.dsh/source`安装;尊重与检出不同的显式`DSH_SOURCE`保留了该路径。 + +## 影响 + +现在从检出目录运行`sh scripts/install.sh`会安装、软链接并启动该检出,而不是克隆一份平行副本,这也让本地脚本可以针对本地源码进行测试。代价是一段与仓库布局耦合的检测逻辑(`scripts/`与`bin/dsh`并列);若启动器或脚本将来移动,标记必须随之移动。该行为记录在脚本头部和两份 README 中,并通过运行四条路径来验证(检出内跳过、curl 式克隆、显式`DSH_SOURCE`指向他处而回到克隆、显式`DSH_SOURCE`等于仓库根仍跳过)。 diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100755 index 0000000000..d54c52bad3 --- /dev/null +++ b/scripts/install.sh @@ -0,0 +1,284 @@ +#!/bin/sh +# dsh one-line installer. +# +# curl -fsSL https://raw.githubusercontent.com/deepseek-harness/deepseek-harness/master/scripts/install.sh | sh +# +# It clones the harness to ~/.dsh/source, checks host dependencies (git, Node, +# pnpm) and offers to install a missing pnpm, runs `pnpm install` (no build — +# the `bin/dsh` launcher runs the TypeScript source through the repo's own tsx), +# symlinks `dsh` onto PATH, records your API credentials in the personal config +# dir dsh reads at boot, and drops you into `dsh`. +# +# When run from inside an existing checkout (e.g. `sh scripts/install.sh` rather +# than `curl ... | sh`) it reuses that checkout and skips the clone/update, leaving +# the working tree untouched; DSH_REF is ignored in that mode. Setting DSH_SOURCE +# to a different directory opts back into the normal clone/update path. +# +# When run through `curl | sh` the script text arrives on stdin, so every +# prompt and the final launch read the controlling terminal (/dev/tty) directly; +# with no terminal the script prints the manual next steps instead. +# +# Overridable via environment: +# DSH_REF branch or tag to clone/checkout (default: master) +# DSH_REPO clone URL (default: the GitHub repo) +# DSH_SOURCE checkout location (default: ~/.dsh/source) +# DSH_BIN_DIR directory the `dsh` symlink lands in (default: ~/.local/bin) +# DSH_CONFIG_HOME personal config dir (also XDG_CONFIG_HOME/dsh, ~/.config/dsh) +set -eu + +DSH_REF=${DSH_REF:-master} +DSH_REPO=${DSH_REPO:-https://github.com/deepseek-harness/deepseek-harness.git} +# Remember whether the caller pinned a source location before defaulting it, so +# in-repo detection only repoints an unset DSH_SOURCE. +if [ -n "${DSH_SOURCE:-}" ]; then DSH_SOURCE_EXPLICIT=1; else DSH_SOURCE_EXPLICIT=0; fi +DSH_SOURCE=${DSH_SOURCE:-$HOME/.dsh/source} +DSH_BIN_DIR=${DSH_BIN_DIR:-$HOME/.local/bin} + +# --- in-repo detection --------------------------------------------------------- +# Under `curl ... | sh` the script text arrives on stdin, so $0 is the shell +# name and no file path resolves; running a checked-out copy (`sh +# scripts/install.sh`) makes $0 the script file. When $0 is a readable file whose +# parent is a scripts/ dir inside a real dsh checkout (bin/dsh launcher present), +# reuse that checkout and skip the clone. An explicit DSH_SOURCE pointing +# elsewhere opts back into the clone/update path. +IN_REPO=0 +if [ -f "$0" ]; then + _self_dir=$(CDPATH= cd -- "$(dirname -- "$0")" 2>/dev/null && pwd -P) || _self_dir='' + if [ -n "$_self_dir" ]; then + _repo_root=$(dirname -- "$_self_dir") + if [ "$(basename -- "$_self_dir")" = scripts ] \ + && [ -x "$_repo_root/bin/dsh" ] && [ -f "$_repo_root/scripts/install.sh" ]; then + if [ "$DSH_SOURCE_EXPLICIT" = 0 ] || [ "$DSH_SOURCE" = "$_repo_root" ]; then + IN_REPO=1 + DSH_SOURCE=$_repo_root + fi + fi + fi +fi + +# --- terminal-aware prompting -------------------------------------------------- +# stdin is the piped script, so read the controlling terminal for input. +if { true /dev/null; then + HAS_TTY=1 + # Restore terminal echo on exit or interrupt: ask_secret disables echo between + # its stty toggles, and dash (a common `sh`) does not run an EXIT trap when the + # shell is killed by a signal, so the fatal signals need their own handler. A + # successful run ends in exec, which replaces this process and drops the traps. + trap 'stty echo /dev/null || true' EXIT + trap 'stty echo /dev/null || true; exit 130' INT TERM HUP +else + HAS_TTY=0 +fi + +# Colour only when writing to a terminal. +if [ -t 1 ]; then + B=$(printf '\033[1m'); DIM=$(printf '\033[2m'); RED=$(printf '\033[31m') + GRN=$(printf '\033[32m'); YEL=$(printf '\033[33m'); RST=$(printf '\033[0m') +else + B=''; DIM=''; RED=''; GRN=''; YEL=''; RST='' +fi + +info() { printf '%s==>%s %s\n' "$GRN" "$RST" "$1"; } +step() { printf '\n%s==>%s %s%s%s\n' "$GRN" "$RST" "$B" "$1" "$RST"; } +warn() { printf '%s warn%s %s\n' "$YEL" "$RST" "$1" >&2; } +die() { printf '%serror%s %s\n' "$RED" "$RST" "$1" >&2; exit 1; } + +# ask PROMPT [DEFAULT] -> answer on stdout (plain-text line). +ask() { + [ "$HAS_TTY" = 1 ] || die "no terminal available for input; re-run in an interactive shell" + printf '%s%s%s ' "$B" "$1" "$RST" >/dev/tty + IFS= read -r _ans answer on stdout, with terminal echo suppressed. +ask_secret() { + [ "$HAS_TTY" = 1 ] || die "no terminal available for input; re-run in an interactive shell" + printf '%s%s%s ' "$B" "$1" "$RST" >/dev/tty + stty -echo /dev/null || true + IFS= read -r _sec /dev/null || true + printf '\n' >/dev/tty + printf '%s' "$_sec" +} + +# confirm PROMPT [Y] -> exit 0 on yes. Default is no unless second arg is "Y". +confirm() { + _def=${2:-N} + if [ "$HAS_TTY" != 1 ]; then + [ "$_def" = Y ] # non-interactive: take the default + return + fi + if [ "$_def" = Y ]; then _hint='[Y/n]'; else _hint='[y/N]'; fi + printf '%s%s%s %s ' "$B" "$1" "$RST" "$_hint" >/dev/tty + IFS= read -r _r /dev/null 2>&1 || die "git is required but not found. Install git, then re-run." +info "git ... ok" + +# Node ^22.19.0 || >=24.0.0 (see the root package.json "engines" field). +node_ok() { + command -v node >/dev/null 2>&1 || return 1 + _v=$(node -v 2>/dev/null) || return 1 + _v=${_v#v} + _major=${_v%%.*} + _rest=${_v#*.} + _minor=${_rest%%.*} + case "$_major" in ''|*[!0-9]*) return 1 ;; esac + case "$_minor" in ''|*[!0-9]*) _minor=0 ;; esac + [ "$_major" -ge 24 ] && return 0 + [ "$_major" -eq 22 ] && [ "$_minor" -ge 19 ] && return 0 + return 1 +} +if node_ok; then + info "node $(node -v) ... ok" +else + if command -v node >/dev/null 2>&1; then + die "Node $(node -v) is unsupported. dsh needs ^22.19.0 || >=24.0.0 — upgrade Node, then re-run." + fi + die "Node is required but not found. Install Node ^22.19.0 || >=24, then re-run." +fi + +# pnpm is the only dependency we offer to install for you. +if command -v pnpm >/dev/null 2>&1; then + info "pnpm $(pnpm --version 2>/dev/null) ... ok" +else + warn "pnpm is not installed." + if confirm "Install pnpm now?" Y; then + if command -v corepack >/dev/null 2>&1 && corepack enable pnpm >/dev/null 2>&1; then + info "enabled pnpm via corepack" + elif command -v npm >/dev/null 2>&1 && npm install -g pnpm >/dev/null 2>&1; then + info "installed pnpm via npm" + else + die "could not install pnpm automatically. Install it (https://pnpm.io/installation), then re-run." + fi + command -v pnpm >/dev/null 2>&1 || die "pnpm still not on PATH after install. Open a new shell, then re-run." + else + die "pnpm is required. Install it (https://pnpm.io/installation), then re-run." + fi +fi + +# --- 2. clone (or update) the source ------------------------------------------ +if [ "$IN_REPO" = 1 ]; then + step "Using existing checkout at $DSH_SOURCE" + info "running from inside the repo — skipping clone (DSH_REF ignored, working tree left untouched)" +else +step "Fetching source into $DSH_SOURCE" +if [ -d "$DSH_SOURCE/.git" ]; then + info "existing checkout found — updating" + git -C "$DSH_SOURCE" fetch --depth 1 origin "$DSH_REF" + # Reset the checkout to the freshly fetched tip. FETCH_HEAD (not + # origin/) so this resolves for a tag as well as a branch, and -B makes + # the re-run idempotent whether or not DSH_REF changed since the last install. + git -C "$DSH_SOURCE" checkout -q -B "$DSH_REF" FETCH_HEAD +else + mkdir -p "$(dirname "$DSH_SOURCE")" + git clone --depth 1 --branch "$DSH_REF" "$DSH_REPO" "$DSH_SOURCE" +fi +fi + +# --- 3. install dependencies (no build; the launcher runs from source) -------- +step "Installing dependencies with pnpm (this can take a while)" +( cd "$DSH_SOURCE" && pnpm install ) + +[ -x "$DSH_SOURCE/bin/dsh" ] || die "launcher $DSH_SOURCE/bin/dsh missing after install — is DSH_REF a branch that ships apps/cli?" + +# --- 4. put `dsh` on PATH ------------------------------------------------------ +step "Linking dsh into $DSH_BIN_DIR" +mkdir -p "$DSH_BIN_DIR" +ln -sf "$DSH_SOURCE/bin/dsh" "$DSH_BIN_DIR/dsh" +info "linked $DSH_BIN_DIR/dsh -> $DSH_SOURCE/bin/dsh" + +case ":$PATH:" in + *":$DSH_BIN_DIR:"*) ON_PATH=1 ;; + *) ON_PATH=0 ;; +esac +if [ "$ON_PATH" = 0 ]; then + warn "$DSH_BIN_DIR is not on your PATH." + _line="export PATH=\"$DSH_BIN_DIR:\$PATH\"" + _rc='' + _sh=${SHELL:-} # SHELL may be unset; word-removal on an unset var trips set -u under dash. + case "${_sh##*/}" in + zsh) _rc="$HOME/.zshrc" ;; + bash) _rc="$HOME/.bashrc" ;; + esac + if [ -n "$_rc" ] && [ -f "$_rc" ] && grep -qF "$_line" "$_rc" 2>/dev/null; then + info "$_rc already exports $DSH_BIN_DIR — open a new shell to pick it up" + elif [ -n "$_rc" ] && confirm "Add it to $_rc?" Y; then + printf '\n# Added by the dsh installer\n%s\n' "$_line" >>"$_rc" + info "updated $_rc — run 'source $_rc' or open a new shell to pick it up" + else + warn "add this line to your shell profile yourself:" + printf ' %s\n' "$_line" + fi +fi + +# --- 5. credentials ------------------------------------------------------------ +# Mirror app-boot's resolvePersonalConfigDir precedence so creds land where dsh reads them. +if [ -n "${DSH_CONFIG_HOME:-}" ]; then + CONF="$DSH_CONFIG_HOME" +elif [ -n "${XDG_CONFIG_HOME:-}" ]; then + CONF="$XDG_CONFIG_HOME/dsh" +else + CONF="$HOME/.config/dsh" +fi +ENV_FILE="$CONF/.env" + +step "Configuring credentials" +if [ -f "$ENV_FILE" ] && grep -q '^DEEPSEEK_API_KEY=' "$ENV_FILE" 2>/dev/null; then + info "DEEPSEEK_API_KEY already set in $ENV_FILE" + if ! confirm "Replace it?" N; then + SKIP_CREDS=1 + fi +fi +if [ "${SKIP_CREDS:-0}" != 1 ]; then + if [ "$HAS_TTY" = 1 ]; then + API_KEY=$(ask_secret "DeepSeek API key (input hidden):") + if [ -z "$API_KEY" ]; then + warn "no key entered — skipping. Set DEEPSEEK_API_KEY in $ENV_FILE before using dsh." + else + BASE_URL=$(ask "DeepSeek base URL (optional, Enter to skip):") + mkdir -p "$CONF" + # The installer owns exactly the two DEEPSEEK_* lines; any other lines the + # user keeps in this .env are preserved. The rewrite happens in a subshell + # so umask 077 (which closes the create-time permission race) does not leak + # into the exec'd dsh, and lands atomically via a same-dir temp + mv. + _tmp="$ENV_FILE.dsh.$$" + ( + umask 077 + if [ -f "$ENV_FILE" ]; then + grep -v -e '^DEEPSEEK_API_KEY=' -e '^DEEPSEEK_BASE_URL=' "$ENV_FILE" >"$_tmp" || true + else + : >"$_tmp" + fi + printf 'DEEPSEEK_API_KEY=%s\n' "$API_KEY" >>"$_tmp" + if [ -n "$BASE_URL" ]; then printf 'DEEPSEEK_BASE_URL=%s\n' "$BASE_URL" >>"$_tmp"; fi + ) + mv "$_tmp" "$ENV_FILE" + chmod 600 "$ENV_FILE" 2>/dev/null || true + info "wrote $ENV_FILE" + fi + else + warn "no terminal for credential input — set DEEPSEEK_API_KEY in $ENV_FILE before using dsh." + fi +fi + +# --- 6. launch ----------------------------------------------------------------- +step "Done" +if [ "$HAS_TTY" = 1 ]; then + info "launching dsh — run 'dsh' anytime to start again" + exec "$DSH_BIN_DIR/dsh"