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
327 changes: 327 additions & 0 deletions package-lock.json

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,17 +49,20 @@
"homepage": "https://github.com/getagentseal/codeburn#readme",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.29.0",
"bonjour-service": "^1.4.1",
"chalk": "^5.4.1",
"commander": "^13.1.0",
"ink": "^7.0.0",
"react": "^19.2.5",
"selfsigned": "^5.5.0",
"strip-ansi": "^7.2.0",
"undici": "^7.27.2",
"zod": "^3.25.76"
},
"devDependencies": {
"@types/node": "^22.19.17",
"@types/react": "^19.2.14",
"@types/selfsigned": "^2.0.4",
"tsup": "^8.4.0",
"tsx": "^4.19.0",
"typescript": "^5.8.0",
Expand Down
71 changes: 71 additions & 0 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,12 @@ import { buildPeriodData, buildMenubarPayloadForRange } from './usage-aggregator
import { renderDashboard } from './dashboard.js'
import { renderOverview } from './overview.js'
import { runWebDashboard } from './web-dashboard.js'
import { hostname } from 'os'
import { runShareServer } from './sharing/share-run.js'
import { addRemote, linkRemote, pullDevices, renderDevices } from './sharing/host.js'
import { browse } from './sharing/discovery.js'
import { promptChoice } from './sharing/prompt.js'
import { loadRemotes, saveRemotes } from './sharing/store.js'
import { formatDateRangeLabel, parseDateRangeFlags, parseDayFlag, parseDaysFlag, getDateRange, toPeriod, type Period } from './cli-date.js'
import { runOptimize } from './optimize.js'
import { renderCompare } from './compare.js'
Expand Down Expand Up @@ -478,6 +484,71 @@ program
await renderDashboard(period, opts.provider, opts.refresh, opts.project, opts.exclude, customRange, customRangeLabel, daySelection?.day)
})

program
.command('share')
.description("Securely share this device's usage with your other devices on the same network")
.option('--port <number>', 'Port to listen on', parseInteger, 7777)
.option('--pair', 'Open a pairing window and print a PIN to add a new device')
.option('--always', 'Keep sharing until stopped (default stops after 10 min idle)')
.action(async (opts) => {
await runShareServer({ port: opts.port, pair: !!opts.pair, always: !!opts.always })
})

program
.command('devices [action] [target]')
.description('Combined usage across your devices. Actions: add (find nearby & pair) | add <host> --pin <pin> (manual) | rm <name>')
.option('--pin <pin>', 'Pairing PIN shown on the device you are adding')
.option('-p, --period <period>', 'Period: today, week, 30days, month, all', 'month')
.option('--port <number>', 'Default port when adding a device', parseInteger, 7777)
.action(async (action: string | undefined, target: string | undefined, opts) => {
await loadPricing()
if (action === 'add') {
if (target && opts.pin) {
const device = await addRemote(target, opts.pin, { defaultPort: opts.port })
console.log(`\n Paired with "${device.name}" (${device.host}:${device.port}).\n`)
return
}
process.stdout.write('\n Looking for devices on your network...\n')
const found = await browse(3000)
if (found.length === 0) {
console.error(' No devices found. On the other Mac run `codeburn share`, and make sure both are on the same Wi-Fi.\n')
process.exit(1)
}
let chosen = found[0]!
if (found.length > 1) {
found.forEach((d, i) => process.stdout.write(` ${i + 1}) ${d.name} (${d.host})\n`))
const n = await promptChoice(' Connect to which? [number]', found.length)
if (n < 1) {
console.error(' Cancelled.\n')
process.exit(1)
}
chosen = found[n - 1]!
}
const device = await linkRemote(chosen, {
onCode: (code) =>
process.stdout.write(`\n Connecting to "${chosen.name}". Confirm this code on that device: ${code}\n Waiting for approval...\n`),
})
console.log(`\n Paired with "${device.name}".\n`)
return
}
if (action === 'rm' || action === 'remove') {
const remotes = await loadRemotes()
const next = remotes.filter((r) => r.name !== target && `${r.host}:${r.port}` !== target)
await saveRemotes(next)
console.log(`\n Removed ${remotes.length - next.length} device(s).\n`)
return
}
const localGetUsage = async (q: { period?: string; from?: string; to?: string }) => {
const customRange = parseDateRangeFlags(q.from, q.to)
const periodInfo = customRange
? { range: customRange, label: formatDateRangeLabel(q.from, q.to) }
: getDateRange(toPeriod(q.period ?? opts.period))
return buildMenubarPayloadForRange(periodInfo, { provider: 'all', optimize: false })
}
const results = await pullDevices(localGetUsage, { period: opts.period }, hostname(), {})
process.stdout.write('\n' + renderDevices(results))
})

