Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 21 additions & 7 deletions bootstrap/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,20 @@ import {
import type { KeyAlgorithm, KeyBackend, AAuthPublicJwk } from '@aauth/local-keys'
import { listSkills, getSkill } from './skills.js'
import { bootstrapWithPS } from './bootstrap-ps.js'
import { buildLogEmitter } from './log.js'
import { buildLogEmitter, type LogMode } from './log.js'

// --log → pretty narrative; --jsonl (alias --ndjson) → NDJSON; neither → silent.
function pickLogMode(flags: Record<string, string>): LogMode | undefined {
const log = flags.log === 'true'
const jsonl = flags.jsonl === 'true' || flags.ndjson === 'true'
if (log && jsonl) {
console.error(JSON.stringify({ error: '--log and --jsonl are mutually exclusive (same events, different formats). Pick one.' }))
process.exit(1)
}
if (log) return 'pretty'
if (jsonl) return 'jsonl'
return undefined
}
import { createHash } from 'node:crypto'
import { createRequire } from 'node:module'

Expand Down Expand Up @@ -67,7 +80,7 @@ function parseArgs(args: string[]) {
// === Commands ===

function cmdDiscover(flags: Record<string, string>) {
const onEvent = buildLogEmitter(flags.log === 'true')
const onEvent = buildLogEmitter(pickLogMode(flags))
onEvent?.({ step: 'backend_discovery', phase: 'start' })
const backends = discoverBackends()
onEvent?.({ step: 'backend_discovery', phase: 'done', backends: backends.map(b => b.backend) })
Expand All @@ -79,7 +92,7 @@ async function cmdGenerate(flags: Record<string, string>) {
const algorithm = (flags.algorithm || (backend === 'software' ? 'EdDSA' : 'ES256')) as KeyAlgorithm
const agentUrl = flags.agent
const kid = generateKid()
const onEvent = buildLogEmitter(flags.log === 'true')
const onEvent = buildLogEmitter(pickLogMode(flags))

const driver = getBackend(backend)
const deviceLabel = driver.getDeviceLabel()
Expand Down Expand Up @@ -138,7 +151,7 @@ async function cmdGenerate(flags: Record<string, string>) {
async function cmdSignToken(flags: Record<string, string>) {
const agentUrl = flags.agent
const lifetime = parseInt(flags.lifetime || '3600', 10)
const onEvent = buildLogEmitter(flags.log === 'true')
const onEvent = buildLogEmitter(pickLogMode(flags))

if (!agentUrl) {
console.error(JSON.stringify({ error: '--agent <url> required' }))
Expand Down Expand Up @@ -244,7 +257,7 @@ function cmdConfig() {
}

function cmdShow(flags: Record<string, string> = {}) {
const onEvent = buildLogEmitter(flags.log === 'true')
const onEvent = buildLogEmitter(pickLogMode(flags))

console.log('@aauth/bootstrap — set up an agent identity for AAuth')
console.log('')
Expand Down Expand Up @@ -404,8 +417,9 @@ async function runBootstrapPS(flags: Record<string, string>) {
}
}

const logEnabled = flags.log === 'true'
const onEvent = buildLogEmitter(logEnabled)
const logMode = pickLogMode(flags)
const onEvent = buildLogEmitter(logMode)
const logEnabled = logMode !== undefined

if (logEnabled) {
onEvent?.({ step: 'bootstrap_started', phase: 'info', agentUrl, personServerUrl })
Expand Down
157 changes: 69 additions & 88 deletions bootstrap/src/log.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,3 @@
import { homedir } from 'node:os'
import { join } from 'node:path'
import { writeFileSync, mkdirSync } from 'node:fs'

export interface BootstrapEvent {
step: string
phase: 'start' | 'done' | 'info'
Expand All @@ -10,8 +6,6 @@ export interface BootstrapEvent {

export type OnBootstrapEvent = (event: BootstrapEvent) => void

const MARKER_PATH = join(homedir(), '.aauth', '.tldr-shown')

// ── ANSI styling (TTY only, respects NO_COLOR) ────────────────────────────────
const IS_TTY = process.stderr.isTTY === true || process.env.AAUTH_FORCE_PRETTY === '1'
const COLOR_ENABLED = IS_TTY && !process.env.NO_COLOR
Expand All @@ -28,48 +22,6 @@ const c = {
const RULE = '─'.repeat(80)
const section = (title: string) => `${c.dim('─── ')}${c.bold(title)} ${c.dim(RULE.slice(title.length + 5))}`

// ── TL;DR block (shown once at the top of bootstrap --ps --log) ───────────────
function renderTldr(): string {
return [
section('What is AAuth?'),
'',
'AAuth gives every agent its own cryptographic identity. The agent signs every',
'HTTP request with a private key only it holds; resources verify the signature',
'and decide whether to authorize. A Person Server represents the user and',
'grants the agent permission to act on their behalf — no pre-registration, no',
'shared secrets.',
'',
'Protocol parties:',
'',
` ${c.cyan('AGENT')} this CLI on your device. Identifies via an Ed25519 keypair`,
' generated locally — the private key never leaves the OS keychain.',
` ${c.green('RESOURCE')} the API the agent wants to call.`,
` ${c.magenta('PERSON SERVER')} represents the user. Holds identity, decides authorization,`,
' issues auth_tokens the resource will trust.',
` ${c.dim('ACCESS SERVER (out of scope for this demo) policy engine that guards')}`,
` ${c.dim('resources in federated mode.')}`,
'',
'The user (you) approves consent in a browser the first time the PS sees',
'this agent.',
'',
'The flow:',
'',
` ${c.dim('one-time')} ${c.cyan('AGENT')} generates keypair on this device`,
` ${c.cyan('AGENT')} registers a Person Server it will delegate consent to`,
` ${c.dim('per call')} ${c.cyan('AGENT')} ─▶ ${c.green('RESOURCE')} (401: who are you?)`,
` ${c.cyan('AGENT')} ─▶ ${c.magenta('PERSON SERVER')} (token exchange — first time needs consent)`,
` ${c.yellow('user')} ─▶ ${c.magenta('PERSON SERVER')} (approve in browser, first time only)`,
` ${c.cyan('AGENT')} ─▶ ${c.green('RESOURCE')} (200: data)`,
'',
`${c.dim('Key properties: agent identity without pre-registration · proof-of-possession')}`,
`${c.dim('on every request · user consent at the Person Server, never at the resource.')}`,
'',
`${c.dim("You're about to run the one-time setup.")}`,
'',
'',
].join('\n')
}

// ── Step 0 card builder (accumulates from events) ─────────────────────────────
interface Step0State {
agentUrl?: string
Expand Down Expand Up @@ -104,17 +56,15 @@ function renderStep0(state: Step0State, hasNewKey: boolean): string {

// Sub-bullet 1: keypair
if (hasNewKey && state.publicJwk && state.kid) {
lines.push(' • Generate Ed25519 keypair on this device — the private key stays in the OS')
lines.push(' keychain and never leaves. The public key thumbprint is the agent\'s identity.')
lines.push(...bulletWrap(describe('key_generation', 'start', { algorithm: state.algorithm ?? 'Ed25519' }) ?? ''))
lines.push('')
if (state.agentUrl) lines.push(` ${c.bold('agent')} ${state.agentUrl}`)
if (state.kid) lines.push(` ${c.bold('kid')} ${state.kid}`)
if (state.publicJwk) lines.push(` ${c.bold('public key')} ${renderJwk(state.publicJwk)}`)
if (state.jkt) lines.push(` ${c.bold('jkt')} ${state.jkt}`)
lines.push('')
} else if (state.agentUrl) {
lines.push(' • Use the existing keypair on this device — no new key generated.')
lines.push(' The public key thumbprint below is this agent\'s identity.')
lines.push(...bulletWrap(describe('key_info', 'info') ?? ''))
lines.push('')
lines.push(` ${c.bold('agent')} ${state.agentUrl}`)
if (state.kid) lines.push(` ${c.bold('kid')} ${state.kid} ${c.dim('(current)')}`)
Expand All @@ -125,14 +75,14 @@ function renderStep0(state: Step0State, hasNewKey: boolean): string {

// Sub-bullet 2: PS metadata
if (state.metadataUrl) {
lines.push(' • Fetch Person Server metadata to confirm it\'s reachable and well-formed.')
lines.push(...bulletWrap(describe('ps_metadata_request', 'start') ?? ''))
lines.push('')
const url = new URL(state.metadataUrl)
lines.push(` ${c.bold('GET')} ${url.pathname} HTTP/1.1`)
lines.push(` ${c.bold('Host:')} ${url.host}`)
lines.push('')
const statusColor = state.metadataStatus && state.metadataStatus < 300 ? c.green : c.red
lines.push(` ${c.dim('←')} HTTP/1.1 ${statusColor(String(state.metadataStatus ?? '?'))} ${state.metadataStatus === 200 ? 'OK' : ''}`)
lines.push(` HTTP/1.1 ${statusColor(String(state.metadataStatus ?? '?'))} ${state.metadataStatus === 200 ? 'OK' : ''}`)
lines.push(` ${c.bold('Content-Type:')} application/json`)
if (state.metadataBody) {
const body = JSON.stringify(state.metadataBody, null, 2).split('\n').map(l => ` ${l}`).join('\n')
Expand All @@ -142,25 +92,21 @@ function renderStep0(state: Step0State, hasNewKey: boolean): string {
}

if (state.personServerUrl) {
lines.push(` ${c.green('✓')} Bootstrap complete. The agent will bind to a user on its first authorized request.`)
const desc = describe('bootstrap_complete', 'info') ?? ''
const wrapped = wrap(desc, 76)
wrapped.forEach((line, i) => {
lines.push(i === 0 ? ` ${c.green('✓')} ${line}` : ` ${line}`)
})
lines.push('')
}
return lines.join('\n')
}

// ── Marker file ──────────────────────────────────────────────────────────────
function writeTldrMarker(): void {
try {
mkdirSync(join(homedir(), '.aauth'), { recursive: true })
writeFileSync(MARKER_PATH, new Date().toISOString(), 'utf8')
} catch {
// Non-fatal — marker is purely a UX hint
}
}

// ── Public API ────────────────────────────────────────────────────────────────

const narrations: Record<string, (e: BootstrapEvent) => string | undefined> = {
type EventDescriber = (e: BootstrapEvent) => string | undefined

const narrations: Record<string, EventDescriber> = {
backend_discovery: (e) => e.phase === 'start'
? 'Discovering available key backends on this machine'
: `Found ${(e.backends as unknown[] | undefined)?.length ?? 0} backend(s)`,
Expand All @@ -177,54 +123,89 @@ const narrations: Record<string, (e: BootstrapEvent) => string | undefined> = {
sign_token: (e) => e.phase === 'start' ? `Signing agent_token` : 'Agent token signed',
}

// Long-form per-step prose explaining what's happening at the protocol level.
// Single source of truth for both pretty (renderStep0) and JSON (--jsonl)
// consumers — same map approach as fetch/src/log.ts uses for the per-call flow.
const descriptions: Record<string, EventDescriber> = {
key_info: () =>
"Use the existing keypair on this device — no new key generated. The public key thumbprint below is this agent's identity.",
key_generation: (e) => e.phase === 'start'
? `Generate ${e.algorithm ?? 'Ed25519'} keypair on this device — the private key stays in the OS keychain and never leaves. The public key thumbprint is the agent's identity.`
: undefined,
ps_metadata_request: (e) => e.phase === 'start'
? "Fetch Person Server metadata to confirm it's reachable and well-formed."
: undefined,
bootstrap_complete: () =>
"Bootstrap complete. The agent will bind to a user on its first authorized request.",
}

function formatNdjson(event: BootstrapEvent): string {
const narration = narrations[event.step]?.(event)
const line = narration ? { ...event, narration } : event
const description = descriptions[event.step]?.(event)
const line: Record<string, unknown> = { ...event }
if (narration) line.narration = narration
if (description) line.description = description
return JSON.stringify(line) + '\n'
}

// Word-wrap a paragraph at `width` columns for terminal rendering.
function wrap(text: string, width = 78): string[] {
const words = text.split(/\s+/).filter(Boolean)
const lines: string[] = []
let cur = ''
for (const w of words) {
if (cur.length === 0) cur = w
else if (cur.length + 1 + w.length <= width) cur += ' ' + w
else { lines.push(cur); cur = w }
}
if (cur) lines.push(cur)
return lines
}

// Render a paragraph as a bullet with hanging-indent continuation lines.
// " • first line of paragraph..."
// " continuation..."
function bulletWrap(text: string, width = 76): string[] {
return wrap(text, width).map((line, i) => i === 0 ? ` • ${line}` : ` ${line}`)
}

// Look up a description for a synthetic event shape — used by renderStep0
// so both pretty and JSON consumers read from the same map.
function describe(step: string, phase: 'start' | 'done' | 'info', extra?: Record<string, unknown>): string | undefined {
const e: BootstrapEvent = { step, phase, ...(extra ?? {}) }
return descriptions[step]?.(e)
}

/**
* Build a stream-aware bootstrap event handler.
*
* When stderr is a TTY: prints TL;DR + Step 0 grouped card, then writes a
* marker file so a subsequent `fetch --log` can suppress its own TL;DR.
*
* When stderr is piped: emits NDJSON (one line per event) as before.
* mode='pretty': prints Step 0 grouped card on stderr.
* mode='jsonl': emits each event as one JSON object per line on stderr.
* mode=undefined: returns undefined (no logging).
*/
export function buildLogEmitter(enabled: boolean): OnBootstrapEvent | undefined {
if (!enabled) return undefined
export type LogMode = 'pretty' | 'jsonl'

const pretty = IS_TTY
export function buildLogEmitter(mode: LogMode | undefined): OnBootstrapEvent | undefined {
if (!mode) return undefined

if (!pretty) {
// Piped — keep NDJSON shape for programmatic consumers.
if (mode === 'jsonl') {
return (event: BootstrapEvent) => {
process.stderr.write(formatNdjson(event))
}
}

// TTY: collect events, render TL;DR once, then render Step 0 on completion.
let printedTldr = false
// TTY: collect events, render Step 0 on completion.
const state: Step0State = {}
let hasNewKey = false
let rendered = false

function maybePrintTldr() {
if (!printedTldr) {
process.stderr.write(renderTldr())
printedTldr = true
}
}

function finalize() {
if (rendered) return
rendered = true
process.stderr.write(renderStep0(state, hasNewKey))
writeTldrMarker()
}

return (event: BootstrapEvent) => {
maybePrintTldr()
switch (event.step) {
case 'bootstrap_started':
state.agentUrl = event.agentUrl as string | undefined
Expand Down Expand Up @@ -270,5 +251,5 @@ export function buildLogEmitter(enabled: boolean): OnBootstrapEvent | undefined

export function logEvent(enabled: boolean, event: BootstrapEvent): void {
if (!enabled) return
buildLogEmitter(true)?.(event)
buildLogEmitter('jsonl')?.(event)
}
18 changes: 17 additions & 1 deletion fetch/src/args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ export interface FetchArgs {
verbose: boolean
debug: boolean
log: boolean
jsonl: boolean
}

function usage(): never {
Expand Down Expand Up @@ -88,7 +89,10 @@ Interaction:
Output:
-v, --verbose Show headers + status on stderr
--debug Show all requests/responses with headers on stderr
--log Narrate each AAuth protocol step on stderr (JSONL)
--log Narrate each AAuth protocol step on stderr (human-readable)
--jsonl, --ndjson Emit each AAuth protocol step on stderr as one JSON object
per line. Each event carries 'narration' (one-line) and
'description' (paragraph) fields. Mutually exclusive with --log.
`)
process.exit(1)
}
Expand All @@ -109,6 +113,7 @@ export function parseArgs(argv: string[]): FetchArgs {
verbose: false,
debug: false,
log: false,
jsonl: false,
}

for (let i = 0; i < args.length; i++) {
Expand Down Expand Up @@ -215,6 +220,10 @@ export function parseArgs(argv: string[]): FetchArgs {
case '--log':
result.log = true
break
case '--jsonl':
case '--ndjson':
result.jsonl = true
break

default:
if (args[i].startsWith('-')) {
Expand All @@ -234,5 +243,12 @@ export function parseArgs(argv: string[]): FetchArgs {
result.signingKey = result.signingKey ?? process.env.AAUTH_SIGNING_KEY
result.personServer = result.personServer ?? process.env.AAUTH_PERSON_SERVER

// --log and --jsonl are mutually exclusive — they emit the same protocol
// events in different formats. Pick one.
if (result.log && result.jsonl) {
console.error(JSON.stringify({ error: '--log and --jsonl are mutually exclusive (same events, different formats). Pick one.' }))
process.exit(1)
}

return result
}
Loading
Loading