install.sh 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427
  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. # builds the repository artifacts, and launches the Web UI. Keeping every
  14. # checkout under ~/.dsh/source keeps successive
  15. # upgrades in one place instead of scattered sibling clones, and lets staging
  16. # worktrees share the master clone's object store. The PATH symlink resolves through
  17. # `current`, so an upgrade repoints one stable symlink instead of relinking PATH:
  18. # the `dsh` on PATH never moves and can never dangle.
  19. #
  20. # When run from inside an existing checkout (e.g. `sh scripts/install.sh` rather
  21. # than `curl ... | sh`) it never clones and never touches that working tree;
  22. # DSH_REF is ignored. Instead it *adopts* the checkout: `git rev-parse
  23. # --git-common-dir` resolves the repository behind it (for a linked worktree that
  24. # is the real clone, not the worktree), and a fresh staging worktree branched
  25. # from the checkout's HEAD lands in the source container beside `current`. The
  26. # container owns staging worktrees and `current`; the clone is discovered, not
  27. # owned, so an arbitrary clone (~/src/dsh) and a managed one converge on one
  28. # layout and stay upgradable. Adoption carries committed work only: the staging
  29. # worktree branches from HEAD, so uncommitted changes stay in the checkout.
  30. # Setting DSH_SOURCE to a different directory opts back into the normal
  31. # clone/worktree path.
  32. #
  33. # Adopting an arbitrary clone leaves the container not self-contained: its
  34. # staging worktrees hold an absolute gitdir pointer into that clone, so deleting
  35. # it breaks them. `git worktree list` in that clone is the record of which
  36. # worktrees depend on it.
  37. #
  38. # When run through `curl | sh` the script text arrives on stdin, so every
  39. # prompt and the final launch read the controlling terminal (/dev/tty) directly;
  40. # with no terminal the script prints the manual next steps instead.
  41. #
  42. # Overridable via environment:
  43. # DSH_REF branch or tag to clone/checkout (default: master)
  44. # DSH_REPO clone URL (default: the GitHub repo)
  45. # DSH_SOURCE source container directory (default: ~/.dsh/source)
  46. # DSH_MASTER master clone directory (default: $DSH_SOURCE/master)
  47. # DSH_CURRENT stable symlink to the active worktree (default: $DSH_SOURCE/current)
  48. # DSH_BIN_DIR directory the `dsh` symlink lands in (default: ~/.local/bin)
  49. # DSH_HOME Harness home holding profiles and user patches (default: ~/.dsh)
  50. # FIXME(install-ts): Move the post-checkout workflow into a tested TypeScript
  51. # entrypoint; keep this POSIX shell file as the curl/source bootstrap.
  52. set -eu
  53. DSH_REF=${DSH_REF:-master}
  54. DSH_REPO=${DSH_REPO:-https://github.com/deepseek-harness/deepseek-harness.git}
  55. # DSH_SOURCE is the staging-worktree container and the default home of `current`.
  56. # DSH_MASTER names the main clone: clone mode defaults it inside DSH_SOURCE,
  57. # while adoption discovers an existing clone anywhere on disk. Remember whether
  58. # DSH_SOURCE was explicit so a different path selects clone mode.
  59. if [ -n "${DSH_SOURCE:-}" ]; then DSH_SOURCE_EXPLICIT=1; else DSH_SOURCE_EXPLICIT=0; fi
  60. DSH_SOURCE=${DSH_SOURCE:-$HOME/.dsh/source}
  61. DSH_MASTER=${DSH_MASTER:-$DSH_SOURCE/master}
  62. # The stable symlink the PATH launcher resolves through: PATH/dsh ->
  63. # current/bin/dsh -> <staging>/bin/dsh. Installs and upgrades repoint `current`;
  64. # the PATH target remains current/bin/dsh.
  65. DSH_CURRENT=${DSH_CURRENT:-$DSH_SOURCE/current}
  66. DSH_BIN_DIR=${DSH_BIN_DIR:-$HOME/.local/bin}
  67. # One UTC basic timestamp names this install's staging branch and worktree.
  68. DSH_STAMP=$(date -u +%Y%m%dT%H%M%SZ)
  69. DSH_STAGING_BRANCH=dsh-staging/$DSH_STAMP
  70. DSH_STAGING=$DSH_SOURCE/staging-$DSH_STAMP
  71. # --- path helpers ---------------------------------------------------------------
  72. # Every path comparison below runs on physical paths. Git always reports resolved
  73. # paths, so comparing one against an unresolved path disagrees whenever a symlink
  74. # sits anywhere above the checkout — a symlinked home directory is enough, and
  75. # macOS reaches every mktemp path that way through /var -> private/var. The
  76. # mismatch silently misclassifies an existing managed install as a foreign clone
  77. # and builds a second container beside the real one.
  78. # `git rev-parse --path-format=absolute` would do this, but it needs git 2.31+.
  79. #
  80. # A not-yet-created directory (the container on a fresh install) has no physical
  81. # path. Falling back here rather than at each call site keeps every caller a
  82. # plain assignment, so no site can compare against an empty path by forgetting
  83. # its own fallback.
  84. resolve_dir() { CDPATH= cd -- "$1" 2>/dev/null && pwd -P || printf '%s\n' "$1"; }
  85. # --- in-repo detection ---------------------------------------------------------
  86. # Under `curl ... | sh` the script text arrives on stdin, so $0 is the shell
  87. # name and no file path resolves; running a checked-out copy (`sh
  88. # scripts/install.sh`) makes $0 the script file. When $0 is a readable file whose
  89. # parent is a scripts/ dir inside a real dsh checkout (bin/dsh launcher present),
  90. # this is in-repo mode: never clone, never touch that working tree. An explicit
  91. # DSH_SOURCE pointing elsewhere opts back into the clone/worktree path.
  92. IN_REPO=0
  93. DSH_CHECKOUT=''
  94. if [ -f "$0" ]; then
  95. _self_dir=$(resolve_dir "$(dirname -- "$0")")
  96. if [ -n "$_self_dir" ]; then
  97. # Physical without its own resolve_dir: dirname is textual, so trimming a
  98. # resolved path leaves one. The comparison below depends on that.
  99. _repo_root=$(dirname -- "$_self_dir")
  100. if [ "$(basename -- "$_self_dir")" = scripts ] \
  101. && [ -x "$_repo_root/bin/dsh" ] && [ -f "$_repo_root/scripts/install.sh" ]; then
  102. # Compare the explicit DSH_SOURCE physically: an unresolved but equivalent
  103. # path must still count as "the caller meant this checkout".
  104. _src_resolved=$(resolve_dir "$DSH_SOURCE")
  105. if [ "$DSH_SOURCE_EXPLICIT" = 0 ] || [ "$_src_resolved" = "$_repo_root" ]; then
  106. IN_REPO=1
  107. DSH_CHECKOUT=$_repo_root
  108. fi
  109. fi
  110. fi
  111. fi
  112. # --- terminal-aware prompting --------------------------------------------------
  113. # stdin is the piped script, so read the controlling terminal for input.
  114. if { true </dev/tty; } 2>/dev/null; then
  115. HAS_TTY=1
  116. # Restore terminal echo on exit or interrupt: ask_secret disables echo between
  117. # its stty toggles, and dash (a common `sh`) does not run an EXIT trap when the
  118. # shell is killed by a signal, so the fatal signals need their own handler. A
  119. # successful run ends in exec, which replaces this process and drops the traps.
  120. trap 'stty echo </dev/tty 2>/dev/null || true' EXIT
  121. trap 'stty echo </dev/tty 2>/dev/null || true; exit 130' INT TERM HUP
  122. else
  123. HAS_TTY=0
  124. fi
  125. # Colour only when writing to a terminal.
  126. if [ -t 1 ]; then
  127. B=$(printf '\033[1m'); DIM=$(printf '\033[2m'); RED=$(printf '\033[31m')
  128. GRN=$(printf '\033[32m'); YEL=$(printf '\033[33m'); RST=$(printf '\033[0m')
  129. else
  130. B=''; DIM=''; RED=''; GRN=''; YEL=''; RST=''
  131. fi
  132. info() { printf '%s==>%s %s\n' "$GRN" "$RST" "$1"; }
  133. step() { printf '\n%s==>%s %s%s%s\n' "$GRN" "$RST" "$B" "$1" "$RST"; }
  134. warn() { printf '%s warn%s %s\n' "$YEL" "$RST" "$1" >&2; }
  135. die() { printf '%serror%s %s\n' "$RED" "$RST" "$1" >&2; exit 1; }
  136. # ask PROMPT [DEFAULT] -> answer on stdout (plain-text line).
  137. ask() {
  138. [ "$HAS_TTY" = 1 ] || die "no terminal available for input; re-run in an interactive shell"
  139. printf '%s%s%s ' "$B" "$1" "$RST" >/dev/tty
  140. IFS= read -r _ans </dev/tty || _ans=''
  141. [ -n "$_ans" ] || _ans=${2:-}
  142. printf '%s' "$_ans"
  143. }
  144. # ask_secret PROMPT -> answer on stdout, with terminal echo suppressed.
  145. ask_secret() {
  146. [ "$HAS_TTY" = 1 ] || die "no terminal available for input; re-run in an interactive shell"
  147. printf '%s%s%s ' "$B" "$1" "$RST" >/dev/tty
  148. stty -echo </dev/tty 2>/dev/null || true
  149. IFS= read -r _sec </dev/tty || _sec=''
  150. stty echo </dev/tty 2>/dev/null || true
  151. printf '\n' >/dev/tty
  152. printf '%s' "$_sec"
  153. }
  154. # confirm PROMPT [Y] -> exit 0 on yes. Default is no unless second arg is "Y".
  155. confirm() {
  156. _def=${2:-N}
  157. if [ "$HAS_TTY" != 1 ]; then
  158. [ "$_def" = Y ] # non-interactive: take the default
  159. return
  160. fi
  161. if [ "$_def" = Y ]; then _hint='[Y/n]'; else _hint='[y/N]'; fi
  162. printf '%s%s%s %s ' "$B" "$1" "$RST" "$_hint" >/dev/tty
  163. IFS= read -r _r </dev/tty || _r=''
  164. [ -n "$_r" ] || _r=$_def
  165. case "$_r" in [yY]|[yY][eE][sS]) return 0 ;; *) return 1 ;; esac
  166. }
  167. printf '%s\n' "${B}DeepSeek Harness — dsh installer${RST}"
  168. if [ "$IN_REPO" = 1 ]; then
  169. printf '%scheckout %s%s\n' "$DIM" "$DSH_CHECKOUT" "$RST"
  170. else
  171. printf '%smaster %s @ %s%s\n' "$DIM" "$DSH_MASTER" "$DSH_REF" "$RST"
  172. printf '%sstaging %s%s\n' "$DIM" "$DSH_STAGING" "$RST"
  173. printf '%scurrent %s%s\n' "$DIM" "$DSH_CURRENT" "$RST"
  174. fi
  175. # --- 1. dependency check -------------------------------------------------------
  176. step "Checking dependencies"
  177. command -v git >/dev/null 2>&1 || die "git is required but not found. Install git, then re-run."
  178. info "git ... ok"
  179. # Node ^22.19.0 || >=24.0.0 (see the root package.json "engines" field).
  180. node_ok() {
  181. command -v node >/dev/null 2>&1 || return 1
  182. _v=$(node -v 2>/dev/null) || return 1
  183. _v=${_v#v}
  184. _major=${_v%%.*}
  185. _rest=${_v#*.}
  186. _minor=${_rest%%.*}
  187. case "$_major" in ''|*[!0-9]*) return 1 ;; esac
  188. case "$_minor" in ''|*[!0-9]*) _minor=0 ;; esac
  189. [ "$_major" -ge 24 ] && return 0
  190. [ "$_major" -eq 22 ] && [ "$_minor" -ge 19 ] && return 0
  191. return 1
  192. }
  193. if node_ok; then
  194. info "node $(node -v) ... ok"
  195. else
  196. if command -v node >/dev/null 2>&1; then
  197. die "Node $(node -v) is unsupported. dsh needs ^22.19.0 || >=24.0.0 — upgrade Node, then re-run."
  198. fi
  199. die "Node is required but not found. Install Node ^22.19.0 || >=24, then re-run."
  200. fi
  201. # pnpm is the only dependency we offer to install for you.
  202. if command -v pnpm >/dev/null 2>&1; then
  203. info "pnpm $(pnpm --version) ... ok"
  204. else
  205. warn "pnpm is not installed."
  206. if confirm "Install pnpm now?" Y; then
  207. if command -v corepack >/dev/null 2>&1 && corepack enable pnpm >/dev/null 2>&1; then
  208. info "enabled pnpm via corepack"
  209. elif command -v npm >/dev/null 2>&1 && npm install -g pnpm >/dev/null 2>&1; then
  210. info "installed pnpm via npm"
  211. else
  212. die "could not install pnpm automatically. Install it (https://pnpm.io/installation), then re-run."
  213. fi
  214. command -v pnpm >/dev/null 2>&1 || die "pnpm still not on PATH after install. Open a new shell, then re-run."
  215. else
  216. die "pnpm is required. Install it (https://pnpm.io/installation), then re-run."
  217. fi
  218. fi
  219. # --- 2. resolve the repository and lay out the staging worktree ---------------
  220. # The source container owns staging worktrees and `current`; the repository is
  221. # *discovered*, not owned. A curl install discovers it by cloning to $DSH_MASTER;
  222. # in-repo adoption discovers it from the checkout. Both then run one shared
  223. # worktree/exclude/lock path, so an arbitrary clone and a managed install
  224. # converge on the same layout.
  225. #
  226. # REPO_COMMON is the shared git directory every worktree of the repository
  227. # points at; REPO_ROOT is the working tree that owns it (the master clone).
  228. REPO_COMMON=''
  229. REPO_ROOT=''
  230. if [ "$IN_REPO" = 1 ]; then
  231. step "Using existing checkout at $DSH_CHECKOUT"
  232. info "running from inside the repo — never cloning, and DSH_REF is ignored"
  233. # Resolve the repository behind the checkout. --git-common-dir returns the
  234. # SHARED git dir, so a linked worktree resolves to the real clone rather than
  235. # itself; it is relative for a plain clone, so anchor it before resolving.
  236. # Require the resolved git dir to exist: resolve_dir echoes its argument back
  237. # for a missing path, so test the directory rather than the returned string.
  238. if _common=$(git -C "$DSH_CHECKOUT" rev-parse --git-common-dir 2>/dev/null) && [ -n "$_common" ]; then
  239. case "$_common" in /*) ;; *) _common=$DSH_CHECKOUT/$_common ;; esac
  240. [ -d "$_common" ] && REPO_COMMON=$(resolve_dir "$_common")
  241. fi
  242. [ -n "$REPO_COMMON" ] || die "$DSH_CHECKOUT is not a git repository — cannot adopt it."
  243. REPO_ROOT=$(dirname -- "$REPO_COMMON")
  244. # Reuse the container when the repository already lives inside it (the normal
  245. # managed install re-running its own script); otherwise treat that clone as
  246. # its own master and keep worktrees in the default container.
  247. _src_resolved=$(resolve_dir "$DSH_SOURCE")
  248. case "$REPO_ROOT/" in
  249. "$_src_resolved"/*) info "repository $REPO_ROOT is already inside $DSH_SOURCE" ;;
  250. *) info "adopting clone $REPO_ROOT as its own master" ;;
  251. esac
  252. DSH_MASTER=$REPO_ROOT
  253. else
  254. step "Fetching source into $DSH_MASTER"
  255. if [ -d "$DSH_MASTER/.git" ]; then
  256. info "existing master clone found — updating"
  257. git -C "$DSH_MASTER" fetch origin "$DSH_REF"
  258. # Reset the master checkout to the freshly fetched tip. FETCH_HEAD (not
  259. # origin/<ref>) so this resolves for a tag as well as a branch, and -B makes
  260. # the re-run idempotent whether or not DSH_REF changed since the last install.
  261. git -C "$DSH_MASTER" checkout -q -B "$DSH_REF" FETCH_HEAD
  262. else
  263. mkdir -p "$DSH_SOURCE"
  264. git clone --branch "$DSH_REF" "$DSH_REPO" "$DSH_MASTER"
  265. fi
  266. # Physical on both branches: REPO_ROOT is compared against resolved paths
  267. # below, and REPO_COMMON stays symmetric with it so neither can be read as
  268. # carrying a different kind of path.
  269. REPO_COMMON=$(resolve_dir "$DSH_MASTER/.git")
  270. REPO_ROOT=$(resolve_dir "$DSH_MASTER")
  271. fi
  272. step "Adding staging worktree at $DSH_STAGING"
  273. [ -e "$DSH_STAGING" ] && die "staging path $DSH_STAGING already exists — remove it or set DSH_SOURCE elsewhere, then re-run."
  274. mkdir -p "$DSH_SOURCE"
  275. # The staging worktree owns the branch dsh runs from; the repository stays as
  276. # the fetch/upgrade base and is never a launcher target. A clone install
  277. # branches from the ref it just fetched; adoption branches from the checkout's
  278. # HEAD so the contributor's committed work is what runs.
  279. if [ "$IN_REPO" = 1 ]; then
  280. git -C "$DSH_CHECKOUT" worktree add -b "$DSH_STAGING_BRANCH" "$DSH_STAGING" HEAD
  281. else
  282. git -C "$DSH_MASTER" worktree add -b "$DSH_STAGING_BRANCH" "$DSH_STAGING" FETCH_HEAD 2>/dev/null \
  283. || git -C "$DSH_MASTER" worktree add -b "$DSH_STAGING_BRANCH" "$DSH_STAGING" HEAD
  284. fi
  285. # Exclude the per-worktree merge lock in the shared git dir's info/exclude,
  286. # which every linked worktree inherits.
  287. _exclude="$REPO_COMMON/info/exclude"
  288. if [ -f "$_exclude" ] && ! grep -qxF '.agents/merge.lock' "$_exclude" 2>/dev/null; then
  289. printf '.agents/merge.lock\n' >>"$_exclude"
  290. fi
  291. mkdir -p "$DSH_STAGING/.agents"
  292. : >"$DSH_STAGING/.agents/merge.lock"
  293. # --- 3. install dependencies (no build; the launcher runs from source) --------
  294. step "Installing dependencies with pnpm (this can take a while)"
  295. ( cd "$DSH_STAGING" && pnpm install )
  296. [ -x "$DSH_STAGING/bin/dsh" ] || die "launcher $DSH_STAGING/bin/dsh missing after install — is DSH_REF a branch that ships apps/cli?"
  297. # --- 4. put `dsh` on PATH ------------------------------------------------------
  298. # Every install goes through a stable `current` symlink so an upgrade repoints
  299. # one symlink (current -> new worktree) and the PATH launcher never moves:
  300. # PATH/dsh -> current/bin/dsh -> <staging>/bin/dsh.
  301. step "Linking dsh into $DSH_BIN_DIR"
  302. mkdir -p "$DSH_BIN_DIR"
  303. # The launcher must resolve to a staging worktree, never to the repository
  304. # itself: an upgrade repoints `current`, so aliasing it onto the master clone
  305. # would make every upgrade rewrite the fetch/upgrade base. Compare physical
  306. # paths — a symlinked or unresolved path would slip past a string compare.
  307. _staging_resolved=$(resolve_dir "$DSH_STAGING")
  308. [ "$_staging_resolved" = "$REPO_ROOT" ] \
  309. && die "refusing to point $DSH_CURRENT at the repository $REPO_ROOT — the launcher must resolve to a staging worktree."
  310. # Point `current` at this staging worktree with `ln -sfn`: -f replaces an
  311. # existing `current` (re-run or upgrade) and -n stops `ln` from dereferencing
  312. # an existing symlink-to-directory and dropping the new link *inside* the old
  313. # worktree. `mv` is unusable here — BSD/macOS `mv` follows the existing dir
  314. # symlink the same way. The swap is one unlink+symlink pair on a local fs; the
  315. # installer holds no other process racing this path.
  316. ln -sfn "$DSH_STAGING" "$DSH_CURRENT"
  317. info "pointed $DSH_CURRENT -> $DSH_STAGING"
  318. DSH_LAUNCH_TARGET=$DSH_CURRENT/bin/dsh
  319. ln -sf "$DSH_LAUNCH_TARGET" "$DSH_BIN_DIR/dsh"
  320. info "linked $DSH_BIN_DIR/dsh -> $DSH_LAUNCH_TARGET"
  321. case ":$PATH:" in
  322. *":$DSH_BIN_DIR:"*) ON_PATH=1 ;;
  323. *) ON_PATH=0 ;;
  324. esac
  325. if [ "$ON_PATH" = 0 ]; then
  326. warn "$DSH_BIN_DIR is not on your PATH."
  327. _line="export PATH=\"$DSH_BIN_DIR:\$PATH\""
  328. _rc=''
  329. _sh=${SHELL:-} # SHELL may be unset; word-removal on an unset var trips set -u under dash.
  330. case "${_sh##*/}" in
  331. zsh) _rc="$HOME/.zshrc" ;;
  332. bash) _rc="$HOME/.bashrc" ;;
  333. esac
  334. if [ -n "$_rc" ] && [ -f "$_rc" ] && grep -qF "$_line" "$_rc" 2>/dev/null; then
  335. info "$_rc already exports $DSH_BIN_DIR — open a new shell to pick it up"
  336. elif [ -n "$_rc" ] && confirm "Add it to $_rc?" Y; then
  337. printf '\n# Added by the dsh installer\n%s\n' "$_line" >>"$_rc"
  338. info "updated $_rc — run 'source $_rc' or open a new shell to pick it up"
  339. else
  340. warn "add this line to your shell profile yourself:"
  341. printf ' %s\n' "$_line"
  342. fi
  343. fi
  344. # --- 5. credentials ------------------------------------------------------------
  345. # Mirror app-boot's resolveDshHome precedence ($DSH_HOME, else ~/.dsh) so creds land where dsh reads them.
  346. if [ -n "${DSH_HOME:-}" ]; then
  347. CONF="$DSH_HOME"
  348. else
  349. CONF="$HOME/.dsh"
  350. fi
  351. ENV_FILE="$CONF/.env"
  352. step "Configuring credentials"
  353. if [ -f "$ENV_FILE" ] && grep -q '^DEEPSEEK_API_KEY=' "$ENV_FILE" 2>/dev/null; then
  354. info "DEEPSEEK_API_KEY already set in $ENV_FILE"
  355. if ! confirm "Replace it?" N; then
  356. SKIP_CREDS=1
  357. fi
  358. fi
  359. if [ "${SKIP_CREDS:-0}" != 1 ]; then
  360. if [ "$HAS_TTY" = 1 ]; then
  361. API_KEY=$(ask_secret "DeepSeek API key (input hidden):")
  362. if [ -z "$API_KEY" ]; then
  363. warn "no key entered — skipping. Set DEEPSEEK_API_KEY in $ENV_FILE before using dsh."
  364. else
  365. BASE_URL=$(ask "DeepSeek base URL (optional, Enter to skip):")
  366. mkdir -p "$CONF"
  367. # The installer owns exactly the two DEEPSEEK_* lines; any other lines the
  368. # user keeps in this .env are preserved. The rewrite happens in a subshell
  369. # so umask 077 (which closes the create-time permission race) does not leak
  370. # into the exec'd dsh, and lands atomically via a same-dir temp + mv.
  371. _tmp="$ENV_FILE.dsh.$$"
  372. (
  373. umask 077
  374. if [ -f "$ENV_FILE" ]; then
  375. grep -v -e '^DEEPSEEK_API_KEY=' -e '^DEEPSEEK_BASE_URL=' "$ENV_FILE" >"$_tmp" || true
  376. else
  377. : >"$_tmp"
  378. fi
  379. printf 'DEEPSEEK_API_KEY=%s\n' "$API_KEY" >>"$_tmp"
  380. if [ -n "$BASE_URL" ]; then printf 'DEEPSEEK_BASE_URL=%s\n' "$BASE_URL" >>"$_tmp"; fi
  381. )
  382. mv "$_tmp" "$ENV_FILE"
  383. chmod 600 "$ENV_FILE" 2>/dev/null || true
  384. info "wrote $ENV_FILE"
  385. fi
  386. else
  387. warn "no terminal for credential input — set DEEPSEEK_API_KEY in $ENV_FILE before using dsh."
  388. fi
  389. fi
  390. # --- 6. build and launch the Web interface -------------------------------------
  391. step "Done"
  392. if [ "$HAS_TTY" = 1 ]; then
  393. step "Building DeepSeek Harness for Web UI"
  394. ( cd "$DSH_STAGING" && pnpm run build )
  395. info "launching Web UI — run 'dsh web' anytime to start again"
  396. exec "$DSH_BIN_DIR/dsh" web </dev/tty
  397. else
  398. info "install complete. Build and start the Web UI with:"
  399. printf ' (cd %s && pnpm run build)\n' "$DSH_STAGING"
  400. printf ' %s web\n' "$DSH_BIN_DIR/dsh"
  401. fi