program
.command('overview')
.description('Plain-text usage overview, copy-pasteable (defaults to this month)')
Expand Down
89 changes: 89 additions & 0 deletions src/sharing/client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { request } from 'https'
import type { TLSSocket } from 'tls'

import { certFingerprint } from './pairing.js'
import type { Identity } from './identity.js'
import type { UsageQuery } from './share-server.js'

export type PeerEndpoint = {
identity: Identity // our own identity (we present our cert so the peer can bind a token to us)
host: string
port: number
// When set, the connection is aborted unless the peer's cert fingerprint matches.
expectedFingerprint?: string
}

export type Response = { status: number; serverFingerprint: string; json: unknown }

// One request to a peer. Self-signed certs are accepted at the TLS layer
// (rejectUnauthorized:false) but the peer is authenticated by pinning its cert
// fingerprint, the SSH/Syncthing trust-on-first-use model.
function call(
ep: PeerEndpoint,
method: string,
path: string,
headers: Record<string, string> = {},
body?: string,
): Promise<Response> {
return new Promise((resolve, reject) => {
const req = request(
{
host: ep.host,
port: ep.port,
method,
path,
key: ep.identity.key,
cert: ep.identity.cert,
rejectUnauthorized: false,
checkServerIdentity: () => undefined,
headers: { ...headers, ...(body ? { 'content-type': 'application/json' } : {}) },
},
(res) => {
const cert = (res.socket as TLSSocket).getPeerCertificate?.()
const serverFingerprint = cert?.raw ? certFingerprint(cert.raw) : ''
if (ep.expectedFingerprint && serverFingerprint !== ep.expectedFingerprint) {
res.destroy()
reject(new Error('server fingerprint mismatch'))
return
}
let data = ''
res.on('data', (chunk) => {
data += chunk
})
res.on('end', () => resolve({ status: res.statusCode ?? 0, serverFingerprint, json: safeJson(data) }))
},
)
req.on('error', reject)
if (body) req.write(body)
req.end()
})
}

export function hello(ep: PeerEndpoint): Promise<Response> {
return call(ep, 'GET', '/api/peer/hello')
}

export function pair(ep: PeerEndpoint, pin: string, name: string): Promise<Response> {
return call(ep, 'POST', '/api/peer/pair', {}, JSON.stringify({ pin, name }))
}

// Approve-style pairing: no PIN. The peer prompts its user to approve; this
// request stays open until they accept or decline.
export function pairRequest(ep: PeerEndpoint, name: string): Promise<Response> {
return call(ep, 'POST', '/api/peer/pair-request', {}, JSON.stringify({ name }))
}

export function fetchUsage(ep: PeerEndpoint, token: string, query: UsageQuery = {}): Promise<Response> {
const params = new URLSearchParams()
for (const [k, v] of Object.entries(query)) if (v) params.set(k, v)
const qs = params.toString()
return call(ep, 'GET', `/api/usage${qs ? `?${qs}` : ''}`, { authorization: `Bearer ${token}` })
}

function safeJson(s: string): unknown {
try {
return JSON.parse(s)
} catch {
return null
}
}
54 changes: 54 additions & 0 deletions src/sharing/discovery.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { Bonjour, type Service } from 'bonjour-service'

const SERVICE_TYPE = 'codeburn'

