// The operator CLI — `ap-bridge admin …`.
//
// ⛔ A SUBCOMMAND, NOT A SECOND BINARY, and that is a constraint rather than a preference. pgh installs
// exactly ONE launcher per app — `pgh.bin` is a single name and schema.js rejects anything else — and
// the launcher is `exec … index.js "$@"`, so arguments already arrive here. The first version shipped a
// bin/ap-bridge-admin.js: a file present in the tree, absent from PATH, and documented in a command
// that silently did not exist.
import '../lib/bare-globals.js'
import test from 'brittle'
import fs from 'bare-fs'
import { runAdminCli, USAGE } from '../lib/admin-cli.js'
// A stand-in for the control socket, so the CLI's behaviour is testable without binding one.
function fakeControl (responses = {}) {
const calls = []
return {
calls,
connect: async () => ({
call: async (name, params) => {
calls.push([name, params])
if (responses[name] instanceof Error) throw responses[name]
return responses[name]
}
})
}
}
const capture = () => { const lines = []; return { lines, write: (l) => lines.push(String(l)) } }
test('★★ status reports who operates the bridge', async (t) => {
const c = fakeControl({ 'admin-status': { unclaimed: false, operators: [{ publicKey: 'abc=', revoked: false }, { publicKey: 'def=', revoked: true }] } })
const out = capture()
const code = await runAdminCli(['status'], { uds: '/x', out: out.write, err: () => {}, connect: c.connect })
t.is(code, 0)
t.ok(out.lines.some((l) => /claimed/.test(l)))
t.ok(out.lines.some((l) => /abc=/.test(l)))
// ⛔ A revoked operator is LISTED, not filtered. It is still registered — which is why revoking the
// claimant does not reopen the instance — and hiding it would read as "removed".
t.ok(out.lines.some((l) => /def=.*revoked/.test(l)), 'revoked is shown, not hidden')
})
test('★★★ invite prints the token alone on stdout', async (t) => {
// ⚠ So `ap-bridge admin invite | pbcopy` copies a token and not a paragraph. Everything else goes to
// stderr, which is the difference between a CLI that composes and one that only reads well.
const c = fakeControl({ 'admin-invite': { token: 'deadbeefcafe', expiresAt: Date.now() + 60000 } })
const out = capture()
const err = capture()
const code = await runAdminCli(['invite'], { uds: '/x', out: out.write, err: err.write, connect: c.connect })
t.is(code, 0)
t.alike(out.lines, ['deadbeefcafe'], 'stdout is exactly the token')
t.ok(err.lines.some((l) => /single-use/.test(l)), 'and the advice went to stderr')
})
test('★★★ an unreachable socket says what to check, and fails', async (t) => {
// ⛔ THE MOST LIKELY FAILURE, since the control socket only comes up when AP_ADMIN_PORT is set. A
// bare "connection refused" would send an operator looking at the wrong thing.
const err = capture()
const code = await runAdminCli(['status'], { uds: '/nope/admin.sock', out: () => {}, err: err.write, connect: async () => { throw new Error('ENOENT') } })
t.is(code, 1, 'a non-zero exit, so a script notices')
t.ok(err.lines.some((l) => /no control socket at \/nope/.test(l)), 'it names the path it tried')
t.ok(err.lines.some((l) => /AP_ADMIN_PORT/.test(l)), 'and the likeliest reason')
})
test('★★ revoke needs a key, and says so', async (t) => {
const c = fakeControl({ 'admin-revoke': { revoked: 'abc=' } })
const err = capture()
t.is(await runAdminCli(['revoke'], { uds: '/x', out: () => {}, err: err.write, connect: c.connect }), 1)
t.is(c.calls.length, 0, 'nothing was called without a key')
const out = capture()
t.is(await runAdminCli(['revoke', 'abc='], { uds: '/x', out: out.write, err: () => {}, connect: c.connect }), 0)
t.alike(c.calls[0], ['admin-revoke', { publicKey: 'abc=' }])
})
test('★★ no arguments prints usage and succeeds', async (t) => {
const out = capture()
t.is(await runAdminCli([], { uds: '/x', out: out.write, err: () => {}, connect: async () => { throw new Error('should not connect') } }), 0)
t.ok(out.lines.join('\n').includes('ap-bridge admin'), 'usage names the real command')
t.ok(USAGE.includes('revoke <pubkey>'))
})
test('★★★ the entry dispatches the subcommand before booting the daemon', (t) => {
// ⛔ Running the CLI must not also bind the fediverse listener of a bridge that is already running.
const src = fs.readFileSync(new URL('../index.js', import.meta.url).pathname, 'utf8')
const dispatch = src.indexOf("process.argv[2] === 'admin'")
// Everything that binds lives in lib/bridge.js since 0.10.15; the entry reaches it through one call.
const start = src.indexOf('startApBridge(config')
t.ok(dispatch > 0, 'the entry knows the subcommand')
t.ok(start > 0 && dispatch < start, 'and handles it before anything binds')
t.absent(/\.listen\(/.test(src), 'and the entry itself binds nothing')
t.ok(/process\.exit\(await runAdminCli/.test(src), 'then exits rather than falling through')
})