1.0.1: check both tea-cli and tea for every tea command, with a test for each case
This commit is contained in:
1 parent
e303c51b23
commit
25cea75eb5
6 files changed
+93
-19
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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') }))),
|
||||
|
||||
@@ -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)
|
||||
|
||||
+2
-1
@@ -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": {
|
||||
|
||||
@@ -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/)
|
||||
})
|
||||
Reference in new issue
Block a user