export type DiscoveredDevice = { name: string; host: string; port: number; fingerprint: string }

export type Advertiser = { stop: () => Promise<void> }

// Announce this device on the local network so others can find it without an IP.
export function advertise(opts: { name: string; port: number; fingerprint: string }): Advertiser {
const bonjour = new Bonjour()
bonjour.publish({
name: opts.name,
type: SERVICE_TYPE,
port: opts.port,
txt: { fp: opts.fingerprint, dn: opts.name, v: '1' },
})
return {
stop: () =>
new Promise<void>((resolve) => {
bonjour.unpublishAll(() => bonjour.destroy(() => resolve()))
}),
}
}

function pickAddress(service: Service): string | null {
const addrs = service.addresses ?? []
const ipv4 = addrs.find((a) => /^\d+\.\d+\.\d+\.\d+$/.test(a))
if (ipv4) return ipv4
if (service.host) return service.host
return addrs[0] ?? null
}

// Browse the local network for sharing devices for `timeoutMs`. Resolves to the
// devices found, deduped by fingerprint.
export function browse(timeoutMs = 2500): Promise<DiscoveredDevice[]> {
return new Promise((resolve) => {
const bonjour = new Bonjour()
const found = new Map<string, DiscoveredDevice>()
const browser = bonjour.find({ type: SERVICE_TYPE }, (service) => {
const txt = (service.txt ?? {}) as Record<string, string>
const fingerprint = txt['fp']
const address = pickAddress(service)
if (!fingerprint || !address) return
const name = txt['dn'] || service.name || address
found.set(fingerprint, { name, host: address, port: service.port, fingerprint })
})
const timer = setTimeout(() => {
browser.stop()
bonjour.destroy(() => resolve([...found.values()]))
}, timeoutMs)
timer.unref?.()
})
}
137 changes: 137 additions & 0 deletions src/sharing/host.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import { hello, pair, pairRequest, fetchUsage } from './client.js'
import { loadOrCreateIdentity } from './identity.js'
import { pairingCode } from './pairing.js'
import type { DiscoveredDevice } from './discovery.js'
import type { UsageQuery } from './share-server.js'
import { getSharingDir, loadRemotes, saveRemotes, type RemoteDevice } from './store.js'
import { formatCost } from '../currency.js'
import { formatTokens } from '../format.js'

// Minimal shape we read from a device's usage payload (the menubar payload).
type DevicePayload = {
current?: { cost?: number; calls?: number; sessions?: number; inputTokens?: number; outputTokens?: number }
}

export type DeviceUsage = {
name: string
local: boolean
payload?: DevicePayload
error?: string
}

function parseHostPort(input: string, defaultPort: number): { host: string; port: number } {
const idx = input.lastIndexOf(':')
if (idx > 0 && /^\d+$/.test(input.slice(idx + 1))) {
return { host: input.slice(0, idx), port: Number(input.slice(idx + 1)) }
}
return { host: input, port: defaultPort }
}

// Pair with a device the user is currently sharing (PIN shown on that device),
// pin its fingerprint, store the issued token, and persist it.
export async function addRemote(
input: string,
pin: string,
opts: { defaultPort: number; dir?: string },
): Promise<RemoteDevice> {
const dir = opts.dir ?? getSharingDir()
const identity = await loadOrCreateIdentity(dir)
const { host, port } = parseHostPort(input, opts.defaultPort)

const h = await hello({ identity, host, port })
if (h.status !== 200) throw new Error(`could not reach a CodeBurn device at ${host}:${port}`)
const info = h.json as { fingerprint: string; name: string }

const pr = await pair({ identity, host, port, expectedFingerprint: info.fingerprint }, pin, identity.name)
if (pr.status !== 200) {
const err = (pr.json as { error?: string })?.error ?? `HTTP ${pr.status}`
throw new Error(`pairing failed: ${err}`)
}
const token = (pr.json as { token: string }).token

const device: RemoteDevice = { name: info.name, host, port, fingerprint: info.fingerprint, token, addedAt: Date.now() }
const remotes = (await loadRemotes(dir)).filter((r) => r.fingerprint !== device.fingerprint)
remotes.push(device)
await saveRemotes(remotes, dir)
return device
}

