Something went wrong. Try again.
A collaborative coding-agent orchestrator for atproto radl.app
Something went wrong. Try again.
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702#!/usr/bin/env node
import { readFile, writeFile } from 'node:fs/promises'import { argv, stdin } from 'node:process'import { realpathSync } from 'node:fs'import { pathToFileURL } from 'node:url'import { CredentialClient, FetchRepoTransport, StrongRefResolver, ForeignStrongRefResolver, createSession, resolveHandleDid, resolveIdentityPds,} from '@radial/atproto'import { FileSessionStore, defaultDataDirectory, nodeIdentityResolution } from '@radial/atproto/node'import { COLLECTIONS, MemoryRecordStore, deviceKeyStatus, type MaterializedIndex,} from '@radial/core'import { FileDeviceKeyStore } from '@radial/core/node'import { MemorySyncStateStore, RepoPoller, SpaceIngestor } from '@radial/ingest'import { runCli } from './commands.js'import { acceptTicket, addPrivateBlob, createPrivateSpace, detectPrivateSpace, exportPrivateSpace, getPrivateBlob, importPrivateSpace, listPrivateBlobs, mintSpaceTicket, openPrivateCommandContext, privateStatus, privateSync, retireDevice, rotateDevice, type PrivateCliContext,} from './private-cli.js'import { listPrivateSpaces, type OpenPrivateSpace } from './private-space.js'import { buildTurnRpc, sendTurnRpc } from './socket.js'import { formatThreadLine, threadListing } from './thread.js'
export const help = `radial — human CLI for Radial records
radial auth login --profile NAME --identifier HANDLE --password-stdin [--pds URL] radial space create --name NAME [--description TEXT] [--private [--label TEXT]] --private creates a space whose records never reach a PDS: they are sealed into signed envelopes and stored under this machine's data directory, which IS the space until another replica holds it too (design §18). Use the same --data-dir as radiald: start it long enough to publish and catch up, stop it completely, run "radial ticket mint", then restart it before the recipient syncs. The CLI and daemon must not overlap on a replica. radial ticket mint --space REF [--relay URL]... [--endpoint ID] an invitation to a private space: the space, its founder, and the signing key that verifies its oldest envelopes. It grants no membership — that is an admin's "member add" — but it reveals the space exists, so treat it as a secret and hand it over out of band. radial ticket accept (--ticket TEXT | --ticket-file PATH) [--label TEXT] [--peer DID]... publishes this device's key, writes your own public "join" bookmark, and opens a local replica. The ticket already names the devices it was minted as reachable; --peer adds more. Each is disclosed publicly as a likely participant. Accepting again on a second machine is the supported way to add one, and leaves the bookmark you already wrote alone. radial private list | private status [--space REF] [--sync] | private sync [--space REF] radial private export [--space REF] [--out PATH] every envelope this replica holds, as a file (stdout without --out). No PDS holds a copy of a private space, so this is the backup — and it is the corpus in the clear: --out writes 0600, and where it goes next is on you. It carries no signing key and no device directory. radial private import --file PATH [--space REF] [--ticket TEXT | --ticket-file PATH] restores an archive, offering every envelope to the same admission gate a peer's would pass. Give a ticket to restore onto a machine that holds no replica of the space yet: an archive cannot say which space record is the space, so that has to come from out of band, as it always does. radial private blob add --file PATH [--mime TYPE] [--space REF] radial private blob list [--space REF] radial private blob get --cid CID --out PATH [--space REF] the bytes a picture is made of. A private blob is too large to travel in an envelope, so it is stored here by the CID of its own bytes and served to peers over the private bus — never to a PDS. "add" prints the reference to pass to "image create"; "list" also names the blobs records here point at that this replica does not hold. radial device list | device rotate | device retire DEVICEKEYID rotate publishes a new signing key and retires the old one, which the fold never notices. retire withdraws a device's published address, so no replica dials it or serves it — the answer to a stolen laptop, runnable from any machine that still has the session. Neither changes what anybody counts: everything the key signed keeps counting, and a key stolen together with the repo credentials republishes its own address (remove the member). radial member add --space REF --actor HANDLE|DID --kind human|agent --role ROLE radial member remove --space REF --actor HANDLE|DID radial type create --space REF --name NAME (--brief TEXT | --brief-file PATH) --output-description TEXT [--format markdown|json] [--scope goal|project] [--durability living|append-only] [--schema-file JSON] radial type list --space REF radial project create --space REF --name NAME --git-url URL radial project edit --project REF [--name NAME] [--git-url URL] [--default-branch NAME] (the project author only) radial project archive|unarchive --project REF (the project author only) radial project set-auto-review --project REF --type NAME --enabled true|false radial project feedback-source --project REF --source LINK|AT-URI [--off] (admins only. LINK is a board page on userinput.app; it is stored as the at:// URI it names) radial space guest-comments --space REF --enabled true|false (admins only) whether the space SOLICITS comments from non-members. It governs what the app offers and shows; nothing can stop a guest writing one into their own repo, and blessings already written stay blessed. radial goal create --project REF --title TITLE [--body TEXT | --body-file PATH] [--origin AT-URI#CID] [--source-kind tangled-issue --source-uri AT-URI [--source-cid CID]] where the text of an imported goal came from. The goal carries the pointer so the space can tell which issues it has taken up; the text is what its author signed, and no turn bundle carries the pointer or reads the issue. radial goal close --goal REF [--disposition completed|dropped|superseded|parked] (any active member; another word warns) radial goal reopen --goal REF (any active member) radial goal archive|unarchive --goal REF (deprecated aliases for close/reopen) radial goal label --goal REF --set "a, b, c" (any active member) sets the goal's WHOLE label set, comma separated; --set "" clears it. A later write replaces an earlier one rather than merging with it, so two members relabelling one goal in the same ingestion window lose an edit — relabel to recover. Labels are trimmed, inner whitespace collapsed, and deduplicated case-insensitively keeping the first spelling. At most 20, of at most 100 characters each. There is no --add/--remove: computing one needs the goal's current set, which is a fold and not a record read. radial view save --space REF --name NAME [--label L]... [--state live|ended|all] [--project REF] saves (or re-saves) YOUR named filter over the space's goals. A goal matches when it carries every --label, matches --state (default live), and, with --project, sits under that project. Re-saving the same --name is how a view is edited; views are personal, so two members may each hold one called the same thing. radial view delete --space REF --name NAME tombstones your view of that name. The record stays in your repo; the fold reads it as "this name holds no view any more". radial request create (--goal REF | --project REF) --type TYPE [--assignee HANDLE|DID] [--subject REF] [--based-on REF]... [--brief TEXT] radial request retract --request REF radial artifact post --request REF --title TITLE (--body TEXT | --body-file PATH) [--prev REF] [--criterion TEXT]... --title is a short phrase naming the artifact (at most 200 characters) — what a row shows instead of the first paragraph of the body. Required, and it must not be blank. radial message post (--goal REF | --artifact REF) (--body TEXT | --body-file PATH) [--re REF] [--parent REF] [--mention HANDLE|DID]... --parent makes the message a REPLY to another message on the same target; the thread root is derived from it. Replying to an agent's question is what unblocks a request that is awaiting input. radial message list (--goal REF | --project REF) [--unanswered] the target's thread, oldest first, each message with the pinned locator --parent and --subject take. A goal's thread includes messages anchored to its artifacts. --unanswered narrows it to the questions blocking a request. radial comment bless --goal REF --comment AT-URI (any active member) vouches for a NON-MEMBER's comment on a goal: the record is fetched from the guest's own PDS, pinned by uri#cid, and its body copied into the blessing. The copy is what makes it readable — nothing polls a guest's repo, so an unblessed comment reaches no turn bundle at all. radial comment retract --bless REF (its author, or an admin) withdraws a blessing. The blessing stays visible and badged; nothing downstream, the bundle included, reads it again. radial review post --subject REF --verdict approve|request_changes [--request REF] [--findings-file PATH | --findings-json JSON] radial image create --blob JSON [--alt TEXT] [--name TEXT] the record half of an image upload: --blob is the reference a PDS returned from com.atproto.repo.uploadBlob, and the record is what stops the blob being reclaimed. A body shows it with . Uploading the bytes is the caller's job — this command reads no files. In a private space the bytes go to this replica instead: run "radial private blob add" first, and pass the reference it prints.
Inside a turn container the allowlist is narrower and the daemon anchors everyrecord: "artifact submit", "review submit", "answer submit" (the reply an answerturn was commissioned for) and "message post" (the can't-complete question).
References may be bare at:// URIs or pinned at://...#CID locators.Use --profile NAME to select an actor and --json for structured output.Use --data-dir PATH (else $RADIAL_DATA_DIR) to read a different instance'ssessions.json, when this machine runs more than one radiald.`
async function readStdin(): Promise<string> { let value = '' for await (const chunk of stdin) value += new TextDecoder().decode(chunk) return value.trim()}
function value(args: string[], flag: string): string | undefined { const index = args.lastIndexOf(flag) return index === -1 ? undefined : args[index + 1]}
export async function main(args = argv.slice(2)): Promise<void> { if (args.length === 0 || args.includes('--help') || args[0] === 'help') { console.log(help) return } if (process.env.RADIAL_SIDECAR_SOCKET) { const token = process.env.RADIAL_TURN_TOKEN if (!token) throw new Error('RADIAL_TURN_TOKEN is required in turn socket mode') const rpc = await buildTurnRpc(args, token, async (path) => (path === '-' ? readStdin() : readFile(path, 'utf8'))) const response = await sendTurnRpc(process.env.RADIAL_SIDECAR_SOCKET, rpc) if (!response.ok) throw new Error(response.error) if (args.includes('--json')) console.log(JSON.stringify({ ref: response.ref })) else console.log(`${response.ref.uri}#${response.ref.cid}`) return }
// `--data-dir` selects WHICH set of identities this CLI speaks for, the same way it does for // `radiald`: one machine may hold several instances, each with its own `sessions.json`. Read here // rather than in `runCli`, which only ever sees flags it knows and ignores the rest. const dataDir = value(args, '--data-dir') const store = dataDir ? new FileSessionStore(dataDir) : new FileSessionStore() if (args[0] === 'auth' && args[1] === 'login') { const profile = value(args, '--profile') const identifier = value(args, '--identifier') if (!profile || !identifier || !args.includes('--password-stdin')) { throw new Error('auth login requires --profile, --identifier, and --password-stdin') } // --pds only overrides what the identity's DID document already advertises. const service = value(args, '--pds') ?? String(await resolveIdentityPds(identifier, nodeIdentityResolution)) const session = await createSession(service, identifier, await readStdin()) await store.save({ ...session, profile }) console.log(JSON.stringify({ profile, did: session.did, handle: session.handle, service: session.service })) return }
const profile = value(args, '--profile') ?? process.env.RADIAL_PROFILE const actor = await store.get(profile) if (!actor) throw new Error('No Radial identity is configured; run radial auth login') const client = new CredentialClient( actor, fetch, undefined, (current, refresh) => store.coordinateRefresh(actor.profile, current, refresh), ) const transport = new FetchRepoTransport() const resolver = new StrongRefResolver(transport) const foreignResolver = new ForeignStrongRefResolver(transport)
// Private mode (design §18). The context is built for every invocation because it is cheap — a // path and a session — and because the routing below has to be able to ask it questions. const privateContext: PrivateCliContext = { dataDir: dataDir ?? defaultDataDirectory(), actorDid: actor.did, transport, publicWriter: client, readText: async (path) => (path === '-' ? readStdin() : readFile(path, 'utf8')), readBytes: async (path) => new Uint8Array(await readFile(path)), writeBytes: (path, bytes) => writeFile(path, bytes), resolveActor: (identifier) => resolveHandleDid(identifier, nodeIdentityResolution), log: (line) => console.error(line), warn: (line) => console.error(line), } const handled = await runPrivateCommand(args, privateContext) if (handled) return
// A private record's URI is a name and not a location, so nothing in a command's arguments says // which bus it belongs to — the only way to know is to look at what this machine holds. A match // routes the whole command, read or write, through this machine's replica; no match leaves it // exactly where it was. const privateTarget = await detectPrivateSpace(privateContext, args)
// `type list` is a read command, not a record write, so it does not fit runCli's write-and-return // contract; handled here like `auth login`. Materializes the space and prints its registry types. if (args[0] === 'type' && args[1] === 'list' && !privateTarget) { const spaceLocator = value(args, '--space') if (!spaceLocator) throw new Error('type list requires --space') // Resolve the ref like the write commands do (validates a supplied #CID and the collection) // rather than blindly stripping the fragment, which would list the space for a wrong CID. const space = await resolver.resolve(spaceLocator, COLLECTIONS.space) const records = new MemoryRecordStore() const ingestor = new SpaceIngestor( space.uri, new RepoPoller(transport, records, new MemorySyncStateStore()), records, ) printArtifactTypes(await ingestor.sync(), args) return }
// `message list` is the other read command, and it is what makes the reply commands usable: every // write that threads a message (`message post --parent`, `request create --type answer --subject`) // takes a message's pinned uri#cid, and until this existed the only way to learn one outside the UI // was to open the daemon's record store with a sqlite client. Same shape as `type list` — resolve // the ref, materialize the space, print — because a thread is a fold over the space, not a query. if (args[0] === 'message' && args[1] === 'list' && !privateTarget) { const target = await resolver.resolve( threadTargetLocator(args, value), value(args, '--goal') ? COLLECTIONS.goal : COLLECTIONS.project, ) // Both records name the space they belong to, so resolving the target is the whole lookup. const spaceUri = (target.value as { space: { uri: string } }).space.uri const records = new MemoryRecordStore() const index = await new SpaceIngestor( spaceUri, new RepoPoller(transport, records, new MemorySyncStateStore()), records, ).sync() printThread(index, target.uri, spaceUri, args) return }
if (privateTarget) { const opened = await openPrivateCommandContext(privateContext, privateTarget) try { // The two reads work here too, off the local fold rather than off member repos: a private // space has no PDS to index, and `message list` is how a human finds the pinned locator every // reply command takes. if (args[0] === 'type' && args[1] === 'list') { printArtifactTypes(opened.space.index(), args) return } if (args[0] === 'message' && args[1] === 'list') { const locator = threadTargetLocator(args, value) const resolved = await opened.deps.resolver.resolve( locator, value(args, '--goal') ? COLLECTIONS.goal : COLLECTIONS.project, ) printThread(opened.space.index(), resolved.uri, privateTarget.spaceUri, args) return } const written = await runCli(args, opened.deps) reportPrivateWrite(opened.space, args) if (args.includes('--json')) console.log(JSON.stringify({ ...written, ref: written.primary })) else console.log(`${written.primary.uri}#${written.primary.cid}`) } finally { opened.close() } return }
const result = await runCli(args, { writer: client, resolver, foreignResolver, readText: async (path) => (path === '-' ? readStdin() : readFile(path, 'utf8')), resolveActor: (identifier) => resolveHandleDid(identifier, nodeIdentityResolution), resolveMembership: async (spaceUri, did) => { const records = new MemoryRecordStore() const ingestor = new SpaceIngestor( spaceUri, new RepoPoller(transport, records, new MemorySyncStateStore()), records, ) const index = await ingestor.sync() const target = index.members.find((member) => member.did === did) if (!target?.active || !target.membership) throw new Error(`Member is not active: ${did}`) const author = index.members.find((member) => member.did === actor.did) if (actor.did !== index.space.did && (!author?.active || author.role !== 'admin')) { throw new Error('The selected actor is not an active space admin') } return target.membership }, findArtifactType: async (name, spaceUri) => { const records = new MemoryRecordStore() const index = await new SpaceIngestor( spaceUri, new RepoPoller(transport, records, new MemorySyncStateStore()), records, ).sync() return index.artifactTypes.find((type) => type.value.name === name)?.uri }, hasApprovingReview: async (subject, spaceUri) => { const records = new MemoryRecordStore() const ingestor = new SpaceIngestor( spaceUri, new RepoPoller(transport, records, new MemorySyncStateStore()), records, ) const index = await ingestor.sync() return [...index.goals, ...index.projects].some((view) => view.reviews.some( (review) => review.value.verdict === 'approve' && review.value.subject.uri === subject.uri && review.value.subject.cid === subject.cid, ), ) }, // stderr, so it never corrupts `--json` stdout. warn: (message) => console.error(message), }) if (args.includes('--json')) console.log(JSON.stringify({ ...result, ref: result.primary })) else console.log(`${result.primary.uri}#${result.primary.cid}`)}
/** `message list`'s target, whichever way it was named. Exactly one of the two flags. */function threadTargetLocator(args: string[], read: (args: string[], flag: string) => string | undefined): string { const goal = read(args, '--goal') const project = read(args, '--project') if (Boolean(goal) === Boolean(project)) { throw new Error('message list requires exactly one of --goal or --project') } return (goal ?? project) as string}
/** `type list`'s output, from a fold — the same on both buses, because a registry is a fold. */function printArtifactTypes(index: MaterializedIndex, args: string[]): void { if (args.includes('--json')) { console.log( JSON.stringify( index.artifactTypes.map((type) => ({ name: type.value.name, scope: type.value.scope, // Absent means `living` — the registry's default, resolved here so consumers of // `type list --json` never have to know the convention. durability: type.value.durability ?? 'living', brief: type.value.brief, })), ), ) return } for (const type of index.artifactTypes) { const summary = type.value.brief.split('\n')[0]?.slice(0, 80) ?? '' console.log(`${type.value.name}\t${type.value.scope}\t${summary}`) }}
/** `message list`'s output: a thread is a fold over the space, not a query. */function printThread(index: MaterializedIndex, targetUri: string, spaceUri: string, args: string[]): void { const view = [...index.goals, ...index.projects].find((candidate) => candidate.target.uri === targetUri) // A target the fold does not hold is a real answer, not an empty thread: it was written by // somebody the space does not trust, or archived out of the live view. Say so rather than print // nothing, which a caller would read as "no messages yet". if (!view) throw new Error(`${targetUri} is not in the trusted view of ${spaceUri}`) const unansweredOnly = args.includes('--unanswered') const lines = threadListing(view).filter((line) => !unansweredOnly || line.unanswered) if (args.includes('--json')) console.log(JSON.stringify(lines)) else for (const line of lines) console.log(formatThreadLine(line))}
/** * What a write into a private space is, and is not. * * A record written here is committed — it is in this replica's store, which is what "written" means * when there is no server to be the authority — and this command reached nobody, because the CLI * holds no transport: the endpoint lives in `radiald run` (ADR §20), which serves this same replica * and carries the envelope to peers on its next round. Said every time rather than once at creation, * because the gap between "committed" and "somebody else has it" is exactly what an operator has to * keep in mind on a bus with no server in the middle. */function reportPrivateWrite(space: OpenPrivateSpace, args: string[]): void { if (args.includes('--quiet')) return console.error( `⚠️ written into your local replica of ${space.spaceUri} (${space.directory}). ` + `${space.bus.unsent} envelope(s) left this command undelivered: peers receive them from a daemon ` + 'serving this space (radiald run), and until one does, this store is the only copy ' + '(design §18, "Backups").', )}
/** * The private-mode commands, printed for a human. Returns whether it handled the invocation. * * The commands that WRITE records are not here: those run through `runCli` like every other write, * over an envelope writer. What is here is the machinery around them — keys, tickets, replicas — * which has no equivalent on the public path because a PDS was doing all of it. */async function runPrivateCommand(args: string[], context: PrivateCliContext): Promise<boolean> { const [group, action] = args const json = args.includes('--json') const print = (value: unknown, lines: () => string[]): void => { if (json) console.log(JSON.stringify(value)) else for (const line of lines()) console.log(line) }
if (group === 'space' && action === 'create' && args.includes('--private')) { const created = await createPrivateSpace(context, args) print({ ...created, ref: created.primary }, () => [ `${created.primary.uri}#${created.primary.cid}`, '', 'Use the same data directory as radiald. Start it long enough to publish and catch up, then stop it', 'completely before running "radial ticket mint". Restart radiald before the recipient syncs:', 'the CLI and daemon must not overlap on this replica.', ]) return true }
if (group === 'ticket' && action === 'mint') { const minted = await mintSpaceTicket(context, args) print(minted, () => [ minted.ticket, '', `fingerprint ${minted.fingerprint}`, 'Restart radiald before the recipient syncs; the CLI and daemon must not overlap on this replica.', ]) return true }
if (group === 'ticket' && action === 'accept') { const accepted = await acceptTicket(context, args) for (const warning of accepted.warnings) console.error(`⚠️ ${warning}`) print(accepted, () => [ `joined ${accepted.meta.spaceUri} (ticket ${accepted.fingerprint})`, `founder ${accepted.meta.founder}`, 'Accepting grants no membership: an admin has to write an addMember grant, and this replica', 'will see it once a daemon serving this space (radiald run) has caught it up from a peer.', ]) return true }
if (group === 'private' && action === 'list') { const spaces = await listPrivateSpaces(context.dataDir) print(spaces, () => spaces.length === 0 ? ['no private space replicas on this machine'] : spaces.map((space) => `${space.spaceUri}\t${space.label ?? ''}\tfounder ${space.founder}`), ) return true }
if (group === 'private' && action === 'status') { const status = await privateStatus(context, args) for (const warning of status.warnings) console.error(`⚠️ ${warning}`) print(status, () => [ `${status.space.spaceUri}`, `replica ${status.directory}`, `records ${status.records}, envelopes ${status.envelopes}, active members ${status.members}, goals ${status.goals}`, `quarantined ${status.outstanding.quarantined}, outstanding wants ${status.outstanding.wants}`, `blobs ${status.blobs.held} held, ${status.blobs.missing} named by a record here and missing`, status.connected ? 'transport: connected' : 'transport: not from here — the CLI holds no endpoint; a daemon serving this space ' + '(radiald run) is what connects this replica to peers (ADR §20)', ...status.devices.map( (device) => `device ${device.deviceKeyId}\t${device.kind}\t${device.status}\t${device.published ? 'published' : 'not in the directory this replica has read'}`, ), ]) return true }
if (group === 'private' && action === 'sync') { const synced = await privateSync(context, args) if (synced.spaceMismatch) console.error(`⚠️ ${synced.spaceMismatch.reason}`) for (const rejection of synced.rejected) console.error(`⚠️ ${rejection.uri}: ${rejection.reason}`) print( { space: synced.meta.spaceUri, records: synced.records, envelopes: synced.envelopes, admitted: synced.admitted, outstanding: synced.outstanding, folds: synced.index !== undefined, }, () => [ `${synced.meta.spaceUri}: ${synced.records} record(s), ${synced.envelopes} envelope(s)`, `outstanding: ${synced.outstanding.quarantined} quarantined, ` + `${synced.outstanding.wants} want(s), ${synced.outstanding.blobs} blob(s) missing`, ...(synced.index ? [] : ['this replica holds no space record yet — there is nothing to fold until a peer hands it one']), 'A sync from here polls the public device directory and folds what this replica already', 'holds. It admits nothing new: catch-up needs an endpoint, and the daemon serving this space', '(radiald run) is what holds one.', ], ) return true }
if (group === 'private' && action === 'export') { const exported = await exportPrivateSpace(context, args) for (const warning of exported.warnings) console.error(`⚠️ ${warning}`) if (exported.path === undefined) { // No --out: the archive itself is the output, so nothing else may go to stdout. Even --json // prints the archive, because a caller piping this wants the file and not a description of it. process.stdout.write(exported.text) console.error( `${exported.envelopes} envelope(s) and ${exported.blobs} blob(s) from ${exported.space.spaceUri}. ` + 'This is the whole corpus in the clear — as sensitive as the replica it came from.', ) return true } print({ ...exported, text: undefined }, () => [ `wrote ${exported.envelopes} envelope(s) and ${exported.blobs} blob(s) from ${exported.space.spaceUri} to ${exported.path as string} (0600)`, 'It is the whole corpus in the clear, and it holds no signing key: restore it with', '"radial private import --file", and mint a key there with "radial device rotate".', ]) return true }
if (group === 'private' && action === 'import') { const imported = await importPrivateSpace(context, args) for (const warning of imported.warnings) console.error(`⚠️ ${warning}`) // The index is summarised rather than serialised: `--json` on a restore is for a script // checking what landed, and a whole fold in it would bury that in the space's entire contents. const folded = imported.index ? { members: imported.index.members.filter((member) => member.active).length, goals: imported.index.goals.length, } : undefined print({ ...imported, index: undefined, folded }, () => [ ...(imported.bootstrapped ? [`created this machine's replica of ${imported.space.spaceUri} from the ticket`] : []), `${imported.space.spaceUri}: admitted ${imported.admitted} envelope(s) (${imported.held} already held)`, ...imported.rejected.map((rejection) => `refused ${rejection.uri}: ${rejection.reason}`), `quarantined ${imported.outstanding.quarantined}, outstanding wants ${imported.outstanding.wants}`, `restored ${imported.blobs} blob(s); ${imported.outstanding.blobs} still missing`, ...(folded ? [`${folded.members} active member(s), ${folded.goals} goal(s)`] : []), ]) return true }
if (group === 'private' && action === 'blob') { const what = args[2] if (what === 'add') { const added = await addPrivateBlob(context, args) print(added, () => [ `${added.held ? 'already held' : 'stored'} ${added.cid} (${added.size} bytes, ${added.mimeType})`, 'in this replica only — a private blob reaches a peer over the private bus and never a PDS.', 'Now write the record that anchors it:', ` radial image create --blob '${added.ref}' --alt "…"`, ]) return true } if (what === 'list') { const listed = await listPrivateBlobs(context, args) print(listed, () => [ ...listed.held.map((cid) => `held\t${cid}`), ...listed.missing.map((need) => `missing\t${need.cid}\t${need.size} bytes\t${need.mimeType}`), ...(listed.held.length === 0 && listed.missing.length === 0 ? [`no records in ${listed.space.spaceUri} name any bytes`] : []), ]) return true } if (what === 'get') { const got = await getPrivateBlob(context, args) print(got, () => [`wrote ${got.size} bytes of ${got.cid} to ${got.path}`]) return true } throw new Error('Usage: radial private blob add|list|get') }
if (group === 'device' && action === 'list') { const keys = await new FileDeviceKeyStore(context.dataDir).list(context.actorDid) print(keys.map((key) => ({ ...key, privateKey: undefined, status: deviceKeyStatus(key) })), () => keys.length === 0 ? ['no device keys on this machine for this identity'] : keys.map((key) => `${key.deviceKeyId}\t${key.kind}\t${deviceKeyStatus(key)}\t${key.createdAt}`), ) return true }
if (group === 'device' && action === 'rotate') { const rotated = await rotateDevice(context) print( { created: { ...rotated.created, privateKey: undefined }, retired: rotated.retired.map((key) => key.deviceKeyId), addressesRetired: rotated.addressesRetired, }, () => [ `published ${rotated.created.deviceKeyId}`, ...rotated.retired.map((key) => `retired ${key.deviceKeyId}`), ...rotated.addressesRetired.map((key) => `withdrew the published address of ${key}`), 'Rotation is invisible to the fold: everything the retired key signed keeps counting.', ], ) return true }
if (group === 'device' && action === 'retire') { const deviceKeyId = args[2] if (!deviceKeyId || deviceKeyId.startsWith('--')) { throw new Error('Usage: radial device retire <deviceKeyId>') } const retired = await retireDevice(context, deviceKeyId) print(retired, () => [ retired.changed ? `withdrew the published address of ${deviceKeyId}` : `${deviceKeyId} publishes no address, so nothing dials it already`, 'No replica will dial it or serve it once they have read this. Everything it signed keeps', 'counting: this is containment and not repair, and a key whose owner’s repo is also', 'compromised can republish its address — remove the member instead.', ...(retired.local ? ['This machine still holds that key. Run "radial device rotate" to stop signing with it.'] : []), ]) return true }
return false}
const entry = argv[1]if (entry && import.meta.url === pathToFileURL(realpathSync(entry)).href) { main().catch((error: unknown) => { console.error(error instanceof Error ? error.message : error) process.exitCode = 1 })}