From 25cea75eb59138b0615e26032e1438ac681dfe5b Mon Sep 17 00:00:00 2001 From: Agent Date: Mon, 12 Oct 2026 15:45:24 +0000 Subject: [PATCH] 1.0.1: check both tea-cli and tea for every tea command, with a test for each case --- README.md | 3 +- auth.mjs | 38 ++++++++++++++++--------- client.js | 6 ++-- forge.mjs | 2 +- package.json | 3 +- test/tea-binary.test.mjs | 60 ++++++++++++++++++++++++++++++++++++++++ 6 files changed, 93 insertions(+), 19 deletions(-) create mode 100644 test/tea-binary.test.mjs diff --git a/README.md b/README.md index 26d07ba..dfc234e 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,8 @@ Clone and push use a bundled git **credential helper** (`credential-helper.mjs`) ## Requirements - [`gh`](https://cli.github.com/) for GitHub accounts (optional). -- [`tea`](https://gitea.com/gitea/tea) (binary named `tea-cli` or `tea`) for Gitea/Forgejo accounts (optional). +- [`tea`](https://gitea.com/gitea/tea) for Gitea/Forgejo accounts (optional). Both binary names are checked, `tea-cli` + first, then `tea`; whichever runs is used for logins, logout and the status line in settings. - `git` and Node.js (already required by DSH). At least one of `gh` / `tea` must be installed and logged in for the account features to do anything. diff --git a/auth.mjs b/auth.mjs index 6e254a7..9b40efc 100644 --- a/auth.mjs +++ b/auth.mjs @@ -5,17 +5,35 @@ import { randomUUID } from 'node:crypto' import { ForgeError, listAccounts, publicAccount, resetAccountCache, run } from './forge.mjs' const TEA_BINARIES = ['tea-cli', 'tea'] -let teaBinary +const TEA_MISSING = 'neither tea-cli nor tea is installed on the host' +let teaBinary // the binary that worked last (tried first). A miss is never cached, so installing one later just works. async function which(cmd) { const r = await run(cmd, ['--version'], { timeoutMs: 10000 }) return r.code === 0 ? (r.stdout || r.stderr).replace(/\x1b\[[0-9;]*m/g, "").trim().split("\n")[0] : null } +/** Candidate order for tea commands: the last working binary first, then the rest of tea-cli / tea. */ +const teaOrder = () => (teaBinary ? [teaBinary, ...TEA_BINARIES.filter((b) => b !== teaBinary)] : TEA_BINARIES) + +/** First of tea-cli / tea that runs on this host, or null. */ async function teaBin() { - if (teaBinary !== undefined) return teaBinary - for (const b of TEA_BINARIES) if (await which(b)) return (teaBinary = b) - return (teaBinary = null) + for (const b of teaOrder()) if (await which(b)) return (teaBinary = b) + return null +} + +/** + * Run a tea command with whichever of tea-cli / tea exists: both names are tried, and a binary that is not + * there (ENOENT) is skipped. Resolves like `run`, plus the `bin` that ran it. + */ +async function runTea(args, opts) { + for (const bin of teaOrder()) { + const r = await run(bin, args, opts) + if (r.error?.code === 'ENOENT') continue + teaBinary = bin + return { ...r, bin } + } + throw new ForgeError(TEA_MISSING, 400) } export async function cliStatus() { @@ -34,8 +52,6 @@ const cleanHost = (h) => String(h || 'github.com').trim().replace(/^https?:\/\// /** Add a Gitea/Forgejo login with a token (or user+password, from which tea creates a token). */ export async function giteaLogin({ name, url, token, user, password, insecure }) { - const bin = await teaBin() - if (!bin) throw new ForgeError('tea-cli is not installed on the host', 400) if (!url) throw new ForgeError('server URL is required', 400) if (!token && !(user && password)) throw new ForgeError('an access token (or user and password) is required', 400) let u @@ -46,8 +62,8 @@ export async function giteaLogin({ name, url, token, user, password, insecure }) if (token) env.GITEA_SERVER_TOKEN = token else { env.GITEA_SERVER_USER = user; env.GITEA_SERVER_PASSWORD = password } if (insecure) args.push('--insecure') - const r = await run(bin, args, { env, timeoutMs: 60000 }) - if (r.code !== 0) throw fail(r, 'tea-cli logins add') + const r = await runTea(args, { env, timeoutMs: 60000 }) + if (r.code !== 0) throw fail(r, `${r.bin} logins add`) done() return { ok: true, message: (r.stdout || '').trim() } } @@ -132,11 +148,7 @@ export async function logout(accountId) { if (!a) throw new ForgeError(`unknown account ${accountId}`, 404) let r if (a.kind === 'github') r = await run('gh', ['auth', 'logout', '--hostname', a.host, '--user', a.user], { timeoutMs: 30000 }) - else { - const bin = await teaBin() - if (!bin) throw new ForgeError('tea-cli is not installed on the host', 400) - r = await run(bin, ['logins', 'delete', a.name], { timeoutMs: 30000 }) - } + else r = await runTea(['logins', 'delete', a.name], { timeoutMs: 30000 }) if (r.code !== 0) throw fail(r, 'logout') done() return { ok: true } diff --git a/client.js b/client.js index ef90013..16ee7dc 100644 --- a/client.js +++ b/client.js @@ -741,7 +741,7 @@ window.__ModuleLoader__.load({ setGt((g) => ({ ...g, token: '', password: '' })); setMsg({ text: t('settings.added') }); await refresh(true); }); const doLogout = (a) => act('logout:' + a.id, async () => { - const cli = a.kind === 'github' ? 'gh' : 'tea-cli'; + const cli = a.kind === 'github' ? 'gh' : ((status && status.cli && status.cli.tea.binary) || 'tea-cli'); if (!window.confirm(fill(t('settings.logoutConfirm'), { id: a.id, cli }))) return; await post('auth/logout', { account: a.id }); setMsg({ text: t('settings.removed') }); await refresh(true); })(); @@ -775,7 +775,7 @@ window.__ModuleLoader__.load({ h(P.Button, { variant: 'outline', size: 'sm', disabled: !!busy, onClick: () => doLogout(a) }, t('settings.logout'))))), cli ? h('div', { style: S.caption }, t('settings.cli') + ': gh ' + (cli.gh.available ? cli.gh.version.replace(/^gh version\s*/, '') : t('settings.missing')) + - ' · ' + (cli.tea.binary || 'tea-cli') + ' ' + (cli.tea.available ? cli.tea.version.replace(/^Version:\s*/, '') : t('settings.missing'))) : null), + ' · ' + (cli.tea.binary || 'tea-cli / tea') + ' ' + (cli.tea.available ? cli.tea.version.replace(/^Version:\s*/, '') : t('settings.missing'))) : null), msg ? h('div', { style: msg.bad ? S.errorBox : S.hint, role: msg.bad ? 'alert' : 'status' }, msg.text) : null, // github h('div', { style: S.section }, @@ -802,7 +802,7 @@ window.__ModuleLoader__.load({ // gitea h('div', { style: S.section }, h('span', { style: S.label }, t('settings.add') + ' — ' + t('settings.gitea')), - !teaOk ? h('div', { style: S.error }, 'tea-cli ' + t('settings.missing')) : h(React.Fragment, null, + !teaOk ? h('div', { style: S.error }, 'tea-cli / tea ' + t('settings.missing')) : h(React.Fragment, null, h('div', { style: S.split }, h(Field, { label: t('settings.url') }, h(P.Input, { value: gt.url, placeholder: 'https://gitea.example.com', spellCheck: false, onChange: setG('url') })), h(Field, { label: t('settings.name') }, h(P.Input, { value: gt.name, placeholder: 'optional', spellCheck: false, onChange: setG('name') }))), diff --git a/forge.mjs b/forge.mjs index 03711ea..349735f 100644 --- a/forge.mjs +++ b/forge.mjs @@ -397,7 +397,7 @@ export async function pushRepo({ cwd, remote = 'origin', branch, setUpstream = t /** Resolve "owner/name", a URL, or the cwd's remote into { account, owner, name }. */ export async function resolveRepo({ repo, account: accountId, cwd, remote = 'origin' }) { const accounts = await listAccounts() - if (!accounts.length) throw new ForgeError('no authorized accounts: run `gh auth login` or `tea-cli login add` on the host', 400) + if (!accounts.length) throw new ForgeError('no authorized accounts: run `gh auth login` or `tea-cli login add` (or `tea login add`) on the host', 400) let byId = null if (accountId) { byId = accounts.find((a) => a.id === accountId || a.name === accountId || `${a.user}@${a.host}` === accountId) diff --git a/package.json b/package.json index 927f0c9..e0684c4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@local/git-forge", - "version": "1.0.0", + "version": "1.0.1", "description": "Clone repositories from GitHub (gh) and Gitea/Forgejo (tea-cli) accounts into new workspaces, plus forge_* agent tools for repos, issues, pull requests and authenticated push.", "private": true, "type": "module", @@ -11,6 +11,7 @@ "./locale/*.json": "./locale/*.json" }, "files": ["index.js", "forge.mjs", "auth.mjs", "credential-helper.mjs", "client.js", "cordis.patch.yml", "locale/*.json"], + "scripts": { "test": "node --test test/*.test.mjs" }, "dsh": { "bundle": { "patch": "./cordis.patch.yml" }, "client": { diff --git a/test/tea-binary.test.mjs b/test/tea-binary.test.mjs new file mode 100644 index 0000000..56a09f6 --- /dev/null +++ b/test/tea-binary.test.mjs @@ -0,0 +1,60 @@ +// tea / tea-cli: the plugin accepts either binary name. Each case runs in a child node with a PATH that +// contains only the fake binaries below, so the result does not depend on what is installed on this machine. +import test from 'node:test' +import assert from 'node:assert/strict' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { execFileSync } from 'node:child_process' + +const authUrl = new URL('../auth.mjs', import.meta.url).href +// node's own directory (often /usr/bin) may hold a real tea-cli, so expose node alone through a private dir +const NODE_ONLY = fs.mkdtempSync(path.join(os.tmpdir(), 'tea-node-')) +fs.symlinkSync(process.execPath, path.join(NODE_ONLY, 'node')) +test.after(() => fs.rmSync(NODE_ONLY, { recursive: true, force: true })) + +function fakeBin(names) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'tea-test-')) + for (const n of names) { + // ` --version` prints a marker; any other call (logins add …) echoes which binary ran. + fs.writeFileSync(path.join(dir, n), `#!/bin/sh\nif [ "$1" = "--version" ]; then echo "Version: ${n}-fake"; else echo "ran:${n} $*"; fi\n`, { mode: 0o755 }) + } + return dir +} + +function probe(names, script) { + const dir = fakeBin(names) + try { + const out = execFileSync(process.execPath, ['--input-type=module', '-e', script], { + env: { PATH: `${dir}:${NODE_ONLY}`, HOME: dir, XDG_CONFIG_HOME: dir }, + encoding: 'utf8', + }) + return JSON.parse(out.trim().split('\n').pop()) + } finally { fs.rmSync(dir, { recursive: true, force: true }) } +} + +const status = `import { cliStatus } from ${JSON.stringify(authUrl)}; console.log(JSON.stringify((await cliStatus()).tea))` +const login = `import { giteaLogin } from ${JSON.stringify(authUrl)}; + try { console.log(JSON.stringify(await giteaLogin({ url: 'https://g.example', token: 't', name: 'n' }))) } + catch (e) { console.log(JSON.stringify({ error: e.message })) }` + +test('only `tea` installed: it is detected and used', () => { + const t = probe(['tea'], status) + assert.equal(t.available, true) + assert.equal(t.binary, 'tea') + assert.match(probe(['tea'], login).message, /^ran:tea logins add /) +}) + +test('only `tea-cli` installed: it is detected and used', () => { + assert.equal(probe(['tea-cli'], status).binary, 'tea-cli') + assert.match(probe(['tea-cli'], login).message, /^ran:tea-cli logins add /) +}) + +test('both installed: tea-cli wins, tea is the fallback', () => { + assert.equal(probe(['tea', 'tea-cli'], status).binary, 'tea-cli') +}) + +test('neither installed: clear error naming both, and not cached as missing', () => { + assert.equal(probe([], status).available, false) + assert.match(probe([], login).error, /neither tea-cli nor tea/) +})