1.0.1: check both tea-cli and tea for every tea command, with a test for each case

This commit is contained in:
Agent committed 2026-10-12 15:45:24 +00:00
1 parent e303c51b23
commit 25cea75eb5
6 files changed
+93 -19

No files matched your search

+2 -1
View File
@@ -27,7 +27,8 @@ Clone and push use a bundled git **credential helper** (`credential-helper.mjs`)
## Requirements ## Requirements
- [`gh`](https://cli.github.com/) for GitHub accounts (optional). - [`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). - `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. At least one of `gh` / `tea` must be installed and logged in for the account features to do anything.
+25 -13
View File
@@ -5,17 +5,35 @@ import { randomUUID } from 'node:crypto'
import { ForgeError, listAccounts, publicAccount, resetAccountCache, run } from './forge.mjs' import { ForgeError, listAccounts, publicAccount, resetAccountCache, run } from './forge.mjs'
const TEA_BINARIES = ['tea-cli', 'tea'] 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) { async function which(cmd) {
const r = await run(cmd, ['--version'], { timeoutMs: 10000 }) 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 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() { async function teaBin() {
if (teaBinary !== undefined) return teaBinary for (const b of teaOrder()) if (await which(b)) return (teaBinary = b)
for (const b of TEA_BINARIES) if (await which(b)) return (teaBinary = b) return null
return (teaBinary = 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() { 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). */ /** 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 }) { 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 (!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) if (!token && !(user && password)) throw new ForgeError('an access token (or user and password) is required', 400)
let u let u
@@ -46,8 +62,8 @@ export async function giteaLogin({ name, url, token, user, password, insecure })
if (token) env.GITEA_SERVER_TOKEN = token if (token) env.GITEA_SERVER_TOKEN = token
else { env.GITEA_SERVER_USER = user; env.GITEA_SERVER_PASSWORD = password } else { env.GITEA_SERVER_USER = user; env.GITEA_SERVER_PASSWORD = password }
if (insecure) args.push('--insecure') if (insecure) args.push('--insecure')
const r = await run(bin, args, { env, timeoutMs: 60000 }) const r = await runTea(args, { env, timeoutMs: 60000 })
if (r.code !== 0) throw fail(r, 'tea-cli logins add') if (r.code !== 0) throw fail(r, `${r.bin} logins add`)
done() done()
return { ok: true, message: (r.stdout || '').trim() } 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) if (!a) throw new ForgeError(`unknown account ${accountId}`, 404)
let r let r
if (a.kind === 'github') r = await run('gh', ['auth', 'logout', '--hostname', a.host, '--user', a.user], { timeoutMs: 30000 }) if (a.kind === 'github') r = await run('gh', ['auth', 'logout', '--hostname', a.host, '--user', a.user], { timeoutMs: 30000 })
else { else r = await runTea(['logins', 'delete', a.name], { timeoutMs: 30000 })
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 })
}
if (r.code !== 0) throw fail(r, 'logout') if (r.code !== 0) throw fail(r, 'logout')
done() done()
return { ok: true } return { ok: true }
+3 -3
View File
@@ -741,7 +741,7 @@ window.__ModuleLoader__.load({
setGt((g) => ({ ...g, token: '', password: '' })); setMsg({ text: t('settings.added') }); await refresh(true); setGt((g) => ({ ...g, token: '', password: '' })); setMsg({ text: t('settings.added') }); await refresh(true);
}); });
const doLogout = (a) => act('logout:' + a.id, async () => { 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; 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); 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'))))), h(P.Button, { variant: 'outline', size: 'sm', disabled: !!busy, onClick: () => doLogout(a) }, t('settings.logout'))))),
cli ? h('div', { style: S.caption }, cli ? h('div', { style: S.caption },
t('settings.cli') + ': gh ' + (cli.gh.available ? cli.gh.version.replace(/^gh version\s*/, '') : t('settings.missing')) + 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, msg ? h('div', { style: msg.bad ? S.errorBox : S.hint, role: msg.bad ? 'alert' : 'status' }, msg.text) : null,
// github // github
h('div', { style: S.section }, h('div', { style: S.section },
@@ -802,7 +802,7 @@ window.__ModuleLoader__.load({
// gitea // gitea
h('div', { style: S.section }, h('div', { style: S.section },
h('span', { style: S.label }, t('settings.add') + ' — ' + t('settings.gitea')), 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('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.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') }))), h(Field, { label: t('settings.name') }, h(P.Input, { value: gt.name, placeholder: 'optional', spellCheck: false, onChange: setG('name') }))),
+1 -1
View File
@@ -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 }. */ /** Resolve "owner/name", a URL, or the cwd's remote into { account, owner, name }. */
export async function resolveRepo({ repo, account: accountId, cwd, remote = 'origin' }) { export async function resolveRepo({ repo, account: accountId, cwd, remote = 'origin' }) {
const accounts = await listAccounts() 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 let byId = null
if (accountId) { if (accountId) {
byId = accounts.find((a) => a.id === accountId || a.name === accountId || `${a.user}@${a.host}` === accountId) byId = accounts.find((a) => a.id === accountId || a.name === accountId || `${a.user}@${a.host}` === accountId)
+2 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@local/git-forge", "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.", "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, "private": true,
"type": "module", "type": "module",
@@ -11,6 +11,7 @@
"./locale/*.json": "./locale/*.json" "./locale/*.json": "./locale/*.json"
}, },
"files": ["index.js", "forge.mjs", "auth.mjs", "credential-helper.mjs", "client.js", "cordis.patch.yml", "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": { "dsh": {
"bundle": { "patch": "./cordis.patch.yml" }, "bundle": { "patch": "./cordis.patch.yml" },
"client": { "client": {
+60
View File
@@ -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) {
// `<name> --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/)
})