diff --git a/.github/workflows/codewhale.yml b/.github/workflows/codewhale.yml index f2e297d..e8d0f08 100644 --- a/.github/workflows/codewhale.yml +++ b/.github/workflows/codewhale.yml @@ -1,5 +1,9 @@ name: Codewhale installer -on: [push, pull_request, workflow_dispatch] +on: + push: + branches: [main] + pull_request: + workflow_dispatch: permissions: contents: read concurrency: diff --git a/.github/workflows/opencode.yml b/.github/workflows/opencode.yml new file mode 100644 index 0000000..c6a02c1 --- /dev/null +++ b/.github/workflows/opencode.yml @@ -0,0 +1,38 @@ +name: OpenCode installer +on: + push: + branches: [main] + pull_request: + workflow_dispatch: +permissions: + contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true +jobs: + verify: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + host: [latest, baseline] + runs-on: ${{ matrix.os }} + timeout-minutes: 15 + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 + with: + node-version: '22.19.0' + - name: Configuration preservation + run: node --test test-opencode.mjs test-opencode-entry.mjs + - name: PowerShell entrypoint + shell: pwsh + run: ./test-opencode.ps1 + - name: Windows PowerShell 5.1 entrypoint + if: runner.os == 'Windows' + shell: powershell + run: .\test-opencode.ps1 + - name: Real install and native plugin loading without OAuth or inference + env: + TEST_OPENCODE_BASELINE: ${{ matrix.host == 'baseline' && 'true' || 'false' }} + run: node test-opencode-install.mjs diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f16071d..d096c31 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,5 +1,9 @@ name: Installer checks -on: [push, pull_request, workflow_dispatch] +on: + push: + branches: [main] + pull_request: + workflow_dispatch: permissions: contents: read concurrency: diff --git a/README.md b/README.md index 5f7407f..894184f 100644 --- a/README.md +++ b/README.md @@ -22,9 +22,12 @@ irm https://raw.githubusercontent.com/TokenNotIncluded/lmm-scripts/main/menu.ps1 | `cc-switch` | Linux 发行包/AUR,macOS Homebrew,Windows 官方 MSI | 桌面应用 | | `clash-verge-rev` | Linux deb/rpm/AUR,macOS Homebrew,Windows WinGet | 桌面应用 | | `lmm` | 0.1.0 预览版发行包;另支持 `--from-source` / `-FromSource` | 安装结束打印的完整路径 | +| `opencode` | 官方 npm 最新 OpenCode + 独立 LMM OAuth 插件 | `opencode auth login --provider lmm`,然后 `opencode` | | `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 安装官方 npm `latest` 宿主和 pnpm,LMM 插件安装当前 GitHub Release 的已构建包;其当前官方 README 使用 npx,没有现行的独立安装脚本,不能拿归档记录中的旧脚本替代。 +Pi 主程序通过官方最新安装器安装;脚本用 npm 的 `@alpha` 入口执行 LMM 插件来源切换,实际安装 GitHub 最新 `main`。入口先确认安装成功,再清理同插件的其他来源;保留无关扩展,失败时保留旧来源。不设 Pi 宿主版本上限。安装 LMM 插件另需 Node.js 22.19+ 和 npm。Pi 的安装位置、权限和 Windows Git Bash 仍由官方安装器处理。 + +DSH 安装官方 npm `latest` 宿主和 pnpm,LMM 插件安装当前 GitHub Release 的已构建包;其当前官方 README 使用 npx,没有现行的独立安装脚本,不能拿归档记录中的旧脚本替代。 `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 安装不会进入桌面端。 @@ -34,6 +37,14 @@ Pi 的版本、安装位置、权限和 Windows Git Bash 交给官方安装器 菜单新增第 8 项 `codewhale`(Termux 为第 6 项),进入安装、登录、选模型启动、余额/用量和登出子菜单,原有工具编号不变。可直接运行 `bash codewhale.sh` 或 `.\codewhale.ps1` 完成安装和授权;非交互环境必须显式使用 `install`。需要服务端先部署 `lmm-codewhale` 注册,安装成功不代表生产登录已验证。 +## OpenCode + +运行 `bash opencode.sh` 或 `.\opencode.ps1` 安装官方最新版 OpenCode(最近验证 **1.18.34**) 和独立仓库 [opencode-lmm-auth](https://github.com/TokenNotIncluded/opencode-lmm-auth) 的最新正式 Release 插件。桌面菜单第 9 项提供同一入口;不限制宿主版本更新;不在 Termux 菜单显示。 + +需要 Node.js 22.19+、npm、tar;运行时解析官方 GitHub 最新正式 Release,校验仓库、完整源提交、资产地址和 SHA-256,npm 单次安装使用官方 registry,不改变用户 registry 设置。插件放入用户 OpenCode 配置目录(支持 `OPENCODE_CONFIG_DIR` 和 `XDG_CONFIG_HOME`),配置中添加 `file:` 插件入口;保留 JSONC 注释、其他插件和供应商配置,修改前备份,重复安装不重复添加。用户已有手动安装的同名插件时会停止并提示先处理重复来源。 + +安装后运行 `opencode auth login --provider lmm`,选择 **Sign in with LMM (OAuth)**,在浏览器授权,然后启动或重启 OpenCode。安装器不登录、不保存 API Key、不发起付费请求。服务端需要注册 `lmm-opencode` OAuth 客户端;本地安装与宿主加载验证不代表线上授权、余额和实际模型调用已经通过。 + ## 环境 Unix 入口需要 Bash、curl。Pi 主程序的依赖提示由官方处理;Pi LMM 插件和 DSH 需要 Node 22.19+(22.x)或 24+、npm;Codewhale LMM 适配器需要 Node 22+ 和 npm。不修改 npm registry。 @@ -54,10 +65,12 @@ Codex/Claude 使用**已有的 PRoot Linux 环境**,不是 Android 原生二 旧的 `--network`、`--root`、`--check`、`--update` 等自定义参数已移除;不要继续传入。Codex/Claude 只接受各自官方参数,DSH 可传 profile。旧版 `lmm-tools` 目录不会被删除;从 PATH 中移除旧的 `lmm-tools/bin`,避免旧启动器优先于新安装。配置和账号数据不迁移、不清空。 -所有安装代码平铺在根目录;两个 Unix 桌面入口共用 `desktop.sh`,Codewhale 两个平台入口共用 `codewhale.mjs`。本地运行用同目录文件,单独下载运行时用固定 Git 提交获取共用脚本;Codewhale 额外校验 SHA-256。菜单同样固定到完整的安装器提交。不维护生成器或独立哈希清单。 +所有安装代码平铺在根目录;两个 Unix 桌面入口共用 `desktop.sh`,Codewhale 与 OpenCode 两个平台入口分别共用 `codewhale.mjs` 和 `opencode.mjs`。本地运行用同目录文件,单独下载运行时用固定 Git 提交获取共用脚本;Codewhale 和 OpenCode 额外校验 SHA-256。菜单同样固定到完整的安装器提交。不维护生成器或独立哈希清单。 ## 依据与测试 2026-09-21 核查:[Pi 官网安装入口](https://pi.dev/)([Shell](https://pi.dev/install.sh) / [PowerShell](https://pi.dev/install.ps1))· [Codex](https://learn.chatgpt.com/docs/codex/cli) · [Claude Code](https://code.claude.com/docs/en/setup) · [Pi Termux](https://pi.dev/docs/latest/termux) · [DSH 当前 README](https://github.com/deepseek-ai/deepseek-harness/blob/master/README.md) · [DSH 插件](https://deepseek-harness.github.io/deepseek-harness/en/develop/basic/publish) · [CC Switch](https://github.com/farion1231/cc-switch#download--installation) · [Clash Verge Rev](https://www.clashverge.dev/install.html)。LMM 插件适配版本见 [Pi 插件](https://github.com/TokenNotIncluded/pi-lmm-provider) 和 [DSH 插件](https://github.com/TokenNotIncluded/dsh-lmm-provider)。Codewhale 依据和平台限制见 [专用说明](CODEWHALE.md)。 本地检查:`python3 test.py`、`pwsh -NoProfile -File test.ps1`、`shellcheck *.sh`。Codewhale 另有 `node --test test-codewhale.mjs`、`python3 test-codewhale-menu.py`、`pwsh -NoProfile -File test-codewhale.ps1`。CI 另做三平台 CLI 实装和 Debian/Alpine 实装;不把模拟测试当作桌面 GUI、Termux 真机、账号登录或代理功能实测。 + +OpenCode 检查:`node --test test-opencode.mjs test-opencode-entry.mjs`、`pwsh -NoProfile -File test-opencode.ps1`、`node test-opencode-install.mjs`。最后一项隔离安装两次并启动真实宿主确认 LMM OAuth 注册,不登录或调用模型。三平台 CI 分别验证官方最新宿主与最近验证的基线宿主,安装器始终安装宿主及插件的官方最新版。 diff --git a/menu.ps1 b/menu.ps1 index 7ad06af..d126734 100644 --- a/menu.ps1 +++ b/menu.ps1 @@ -1,6 +1,6 @@ $ErrorActionPreference = 'Stop' if ($args.Count) { throw 'Usage: .\menu.ps1' } -$tools = @('pi','dsh','lmm','codex','claude-code','cc-switch','clash-verge-rev','codewhale') +$tools = @('pi','dsh','lmm','codex','claude-code','cc-switch','clash-verge-rev','codewhale','opencode') $work = Join-Path ([IO.Path]::GetTempPath()) ("lmm-menu-" + [guid]::NewGuid()) try { New-Item -ItemType Directory $work | Out-Null @@ -19,7 +19,7 @@ try { if ($PSScriptRoot -and (Test-Path (Join-Path $PSScriptRoot $name))) { $path = Join-Path $PSScriptRoot $name } else { - Invoke-WebRequest -UseBasicParsing "https://raw.githubusercontent.com/TokenNotIncluded/lmm-scripts/bd0a81de99d28c0ed5701cf290dade1e124dcecd/$name" -OutFile $path + Invoke-WebRequest -UseBasicParsing "https://raw.githubusercontent.com/TokenNotIncluded/lmm-scripts/873bb8de2d9af00fb2a5fbb592b3da094e665271/$name" -OutFile $path } & (Get-Process -Id $PID).Path -NoProfile -ExecutionPolicy Bypass -File $path @installerArgs if ($LASTEXITCODE) { Write-Warning "Installer exited with $LASTEXITCODE" } diff --git a/menu.sh b/menu.sh index a0fcfde..a707c67 100755 --- a/menu.sh +++ b/menu.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash set -euo pipefail [[ $# = 0 ]] || { echo 'Usage: bash menu.sh' >&2; exit 2; } -tools=(pi dsh lmm codex claude-code cc-switch clash-verge-rev codewhale) +tools=(pi dsh lmm codex claude-code cc-switch clash-verge-rev codewhale opencode) if [[ -n ${TERMUX_VERSION:-} || ${PREFIX:-} == */com.termux/files/usr ]]; then tools=(pi dsh lmm codex claude-code codewhale); fi dir=$(dirname -- "${BASH_SOURCE[0]:-}") while true; do @@ -18,7 +18,7 @@ while true; do if [[ -n ${BASH_SOURCE[0]:-} && -f $dir/$tool.sh ]]; then bash "$dir/$tool.sh" "$@" &2 else - script=$(curl -fsSL "https://raw.githubusercontent.com/TokenNotIncluded/lmm-scripts/bd0a81de99d28c0ed5701cf290dade1e124dcecd/$tool.sh") || continue + script=$(curl -fsSL "https://raw.githubusercontent.com/TokenNotIncluded/lmm-scripts/873bb8de2d9af00fb2a5fbb592b3da094e665271/$tool.sh") || continue bash -c "$script" -- "$@" &2 fi done diff --git a/opencode.mjs b/opencode.mjs new file mode 100644 index 0000000..378a70d --- /dev/null +++ b/opencode.mjs @@ -0,0 +1,195 @@ +#!/usr/bin/env node +import fs from 'node:fs/promises'; +import {existsSync, realpathSync} from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import {createHash} from 'node:crypto'; +import {createRequire} from 'node:module'; +import {pathToFileURL} from 'node:url'; +import {spawnSync} from 'node:child_process'; + +export const LAST_TESTED_OPENCODE_VERSION = '1.18.34'; +const REPOSITORY = 'TokenNotIncluded/opencode-lmm-auth'; +export function resolveRelease(release, checksums) { + if (!release || release.draft !== false || release.prerelease !== false || !/^v[0-9]+\.[0-9]+\.[0-9]+(?:[-+][0-9A-Za-z.-]+)?$/.test(release.tag_name ?? '') || !/^[a-f0-9]{40}$/.test(release.target_commitish ?? '')) throw new Error('Official plugin release metadata is invalid.'); + const base = `https://github.com/${REPOSITORY}/releases`; + if (release.html_url !== `${base}/tag/${release.tag_name}` || !Array.isArray(release.assets)) throw new Error('Plugin release is outside the official repository.'); + const name = `opencode-lmm-auth-${release.target_commitish}.tgz`; + const asset = file => { + const entries = release.assets.filter(value => value.name === file); + const expected = `${base}/download/${release.tag_name}/${file}`; + if (entries.length !== 1 || entries[0].browser_download_url !== expected) throw new Error('Plugin release assets are missing or outside the official repository.'); + return expected; + }; + const url = asset(name); + const checksumUrl = asset('SHA256SUMS'); + if (checksums === undefined) return {commit:release.target_commitish, url, checksumUrl}; + const matches = checksums.split(/\r?\n/).filter(line => line.endsWith(' '+name)); + if (matches.length !== 1 || !/^[a-f0-9]{64} /.test(matches[0]) || matches[0].length !== 66 + name.length) throw new Error('Plugin release checksum manifest is invalid.'); + return {commit:release.target_commitish, url, checksumUrl, sha256:matches[0].slice(0,64)}; +} +export function resolveReleaseRedirect(location) { + let url; + try { url = new URL(location, `https://github.com/${REPOSITORY}/releases/latest/download/release.json`); } + catch { throw new Error('Official plugin release redirect is invalid.'); } + const prefix = `/${REPOSITORY}/releases/download/`; + if (url.origin !== 'https://github.com' || url.username || url.password || url.search || url.hash || !url.pathname.startsWith(prefix) || !url.pathname.endsWith('/release.json')) throw new Error('Plugin release redirect is outside the official repository.'); + const tag = decodeURIComponent(url.pathname.slice(prefix.length, -'/release.json'.length)); + if (!/^v[0-9]+\.[0-9]+\.[0-9]+(?:[-+][0-9A-Za-z.-]+)?$/.test(tag)) throw new Error('Official plugin release tag is invalid.'); + return {tag,url:url.href}; +} +export function releaseMetadata(document, tag) { + if (!document || document.schema_version !== 1 || document.repository !== REPOSITORY || document.tag !== tag || !/^[a-f0-9]{40}$/.test(document.source_sha ?? '') || !/^[a-f0-9]{64}$/.test(document.sha256 ?? '') || document.filename !== `opencode-lmm-auth-${document.source_sha}.tgz`) throw new Error('Official plugin release document is invalid.'); + const base = `https://github.com/${REPOSITORY}/releases`; + return {draft:false,prerelease:false,tag_name:tag,target_commitish:document.source_sha,html_url:`${base}/tag/${tag}`,assets:[document.filename,'SHA256SUMS'].map(name=>({name,browser_download_url:`${base}/download/${tag}/${name}`}))}; +} +async function latestRelease() { + // The public download redirect avoids GitHub's anonymous API rate limit. + const response = await fetch(`https://github.com/${REPOSITORY}/releases/latest/download/release.json`, {redirect:'manual',signal:AbortSignal.timeout(30000)}); + if (![301,302,303,307,308].includes(response.status)) throw new Error(`Official plugin release lookup failed: HTTP ${response.status}`); + const redirect = resolveReleaseRedirect(response.headers.get('location')); + const metadataResponse = await fetch(redirect.url,{signal:AbortSignal.timeout(30000)}); + if (!metadataResponse.ok) throw new Error(`Official plugin release document download failed: HTTP ${metadataResponse.status}`); + const text = await metadataResponse.text(); + if (text.length > 131072) throw new Error('Plugin release document is too large.'); + let document; + try { document = JSON.parse(text); } catch { throw new Error('Plugin release document is invalid JSON.'); } + const release = releaseMetadata(document,redirect.tag); + const source = resolveRelease(release); + const manifest = await fetch(source.checksumUrl,{signal:AbortSignal.timeout(30000)}); + if (!manifest.ok) throw new Error(`Official plugin checksum download failed: HTTP ${manifest.status}`); + const checksums = await manifest.text(); + if (checksums.length > 131072) throw new Error('Plugin checksum manifest is too large.'); + const verified = resolveRelease(release,checksums); + if (verified.sha256 !== document.sha256) throw new Error('Plugin release document and checksum manifest disagree.'); + return verified; +} + +function run(command, args) { + const result = spawnSync(command, args, {stdio: 'inherit', windowsHide: true}); + if (result.error) throw result.error; + if (result.status !== 0) throw new Error(`${path.basename(command)} exited with ${result.status}`); +} +export function npmCli() { + for (const cli of [path.resolve(path.dirname(process.execPath), "../lib/node_modules/npm/bin/npm-cli.js"), path.join(path.dirname(process.execPath), "node_modules/npm/bin/npm-cli.js")]) { + if (existsSync(cli)) return cli; + } + for (const dir of (process.env.PATH || '').split(path.delimiter)) { + const executable = path.join(dir, process.platform === 'win32' ? 'npm.cmd' : 'npm'); + if (!existsSync(executable)) continue; + const real = realpathSync(executable); + if (path.basename(real) === "npm-cli.js") return real; + const cli = path.join(path.dirname(real), process.platform === 'win32' ? 'node_modules/npm/bin/npm-cli.js' : '../lib/node_modules/npm/bin/npm-cli.js'); + if (existsSync(cli)) return cli; + } + throw new Error('npm is required. Install Node.js with npm first.'); +} + +export function mergePlugin(text, entry, jsonc) { + const errors = []; + const config = jsonc.parse(text, errors, {allowTrailingComma: true}); + if (errors.length || !config || typeof config !== 'object' || Array.isArray(config)) { + throw new Error('Existing OpenCode configuration is invalid; it was not changed.'); + } + if (config.plugin !== undefined && !Array.isArray(config.plugin)) throw new Error('OpenCode plugin must be an array; configuration was not changed.'); + const plugins = config.plugin || []; + if (plugins.some(value => { const source = Array.isArray(value) ? value[0] : value; return typeof source === "string" && source.includes("opencode-lmm-auth"); })) throw new Error("An unmanaged LMM plugin source already exists. Remove that duplicate entry manually before using this installer."); + // Replace only our previously managed entry. Other plugin entries remain intact. + const managed = value => typeof value === 'string' && value.startsWith('file:') && /\/lmm-auth\/[a-f0-9]{40}\/dist\/index\.js$/.test(value); + if (plugins.filter(value => managed(Array.isArray(value) ? value[0] : value)).length > 1) throw new Error('Multiple managed LMM plugin entries exist; remove duplicates before installing.'); + const matching = plugins.findIndex(value => managed(Array.isArray(value) ? value[0] : value)); + if (matching >= 0) { + const old = plugins[matching]; + const updated = Array.isArray(old) ? [entry, ...old.slice(1)] : entry; + return jsonc.applyEdits(text, jsonc.modify(text, ['plugin', matching], updated, {formattingOptions: {insertSpaces: true, tabSize: 2}})); + } + if (plugins.some(value => value === entry || (Array.isArray(value) && value[0] === entry))) return text; + const location = config.plugin === undefined ? ['plugin'] : ['plugin', -1]; + return jsonc.applyEdits(text, jsonc.modify(text, location, config.plugin === undefined ? [entry] : entry, {formattingOptions: {insertSpaces: true, tabSize: 2}})); +} + +export function selectConfig(json, jsoncText, jsonc) { + const sources = [json, jsoncText].map(text => { + if (text === undefined) return false; + const errors = []; + const config = jsonc.parse(text.replace(/^\uFEFF/, ''), errors, {allowTrailingComma:true}); + if (errors.length || !config || typeof config !== 'object' || Array.isArray(config)) throw new Error('Existing OpenCode configuration is invalid; it was not changed.'); + if (config.plugin !== undefined && !Array.isArray(config.plugin)) throw new Error('OpenCode plugin must be an array; configuration was not changed.'); + return (config.plugin ?? []).some(value => {const source = Array.isArray(value) ? value[0] : value; return typeof source === 'string' && (/\/lmm-auth\/[a-f0-9]{40}\/dist\/index\.js$/.test(source) || source.includes('opencode-lmm-auth'));}); + }); + if (sources[0] && sources[1]) throw new Error('LMM plugin entries exist in both OpenCode configs; remove the duplicate before installing.'); + return sources[0] ? 'json' : jsoncText === undefined ? 'json' : 'jsonc'; +} + +export function configDirectory(env = process.env, home = os.homedir()) { + return env.OPENCODE_CONFIG_DIR || path.join(env.XDG_CONFIG_HOME || path.join(home, '.config'), 'opencode'); +} + +async function main() { + if (process.argv.slice(2).join(' ') === '--help') { console.log('OpenCode + LMM: installs the latest official OpenCode and the latest official OAuth plugin. Usage: node opencode.mjs'); return; } + if (process.argv.length > 2) throw new Error('Usage: node opencode.mjs'); + const [major, minor] = process.versions.node.split('.').map(Number); + if (major < 22 || (major === 22 && minor < 19)) throw new Error('Node.js 22.19+ with npm is required.'); + const source = await latestRelease(); + const configDir = configDirectory(); + await fs.mkdir(configDir, {recursive: true}); + const jsonPath = path.join(configDir, 'opencode.json'); + const jsoncPath = path.join(configDir, 'opencode.jsonc'); + const work = await fs.mkdtemp(path.join(os.tmpdir(), 'lmm-opencode-')); + try { + run(process.execPath, [npmCli(), 'install', '--prefix', work, '--ignore-scripts', '--no-audit', '--no-fund', '--package-lock=false', '--registry=https://registry.npmjs.org', 'jsonc-parser@3.3.1']); + const require = createRequire(path.join(work, 'package.json')); + const jsonc = require('jsonc-parser'); + const selected = selectConfig(existsSync(jsonPath) ? await fs.readFile(jsonPath, 'utf8') : undefined, existsSync(jsoncPath) ? await fs.readFile(jsoncPath, 'utf8') : undefined, jsonc); + let configPath = selected === 'json' ? jsonPath : jsoncPath; + const hadConfig = existsSync(configPath); + if (hadConfig) configPath = await fs.realpath(configPath); + const original = hadConfig ? await fs.readFile(configPath, 'utf8') : '{}\n'; + const installDir = path.join(configDir, 'lmm-auth', source.commit); + const entry = pathToFileURL(path.join(installDir, 'dist/index.js')).href; + // Validate the original before installing or writing anything to the config. + const changed = mergePlugin(original.replace(/^\uFEFF/, ''), entry, jsonc); + run(process.execPath, [npmCli(), 'install', '--global', '--no-audit', '--no-fund', '--registry=https://registry.npmjs.org', 'opencode-ai@latest']); + const npmRoot = spawnSync(process.execPath, [npmCli(), 'root', '--global'], {encoding:'utf8', windowsHide:true}); + if (npmRoot.status !== 0) throw new Error('Cannot locate the installed OpenCode package.'); + const packageDir = path.join(npmRoot.stdout.trim(), 'opencode-ai'); + const metadata = JSON.parse(await fs.readFile(path.join(packageDir, 'package.json'), 'utf8')); + const bin = typeof metadata.bin === 'string' ? metadata.bin : metadata.bin?.opencode; + if (typeof bin !== 'string') throw new Error('Official OpenCode package has no opencode executable.'); + const installedBinary = path.resolve(packageDir, bin); + const installedVersion = spawnSync(installedBinary, ['--version'], {encoding:'utf8', timeout:15000, windowsHide:true}); + if (installedVersion.status !== 0 || !/^[0-9]+\.[0-9]+\.[0-9]+(?:[-+][0-9A-Za-z.-]+)?$/.test(installedVersion.stdout.trim())) throw new Error('Installed OpenCode failed version verification; configuration was not changed.'); + if (!existsSync(path.join(installDir, 'dist/index.js'))) { + const response = await fetch(source.url, {signal: AbortSignal.timeout(120000)}); + if (!response.ok) throw new Error(`Plugin download failed: HTTP ${response.status}`); + const archive = Buffer.from(await response.arrayBuffer()); + if (createHash('sha256').update(archive).digest('hex') !== source.sha256) throw new Error('Plugin source checksum mismatch; plugin configuration was not changed.'); + const archivePath = path.join(work, 'plugin.tar.gz'); + const extracted = path.join(work, 'plugin'); + await fs.writeFile(archivePath, archive); + await fs.mkdir(extracted); + run('tar', ['-xzf', archivePath, '--strip-components=1', '-C', extracted]); + if (!existsSync(path.join(extracted, 'dist/index.js'))) throw new Error('Plugin archive does not contain dist/index.js.'); + await fs.mkdir(path.dirname(installDir), {recursive: true}); + // Work can be on another drive, so copy into a same-directory staging path. + const stage = await fs.mkdtemp(path.join(path.dirname(installDir), '.install-')); + try { await fs.cp(extracted, stage, {recursive: true}); await fs.rename(stage, installDir); } + finally { await fs.rm(stage, {recursive: true, force: true}); } + } + if (changed !== original) { + if (existsSync(configPath) !== hadConfig) throw new Error("OpenCode configuration changed during installation; rerun the installer."); + if (hadConfig) { + if (await fs.readFile(configPath, 'utf8') !== original) throw new Error('OpenCode configuration changed during installation; rerun the installer.'); + await fs.copyFile(configPath, `${configPath}.lmm-backup-${Date.now()}`, 1); + } + const staged = path.join(path.dirname(configPath), `.lmm-config-${process.pid}.tmp`); + try { await fs.writeFile(staged, changed, {mode: 0o600, flag: 'wx'}); await fs.rename(staged, configPath); } + finally { await fs.rm(staged, {force: true}); } + } + console.log('LMM OpenCode plugin installed. Run opencode auth login --provider lmm, complete Sign in with LMM (OAuth), then restart OpenCode.'); + console.log('OpenCode version: ' + installedVersion.stdout.trim() + '. Login is explicit; no API keys or OAuth tokens were written by this installer.'); + } finally { await fs.rm(work, {recursive: true, force: true}); } +} +if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) { + main().catch(error => { console.error(error.message); process.exitCode = 1; }); +} diff --git a/opencode.ps1 b/opencode.ps1 new file mode 100644 index 0000000..054ce97 --- /dev/null +++ b/opencode.ps1 @@ -0,0 +1,37 @@ +$ErrorActionPreference = 'Stop' +$node = (Get-Command node -CommandType Application -ErrorAction Stop | Select-Object -First 1).Source +$nodeVersion = & $node -p 'process.versions.node' +if ($LASTEXITCODE) { exit $LASTEXITCODE } +if ([int]($nodeVersion.Split('.')[0]) -lt 22) { throw 'Node.js 22+ with npm is required.' } +# Windows PowerShell 5.1 drops embedded quotes in native @args calls. +# Build an argv-equivalent command line, without cmd.exe or Invoke-Expression. +function ConvertTo-NativeArgument([string]$Value) { + $escaped = [regex]::Replace($Value, '(\\*)"', '$1$1\"') + $escaped = [regex]::Replace($escaped, '(\\+)$', '$1$1') + return '"' + $escaped + '"' +} +$work = $null +try { + $helper = $null + if ($PSScriptRoot -and (Test-Path -LiteralPath (Join-Path $PSScriptRoot 'opencode.mjs') -PathType Leaf)) { + $helper = Join-Path $PSScriptRoot 'opencode.mjs' + } else { + $work = Join-Path ([IO.Path]::GetTempPath()) ('lmm-opencode-' + [guid]::NewGuid()) + New-Item -ItemType Directory -Path $work | Out-Null + $helper = Join-Path $work 'opencode.mjs' + Invoke-WebRequest -UseBasicParsing 'https://raw.githubusercontent.com/TokenNotIncluded/lmm-scripts/911e25385acaf7016e808bb0d5587301125061f5/opencode.mjs' -OutFile $helper + if ((Get-FileHash -LiteralPath $helper -Algorithm SHA256).Hash -ne '94f8966afd70c0218fb86898040ea25f1dbbdbc42a69e388adbebee8399ae31a') { + throw 'OpenCode setup script checksum mismatch; nothing was executed.' + } + } + $start = New-Object System.Diagnostics.ProcessStartInfo + $start.FileName = $node + $start.UseShellExecute = $false + $start.WorkingDirectory = $PWD.Path + $start.Arguments = ((@($helper) + @($args) | ForEach-Object { ConvertTo-NativeArgument $_ }) -join ' ') + $child = [Diagnostics.Process]::Start($start) + try { $child.WaitForExit(); $code = $child.ExitCode } finally { $child.Dispose() } + exit $code +} finally { + if ($work) { Remove-Item -LiteralPath $work -Recurse -Force -ErrorAction SilentlyContinue } +} diff --git a/opencode.sh b/opencode.sh new file mode 100755 index 0000000..81b4abb --- /dev/null +++ b/opencode.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +set -euo pipefail +command -v node >/dev/null 2>&1 || { echo 'Install Node.js 22+ with npm first. Termux: pkg install nodejs npm' >&2; exit 1; } +node -e 'if(Number(process.versions.node.split(".")[0])<22){console.error("Node.js 22+ is required.");process.exit(1)}' +dir=$(dirname -- "${BASH_SOURCE[0]:-}") +if [[ -n ${BASH_SOURCE[0]:-} && -f $dir/opencode.mjs ]]; then + exec node "$dir/opencode.mjs" "$@" +fi +temp_root=${TMPDIR:-/tmp} +if [[ -n ${TERMUX_VERSION:-} || ${PREFIX:-} == */com.termux/files/usr ]]; then temp_root=${TMPDIR:-${PREFIX:-/data/data/com.termux/files/usr}/tmp}; fi +work=$(mktemp -d "$temp_root/lmm-opencode.XXXXXX") +trap 'rm -rf -- "$work"' EXIT +curl --proto '=https' --proto-redir '=https' -fsSL 'https://raw.githubusercontent.com/TokenNotIncluded/lmm-scripts/911e25385acaf7016e808bb0d5587301125061f5/opencode.mjs' -o "$work/opencode.mjs" +node -e 'const fs=require("node:fs"),crypto=require("node:crypto");if(crypto.createHash("sha256").update(fs.readFileSync(process.argv[1])).digest("hex")!==process.argv[2]){console.error("OpenCode setup script checksum mismatch; nothing was executed.");process.exit(1)}' "$work/opencode.mjs" '94f8966afd70c0218fb86898040ea25f1dbbdbc42a69e388adbebee8399ae31a' +node "$work/opencode.mjs" "$@" diff --git a/test-codewhale-menu.py b/test-codewhale-menu.py index ab540f1..b2698f6 100644 --- a/test-codewhale-menu.py +++ b/test-codewhale-menu.py @@ -82,6 +82,13 @@ def test_new_eighth_option_routes_to_codewhale_submenu(self): self.assertIn('8 codewhale', output) self.assertIn('SELECTED:codewhale menu', output) + def test_ninth_option_routes_to_opencode_installer_without_arguments(self): + self.stub('opencode') + output = self.run_menu(['9', '0']) + self.assertIn('9 opencode', output) + self.assertIn('SELECTED:opencode ', output) + self.assertNotIn('SELECTED:opencode menu', output) + def test_old_numbering_and_argument_contract_are_preserved(self): self.stub('pi') output = self.run_menu(['1', '0']) @@ -89,7 +96,7 @@ def test_old_numbering_and_argument_contract_are_preserved(self): self.assertNotIn('SELECTED:pi menu', output) def test_out_of_range_and_overflow_input_return_to_menu(self): - output = self.run_menu(['9', '999999999999999999999999999', '-1', '0']) + output = self.run_menu(['10', '999999999999999999999999999', '-1', '0']) self.assertNotIn('SELECTED:', output) def test_termux_hides_desktop_apps_and_routes_sixth_option(self): diff --git a/test-opencode-entry.mjs b/test-opencode-entry.mjs new file mode 100644 index 0000000..4f8ed8f --- /dev/null +++ b/test-opencode-entry.mjs @@ -0,0 +1,32 @@ +import {test} from 'node:test'; +import assert from 'node:assert/strict'; +import {mkdtemp, copyFile, writeFile, readFile, rm, chmod} from 'node:fs/promises'; +import {join, resolve} from 'node:path'; +import {tmpdir} from 'node:os'; +import {existsSync} from 'node:fs'; +import {spawnSync} from 'node:child_process'; +test('Unix wrapper preserves literal arguments and propagates child failure', {skip:process.platform==='win32'}, async()=>{ + const work=await mkdtemp(join(tmpdir(),'lmm-wrapper-')); + try{ + await copyFile(resolve('opencode.sh'),join(work,'opencode.sh')); + const receipt=join(work,'receipt.json'); + await writeFile(join(work,'opencode.mjs'),'import fs from "node:fs";fs.writeFileSync(process.env.RECEIPT,JSON.stringify(process.argv.slice(2)));process.exit(17);'); + const args=['literal $(no-command); & |', '', 'quoted "data"']; + const result=spawnSync('bash',[join(work,'opencode.sh'),...args],{env:{...process.env,RECEIPT:receipt}}); + assert.equal(result.status,17); + assert.deepEqual(JSON.parse(await readFile(receipt,'utf8')),args); + }finally{await rm(work,{recursive:true,force:true});} +}); +test('Unix detached wrapper never executes a mismatched helper download', {skip:process.platform==='win32'},async()=>{ + const work=await mkdtemp(join(tmpdir(),'lmm-checksum-')); + try{ + await copyFile(resolve('opencode.sh'),join(work,'opencode.sh')); + const marker=join(work,'executed'); + await writeFile(join(work,'curl'),'#!/usr/bin/env node\nconst fs=require("node:fs");fs.writeFileSync(process.argv[process.argv.indexOf("-o")+1],`import fs from "node:fs";fs.writeFileSync(process.env.MARKER,"BAD");`);'); + await chmod(join(work,'curl'),0o755); + const result=spawnSync('bash',[join(work,'opencode.sh')],{env:{...process.env,PATH:work+':'+process.env.PATH,MARKER:marker},encoding:'utf8'}); + assert.notEqual(result.status,0); + assert.match(result.stderr,/checksum mismatch/); + assert.equal(existsSync(marker),false); + }finally{await rm(work,{recursive:true,force:true});} +}); diff --git a/test-opencode-install.mjs b/test-opencode-install.mjs new file mode 100644 index 0000000..98be137 --- /dev/null +++ b/test-opencode-install.mjs @@ -0,0 +1,62 @@ +// Real isolated installation and host load. Never logs in or sends paid requests. +import {mkdtemp, mkdir, writeFile, readFile, rm} from 'node:fs/promises'; +import {tmpdir} from 'node:os'; +import {join, resolve} from 'node:path'; +import {spawn, spawnSync} from 'node:child_process'; +import {createServer} from 'node:net'; +import assert from 'node:assert/strict'; +import {npmCli, LAST_TESTED_OPENCODE_VERSION} from './opencode.mjs'; +const root = await mkdtemp(join(tmpdir(), 'lmm-opencode-install-')); +const env = {...process.env, OPENCODE_CONFIG_DIR:join(root,'config','opencode'), npm_config_prefix: join(root,'npm'), XDG_CONFIG_HOME:join(root,'config'), XDG_DATA_HOME:join(root,'data'), XDG_STATE_HOME:join(root,'state'), XDG_CACHE_HOME:join(root,'cache'), OPENCODE_DISABLE_DEFAULT_PLUGINS:'true'}; +delete env.OPENCODE_CONFIG; +delete env.OPENCODE_CONFIG_CONTENT; +let host; +let logs = ''; +try { + const configPath = join(env.XDG_CONFIG_HOME,'opencode','opencode.jsonc'); + await mkdir(join(env.XDG_CONFIG_HOME,'opencode'), {recursive:true}); + await writeFile(configPath, '{\n// keep user comment\n"provider":{"fixture":{"npm":"@ai-sdk/openai-compatible","models":{}}}\n}\n'); + for (let attempt=0; attempt<2; attempt++) { + const result = spawnSync(process.execPath, [resolve('opencode.mjs')], {env,stdio:'inherit',timeout:240000}); + assert.equal(result.status,0, `installation ${attempt+1} failed: ${result.error ?? ''}`); + } + const config = await readFile(configPath,'utf8'); + assert.ok(config.includes('// keep user comment')); + assert.equal((config.match(/dist\/index\.js/g)||[]).length,1,'rerun duplicated plugin'); + assert.ok(config.includes('fixture'),'existing provider lost'); + if (process.env.TEST_OPENCODE_BASELINE === 'true') { + const install = spawnSync(process.execPath,[npmCli(),'install','--global','--no-audit','--no-fund','--registry=https://registry.npmjs.org',`opencode-ai@${LAST_TESTED_OPENCODE_VERSION}`],{env,stdio:'inherit',timeout:240000}); + assert.equal(install.status,0,'baseline host installation failed'); + } + const packageDir = process.platform === 'win32' ? join(root,'npm','node_modules','opencode-ai') : join(root,'npm','lib','node_modules','opencode-ai'); + const metadata = JSON.parse(await readFile(join(packageDir,'package.json'),'utf8')); + const executable = resolve(packageDir, typeof metadata.bin === 'string' ? metadata.bin : metadata.bin.opencode); + const argsPrefix = []; + const version = spawnSync(executable,['--version'],{env,encoding:'utf8',timeout:15000}); + assert.equal(version.status,0); + assert.match(version.stdout.trim(), /^[0-9]+\.[0-9]+\.[0-9]+(?:[-+][0-9A-Za-z.-]+)?$/); + if (process.env.TEST_OPENCODE_BASELINE === 'true') assert.equal(version.stdout.trim(),LAST_TESTED_OPENCODE_VERSION); + const socket = createServer(); + await new Promise(resolve=>socket.listen(0,'127.0.0.1',resolve)); + const port = socket.address().port; + await new Promise(resolve=>socket.close(resolve)); + host=spawn(executable,[...argsPrefix,'serve','--hostname','127.0.0.1','--port',String(port)],{env,cwd:root}); + host.stdout.on('data',chunk=>{logs+=chunk});host.stderr.on('data',chunk=>{logs+=chunk}); + const endpoint=`http://127.0.0.1:${port}`; + const start=Date.now(); + while(true) { + if(host.exitCode!==null)throw new Error(`host exited: ${logs.slice(-3000)}`); + try{const response=await fetch(endpoint+'/global/health',{signal:AbortSignal.timeout(1500)});if(response.ok)break;}catch{} + if(Date.now()-start>90000)throw new Error(`host startup timed out: ${logs.slice(-3000)}`); + await new Promise(resolve=>setTimeout(resolve,200)); + } + const methods=await fetch(endpoint+'/provider/auth',{signal:AbortSignal.timeout(90000)}).then(response=>response.json()); + assert.deepEqual(methods.lmm,[{type:'oauth',label:'Sign in with LMM (OAuth)'}]); + const loaded=await fetch(endpoint+'/config',{signal:AbortSignal.timeout(90000)}).then(response=>response.json()); + assert.equal(loaded.provider.lmm.options.baseURL,'https://api.lmm.best/v1'); + assert.ok(loaded.provider.fixture); + console.log('Real installation, repeat installation, config preservation and native OpenCode OAuth registration passed. No login or inference performed.'); +} finally { + if(host&&host.exitCode===null){if(process.platform==='win32')spawnSync('taskkill',['/pid',String(host.pid),'/t','/f'],{stdio:'ignore'});else host.kill('SIGTERM');await Promise.race([new Promise(resolve=>host.once('exit',resolve)),new Promise(resolve=>setTimeout(resolve,3000))]);if(host.exitCode===null)host.kill('SIGKILL');} + await rm(root,{recursive:true,force:true}); +} diff --git a/test-opencode.mjs b/test-opencode.mjs new file mode 100644 index 0000000..fcc92b7 --- /dev/null +++ b/test-opencode.mjs @@ -0,0 +1,80 @@ +import {test, after} from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import {spawnSync} from 'node:child_process'; +import {createRequire} from 'node:module'; +import {mergePlugin, selectConfig, configDirectory, resolveRelease, resolveReleaseRedirect, releaseMetadata, npmCli} from './opencode.mjs'; +const work = await fs.mkdtemp(path.join(os.tmpdir(), 'lmm-jsonc-test-')); +// Use the npm CLI through Node, including Windows paths with spaces. +const executable = npmCli(); +const install = spawnSync(process.execPath, [executable, 'install', '--prefix', work, '--ignore-scripts', '--no-audit', '--no-fund', '--package-lock=false', 'jsonc-parser@3.3.1'], {stdio: 'inherit'}); +assert.equal(install.status, 0); +const jsonc = createRequire(path.join(work, 'package.json'))('jsonc-parser'); +after(() => fs.rm(work, {recursive: true, force: true})); +const entry = 'file:///home/user/.config/opencode/lmm-auth/' + 'a'.repeat(40) + '/dist/index.js'; +test('preserves comments, existing providers and unrelated plugins', () => { + const original = '{\n// user comment\n"plugin": ["other",],\n"provider": {"custom": {"options":{"apiKey":"fixture"}}},\n}\n'; + const changed = mergePlugin(original, entry, jsonc); + assert.ok(changed.includes('// user comment')); + const config = jsonc.parse(changed); + assert.deepEqual(config.plugin, ['other', entry]); + assert.deepEqual(config.provider, jsonc.parse(original).provider); + assert.equal(mergePlugin(changed, entry, jsonc), changed); +}); +test('replaces only managed plugin source while preserving tuple options', () => { + const old = entry.replace('a'.repeat(40), 'b'.repeat(40)); + const original = JSON.stringify({plugin: ['other', [old, {issuer: 'https://custom.example'}]], provider:{lmm:{models:{configured:{}}}}}); + const changed = jsonc.parse(mergePlugin(original, entry, jsonc)); + assert.deepEqual(changed.plugin, ['other', [entry, {issuer:'https://custom.example'}]]); + assert.deepEqual(changed.provider, jsonc.parse(original).provider); +}); +test('refuses duplicate manually installed LMM plugin sources', () => { + for (const source of ['@tokennotincluded/opencode-lmm-auth', 'file:///custom/opencode-lmm-auth/dist/index.js']) { + const original = JSON.stringify({plugin: [source, 'file:///custom/index.js']}); + assert.throws(() => mergePlugin(original, entry, jsonc), /unmanaged LMM plugin/); + } + assert.throws(() => mergePlugin(JSON.stringify({plugin:[entry, entry.replace('a'.repeat(40), 'b'.repeat(40))]}), entry, jsonc), /Multiple managed/); +}); +test('rejects malformed config and invalid plugin list', () => { + for (const text of ['{oops}', '[]', '{"plugin": "other"}', 'null']) assert.throws(() => mergePlugin(text, entry, jsonc)); +}); +test('adds plugin array to fresh and commented empty configurations', () => { + for (const text of ['{}\n', '{\n// retain me\n}\n']) assert.deepEqual(jsonc.parse(mergePlugin(text, entry, jsonc)).plugin, [entry]); +}); + +test('updates the config owning the previous LMM plugin without duplicating across json/jsonc', () => { + const managed = JSON.stringify({plugin:[entry]}); + assert.equal(selectConfig(managed, '{// keep comment\n"provider":{}}', jsonc), 'json'); + assert.equal(selectConfig('{}', managed, jsonc), 'jsonc'); + assert.throws(() => selectConfig(managed, managed, jsonc), /both OpenCode configs/); + assert.throws(() => selectConfig('{bad}', '{}', jsonc), /invalid/); +}); + +test('uses the same config directory override as the OpenCode host', () => { + assert.equal(configDirectory({OPENCODE_CONFIG_DIR:'/custom/opencode', XDG_CONFIG_HOME:'/other'}, '/home/user'), '/custom/opencode'); + assert.equal(configDirectory({OPENCODE_CONFIG_DIR:'', XDG_CONFIG_HOME:'/custom'}, '/home/user'), path.join('/custom','opencode')); + assert.equal(configDirectory({XDG_CONFIG_HOME:''}, '/home/user'), path.join('/home/user','.config','opencode')); +}); + +test('official latest release validates repository, full commit, assets and checksum', () => { + const base = 'https://github.com/TokenNotIncluded/opencode-lmm-auth/releases'; + const name = 'opencode-lmm-auth-'+'a'.repeat(40)+'.tgz'; + const release = {draft:false,prerelease:false,tag_name:'v3.2.1',target_commitish:'a'.repeat(40),html_url:base+'/tag/v3.2.1',assets:[name,'SHA256SUMS'].map(name=>({name,browser_download_url:base+'/download/v3.2.1/'+name}))}; + const manifest = 'b'.repeat(64)+' '+name+'\n'; + assert.equal(resolveRelease(release,manifest).commit, 'a'.repeat(40)); + assert.equal(resolveRelease(release,manifest).sha256, 'b'.repeat(64)); + for(const invalid of [{...release,target_commitish:'main'}, {...release,prerelease:true}, {...release,html_url:'https://evil.test/tag/v3.2.1'}, {...release,assets:[...release.assets,{...release.assets[0]}]}, {...release,assets:[{...release.assets[0],browser_download_url:'https://evil.test/plugin.tar.gz'},release.assets[1]]}]) assert.throws(()=>resolveRelease(invalid,manifest)); + assert.throws(()=>resolveRelease(release,manifest+manifest)); + assert.throws(()=>resolveRelease(release,'bad '+name)); +}); + +test('latest public asset redirect requires official repository and tagged manifest', () => { + const base='https://github.com/TokenNotIncluded/opencode-lmm-auth/releases/download/'; + assert.equal(resolveReleaseRedirect(base+'v2.5.0/release.json').tag,'v2.5.0'); + for(const url of ['https://evil.test/releases/download/v2.5.0/release.json',base+'v2.5.0/release.json?override=1',base+'../other/release.json',base+'v2.5.0/evil.json','https://user@github.com/TokenNotIncluded/opencode-lmm-auth/releases/download/v2.5.0/release.json',base+'v2.5.0/release.json#wrong']) assert.throws(()=>resolveReleaseRedirect(url)); + const doc={schema_version:1,repository:'TokenNotIncluded/opencode-lmm-auth',tag:'v2.5.0',source_sha:'a'.repeat(40),filename:'opencode-lmm-auth-'+'a'.repeat(40)+'.tgz',sha256:'b'.repeat(64)}; + assert.equal(releaseMetadata(doc,'v2.5.0').target_commitish,doc.source_sha); + for(const invalid of [{...doc,repository:'Other/repo'},{...doc,tag:'v9.0.0'},{...doc,filename:'../evil.tgz'},{...doc,source_sha:'main'},{...doc,sha256:'bad'}]) assert.throws(()=>releaseMetadata(invalid,'v2.5.0')); +}); diff --git a/test-opencode.ps1 b/test-opencode.ps1 new file mode 100644 index 0000000..36d0f72 --- /dev/null +++ b/test-opencode.ps1 @@ -0,0 +1,63 @@ +$ErrorActionPreference = 'Stop' +$engine = (Get-Process -Id $PID).Path +$work = Join-Path ([IO.Path]::GetTempPath()) ('lmm-powershell-' + [guid]::NewGuid()) +function Assert-True($Value, $Message) { if (-not $Value) { throw $Message } } +function Literal([string]$Value) { return "'" + $Value.Replace("'", "''") + "'" } +try { + New-Item -ItemType Directory $work | Out-Null + foreach ($name in @('opencode.ps1', 'menu.ps1')) { + $tokens = $null; $parseErrors = $null + [void][System.Management.Automation.Language.Parser]::ParseFile((Join-Path $PSScriptRoot $name), [ref]$tokens, [ref]$parseErrors) + Assert-True ($parseErrors.Count -eq 0) "$name parse failed" + } + $output = & $engine -NoProfile -ExecutionPolicy Bypass -File (Join-Path $PSScriptRoot 'opencode.ps1') --help + Assert-True ($LASTEXITCODE -eq 0) 'Local help failed' + Assert-True (($output -join "`n") -match 'OpenCode \+ LMM') 'Help was not printed' + + $wrapper = Join-Path $work 'opencode.ps1' + $helper = Join-Path $work 'opencode.mjs' + $receipt = Join-Path $work 'args.json' + Copy-Item (Join-Path $PSScriptRoot 'opencode.ps1') $wrapper + $env:LMM_SCRIPT_TEST_RECEIPT = $receipt + [IO.File]::WriteAllText($helper, 'import fs from "node:fs"; fs.writeFileSync(process.env.LMM_SCRIPT_TEST_RECEIPT, JSON.stringify(process.argv.slice(2))); process.exit(17);') + $task = 'literal "quoted" $(not-a-command); & | C:\path with spaces\' + # Empty arguments and backslashes next to quotes must survive the native boundary too. + $expected = @('run', '--model', 'lmm:ZGVmYXVsdA:bW9kZWw', '--', 'exec', $task, '', 'a\"b', 'C:\trailing\\') + $driver = Join-Path $work 'driver.ps1' + $arguments = ($expected | ForEach-Object { Literal $_ }) -join ',' + # The extra driver must splat the argument array and propagate the nested script's exit. + [IO.File]::WriteAllText($driver, ('$forwarded = @(' + $arguments + '); & ' + (Literal $wrapper) + ' @forwarded; exit $LASTEXITCODE')) + & $engine -NoProfile -ExecutionPolicy Bypass -File $driver + Assert-True ($LASTEXITCODE -eq 17) 'Child exit status was lost' + # Do not nest the JSON array with @(): PS 5.1 emits it as one pipeline object. + $received = Get-Content -LiteralPath $receipt -Raw | ConvertFrom-Json + Assert-True ($received.Count -eq $expected.Count) "Argument count changed: expected $($expected.Count), received $($received.Count)" + for ($i=0; $i -lt $expected.Count; $i++) { Assert-True ($received[$i] -ceq $expected[$i]) "Argument $i changed" } + + # A detached download must be rejected before its contents execute. + Remove-Item -LiteralPath $helper + $marker = Join-Path $work 'executed' + $env:LMM_SCRIPT_TEST_MARKER = $marker + $driverCode = @' +function Invoke-WebRequest { + param([switch]$UseBasicParsing, [Parameter(Position=0)][string]$Uri, [string]$OutFile) + [IO.File]::WriteAllText($OutFile, 'import fs from "node:fs";fs.writeFileSync(process.env.LMM_SCRIPT_TEST_MARKER,"BAD");') +} +'@ + [IO.File]::WriteAllText($driver, $driverCode + "`n& " + (Literal $wrapper) + " --help") + $preference = $ErrorActionPreference + try { + $ErrorActionPreference = 'Continue' + & $engine -NoProfile -ExecutionPolicy Bypass -File $driver 2>$null + $failureCode = $LASTEXITCODE + } finally { $ErrorActionPreference = $preference } + Assert-True ($failureCode -ne 0) 'Mismatched helper was accepted' + Assert-True (-not (Test-Path -LiteralPath $marker)) 'Unverified downloaded code executed' + Write-Host 'PowerShell checks passed: parser, help, literal argv/exit status, checksum refusal.' +} finally { + Remove-Item Env:LMM_SCRIPT_TEST_RECEIPT -ErrorAction SilentlyContinue + Remove-Item Env:LMM_SCRIPT_TEST_MARKER -ErrorAction SilentlyContinue + Remove-Item -LiteralPath $work -Recurse -Force -ErrorAction SilentlyContinue +} +# The nonzero child status above is expected and asserted, not a suite failure. +exit 0 diff --git a/test.py b/test.py index a4a3df4..c57c420 100644 --- a/test.py +++ b/test.py @@ -253,8 +253,8 @@ def test_flat_layout_and_size_budget(self): for path in ('templates','tools','docs','versions.json','generate.py'): self.assertFalse((ROOT/path).exists()) scripts=[f for pattern in ('*.sh','*.ps1','*.mjs') for f in ROOT.glob(pattern) if not f.name.startswith('test')] - # Include the shared Codewhale implementation in the explicit size budget. - self.assertLess(sum(len(f.read_bytes()) for f in scripts),32000) + # Include both shared Codewhale and OpenCode implementations in the size budget. + self.assertLess(sum(len(f.read_bytes()) for f in scripts),48000) for file in scripts: text=file.read_text() self.assertNotRegex(text,r'npmmirror|rank_urls|LMM_NETWORK|LMM_RETRIES|LMM_COMMAND_TIMEOUT|configure_npm')