Merge pull request #1085 from deepseek-harness/worktree/install-interface-choice

feat(install): choose Web UI or TUI after setup
This commit is contained in:
Tianyi Cui
2026-08-01 19:41:04 +08:00
committed by GitHub
6 changed files with 190 additions and 21 deletions

View File

@@ -2,5 +2,5 @@
# 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 README.md
README.md: fb956dce51838438fb508db7ea9ebdf9e0b3a50b
README.zh.md: aa80b744465d7d253a54fffeead7262a1fdf69eb
README.md: 8ecd0928ee630eeca1cb8ce8b9c59d19f6984969
README.zh.md: 9ffb3b3086415550df4a0f776c7b91c94122dd97

View File

@@ -24,7 +24,7 @@ cd deepseek-harness
scripts/install.sh
```
The installer requires `git` and Node `^22.19 || >=24`, offers to install `pnpm` when it is missing, and prompts for a DeepSeek API key.
The installer requires `git` and Node `^22.19 || >=24`, offers to install `pnpm` when it is missing, prompts for a DeepSeek API key, then lets you launch the Web UI or TUI. Choosing Web UI builds the required repository artifacts first.
The installer keeps every checkout under `~/.dsh/source`: the master clone at `~/.dsh/source/master` and each install's staging checkout as a git worktree `~/.dsh/source/staging-<timestamp>`. The stable symlink `~/.dsh/source/current` points at the active staging worktree, and `dsh` in `~/.local/bin` links to `current/bin/dsh`, so an upgrade repoints one symlink and the `dsh` on PATH never moves. Re-running the command adds a fresh staging worktree from an updated master and repoints `current` at it. See [`scripts/install.sh`](scripts/install.sh) for alternate install locations and other options.
@@ -32,14 +32,14 @@ The installer keeps every checkout under `~/.dsh/source`: the master clone at `~
### Web UI
For the recommended local interface, build the active checkout after installation and after each update, then start the Web UI:
For the recommended local interface, choose Web UI when the installer finishes. To start it later, or after updating the active checkout, build the repository and run:
```sh
(cd ~/.dsh/source/current && pnpm run build)
dsh web
```
The full build produces the library and client bundles plus the frontend dist. The path above is the installer's default. If you set `DSH_SOURCE` or `DSH_CURRENT`, or reused an existing checkout, replace `~/.dsh/source/current` with that checkout path; see [`scripts/install.sh`](scripts/install.sh) for details. The Web UI is served at `http://127.0.0.1:3080` by default.
The path above is the installer's default. If you set `DSH_SOURCE` or `DSH_CURRENT`, or reused an existing checkout, replace `~/.dsh/source/current` with that checkout path; see [`scripts/install.sh`](scripts/install.sh) for details. The Web UI is served at `http://127.0.0.1:3080` by default.
### TUI

View File

@@ -24,7 +24,7 @@ cd deepseek-harness
scripts/install.sh
```
安装器要求系统已安装 `git` 和 Node `^22.19 || >=24`,缺少 `pnpm` 时可代为安装,并会提示输入 DeepSeek API 密钥。
安装器要求系统已安装 `git` 和 Node `^22.19 || >=24`,缺少 `pnpm` 时可代为安装,并会提示输入 DeepSeek API 密钥,随后让你选择启动 Web UI 或 TUI。选择 Web UI 时,安装器会先构建所需的仓库产物
安装器会把所有检出都放在 `~/.dsh/source`master 克隆位于 `~/.dsh/source/master`,每次安装的 staging 检出是一个 git worktree `~/.dsh/source/staging-<时间戳>`。稳定符号链接 `~/.dsh/source/current` 指向当前生效的 staging worktree`~/.local/bin` 中的 `dsh` 链接到 `current/bin/dsh`因此升级只需重指一个符号链接PATH 上的 `dsh` 从不移动。再次运行该命令会基于更新后的 master 新增一个 staging worktree并把 `current` 重指到它。其他安装位置和选项见 [`scripts/install.sh`](scripts/install.sh)。
@@ -32,14 +32,14 @@ scripts/install.sh
### Web UI
推荐在本地使用 Web UI安装完成后以及每次更新后,请先构建当前生效的检出,再启动 Web UI
推荐在本地使用 Web UI安装结束时,选择 Web UI 即可。以后需要启动时,或更新当前生效的检出后,请构建仓库并运行
```sh
(cd ~/.dsh/source/current && pnpm run build)
dsh web
```
完整构建会生成库与客户端 bundle以及前端 dist。上述路径是安装器的默认位置。如果你设置过 `DSH_SOURCE``DSH_CURRENT`,或者复用了已有检出,请把 `~/.dsh/source/current` 换成该检出路径;详情见 [`scripts/install.sh`](scripts/install.sh)。Web UI 默认通过 `http://127.0.0.1:3080` 提供服务。
上述路径是安装器的默认位置。如果你设置过 `DSH_SOURCE``DSH_CURRENT`,或者复用了已有检出,请把 `~/.dsh/source/current` 换成该检出路径;详情见 [`scripts/install.sh`](scripts/install.sh)。Web UI 默认通过 `http://127.0.0.1:3080` 提供服务。
### TUI

