diff --git a/README.md b/README.md index ef23b88..5f7407f 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ irm https://raw.githubusercontent.com/TokenNotIncluded/lmm-scripts/main/menu.ps1 | 文件名(`.sh` / `.ps1`) | 安装方式 | 启动 | |---|---|---| | `pi` | 官方安装器安装 Pi,再通过 npm 的 LMM 安装入口清理重复来源 | `pi`,然后 `/login` | -| `dsh` | npm + pnpm,从 npm 安装 LMM 插件;默认 web profile | `dsh web` | +| `dsh` | npm + pnpm,安装当前 GitHub Release 的 LMM 插件;默认 web profile | `dsh web` | | `codex` | 官方原生安装器,参数直接传给官方 | `codex` | | `claude-code` | 官方原生安装器,参数直接传给官方 | `claude` | | `cc-switch` | Linux 发行包/AUR,macOS Homebrew,Windows 官方 MSI | 桌面应用 | @@ -24,9 +24,9 @@ irm https://raw.githubusercontent.com/TokenNotIncluded/lmm-scripts/main/menu.ps1 | `lmm` | 0.1.0 预览版发行包;另支持 `--from-source` / `-FromSource` | 安装结束打印的完整路径 | | `codewhale` | 官方 npm 安装 + 固定版本 LMM OAuth 适配器 | `bash codewhale.sh run` / `.\codewhale.ps1 run` | -Pi 的版本、安装位置、权限和 Windows Git Bash 交给官方安装器。官方正常安装后,脚本用 npm 运行 LMM 插件的来源切换入口,并把官方安装器选出的 Pi 路径传给它。该入口先安装 npm 版本,成功后只移除同一插件在当前项目和用户配置中的 Git、本地等其他来源;失败时保留旧来源。LMM alpha 支持 Pi 0.86.1 至 0.87.x,其他版本明确跳过插件。安装 LMM 插件另需 Node.js 22.19+ 和 npm。DSH 的宿主继续固定已适配版本,LMM 插件改从 npm 安装;其当前官方 README 使用 npx,没有现行的独立安装脚本,不能拿归档记录中的旧脚本替代。 +Pi 的版本、安装位置、权限和 Windows Git Bash 交给官方安装器。官方正常安装后,脚本用 npm 运行 LMM 插件的来源切换入口,并把官方安装器选出的 Pi 路径传给它。该入口先安装 npm 版本,成功后只移除同一插件在当前项目和用户配置中的 Git、本地等其他来源;失败时保留旧来源。LMM alpha 支持 Pi 0.86.1 至 0.87.x,其他版本明确跳过插件。安装 LMM 插件另需 Node.js 22.19+ 和 npm。DSH 安装官方 npm `latest` 宿主和 pnpm,LMM 插件安装当前 GitHub Release 的已构建包;其当前官方 README 使用 npx,没有现行的独立安装脚本,不能拿归档记录中的旧脚本替代。 -`dsh.sh` / `dsh.ps1` 只配置 CLI 的 `web` profile,固定 DSH `0.1.5-rc.2` 和对应插件 `0.1.0-alpha.3`。官方 DSH Desktop `0.1.7-alpha.2` 使用独立的 `desktop` profile,应在桌面端“插件”页安装 `@tokennotincluded/dsh-lmm-provider@0.1.0-alpha.4`;CLI 安装不会进入桌面端。 +`dsh.sh` / `dsh.ps1` 默认配置 CLI 的 `web` profile,可传入其他 profile。宿主使用官方 `latest`,插件通过 GitHub 当前 Release 解析版本化下载链接,避免安装过时 npm 预览包或缺少构建文件的源码。最新插件已验证官方 DSH `0.2.0-rc.2`,此版本是验证基线,没有人为上限。官方 DSH Desktop `0.1.7-alpha.2` 使用独立的 `desktop` profile,应在桌面端“插件”页安装 `@tokennotincluded/dsh-lmm-provider@0.1.0-alpha.4`;CLI 安装不会进入桌面端。 原有安装器不写供应商配置、不登录账号、不启用系统代理或 TUN。Codewhale 默认 `setup` 会引导用户在浏览器确认 OAuth;`install` 只安装、不登录。两者都不覆盖原配置,见 [Codewhale 安装与配置](CODEWHALE.md)。 diff --git a/dsh.ps1 b/dsh.ps1 index 4e0c26c..0d2a59f 100644 --- a/dsh.ps1 +++ b/dsh.ps1 @@ -1,7 +1,13 @@ param([ValidatePattern('^[a-zA-Z0-9][a-zA-Z0-9_-]*$')][string]$Profile = 'web') $ErrorActionPreference = 'Stop' if ($args.Count) { throw 'Usage: .\dsh.ps1 [-Profile name]' } -npm.cmd install -g @deepseek-ai/dsh@0.1.5-rc.2 pnpm@11.7.0 +npm.cmd install -g @deepseek-ai/dsh@latest pnpm@latest if ($LASTEXITCODE) { exit $LASTEXITCODE } -dsh.cmd plugin --profile $Profile add @tokennotincluded/dsh-lmm-provider@0.1.0-alpha.3 --ignore-scripts +$response = Invoke-WebRequest -UseBasicParsing -Method Head -Uri 'https://github.com/TokenNotIncluded/dsh-lmm-provider/releases/latest' +$finalUri = $response.BaseResponse.ResponseUri +if (-not $finalUri) { $finalUri = $response.BaseResponse.RequestMessage.RequestUri } +$url = [Uri]$finalUri +if (-not $url -or $url.Scheme -ne 'https' -or $url.Host -ne 'github.com' -or -not $url.IsDefaultPort -or $url.UserInfo -or $url.Query -or $url.Fragment -or $url.AbsolutePath -notmatch '^/TokenNotIncluded/dsh-lmm-provider/releases/tag/([a-zA-Z0-9][a-zA-Z0-9._-]*)$') { throw 'Unexpected plugin release URL' } +$pluginUrl = 'https://github.com/TokenNotIncluded/dsh-lmm-provider/releases/download/' + $Matches[1] + '/dsh-lmm-provider.tgz' +dsh.cmd plugin --profile $Profile add $pluginUrl --ignore-scripts exit $LASTEXITCODE diff --git a/dsh.sh b/dsh.sh index d0299df..89e1aca 100755 --- a/dsh.sh +++ b/dsh.sh @@ -1,5 +1,14 @@ #!/usr/bin/env bash set -euo pipefail [[ $# -le 1 && ${1:-web} =~ ^[a-zA-Z0-9][a-zA-Z0-9_-]*$ ]] || { echo 'Usage: bash dsh.sh [profile]' >&2; exit 2; } -npm install -g @deepseek-ai/dsh@0.1.5-rc.2 pnpm@11.7.0 -dsh plugin --profile "${1:-web}" add @tokennotincluded/dsh-lmm-provider@0.1.0-alpha.3 --ignore-scripts +npm install -g @deepseek-ai/dsh@latest pnpm@latest +# Resolve the current built release rather than installing an unbuilt Git checkout +# or a cached npm preview package. A versioned asset URL also refreshes pnpm's source. +release_url=$(curl -fsSLI -o /dev/null -w '%{url_effective}' https://github.com/TokenNotIncluded/dsh-lmm-provider/releases/latest) +plugin_url=$(node --input-type=module -e ' +const url=new URL(process.argv[1]); +const prefix="/TokenNotIncluded/dsh-lmm-provider/releases/tag/", tag=url.pathname.slice(prefix.length); +if(url.origin!=="https://github.com" || url.username || url.password || url.search || url.hash || !url.pathname.startsWith(prefix) || !/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(tag)) throw Error("Unexpected plugin release URL"); +console.log("https://github.com/TokenNotIncluded/dsh-lmm-provider/releases/download/"+tag+"/dsh-lmm-provider.tgz"); +' "$release_url") +dsh plugin --profile "${1:-web}" add "$plugin_url" --ignore-scripts diff --git a/test.ps1 b/test.ps1 index 22020ef..034a94c 100644 --- a/test.ps1 +++ b/test.ps1 @@ -57,8 +57,11 @@ Check 'dsh.ps1' $npmFailure 9 'npm failed' $npmSuccess = @' function npm.cmd { Write-Output ($args -join '|'); $global:LASTEXITCODE=0 } function dsh.cmd { Write-Output ($args -join '|'); $global:LASTEXITCODE=0 } +function Invoke-WebRequest { param($Uri,$Method,[switch]$UseBasicParsing) if ($Uri -ne 'https://github.com/TokenNotIncluded/dsh-lmm-provider/releases/latest' -or $Method -ne 'Head') { throw 'wrong release URL' }; return [pscustomobject]@{BaseResponse=[pscustomobject]@{ResponseUri=[Uri]'https://github.com/TokenNotIncluded/dsh-lmm-provider/releases/tag/v0.1.0-alpha.5'}} } '@ -Check 'dsh.ps1' $npmSuccess 0 'plugin\|--profile\|headless' '-Profile headless' +Check 'dsh.ps1' $npmSuccess 0 'plugin\|--profile\|headless.*releases/download/v0.1.0-alpha.5/dsh-lmm-provider.tgz' '-Profile headless' +Check 'dsh.ps1' ($npmSuccess+"`nfunction Invoke-WebRequest { throw 'missing-release' }") 1 'missing-release' +Check 'dsh.ps1' ($npmSuccess+"`nfunction Invoke-WebRequest { [pscustomobject]@{BaseResponse=[pscustomobject]@{ResponseUri=[Uri]'https://evil.invalid/plugin.tgz'}} }") 1 'Unexpected plugin release URL' $upstream = @' function Invoke-RestMethod { return 'param([string]$Version) Write-Output "upstream:$Version"' } '@ diff --git a/test.py b/test.py index 313f149..a4a3df4 100644 --- a/test.py +++ b/test.py @@ -39,6 +39,9 @@ print('printf "%s\\n" "$@" > "$HOME/received-args"') elif url.endswith('/desktop.sh'): print(Path(os.environ['PROJECT'], 'desktop.sh').read_text()) + elif url == 'https://github.com/TokenNotIncluded/dsh-lmm-provider/releases/latest': + if os.environ.get('NO_ASSET'): sys.exit(22) + print(os.environ.get('DSH_ASSET_URL','https://github.com/TokenNotIncluded/dsh-lmm-provider/releases/tag/v0.1.0-alpha.5')) elif '/releases/latest' in url: print(json.dumps({'assets': [] if os.environ.get('NO_ASSET') else [ {'name': 'Tool-amd64.deb', 'browser_download_url': 'https://github.com/official/tool.deb'}, @@ -79,7 +82,7 @@ def setUp(self): self.log = self.home/'commands.jsonl' self.log.touch() # Keep host package managers out of simulated platform tests. - for name in ('bash','sh','dirname','mktemp','rm','mkdir','install'): + for name in ('bash','sh','dirname','mktemp','rm','mkdir','install','node'): os.symlink(shutil.which(name), self.bin/name) for name in ('curl','npm','pi','dsh','uname','jq','tar','proot-distro','brew','cargo','sudo','pkg'): self.stub(name) @@ -148,7 +151,16 @@ def test_pi_termux_dependency_failure_stops_before_official_bootstrap(self): def test_dsh_keeps_documented_commands_and_lmm_plugin(self): self.assertEqual(self.run_script('dsh.sh','headless').returncode,0) - self.assertEqual(self.calls()[-1][1][:3],['plugin','--profile','headless']) + self.assertEqual(self.calls()[0], ['npm',['install','-g','@deepseek-ai/dsh@latest','pnpm@latest']]) + self.assertEqual(self.calls()[-1], ['dsh',['plugin','--profile','headless','add','https://github.com/TokenNotIncluded/dsh-lmm-provider/releases/download/v0.1.0-alpha.5/dsh-lmm-provider.tgz','--ignore-scripts']]) + + def test_dsh_missing_release_does_not_install_plugin(self): + self.assertNotEqual(self.run_script('dsh.sh',NO_ASSET='1').returncode,0) + self.assertTrue(all(name!='dsh' for name,_ in self.calls())) + + def test_dsh_rejects_foreign_release_urls(self): + self.assertNotEqual(self.run_script('dsh.sh',DSH_ASSET_URL='https://evil.invalid/plugin.tgz').returncode,0) + self.assertTrue(all(name!='dsh' for name,_ in self.calls())) def test_npm_failure_never_installs_plugin(self): self.assertEqual(self.run_script('dsh.sh',NPM_EXIT='9').returncode,9) @@ -249,7 +261,7 @@ def test_flat_layout_and_size_budget(self): self.assertNotIn('@MENU_REV@',text) self.assertNotIn('@DESKTOP_REV@',text) for name in ('codex','claude-code','pi','dsh'): - self.assertLessEqual(len((ROOT/f'{name}.sh').read_text().splitlines()),13 if name=='pi' else 10) + self.assertLessEqual(len((ROOT/f'{name}.sh').read_text().splitlines()),20 if name=='dsh' else 13 if name=='pi' else 10) for ext in ('sh','ps1'): text=(ROOT/f'pi.{ext}').read_text() self.assertIn(f'https://pi.dev/install.{ext}',text)