fix: restore the files that the previous commit removed by mistake

The previous commit was made on a stale base and so reverted work that
landed on master before it. This commit puts every file back to the
state of its parent's parent. The permission enforcement of the
previous commit stays.

Assisted-by: Claude:claude-sonnet-5-5 go
This commit is contained in:
Ettore Di Giacinto committed 2026-11-03 14:33:05 +00:00
1 parent b129694ffe
commit b15905e90f
158 files changed
+7893 -794

No files matched your search

+2
View File
@@ -0,0 +1,2 @@
# Written by build.mjs; the package build makes it.
/dist/
+15
View File
@@ -0,0 +1,15 @@
import { fileURLToPath } from 'node:url'
import { dirname, resolve } from 'node:path'
import { buildAddonUI } from '../../internal/uibuild/uibuild.mjs'
const here = dirname(fileURLToPath(import.meta.url))
export async function build() {
// The page keeps the host module names it was written with; the compat
// directory answers them through the SDK (addons/internal/host-compat).
return buildAddonUI({ root: here, name: 'github', rgb: [36, 41, 47], compat: resolve(here, '../../internal/host-compat/src') })
}
if (process.argv[1] === fileURLToPath(import.meta.url)) {
build().then((dir) => console.log(`built ${dir}`), (e) => { console.error(e); process.exit(1) })
}
+8
View File
@@ -0,0 +1,8 @@
{
"name": "localai-github-ui",
"private": true,
"version": "0.1.0",
"description": "Native UI of the GitHub add-on: a standalone ES module that the host loads after its own build.",
"type": "module",
"scripts": { "build": "node build.mjs", "test": "node test/run.mjs" }
}
+92
View File
@@ -0,0 +1,92 @@
/* eslint-disable no-unused-vars -- components used only inside JSX look unused to this config, which has no eslint-plugin-react */
import { RouteProvider } from 'react-router-dom'
import { getSdk, } from '../../../internal/host-compat/src/runtime.js'
import { registerLocales } from '../../../internal/host-compat/src/i18n.js'
import ConnectorPage from '../../../internal/connector-ui/src/ConnectorPage'
import { Field, FieldError, Notice, TextInput } from '../../../internal/connector-ui/src/controls'
import en from './i18n/github.en.json'
import { checkLine, extraBody, extraForm, extraValid, githubSecret, parseRepos, reposProblem, tokenProblem } from './repos'
registerLocales({ github: { en } })
// The fine-grained token. It is typed only to replace it.
function GitHubToken({ form, setForm, t }) {
const problem = tokenProblem(form)
return (
<Field label={t('token')} hint={t('tokenHelp')}>
{(p) => (
<TextInput {...p} mono type="password" autoComplete="off" value={form.token || ''} required={!form.id}
placeholder={form.id ? t('tokenSaved') : ''} aria-invalid={problem ? 'true' : undefined}
onChange={(e) => setForm({ ...form, token: e.target.value })} />
)}
</Field>
)
}
// The repositories the agent may read. The host refuses any other one before
// the add-on is called.
function GitHubFields({ form, setForm, t }) {
const problem = reposProblem(form.repos_text)
const count = parseRepos(form.repos_text).length
return (
<>
{tokenProblem(form) && <FieldError role="status" data-testid="token-problem">{t(tokenProblem(form))}</FieldError>}
<Field label={t('repos')} hint={t('reposHelp', { count })}>
{(p) => (
<textarea {...p} className="cn-input cn-input--mono" rows={5} data-testid="github-repos" value={form.repos_text || ''}
placeholder="owner/name" aria-invalid={problem ? 'true' : undefined}
onChange={(e) => setForm({ ...form, repos_text: e.target.value })} />
)}
</Field>
{problem && <FieldError role="status" data-testid="repos-problem">{t(problem)}</FieldError>}
<Notice tone="info" role="note" testId="github-write-mode">{t('writeOff')}</Notice>
</>
)
}
function Help({ t }) {
return (
<details className="cn-help" data-testid="github-setup">
<summary>{t('setup.title')}</summary>
<ol>
<li>{t('setup.step1')}</li>
<li>{t('setup.step2')}</li>
<li>{t('setup.step3')}</li>
</ol>
</details>
)
}
function RowInfo({ c, t }) {
const repos = c.resources || []
return <span className="cn-muted" data-testid="github-row-repos">{repos.length === 0 ? t('noRepos') : t('reposOf', { count: repos.length, list: repos.join(', ') })}</span>
}
// The Test button: the host asks who the token is; this asks, for each listed
// repository, whether the token reaches it.
async function testExtra(c, t) {
const lines = []
for (const repo of c.resources || []) {
try {
const r = await getSdk().api.read({ arguments: { tool: 'check', repo } })
lines.push(checkLine(r?.data ? { ...r.data, repo } : { repo, reachable: false }, t))
} catch (_e) {
lines.push(checkLine({ repo, reachable: false }, t))
}
}
return lines.join(' ')
}
export function createGitHub() {
function Component({ page, path, search }) {
if (page !== 'apps') return null
return (
<RouteProvider value={{ page, path: path || '', search: search || '' }}>
<ConnectorPage kind="github" ns="github" testId="github-page" TokenFields={GitHubToken} tokenOf={githubSecret} messaging={false}
ExtraFields={GitHubFields} extraBody={extraBody} extraForm={extraForm} extraValid={extraValid} testExtra={testExtra}
Help={Help} RowInfo={RowInfo} />
</RouteProvider>
)
}
return { Component, components: {}, chatActions: [], dispose() {} }
}
+49
View File
@@ -0,0 +1,49 @@
{
"title": "GitHub",
"intro": "Let your agents read the code, issues and pull requests of the repositories you list. The agent can read only those, with the token you save here. Nothing is written to GitHub.",
"new": "New GitHub connection",
"edit": "Edit GitHub connection",
"name": "Name",
"token": "Fine-grained access token",
"tokenHelp": "Starts with github_pat_. Give it read access to the repositories below only: Contents, Issues and Pull requests. It is kept sealed and never shown again.",
"tokenSaved": "Saved. Type a token to replace it.",
"tokenShape": "Use a fine-grained token: it starts with github_pat_.",
"tokenSpace": "The token has no spaces.",
"repos": "Repositories the agent may read",
"reposHelp": "One per line, as owner/name. {{count}} listed. The agent is refused any other repository, even if the token could reach it.",
"reposShape": "Each repository is written owner/name.",
"reposMany": "At most 50 repositories.",
"writeOff": "Write access is off: this connection only reads.",
"agent": "Agent",
"agentNone": "Choose an agent",
"agentNoModel": "{{name}} (needs a model)",
"test": "Test",
"testOk": "Token of {{bot}}.",
"checkFailed": "Saved. The token check failed: {{reason}}",
"check": {
"unreachable": "{{repo}}: not reachable with this token.",
"readOnly": "{{repo}}: reachable, read only.",
"readWrite": "{{repo}}: reachable. The token could also write, so give it read access only."
},
"save": "Save",
"cancel": "Cancel",
"editButton": "Edit",
"delete": "Delete",
"empty": "No GitHub connections yet.",
"noRepos": "No repositories listed: the agent can read none.",
"reposOf": "{{count}} repositories: {{list}}",
"agentOf": "Agent: {{name}}",
"inactive": "Not running: {{reason}}.",
"confirmDelete": {
"title": "Delete {{name}}?",
"message": "The agent can no longer read these repositories, and the token saved with it is deleted. To set it up again you must enter the token again.",
"confirm": "Delete"
},
"setup": {
"title": "How to make the token",
"step1": "On GitHub, open Settings, Developer settings, Fine-grained personal access tokens, and make a new token.",
"step2": "Under Repository access, choose Only select repositories and pick the ones you list here.",
"step3": "Under Permissions, give Read access to Contents, Issues and Pull requests. Leave everything else off."
},
"back": "Back to connectors"
}
+14
View File
@@ -0,0 +1,14 @@
// The entry of the GitHub add-on. Its page edits the bindings of GitHub
// tokens through the core API, with the signed-in person's own session. It
// uses the host's React, router and i18n through the stand-ins of
// addons/internal/host-compat, like the Slack add-on.
import { requireHost, setSdk } from '../../../internal/host-compat/src/runtime.js'
const NEEDS = ['ui.ConfirmDialog']
export async function createAddon(sdk) {
requireHost(sdk, NEEDS)
setSdk(sdk)
const { createGitHub } = await import('./addon.jsx')
return createGitHub(sdk)
}
+57
View File
@@ -0,0 +1,57 @@
// Helpers of the GitHub page. No React, so a node test drives them.
// A fine-grained token. A classic token (ghp_) reaches every repository of
// the account, so the page does not take it.
export const TOKEN_PREFIX = 'github_pat_'
// What is wrong with the typed token, as a key of the page's words, or ''.
// Nothing typed is no problem: an update without a token keeps the stored one.
export function tokenProblem(form) {
const token = (form.token || '').trim()
if (!token) return ''
if (/\s/.test(token)) return 'tokenSpace'
if (!token.startsWith(TOKEN_PREFIX)) return 'tokenShape'
return ''
}
// The secret the field makes: the token, or '' when not typed or not right.
export function githubSecret(form) {
return tokenProblem(form) ? '' : (form.token || '').trim()
}
export const MAX_REPOS = 50
const REPO = /^[A-Za-z0-9_.-]{1,100}\/[A-Za-z0-9_.-]{1,100}$/
// The repositories of a text box: one per line, or separated by commas or
// spaces. Blank entries and repeats (any case) are dropped.
export function parseRepos(text) {
const seen = new Set()
const out = []
for (const part of String(text || '').split(/[\s,]+/)) {
const r = part.trim()
if (!r || seen.has(r.toLowerCase())) continue
seen.add(r.toLowerCase())
out.push(r)
}
return out
}
// What is wrong with the typed list, as a key of the page's words, or ''.
export function reposProblem(text) {
const list = parseRepos(text)
if (list.length > MAX_REPOS) return 'reposMany'
if (list.some((r) => !REPO.test(r) || r.includes('..'))) return 'reposShape'
return ''
}
// The fields this page adds to a binding: its form values, the request body
// they make, and whether they allow saving.
export const extraForm = (c) => ({ repos_text: (c.resources || []).join('\n') })
export const extraBody = (form) => ({ resources: parseRepos(form.repos_text) })
export const extraValid = (form) => !reposProblem(form.repos_text)
// The line the Test button adds for each repository, from the check tool.
export function checkLine(r, t) {
if (!r || r.reachable === false) return t('check.unreachable', { repo: r?.repo || '' })
return t(r.canWrite ? 'check.readWrite' : 'check.readOnly', { repo: r.fullName || r.repo })
}
+52
View File
@@ -0,0 +1,52 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { checkLine, extraBody, extraForm, extraValid, githubSecret, parseRepos, reposProblem, tokenProblem } from '../src/repos.js'
import { canSave, connectorPayload, emptyConnector } from '../../../internal/connector-ui/src/connector.js'
import en from '../src/i18n/github.en.json' with { type: 'json' }
test('github: only a fine-grained token is taken, and nothing typed keeps the saved one', () => {
assert.equal(tokenProblem({}), '')
assert.equal(tokenProblem({ token: 'github_pat_abc' }), '')
assert.equal(tokenProblem({ token: 'ghp_classic' }), 'tokenShape')
assert.equal(tokenProblem({ token: 'github_pat_a b' }), 'tokenSpace')
assert.equal(githubSecret({ token: ' github_pat_abc ' }), 'github_pat_abc')
assert.equal(githubSecret({ token: 'ghp_classic' }), '')
})
test('github: the repository list is read from lines, commas and spaces without repeats', () => {
assert.deepEqual(parseRepos('acme/widgets\n Acme/Tools, acme/widgets x/y'), ['acme/widgets', 'Acme/Tools', 'x/y'])
assert.deepEqual(parseRepos(''), [])
assert.equal(reposProblem('acme/widgets\nx/y'), '')
assert.equal(reposProblem('widgets'), 'reposShape')
assert.equal(reposProblem('a/b/c'), 'reposShape')
assert.equal(reposProblem('a/../b'), 'reposShape')
assert.equal(reposProblem(Array.from({ length: 51 }, (_, i) => `o/r${i}`).join('\n')), 'reposMany')
})
test('github: the list rides in the request, a binding is never switched on, and a saved one reads back', () => {
const form = { ...emptyConnector(), name: 'Work', agent_id: 'a', token: 'github_pat_x', ...extraForm({ resources: ['acme/widgets'] }) }
assert.equal(form.repos_text, 'acme/widgets')
const body = connectorPayload(form, 'github', githubSecret(form), { enabled: false, accept_any: false, ...extraBody({ ...form, repos_text: 'acme/widgets\nacme/tools' }) })
assert.equal(body.kind, 'github')
assert.equal(body.token, 'github_pat_x')
assert.equal(body.enabled, false)
assert.deepEqual(body.resources, ['acme/widgets', 'acme/tools'])
const update = connectorPayload({ ...emptyConnector(), id: 'b1', name: 'Work', agent_id: 'a' }, 'github', '', extraBody({ repos_text: '' }))
assert.equal('token' in update, false)
assert.deepEqual(update.resources, [], 'an empty list clears the list')
assert.equal(canSave(form, false, Boolean(githubSecret(form))), true)
assert.equal(extraValid({ repos_text: 'nope' }), false)
})
test('github: the Test lines say reachable, read only, or not reachable', () => {
const t = (key, o = {}) => en.check[key.replace('check.', '')].replace(/\{\{(\w+)\}\}/g, (_, k) => o[k])
assert.match(checkLine({ repo: 'a/b', reachable: false }, t), /not reachable/)
assert.match(checkLine({ repo: 'a/b', reachable: true, fullName: 'a/b', canWrite: false }, t), /read only/)
assert.match(checkLine({ repo: 'a/b', reachable: true, fullName: 'a/b', canWrite: true }, t), /read access only/)
})
test('github: every word the page asks for exists', () => {
for (const key of ['title', 'intro', 'new', 'token', 'tokenHelp', 'tokenShape', 'tokenSpace', 'repos', 'reposHelp', 'reposShape', 'reposMany', 'writeOff', 'test', 'testOk', 'save', 'cancel', 'empty', 'noRepos', 'reposOf', 'agentOf', 'confirmDelete', 'setup']) {
assert.ok(en[key], key)
}
})
+38
View File
@@ -0,0 +1,38 @@
// Runs the node unit tests and prints one JSON line per test:
// {"case":"unit: <name>","status":"passed"|"failed"|"skipped"}
// A test that is skipped or marked todo is reported as skipped, so the case
// inventory of scripts/addons/suites.json rejects it. No dependencies.
//
// Arguments name the test files to run (for example addons.test.mjs); without
// any, every *.test.mjs file runs. Each suite of suites.json names its own, so
// one suite's cases never appear in another's inventory.
import { run } from 'node:test'
import { readdirSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
const here = dirname(fileURLToPath(import.meta.url))
const only = process.argv.slice(2)
const names = readdirSync(here).filter(f => f.endsWith('.test.mjs')).sort()
for (const name of only) {
if (!names.includes(name)) {
console.log(JSON.stringify({ case: `unit: missing test file ${name}`, status: 'failed' }))
process.exit(1)
}
}
const files = names.filter(f => only.length === 0 || only.includes(f)).map(f => join(here, f))
let failed = false
const stream = run({ files, concurrency: 1 })
stream.on('test:pass', ({ name, skip, todo, nesting }) => {
if (nesting > 0) return
const status = skip || todo ? 'skipped' : 'passed'
console.log(JSON.stringify({ case: `unit: ${name}`, status }))
})
stream.on('test:fail', ({ name, nesting, details }) => {
if (nesting > 0) return
failed = true
console.log(JSON.stringify({ case: `unit: ${name}`, status: 'failed', error: String(details?.error?.message || '').slice(0, 300) }))
})
stream.on('test:stderr', ({ message }) => process.stderr.write(message))
stream.on('end', () => { process.exitCode = failed ? 1 : 0 })
stream.resume()