1
0

install.sh 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376
  1. #!/bin/sh
  2. # dsh one-line installer.
  3. #
  4. # curl -fsSL https://raw.githubusercontent.com/deepseek-harness/deepseek-harness/master/scripts/install.sh | sh
  5. #
  6. # It clones the harness under ~/.dsh/source (the master clone at
  7. # ~/.dsh/source/master), adds a per-install staging worktree at
  8. # ~/.dsh/source/staging-<timestamp> on branch dsh-staging/<timestamp>, checks
  9. # host dependencies (git, Node, pnpm) and offers to install a missing pnpm, runs
  10. # `pnpm install`, points the stable `~/.dsh/source/current` symlink
  11. # at that staging worktree and symlinks `dsh` onto PATH at `current/bin/dsh`,
  12. # records your API credentials in the Harness home (`~/.dsh`) dsh reads at boot,
  13. # and lets you launch the Web UI or TUI. The Web choice builds the repository
  14. # artifacts first; the TUI runs directly from TypeScript source through the
  15. # repo's own tsx. Keeping every checkout under ~/.dsh/source keeps successive
  16. # upgrades in one place instead of scattered sibling clones, and lets staging
  17. # worktrees share the master clone's object store. The PATH symlink resolves through
  18. # `current`, so an upgrade repoints one stable symlink instead of relinking PATH:
  19. # the `dsh` on PATH never moves and can never dangle.
  20. #
  21. # When run from inside an existing checkout (e.g. `sh scripts/install.sh` rather
  22. # than `curl ... | sh`) it reuses that checkout in place and skips the
  23. # clone/worktree setup, leaving the working tree untouched and linking `dsh`
  24. # straight at that checkout's `bin/dsh` (no `current` indirection — the checkout
  25. # is not a managed staging worktree under the source container); DSH_REF is
  26. # ignored in that mode. Setting DSH_SOURCE to a different directory opts back
  27. # into the normal clone/worktree path.
  28. #
  29. # When run through `curl | sh` the script text arrives on stdin, so every
  30. # prompt and the final launch read the controlling terminal (/dev/tty) directly;
  31. # with no terminal the script prints the manual next steps instead.
  32. #
  33. # Overridable via environment:
  34. # DSH_REF branch or tag to clone/checkout (default: master)
  35. # DSH_REPO clone URL (default: the GitHub repo)
  36. # DSH_SOURCE source container directory (default: ~/.dsh/source)
  37. # DSH_MASTER master clone directory (default: $DSH_SOURCE/master)
  38. # DSH_CURRENT stable symlink to the active worktree (default: $DSH_SOURCE/current)
  39. # DSH_BIN_DIR directory the `dsh` symlink lands in (default: ~/.local/bin)
  40. # DSH_HOME Harness home holding the personal config (default: ~/.dsh)
  41. # FIXME(install-ts): Move the post-checkout workflow into a tested TypeScript
  42. # entrypoint; keep this POSIX shell file as the curl/source bootstrap.
  43. set -eu
  44. DSH_REF=${DSH_REF:-master}
  45. DSH_REPO=${DSH_REPO:-https://github.com/deepseek-harness/deepseek-harness.git}
  46. # DSH_SOURCE is the container directory that holds the master clone and every
  47. # staging worktree; DSH_MASTER is the one real clone inside it. Remember whether
  48. # the caller pinned the source container before defaulting it, so in-repo
  49. # detection only repoints an unset DSH_SOURCE.
  50. if [ -n "${DSH_SOURCE:-}" ]; then DSH_SOURCE_EXPLICIT=1; else DSH_SOURCE_EXPLICIT=0; fi
  51. DSH_SOURCE=${DSH_SOURCE:-$HOME/.dsh/source}
  52. DSH_MASTER=${DSH_MASTER:-$DSH_SOURCE/master}
  53. # The stable symlink the PATH launcher resolves through: PATH -> current/bin/dsh
  54. # -> <staging>/bin/dsh. Fresh installs and upgrades repoint this one symlink; the
  55. # PATH launcher itself is written once and never moves. In-repo reuse ignores it.
  56. DSH_CURRENT=${DSH_CURRENT:-$DSH_SOURCE/current}
  57. DSH_BIN_DIR=${DSH_BIN_DIR:-$HOME/.local/bin}
  58. # One UTC basic timestamp names this install's staging branch and worktree.
  59. DSH_STAMP=$(date -u +%Y%m%dT%H%M%SZ)
  60. DSH_STAGING_BRANCH=dsh-staging/$DSH_STAMP
  61. DSH_STAGING=$DSH_SOURCE/staging-$DSH_STAMP
  62. # --- in-repo detection ---------------------------------------------------------
  63. # Under `curl ... | sh` the script text arrives on stdin, so $0 is the shell
  64. # name and no file path resolves; running a checked-out copy (`sh
  65. # scripts/install.sh`) makes $0 the script file. When $0 is a readable file whose
  66. # parent is a scripts/ dir inside a real dsh checkout (bin/dsh launcher present),
  67. # reuse that checkout in place — link `dsh` straight at it and skip the
  68. # clone/worktree setup. An explicit DSH_SOURCE pointing elsewhere opts back into
  69. # the clone/worktree path.
  70. IN_REPO=0
  71. if [ -f "$0" ]; then
  72. _self_dir=$(CDPATH= cd -- "$(dirname -- "$0")" 2>/dev/null && pwd -P) || _self_dir=''
  73. if [ -n "$_self_dir" ]; then
  74. _repo_root=$(dirname -- "$_self_dir")
  75. if [ "$(basename -- "$_self_dir")" = scripts ] \
  76. && [ -x "$_repo_root/bin/dsh" ] && [ -f "$_repo_root/scripts/install.sh" ]; then
  77. if [ "$DSH_SOURCE_EXPLICIT" = 0 ] || [ "$DSH_SOURCE" = "$_repo_root" ]; then
  78. IN_REPO=1
  79. # In-repo reuse links `dsh` at this checkout as-is; the master/staging
  80. # split applies only to fresh clone installs.
  81. DSH_STAGING=$_repo_root
  82. fi
  83. fi
  84. fi
  85. fi
  86. # --- terminal-aware prompting --------------------------------------------------
  87. # stdin is the piped script, so read the controlling terminal for input.
  88. if { true </dev/tty; } 2>/dev/null; then
  89. HAS_TTY=1
  90. # Restore terminal echo on exit or interrupt: ask_secret disables echo between
  91. # its stty toggles, and dash (a common `sh`) does not run an EXIT trap when the
  92. # shell is killed by a signal, so the fatal signals need their own handler. A
  93. # successful run ends in exec, which replaces this process and drops the traps.
  94. trap 'stty echo </dev/tty 2>/dev/null || true' EXIT
  95. trap 'stty echo </dev/tty 2>/dev/null || true; exit 130' INT TERM HUP
  96. else
  97. HAS_TTY=0
  98. fi
  99. # Colour only when writing to a terminal.
  100. if [ -t 1 ]; then
  101. B=$(printf '\033[1m'); DIM=$(printf '\033[2m'); RED=$(printf '\033[31m')
  102. GRN=$(printf '\033[32m'); YEL=$(printf '\033[33m'); RST=$(printf '\033[0m')
  103. else
  104. B=''; DIM=''; RED=''; GRN=''; YEL=''; RST=''
  105. fi
  106. info() { printf '%s==>%s %s\n' "$GRN" "$RST" "$1"; }
  107. step() { printf '\n%s==>%s %s%s%s\n' "$GRN" "$RST" "$B" "$1" "$RST"; }
  108. warn() { printf '%s warn%s %s\n' "$YEL" "$RST" "$1" >&2; }
  109. die() { printf '%serror%s %s\n' "$RED" "$RST" "$1" >&2; exit 1; }
  110. # ask PROMPT [DEFAULT] -> answer on stdout (plain-text line).
  111. ask() {
  112. [ "$HAS_TTY" = 1 ] || die "no terminal available for input; re-run in an interactive shell"
  113. printf '%s%s%s ' "$B" "$1" "$RST" >/dev/tty
  114. IFS= read -r _ans </dev/tty || _ans=''
  115. [ -n "$_ans" ] || _ans=${2:-}
  116. printf '%s' "$_ans"
  117. }
  118. # ask_secret PROMPT -> answer on stdout, with terminal echo suppressed.
  119. ask_secret() {
  120. [ "$HAS_TTY" = 1 ] || die "no terminal available for input; re-run in an interactive shell"
  121. printf '%s%s%s ' "$B" "$1" "$RST" >/dev/tty
  122. stty -echo </dev/tty 2>/dev/null || true
  123. IFS= read -r _sec </dev/tty || _sec=''
  124. stty echo </dev/tty 2>/dev/null || true
  125. printf '\n' >/dev/tty
  126. printf '%s' "$_sec"
  127. }
  128. # confirm PROMPT [Y] -> exit 0 on yes. Default is no unless second arg is "Y".
  129. confirm() {
  130. _def=${2:-N}
  131. if [ "$HAS_TTY" != 1 ]; then
  132. [ "$_def" = Y ] # non-interactive: take the default
  133. return
  134. fi
  135. if [ "$_def" = Y ]; then _hint='[Y/n]'; else _hint='[y/N]'; fi
  136. printf '%s%s%s %s ' "$B" "$1" "$RST" "$_hint" >/dev/tty
  137. IFS= read -r _r </dev/tty || _r=''
  138. [ -n "$_r" ] || _r=$_def
  139. case "$_r" in [yY]|[yY][eE][sS]) return 0 ;; *) return 1 ;; esac
  140. }
  141. printf '%s\n' "${B}DeepSeek Harness — dsh installer${RST}"
  142. if [ "$IN_REPO" = 1 ]; then
  143. printf '%ssource %s (in-repo reuse) @ %s%s\n' "$DIM" "$DSH_STAGING" "$DSH_REF" "$RST"
  144. else
  145. printf '%smaster %s @ %s%s\n' "$DIM" "$DSH_MASTER" "$DSH_REF" "$RST"
  146. printf '%sstaging %s%s\n' "$DIM" "$DSH_STAGING" "$RST"
  147. printf '%scurrent %s%s\n' "$DIM" "$DSH_CURRENT" "$RST"
  148. fi
  149. # --- 1. dependency check -------------------------------------------------------
  150. step "Checking dependencies"
  151. command -v git >/dev/null 2>&1 || die "git is required but not found. Install git, then re-run."
  152. info "git ... ok"
  153. # Node ^22.19.0 || >=24.0.0 (see the root package.json "engines" field).
  154. node_ok() {
  155. command -v node >/dev/null 2>&1 || return 1
  156. _v=$(node -v 2>/dev/null) || return 1
  157. _v=${_v#v}
  158. _major=${_v%%.*}
  159. _rest=${_v#*.}
  160. _minor=${_rest%%.*}
  161. case "$_major" in ''|*[!0-9]*) return 1 ;; esac
  162. case "$_minor" in ''|*[!0-9]*) _minor=0 ;; esac
  163. [ "$_major" -ge 24 ] && return 0
  164. [ "$_major" -eq 22 ] && [ "$_minor" -ge 19 ] && return 0
  165. return 1
  166. }
  167. if node_ok; then
  168. info "node $(node -v) ... ok"
  169. else
  170. if command -v node >/dev/null 2>&1; then
  171. die "Node $(node -v) is unsupported. dsh needs ^22.19.0 || >=24.0.0 — upgrade Node, then re-run."
  172. fi
  173. die "Node is required but not found. Install Node ^22.19.0 || >=24, then re-run."
  174. fi
  175. # pnpm is the only dependency we offer to install for you.
  176. if command -v pnpm >/dev/null 2>&1; then
  177. info "pnpm $(pnpm --version 2>/dev/null) ... ok"
  178. else
  179. warn "pnpm is not installed."
  180. if confirm "Install pnpm now?" Y; then
  181. if command -v corepack >/dev/null 2>&1 && corepack enable pnpm >/dev/null 2>&1; then
  182. info "enabled pnpm via corepack"
  183. elif command -v npm >/dev/null 2>&1 && npm install -g pnpm >/dev/null 2>&1; then
  184. info "installed pnpm via npm"
  185. else
  186. die "could not install pnpm automatically. Install it (https://pnpm.io/installation), then re-run."
  187. fi
  188. command -v pnpm >/dev/null 2>&1 || die "pnpm still not on PATH after install. Open a new shell, then re-run."
  189. else
  190. die "pnpm is required. Install it (https://pnpm.io/installation), then re-run."
  191. fi
  192. fi
  193. # --- 2. clone the master and lay out the staging worktree ---------------------
  194. # Fresh installs keep one real clone at $DSH_MASTER and check the running code
  195. # out as a git worktree at $DSH_STAGING, so every checkout lives under
  196. # $DSH_SOURCE and shares one object store. In-repo reuse links `dsh` at the
  197. # existing checkout untouched.
  198. if [ "$IN_REPO" = 1 ]; then
  199. step "Using existing checkout at $DSH_STAGING"
  200. info "running from inside the repo — skipping clone (DSH_REF ignored, working tree left untouched)"
  201. else
  202. step "Fetching source into $DSH_MASTER"
  203. if [ -d "$DSH_MASTER/.git" ]; then
  204. info "existing master clone found — updating"
  205. git -C "$DSH_MASTER" fetch origin "$DSH_REF"
  206. # Reset the master checkout to the freshly fetched tip. FETCH_HEAD (not
  207. # origin/<ref>) so this resolves for a tag as well as a branch, and -B makes
  208. # the re-run idempotent whether or not DSH_REF changed since the last install.
  209. git -C "$DSH_MASTER" checkout -q -B "$DSH_REF" FETCH_HEAD
  210. else
  211. mkdir -p "$DSH_SOURCE"
  212. git clone --branch "$DSH_REF" "$DSH_REPO" "$DSH_MASTER"
  213. fi
  214. step "Adding staging worktree at $DSH_STAGING"
  215. [ -e "$DSH_STAGING" ] && die "staging path $DSH_STAGING already exists — remove it or set DSH_SOURCE elsewhere, then re-run."
  216. # The staging worktree owns the branch dsh runs from; the master clone stays on
  217. # $DSH_REF as the fetch/upgrade base. Exclude the per-worktree merge lock in the
  218. # master clone's info/exclude, which every linked worktree inherits.
  219. git -C "$DSH_MASTER" worktree add -b "$DSH_STAGING_BRANCH" "$DSH_STAGING" FETCH_HEAD 2>/dev/null \
  220. || git -C "$DSH_MASTER" worktree add -b "$DSH_STAGING_BRANCH" "$DSH_STAGING" HEAD
  221. _exclude="$DSH_MASTER/.git/info/exclude"
  222. if [ -f "$_exclude" ] && ! grep -qxF '.agents/merge.lock' "$_exclude" 2>/dev/null; then
  223. printf '.agents/merge.lock\n' >>"$_exclude"
  224. fi
  225. mkdir -p "$DSH_STAGING/.agents"
  226. : >"$DSH_STAGING/.agents/merge.lock"
  227. fi
  228. # --- 3. install dependencies (no build; the launcher runs from source) --------
  229. step "Installing dependencies with pnpm (this can take a while)"
  230. ( cd "$DSH_STAGING" && pnpm install )
  231. [ -x "$DSH_STAGING/bin/dsh" ] || die "launcher $DSH_STAGING/bin/dsh missing after install — is DSH_REF a branch that ships apps/cli?"
  232. # --- 4. put `dsh` on PATH ------------------------------------------------------
  233. # Clone installs go through a stable `current` symlink so an upgrade repoints
  234. # one symlink (current -> new worktree) and the PATH launcher never moves:
  235. # PATH/dsh -> current/bin/dsh -> <staging>/bin/dsh. In-repo reuse links PATH
  236. # straight at the checkout, since that checkout is not a managed worktree.
  237. step "Linking dsh into $DSH_BIN_DIR"
  238. mkdir -p "$DSH_BIN_DIR"
  239. if [ "$IN_REPO" = 1 ]; then
  240. DSH_LAUNCH_TARGET=$DSH_STAGING/bin/dsh
  241. ln -sf "$DSH_LAUNCH_TARGET" "$DSH_BIN_DIR/dsh"
  242. info "linked $DSH_BIN_DIR/dsh -> $DSH_LAUNCH_TARGET"
  243. else
  244. # Point `current` at this staging worktree with `ln -sfn`: -f replaces an
  245. # existing `current` (re-run or upgrade) and -n stops `ln` from dereferencing
  246. # an existing symlink-to-directory and dropping the new link *inside* the old
  247. # worktree. `mv` is unusable here — BSD/macOS `mv` follows the existing dir
  248. # symlink the same way. The swap is one unlink+symlink pair on a local fs; the
  249. # installer holds no other process racing this path.
  250. ln -sfn "$DSH_STAGING" "$DSH_CURRENT"
  251. info "pointed $DSH_CURRENT -> $DSH_STAGING"
  252. DSH_LAUNCH_TARGET=$DSH_CURRENT/bin/dsh
  253. ln -sf "$DSH_LAUNCH_TARGET" "$DSH_BIN_DIR/dsh"
  254. info "linked $DSH_BIN_DIR/dsh -> $DSH_LAUNCH_TARGET"
  255. fi
  256. case ":$PATH:" in
  257. *":$DSH_BIN_DIR:"*) ON_PATH=1 ;;
  258. *) ON_PATH=0 ;;
  259. esac
  260. if [ "$ON_PATH" = 0 ]; then
  261. warn "$DSH_BIN_DIR is not on your PATH."
  262. _line="export PATH=\"$DSH_BIN_DIR:\$PATH\""
  263. _rc=''
  264. _sh=${SHELL:-} # SHELL may be unset; word-removal on an unset var trips set -u under dash.
  265. case "${_sh##*/}" in
  266. zsh) _rc="$HOME/.zshrc" ;;
  267. bash) _rc="$HOME/.bashrc" ;;
  268. esac
  269. if [ -n "$_rc" ] && [ -f "$_rc" ] && grep -qF "$_line" "$_rc" 2>/dev/null; then
  270. info "$_rc already exports $DSH_BIN_DIR — open a new shell to pick it up"
  271. elif [ -n "$_rc" ] && confirm "Add it to $_rc?" Y; then
  272. printf '\n# Added by the dsh installer\n%s\n' "$_line" >>"$_rc"
  273. info "updated $_rc — run 'source $_rc' or open a new shell to pick it up"
  274. else
  275. warn "add this line to your shell profile yourself:"
  276. printf ' %s\n' "$_line"
  277. fi
  278. fi
  279. # --- 5. credentials ------------------------------------------------------------
  280. # Mirror app-boot's resolveDshHome precedence ($DSH_HOME, else ~/.dsh) so creds land where dsh reads them.
  281. if [ -n "${DSH_HOME:-}" ]; then
  282. CONF="$DSH_HOME"
  283. else
  284. CONF="$HOME/.dsh"
  285. fi
  286. ENV_FILE="$CONF/.env"
  287. step "Configuring credentials"
  288. if [ -f "$ENV_FILE" ] && grep -q '^DEEPSEEK_API_KEY=' "$ENV_FILE" 2>/dev/null; then
  289. info "DEEPSEEK_API_KEY already set in $ENV_FILE"
  290. if ! confirm "Replace it?" N; then
  291. SKIP_CREDS=1
  292. fi
  293. fi
  294. if [ "${SKIP_CREDS:-0}" != 1 ]; then
  295. if [ "$HAS_TTY" = 1 ]; then
  296. API_KEY=$(ask_secret "DeepSeek API key (input hidden):")
  297. if [ -z "$API_KEY" ]; then
  298. warn "no key entered — skipping. Set DEEPSEEK_API_KEY in $ENV_FILE before using dsh."
  299. else
  300. BASE_URL=$(ask "DeepSeek base URL (optional, Enter to skip):")
  301. mkdir -p "$CONF"
  302. # The installer owns exactly the two DEEPSEEK_* lines; any other lines the
  303. # user keeps in this .env are preserved. The rewrite happens in a subshell
  304. # so umask 077 (which closes the create-time permission race) does not leak
  305. # into the exec'd dsh, and lands atomically via a same-dir temp + mv.
  306. _tmp="$ENV_FILE.dsh.$$"
  307. (
  308. umask 077
  309. if [ -f "$ENV_FILE" ]; then
  310. grep -v -e '^DEEPSEEK_API_KEY=' -e '^DEEPSEEK_BASE_URL=' "$ENV_FILE" >"$_tmp" || true
  311. else
  312. : >"$_tmp"
  313. fi
  314. printf 'DEEPSEEK_API_KEY=%s\n' "$API_KEY" >>"$_tmp"
  315. if [ -n "$BASE_URL" ]; then printf 'DEEPSEEK_BASE_URL=%s\n' "$BASE_URL" >>"$_tmp"; fi
  316. )
  317. mv "$_tmp" "$ENV_FILE"
  318. chmod 600 "$ENV_FILE" 2>/dev/null || true
  319. info "wrote $ENV_FILE"
  320. fi
  321. else
  322. warn "no terminal for credential input — set DEEPSEEK_API_KEY in $ENV_FILE before using dsh."
  323. fi
  324. fi
  325. # --- 6. choose and launch an interface -----------------------------------------
  326. step "Done"
  327. if [ "$HAS_TTY" = 1 ]; then
  328. printf ' 1) Web UI (recommended)\n'
  329. printf ' 2) TUI\n'
  330. while :; do
  331. LAUNCH_INTERFACE=$(ask "Choose an interface [1/2]:" 1)
  332. case "$LAUNCH_INTERFACE" in
  333. 1|web|Web|WEB)
  334. step "Building DeepSeek Harness for Web UI"
  335. ( cd "$DSH_STAGING" && pnpm run build )
  336. info "launching Web UI — run 'dsh web' anytime to start again"
  337. exec "$DSH_BIN_DIR/dsh" web </dev/tty
  338. ;;
  339. 2|tui|Tui|TUI)
  340. info "launching TUI — run 'dsh' anytime to start again"
  341. exec "$DSH_BIN_DIR/dsh" </dev/tty
  342. ;;
  343. *)
  344. warn "choose 1 for Web UI or 2 for TUI"
  345. ;;
  346. esac
  347. done
  348. else
  349. info "install complete. Build and start the Web UI with:"
  350. printf ' (cd %s && pnpm run build)\n' "$DSH_STAGING"
  351. printf ' %s web\n' "$DSH_BIN_DIR/dsh"
  352. info "or start the TUI with:"
  353. printf ' %s\n' "$DSH_BIN_DIR/dsh"
  354. fi