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
|
## 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.
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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') }))),
|
||||||
|
|||||||
@@ -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
@@ -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": {
|
||||||
|
|||||||
@@ -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