View File

@@ -0,0 +1,147 @@
import { chmodSync, copyFileSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { execa } from 'execa'
import { afterEach, describe, expect, it } from 'vitest'
const installer = fileURLToPath(new URL('../../../scripts/install.sh', import.meta.url))
const fixtures: string[] = []
const PTY_DRIVER = String.raw`
import errno, json, os, pty, select, signal, sys, time
script, cwd, env_json, actions_json = sys.argv[1:]
env = os.environ.copy()
env.update(json.loads(env_json))
actions = json.loads(actions_json)
pid, fd = pty.fork()
if pid == 0:
os.chdir(cwd)
os.execvpe("sh", ["sh", script], env)
output = bytearray()
action_index = 0
deadline = time.monotonic() + 15
status = None
while time.monotonic() < deadline:
ready, _, _ = select.select([fd], [], [], 0.05)
if ready:
try:
chunk = os.read(fd, 65536)
except OSError as error:
if error.errno != errno.EIO:
raise
chunk = b""
output.extend(chunk)
while action_index < len(actions) and actions[action_index]["waitFor"].encode() in output:
os.write(fd, actions[action_index]["send"].encode())
action_index += 1
waited, candidate = os.waitpid(pid, os.WNOHANG)
if waited == pid:
status = candidate
break
if status is None:
os.kill(pid, signal.SIGKILL)
_, status = os.waitpid(pid, 0)
sys.stdout.buffer.write(output)
if action_index != len(actions):
sys.stderr.write(f"completed {action_index}/{len(actions)} PTY actions\n")
sys.exit(124)
sys.exit(os.waitstatus_to_exitcode(status))
`
interface Action {
readonly waitFor: string
readonly send: string
}
interface Fixture {
readonly binDirectory: string
readonly launchLog: string
readonly pnpmLog: string
readonly root: string
readonly script: string
}
afterEach(async () => {
await Promise.all(fixtures.splice(0).map(async (fixture) => { await rm(fixture, { force: true, recursive: true }) }))
})
function executable(path: string, content: string): void {
writeFileSync(path, content)
chmodSync(path, 0o755)
}
async function createFixture(): Promise<Fixture> {
const root = await mkdtemp(join(tmpdir(), 'dsh-install-'))
fixtures.push(root)
const scriptsDirectory = join(root, 'scripts')
const sourceBinDirectory = join(root, 'bin')
const fakeBinDirectory = join(root, 'fake-bin')
const binDirectory = join(root, 'path-bin')
for (const directory of [scriptsDirectory, sourceBinDirectory, fakeBinDirectory, binDirectory, join(root, 'home/.dsh')]) {
mkdirSync(directory, { recursive: true })
}
const script = join(scriptsDirectory, 'install.sh')
copyFileSync(installer, script)
const launchLog = join(root, 'launch.log')
const pnpmLog = join(root, 'pnpm.log')
executable(join(sourceBinDirectory, 'dsh'), '#!/bin/sh\nprintf \'%s\\n\' "$*" >"$DSH_TEST_LAUNCH_LOG"\n')
executable(join(fakeBinDirectory, 'pnpm'), `#!/bin/sh
if [ "\${1:-}" = --version ]; then printf '11.7.0\\n'; exit 0; fi
printf '%s\\n' "$*" >>"$DSH_TEST_PNPM_LOG"
`)
writeFileSync(join(root, 'home/.dsh/.env'), 'DEEPSEEK_API_KEY=test\n')
return { binDirectory, launchLog, pnpmLog, root, script }
}
async function runInstaller(fixture: Fixture, actions: readonly Action[]): Promise<string> {
const result = await execa('python3', [
'-c',
PTY_DRIVER,
fixture.script,
fixture.root,
JSON.stringify({
DSH_BIN_DIR: fixture.binDirectory,
DSH_HOME: join(fixture.root, 'home/.dsh'),
DSH_TEST_LAUNCH_LOG: fixture.launchLog,
DSH_TEST_PNPM_LOG: fixture.pnpmLog,
HOME: join(fixture.root, 'home'),
PATH: `${join(fixture.root, 'fake-bin')}:${fixture.binDirectory}:${process.env.PATH ?? ''}`,
}),
JSON.stringify(actions),
], { reject: false, stripFinalNewline: false, timeout: 20_000 })
expect(result.exitCode, result.stderr).toBe(0)
return result.stdout
}
describe.runIf(process.platform !== 'win32')('one-line installer interface choice', { timeout: 25_000 }, () => {
it('builds and launches the Web UI when the default choice is accepted', async () => {
const fixture = await createFixture()
const output = await runInstaller(fixture, [
{ waitFor: 'Replace it?', send: '\n' },
{ waitFor: 'Choose an interface [1/2]:', send: '\n' },
])
expect(output).toContain('launching Web UI')
expect(readFileSync(fixture.pnpmLog, 'utf8')).toBe('install\nrun build\n')
expect(readFileSync(fixture.launchLog, 'utf8')).toBe('web\n')
})
it('rejects an unknown choice, then launches the TUI without building', async () => {
const fixture = await createFixture()
const output = await runInstaller(fixture, [
{ waitFor: 'Replace it?', send: '\n' },
{ waitFor: 'Choose an interface [1/2]:', send: 'terminal\n' },
{ waitFor: 'choose 1 for Web UI or 2 for TUI', send: '2\n' },
])
expect(output).toContain('launching TUI')
expect(readFileSync(fixture.pnpmLog, 'utf8')).toBe('install\n')
expect(readFileSync(fixture.launchLog, 'utf8')).toBe('\n')
})
})

View File

@@ -7,15 +7,16 @@
# ~/.dsh/source/master), adds a per-install staging worktree at
# ~/.dsh/source/staging-<timestamp> on branch dsh-staging/<timestamp>, 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), points the stable `~/.dsh/source/current` symlink
# `pnpm install`, points the stable `~/.dsh/source/current` symlink
# at that staging worktree and symlinks `dsh` onto PATH at `current/bin/dsh`,
# records your API credentials in the Harness home (`~/.dsh`) dsh reads at boot,
# and drops you into `dsh`. Keeping every checkout under ~/.dsh/source keeps
# successive upgrades in one place instead of scattered sibling clones, and lets
# staging worktrees share the master clone's object store. The PATH symlink
# resolves through `current`, so an upgrade repoints one stable symlink instead
# of relinking PATH: the `dsh` on PATH never moves and can never dangle.
# and lets you launch the Web UI or TUI. The Web choice builds the repository
# artifacts first; the TUI runs directly from TypeScript source through the
# repo's own tsx. Keeping every checkout under ~/.dsh/source keeps successive
# upgrades in one place instead of scattered sibling clones, and lets staging
# worktrees share the master clone's object store. The PATH symlink resolves through
# `current`, so an upgrade repoints one stable symlink instead of relinking PATH:
# the `dsh` on PATH never moves and can never dangle.
#
# When run from inside an existing checkout (e.g. `sh scripts/install.sh` rather
# than `curl ... | sh`) it reuses that checkout in place and skips the
@@ -343,12 +344,33 @@ if [ "${SKIP_CREDS:-0}" != 1 ]; then
fi
fi
# --- 6. launch -----------------------------------------------------------------
# --- 6. choose and launch an interface -----------------------------------------
step "Done"
if [ "$HAS_TTY" = 1 ]; then
info "launching dsh — run 'dsh' anytime to start again"
exec "$DSH_BIN_DIR/dsh" </dev/tty
printf ' 1) Web UI (recommended)\n'
printf ' 2) TUI\n'
while :; do
LAUNCH_INTERFACE=$(ask "Choose an interface [1/2]:" 1)
case "$LAUNCH_INTERFACE" in
1|web|Web|WEB)
step "Building DeepSeek Harness for Web UI"
( cd "$DSH_STAGING" && pnpm run build )
info "launching Web UI — run 'dsh web' anytime to start again"
exec "$DSH_BIN_DIR/dsh" web </dev/tty
;;
2|tui|Tui|TUI)
info "launching TUI — run 'dsh' anytime to start again"
exec "$DSH_BIN_DIR/dsh" </dev/tty
;;
*)
warn "choose 1 for Web UI or 2 for TUI"
;;
esac
done
else
info "install complete. Start it with:"
info "install complete. Build and start the Web UI with:"
printf ' (cd %s && pnpm run build)\n' "$DSH_STAGING"
printf ' %s web\n' "$DSH_BIN_DIR/dsh"
info "or start the TUI with:"
printf ' %s\n' "$DSH_BIN_DIR/dsh"
fi

File diff suppressed because one or more lines are too long