// Pair with a discovered device using approve-style pairing (no PIN). The owner
// of that device approves on their screen after confirming the matching code.
export async function linkRemote(
d: DiscoveredDevice,
opts: { dir?: string; onCode?: (code: string) => void } = {},
): Promise<RemoteDevice> {
const dir = opts.dir ?? getSharingDir()
const identity = await loadOrCreateIdentity(dir)
const code = pairingCode(identity.fingerprint, d.fingerprint)
opts.onCode?.(code)
const r = await pairRequest({ identity, host: d.host, port: d.port, expectedFingerprint: d.fingerprint }, identity.name)
if (r.status !== 200) {
throw new Error(r.status === 403 ? 'the other device declined' : `pairing failed (HTTP ${r.status})`)
}
const token = (r.json as { token: string }).token
const device: RemoteDevice = { name: d.name, host: d.host, port: d.port, fingerprint: d.fingerprint, token, addedAt: Date.now() }
const remotes = (await loadRemotes(dir)).filter((x) => x.fingerprint !== device.fingerprint)
remotes.push(device)
await saveRemotes(remotes, dir)
return device
}

// Pull this machine's usage plus every paired remote's, each kept separate.
export async function pullDevices(
localGetUsage: (q: UsageQuery) => Promise<DevicePayload>,
query: UsageQuery,
localName: string,
opts: { dir?: string } = {},
): Promise<DeviceUsage[]> {
const dir = opts.dir ?? getSharingDir()
const identity = await loadOrCreateIdentity(dir)
const remotes = await loadRemotes(dir)

const results: DeviceUsage[] = [{ name: localName, local: true, payload: await localGetUsage(query) }]
for (const r of remotes) {
try {
const res = await fetchUsage({ identity, host: r.host, port: r.port, expectedFingerprint: r.fingerprint }, r.token, query)
if (res.status === 200) results.push({ name: r.name, local: false, payload: res.json as DevicePayload })
else results.push({ name: r.name, local: false, error: res.status === 401 ? 'not authorized (re-pair?)' : `HTTP ${res.status}` })
} catch (e) {
results.push({ name: r.name, local: false, error: e instanceof Error ? e.message : String(e) })
}
}
return results
}

export function renderDevices(results: DeviceUsage[]): string {
const num = (n: number | undefined): number => n ?? 0
const rows = results.map((d) => {
const c = d.payload?.current
return {
name: d.name + (d.local ? ' (this Mac)' : ''),
cost: num(c?.cost),
tokens: num(c?.inputTokens) + num(c?.outputTokens),
calls: num(c?.calls),
sessions: num(c?.sessions),
error: d.error,
}
})
const combined = rows.reduce(
(a, r) => ({ cost: a.cost + r.cost, tokens: a.tokens + r.tokens, calls: a.calls + r.calls, sessions: a.sessions + r.sessions }),
{ cost: 0, tokens: 0, calls: 0, sessions: 0 },
)

const nameW = Math.max(8, ...rows.map((r) => r.name.length), 'Combined'.length)
const line = (name: string, cost: string, tokens: string, calls: string): string =>
` ${name.padEnd(nameW)} ${cost.padStart(11)} ${tokens.padStart(9)} ${calls.padStart(8)}`

const out: string[] = []
out.push(line('Device', 'Cost', 'Tokens', 'Calls'))
out.push(' ' + '-'.repeat(nameW + 11 + 9 + 8 + 6))
for (const r of rows) {
if (r.error) out.push(line(r.name, '-', '-', r.error))
else out.push(line(r.name, formatCost(r.cost), formatTokens(r.tokens), r.calls.toLocaleString()))
}
out.push(' ' + '-'.repeat(nameW + 11 + 9 + 8 + 6))
out.push(line('Combined', formatCost(combined.cost), formatTokens(combined.tokens), combined.calls.toLocaleString()))
return out.join('\n') + '\n'
}
Loading