diff --git a/README.i18n.yaml b/README.i18n.yaml index b492ed9c37..c37e24e075 100644 --- a/README.i18n.yaml +++ b/README.i18n.yaml @@ -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: b447c9634189353854e8be9d0bf597a8b0c7e371 -README.zh.md: f8bbbc36bc670403c0b9a40977f32f598e77ee46 +README.md: f5367578570a7b4ec7bad13be3b801ac7870de6c +README.zh.md: b9f593857d2a65b5df61983a640022be605ee93b diff --git a/README.md b/README.md index b447c96341..f536757857 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ Install `dsh` with one command: curl -fsSL https://raw.githubusercontent.com/deepseek-harness/deepseek-harness/master/scripts/install.sh | 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 its frontend 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-`. 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. @@ -30,14 +30,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 frontend and run: ```sh -(cd ~/.dsh/source/current && pnpm run build) +(cd ~/.dsh/source/current && pnpm --filter @deepseek-ai/dsh-frontend 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 diff --git a/README.zh.md b/README.zh.md index f8bbbc36bc..b9f593857d 100644 --- a/README.zh.md +++ b/README.zh.md @@ -22,7 +22,7 @@ DeepSeek Harness(`dsh`)是一款基于 DeepSeek Harness SDK 构建的开源 curl -fsSL https://raw.githubusercontent.com/deepseek-harness/deepseek-harness/master/scripts/install.sh | 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)。 @@ -30,14 +30,14 @@ curl -fsSL https://raw.githubusercontent.com/deepseek-harness/deepseek-harness/m ### Web UI -推荐在本地使用 Web UI。安装完成后以及每次更新后,请先构建当前生效的检出,再启动 Web UI: +推荐在本地使用 Web UI;安装结束时,选择 Web UI 即可。以后需要启动时,或更新当前生效的检出后,请构建前端并运行: ```sh -(cd ~/.dsh/source/current && pnpm run build) +(cd ~/.dsh/source/current && pnpm --filter @deepseek-ai/dsh-frontend 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 diff --git a/apps/cli/tests/install-script.spec.ts b/apps/cli/tests/install-script.spec.ts new file mode 100644 index 0000000000..63bb0c307f --- /dev/null +++ b/apps/cli/tests/install-script.spec.ts @@ -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 { + 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 { + 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\n--filter @deepseek-ai/dsh-frontend run 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') + }) +}) diff --git a/scripts/install.sh b/scripts/install.sh index 41d5c749c1..9e110e32cf 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -7,15 +7,16 @@ # ~/.dsh/source/master), adds a per-install staging worktree at # ~/.dsh/source/staging- on branch dsh-staging/, 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 frontend +# artifact 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"