v1.3.0
21 unmodified lines222324252626272829303132333435363738394041424344454647484950111 unmodified linesExpand all1621631641651551661671571681691701711725 unmodified lines17817918018117118218318418518618718818917918019019119219319419552 unmodified lines248249250251252241242253254255256257258393 unmodified linesExpand all6526536546556446456566576586596606616626636646656666676686696706716726736746756766776786796806816826834 unmodified lines6886896906916606616626636646656666676926936946956966976986997007017027037047057067077087097107116696707127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867874 unmodified lines21 unmodified lines privateShares,} from './commands/private.ts'import { pull } from './commands/pull.ts'import { serve } from './commands/serve.ts'import { authenticate, authenticateOAuth, clearDirSession, clearSessions, hasDirSession } from './lib/auth.ts'import { authenticate, authenticateOAuth, clearDirSession, clearSessions, listStoredAccounts, loginWithAppPassword, logoutAccount, resolveAccountForCwd, setDefaultAccount,} from './lib/auth.ts'import { addXrpcAuthOptions, authenticateForXrpc, bindAuthStatusToSpinner, withExit, type XrpcCommandOptions,} from './lib/command-utils.ts'import { createSpinner, pc } from './lib/progress.ts'import { callWispXrpc } from './lib/xrpc.ts'import packageJson from './package.json'
const program = new Command()program.enablePositionalOptions()
111 unmodified linesExpand all }}
program .name('wisp-cli') .name('wispctl') .description('CLI for wisp.place - deploy static sites to the AT Protocol') .version('1.2.0') .version(packageJson.version) .option('-q, --quiet', 'Suppress progress output — useful for CI/agents (also set via WISPCTL_NO_PROGRESS=1)')
// Deploy command (default)program5 unmodified lines .option('--spa', 'Enable SPA mode (serve index.html for all routes)') .option('-c, --concurrency <n>', 'Number of concurrent uploads (backs off to 2 on rate limit)', '3') .option('--force-gzip', 'Force gzip compression for all files regardless of type') .option('--password <password>', 'App password for headless authentication') .option('--db <path>', 'OAuth session database path') .option('--db <path>', 'Account database path') .option('-y, --yes', 'Skip confirmation prompts') .action( withExit(async (handle: string | undefined, options) => { let resolvedHandle = handle let resolvedPath = options.path let resolvedSite = options.site
const hasDirSess = !resolvedHandle && !options.password && (await hasDirSession(options.db)) const needsHandlePrompt = !resolvedHandle && !hasDirSess const knownAccount = resolvedHandle || options.password ? undefined : await resolveAccountForCwd(options.db) const needsHandlePrompt = !resolvedHandle && !knownAccount const needsPrompts = needsHandlePrompt || !resolvedPath || !resolvedSite
if (needsPrompts) { intro(pc.cyan('wisp.place deploy'))52 unmodified lines })
console.log() console.log(pc.dim(` URI: ${result.uri}`)) const displayHandle = resolvedHandle ?? knownAccount?.handle if (resolvedHandle) { console.log(pc.cyan(` URL: https://sites.wisp.place/${resolvedHandle}/${resolvedSite}`)) if (displayHandle) { console.log(pc.cyan(` URL: https://sites.wisp.place/${displayHandle}/${resolvedSite}`)) } console.log(pc.cyan(` URL: ${result.url}`))
if (needsPrompts) {393 unmodified linesExpand all
// Login commandprogram .command('login <handle>') .description('Authenticate and store session for the current directory') .option('--db <path>', 'OAuth session database path') .description('Authenticate and store credentials for use from any directory') .option('--db <path>', 'Account database path') .option('--password <password>', 'Log in with an app password instead of OAuth (or set WISPCTL_APP_PASSWORD)') .action( withExit(async (handle: string, options) => { const appPassword = options.password ?? process.env.WISPCTL_APP_PASSWORD if (appPassword !== undefined) { const authSpinner = createSpinner('Authenticating...').start() const result = await loginWithAppPassword(handle, appPassword, { dbPath: options.db, onStatus: bindAuthStatusToSpinner(authSpinner), }) authSpinner.succeed(`Authenticated as ${result.handle}`) if (result.stored) { console.log(pc.dim(' App password saved to the system keychain')) } else { console.log( pc.yellow(` App password not saved: ${result.keychainDetail ?? 'no OS credential store available.'}`), ) console.log(pc.dim(' Use --password or WISPCTL_APP_PASSWORD for future commands.')) } return }
const authSpinner = createSpinner('Authenticating...').start() const { did } = await authenticateOAuth(handle, { dbPath: options.db, onStatus: bindAuthStatusToSpinner(authSpinner),4 unmodified lines )
// Logout commandprogram .command('logout') .description('Clear the stored session for the current directory') .option('--db <path>', 'OAuth session database path') .option('--all', 'Clear all stored sessions across all directories') .action(async (options) => { if (options.all) { await clearSessions(options.db) } else { .command('logout [handle]') .description('Unlink the current directory, or forget an account everywhere') .option('--db <path>', 'Account database path') .option('--all', 'Forget every stored account and credential') .action( withExit(async (handle: string | undefined, options) => { if (options.all) { await clearSessions(options.db) return } if (handle) { const account = await logoutAccount(handle, options.db) if (account) { console.log(`Forgot ${account.handle ?? account.did} and removed its stored credentials`) } else { console.log(`No stored account for ${handle}`) } return } await clearDirSession(options.db) } }) }), )
// Accounts commandconst accountsCommand = program.command('accounts').description('Manage stored accounts')
accountsCommand .command('list', { isDefault: true }) .description('List stored accounts') .option('--db <path>', 'Account database path') .option('--json', 'Output raw JSON') .action( withExit(async (options) => { const accounts = await listStoredAccounts(options.db)
if (options.json) { console.log(JSON.stringify(accounts, null, 2)) return }
if (accounts.length === 0) { console.log('No stored accounts. Run `wispctl login <handle>` to add one.') return }
console.log() for (const account of accounts) { const tags = [ account.method === 'app-password' ? 'app password' : 'oauth', account.hasCredential ? undefined : pc.yellow('no credential'), account.isDefault ? pc.green('default') : undefined, account.isCurrentDir ? pc.cyan('this directory') : undefined, ].filter(Boolean) console.log(` ${pc.bold(account.handle ?? account.did)} ${pc.dim(`(${tags.join(', ')})`)}`) // Only worth a second line when it is not just the title again. if (account.handle) { console.log(pc.dim(` ${account.did}`)) } if (account.dirs.length > 0) { console.log(pc.dim(` ${account.dirs.length} linked director${account.dirs.length === 1 ? 'y' : 'ies'}`)) } } console.log() }), )
accountsCommand .command('use <handle>') .description('Set the account used in directories with no linked account') .option('--db <path>', 'Account database path') .action( withExit(async (handle: string, options) => { const account = await setDefaultAccount(handle, options.db) console.log(`Default account set to ${account.handle ?? account.did}`) }), )
accountsCommand .command('remove <handle>') .description('Forget an account and remove its stored credentials') .option('--db <path>', 'Account database path') .action( withExit(async (handle: string, options) => { const account = await logoutAccount(handle, options.db) if (account) { console.log(`Forgot ${account.handle ?? account.did} and removed its stored credentials`) } else { console.log(`No stored account for ${handle}`) } }), )
// Set WISPCTL_NO_PROGRESS from --quiet flag before any command runsprogram.hook('preAction', () => { if (program.opts().quiet) { process.env.WISPCTL_NO_PROGRESS = '1'4 unmodified lines1233456744 unmodified lines{ "name": "wispctl", "version": "1.2.1", "version": "1.3.0", "main": "./dist/index.js", "devDependencies": { "@atproto/api": "^0.20.28", "@atproto/identity": "^0.5.6",44 unmodified lines1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556#!/usr/bin/env bash## Build the distributable wispctl binaries into ./binaries.## Binaries keep the legacy `wisp-cli-*` names so existing CI pipelines that curl# them keep working.## Bun 1.3.14 cannot cross-compile a working linux executable from macOS — the# resulting binary dies at startup inside @noble/curves ("bad curve params:# generator point"). The JS bundle itself is fine, so the linux targets are# built natively inside linux containers instead. macOS targets cross-compile# correctly from an arm64 host.set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"OUT_DIR="$REPO_ROOT/binaries"BUN_IMAGE="oven/bun:1.3.14-slim"
mkdir -p "$OUT_DIR"cd "$REPO_ROOT/cli"
host_build() { local target="$1" out="$2" echo "==> $out ($target, host)" bun build ./index.ts --compile --target="$target" --outfile="$OUT_DIR/$out"}
docker_build() { local platform="$1" target="$2" out="$3" echo "==> $out ($target, docker $platform)" # Run from a writable cwd: building a target other than the container's own # downloads that target's runtime next to the working directory, and the repo # is mounted read-only. docker run --rm --platform "$platform" \ -v "$REPO_ROOT":/repo:ro -v "$OUT_DIR":/out -w /tmp \ -e BUN_INSTALL=/tmp/bun \ "$BUN_IMAGE" bun build /repo/cli/index.ts --compile --target="$target" --outfile="/out/$out"}
host_build bun-darwin-arm64 wisp-cli-aarch64-darwinhost_build bun-darwin-x64 wisp-cli-x86_64-darwindocker_build linux/amd64 bun-linux-x64 wisp-cli-x86_64-linuxdocker_build linux/arm64 bun-linux-arm64 wisp-cli-aarch64-linux# Windows has no native runner here. Build it from the linux container, which is# the toolchain that produces working non-macOS output — this binary is the one# target that cannot be smoke-tested locally.docker_build linux/amd64 bun-windows-x64 wisp-cli-x86_64-windows.exe
# Universal macOS binary for the install docs.echo "==> wisp-cli-darwin-universal (lipo)"lipo -create -output "$OUT_DIR/wisp-cli-darwin-universal" \ "$OUT_DIR/wisp-cli-aarch64-darwin" "$OUT_DIR/wisp-cli-x86_64-darwin"
chmod +x "$OUT_DIR"/wisp-cli-*cd "$OUT_DIR"shasum -a 256 wisp-cli-* | tee SHA256SUMS58 unmodified lines59606162636465666768697018 unmodified lines58 unmodified lines
alice_app_password="$existing_app_password"if [ -z "$alice_app_password" ] || ! login "$ALICE_HANDLE" "$alice_app_password" >/dev/null; then log 'creating alice app password' curl -fsS -X POST "$PDS_URL/xrpc/com.atproto.server.revokeAppPassword" \ -H 'Content-Type: application/json' \ -H "Authorization: Bearer $alice_access" \ -d '{"name":"wisp-dev"}' >/dev/null 2>&1 || true alice_app_password="$(curl -fsS -X POST "$PDS_URL/xrpc/com.atproto.server.createAppPassword" \ -H 'Content-Type: application/json' \ -H "Authorization: Bearer $alice_access" \ -d '{"name":"wisp-dev"}' | jq -er '.password')"18 unmodified lines44 unmodified lines45464748495051525354555657585960616257 unmodified lines44 unmodified lines} satisfies Record<string, Author>
const posts: Post[] = [ { slug: 'wispctl-1-3', title: 'wispctl 1.3 ', description: 'Making wispctl\'s auth more ergonomic to use', date: '2026-08-26', author: authors.nekomimi, tags: ['release', 'cli'], draft: true, }, { slug: 'hello-world', title: 'wisp.place has a blog now', description: 'Somewhere to put release notes, architecture write-ups and the occasional postmortem — instead of burying them in commit messages.',57 unmodified lines12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455import { afterEach, describe, expect, test } from 'bun:test'import { mkdtempSync, rmSync, statSync } from 'node:fs'import { tmpdir } from 'node:os'import { join } from 'node:path'import { listAccounts, openKv, readAccount, resolveIdentifierToDid, upsertAccount } from './account-store.ts'import { logoutAccount } from './auth.ts'
const temporaryDirectories: string[] = []
function createDatabasePath(): string { const directory = mkdtempSync(join(tmpdir(), 'wispctl-account-store-')) temporaryDirectories.push(directory) return join(directory, 'state.sqlite')}
afterEach(() => { for (const directory of temporaryDirectories.splice(0)) { rmSync(directory, { recursive: true, force: true }) }})
describe('account store', () => { test('creates the state database with owner-only permissions', async () => { if (process.platform === 'win32') return
const dbPath = createDatabasePath() await openKv(dbPath)
expect(statSync(dbPath).mode & 0o777).toBe(0o600) })
test('keeps a handle alias attached to only one account', async () => { const kv = await openKv(createDatabasePath()) upsertAccount(kv, 'did:plc:old', { handle: 'alice.example', handleChecked: true }) upsertAccount(kv, 'did:plc:new', { handle: 'alice.example', handleChecked: true })
expect(readAccount(kv, 'did:plc:old')?.handle).toBeUndefined() expect(readAccount(kv, 'did:plc:new')?.handle).toBe('alice.example') expect(await resolveIdentifierToDid(kv, '@ALICE.EXAMPLE')).toBe('did:plc:new') })
test('ignores malformed account rows', async () => { const kv = await openKv(createDatabasePath()) kv.set('account:did:plc:broken', '{"did":"did:plc:broken","method":"unknown"}', null)
expect(readAccount(kv, 'did:plc:broken')).toBeUndefined() expect(listAccounts(kv)).toEqual([]) })
test('does not report an unknown did as removed', async () => { const dbPath = createDatabasePath()
expect(await logoutAccount('did:plc:missing', dbPath)).toBeUndefined() })})123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613import { chmodSync, closeSync, mkdirSync, openSync } from 'node:fs'import { homedir } from 'node:os'import { dirname, join } from 'node:path'import { cwd } from 'node:process'import { getHandleForDid, resolveDid } from '@wispplace/atproto-utils'import { isBun } from '@wispplace/bun-firehose'
export const KEYCHAIN_SERVICE = 'wispctl'export const DEFAULT_DB_PATH = join(homedir(), '.config', 'wispctl', 'state.sqlite')
/** * OAuth sessions are stored under the bare DID (the `sub` the atproto session * store hands us), so existing keychain entries keep working untouched. App * passwords are long-lived credentials and live under their own prefix. */const APP_PASSWORD_ACCOUNT_PREFIX = 'app-password:'
/** Read-only sentinel used to check the credential store is reachable. */const PROBE_ACCOUNT = '__wispctl_probe__'
const ACCOUNT_PREFIX = 'account:'const HANDLE_PREFIX = 'handle:'const DIR_PREFIX = 'dir:'const DEFAULT_ACCOUNT_KEY = 'default_account'const STORE_VERSION_KEY = 'store_version'const STORE_VERSION = '1'
/** How long a cached handle -> DID alias is trusted before re-resolving. */const HANDLE_ALIAS_TTL_MS = 24 * 60 * 60 * 1000
export type AuthMethod = 'oauth' | 'app-password'
export interface StoredAccount { did: string handle?: string method: AuthMethod pdsUrl?: string addedAt: number lastUsedAt: number /** When `handle` was last confirmed against the network. */ handleCheckedAt?: number}
// ---------------------------------------------------------------------------// Keychain access// ---------------------------------------------------------------------------
interface KeyringEntryLike { setPassword(password: string): void getPassword(): string | null deletePassword(): void}
export type KeyringEntryConstructor = new (service: string, account: string) => KeyringEntryLike
export interface KeychainProbeResult { available: boolean detail?: string moduleAvailable: boolean}
let keyringEntryConstructor: KeyringEntryConstructor | null | undefined
export async function getKeyringEntryConstructor(): Promise<KeyringEntryConstructor | null> { if (keyringEntryConstructor !== undefined) { return keyringEntryConstructor } try { const module = await import('@napi-rs/keyring') keyringEntryConstructor = module.Entry as KeyringEntryConstructor } catch { keyringEntryConstructor = null } return keyringEntryConstructor}
function formatProbeError(error: unknown): string | undefined { if (error instanceof Error) { return error.message } if (typeof error === 'string') { return error } return undefined}
export function describeUnavailableKeychain(result: KeychainProbeResult): string { if (process.platform === 'darwin') { if (!result.moduleAvailable) { return 'macOS Keychain support is unavailable in this build.' } if (result.detail?.toLowerCase().includes('authorization')) { return 'macOS Keychain access could not be authorized.' } return result.detail ? `macOS Keychain access failed: ${result.detail}` : 'macOS Keychain access is unavailable.' }
if (process.platform === 'linux') { if (!result.moduleAvailable) { return 'System keychain support is unavailable in this build.' } if (result.detail?.toLowerCase().includes('secret service')) { return 'System keychain is unavailable (no Secret Service daemon or equivalent).' } return result.detail ? `System keychain access failed: ${result.detail}` : 'System keychain is unavailable.' }
if (process.platform === 'win32') { if (!result.moduleAvailable) { return 'Windows Credential Manager support is unavailable in this build.' } return result.detail ? `Windows Credential Manager access failed: ${result.detail}` : 'Windows Credential Manager is unavailable.' }
if (!result.moduleAvailable) { return 'Secure OS credential storage is unavailable in this build.' } return result.detail ? `Secure OS credential storage failed: ${result.detail}` : 'Secure OS credential storage is unavailable.'}
/** * Every keychain touch is a separate OS credential-store access, and on macOS an * unauthorized binary is prompted for each one. A single command used to cost * five: a write plus a delete to probe, then reads for the session and the app * password. So keep one `Entry` per account for the life of the process, cache * what we read, and never write just to see whether the store works. */const entryCache = new Map<string, KeyringEntryLike>()const secretCache = new Map<string, string | null>()
async function getEntry(account: string): Promise<KeyringEntryLike | null> { const KeyringEntry = await getKeyringEntryConstructor() if (!KeyringEntry) return null
const cached = entryCache.get(account) if (cached) return cached
const entry = new KeyringEntry(KEYCHAIN_SERVICE, account) entryCache.set(account, entry) return entry}
async function readSecret(account: string): Promise<string | null> { const cached = secretCache.get(account) if (cached !== undefined) return cached
const entry = await getEntry(account) if (!entry) return null
let value: string | null = null try { value = entry.getPassword() } catch { value = null } secretCache.set(account, value) return value}
async function writeSecret(account: string, value: string): Promise<boolean> { const entry = await getEntry(account) if (!entry) return false try { entry.setPassword(value) secretCache.set(account, value) return true } catch { return false }}
async function removeSecret(account: string): Promise<void> { const entry = await getEntry(account) if (!entry) return try { entry.deletePassword() } catch {} secretCache.set(account, null)}
let keychainProbe: KeychainProbeResult | undefined
export async function probeKeychain(): Promise<KeychainProbeResult> { if (keychainProbe) return keychainProbe
const KeyringEntry = await getKeyringEntryConstructor() if (!KeyringEntry) { keychainProbe = { available: false, moduleAvailable: false } return keychainProbe }
try { // Reading a key that was never stored returns null rather than throwing, // which is enough to prove the store is reachable. A missing backend (no // Secret Service, say) throws instead. Unlike the write-then-delete probe // this replaces, it does not ask the user to authorize anything. await getEntry(PROBE_ACCOUNT).then((entry) => entry?.getPassword()) keychainProbe = { available: true, moduleAvailable: true } } catch (error) { keychainProbe = { available: false, detail: formatProbeError(error), moduleAvailable: true, } } return keychainProbe}
export async function getStoredAppPassword(did: string): Promise<string | null> { return await readSecret(`${APP_PASSWORD_ACCOUNT_PREFIX}${did}`)}
/** * Persist an app password. Returns false when there is no OS credential store — * app passwords are long-lived and full-scope, so unlike refreshable OAuth * tokens they are never written to the plaintext SQLite fallback. */export async function setStoredAppPassword(did: string, password: string): Promise<boolean> { return await writeSecret(`${APP_PASSWORD_ACCOUNT_PREFIX}${did}`, password)}
export async function deleteStoredAppPassword(did: string): Promise<void> { await removeSecret(`${APP_PASSWORD_ACCOUNT_PREFIX}${did}`)}
export async function getStoredOAuthSession(did: string): Promise<string | null> { return await readSecret(did)}
export async function setStoredOAuthSession(did: string, session: string): Promise<boolean> { return await writeSecret(did, session)}
export async function deleteStoredOAuthSession(did: string): Promise<void> { await removeSecret(did)}
// ---------------------------------------------------------------------------// Runtime-agnostic KV adapter — all SQL is baked in so return types are known// ---------------------------------------------------------------------------
interface KvRow { value: string expires_at: number | null}
interface KvEntryRow extends KvRow { key: string}
export interface KvAdapter { get(key: string): KvRow | undefined set(key: string, value: string, expiresAt: number | null): void del(key: string): void clear(): void entriesByPrefix(prefix: string): { key: string; value: string }[]}
const SCHEMA = ` CREATE TABLE IF NOT EXISTS kv ( key TEXT PRIMARY KEY, value TEXT NOT NULL, expires_at INTEGER )`
const PREFIX_QUERY = "SELECT key, value, expires_at FROM kv WHERE key LIKE ? ESCAPE '\\'"
/** Escape LIKE wildcards so a prefix is matched literally. */function likePrefix(prefix: string): string { return `${prefix.replace(/[\\%_]/g, '\\$&')}%`}
function liveEntries(rows: KvEntryRow[]): { key: string; value: string }[] { const now = Date.now() return rows .filter((row) => row.expires_at === null || row.expires_at > now) .map((row) => ({ key: row.key, value: row.value }))}
function prepareDatabaseFile(dbPath: string): void { if (dbPath === ':memory:') return
mkdirSync(dirname(dbPath), { recursive: true, mode: 0o700 }) const fd = openSync(dbPath, 'a', 0o600) closeSync(fd) chmodSync(dbPath, 0o600)}
export async function openKv(dbPath: string): Promise<KvAdapter> { prepareDatabaseFile(dbPath)
if (isBun) { const { Database } = await import('bun:sqlite') const db = new Database(dbPath) db.run('PRAGMA journal_mode = WAL') db.run(SCHEMA) const getStmt = db.query<KvRow, [string]>('SELECT value, expires_at FROM kv WHERE key = ?') const setStmt = db.query('INSERT OR REPLACE INTO kv (key, value, expires_at) VALUES (?, ?, ?)') const delStmt = db.query('DELETE FROM kv WHERE key = ?') const prefixStmt = db.query<KvEntryRow, [string]>(PREFIX_QUERY) return { get: (key) => getStmt.get(key) ?? undefined, set: (key, value, expiresAt) => { setStmt.run(key, value, expiresAt) }, del: (key) => { delStmt.run(key) }, clear: () => db.run('DELETE FROM kv'), entriesByPrefix: (prefix) => liveEntries(prefixStmt.all(likePrefix(prefix))), } } else { const { DatabaseSync } = await import('node:sqlite') const db = new DatabaseSync(dbPath) db.exec('PRAGMA journal_mode = WAL') db.exec(SCHEMA) const getStmt = db.prepare('SELECT value, expires_at FROM kv WHERE key = ?') const setStmt = db.prepare('INSERT OR REPLACE INTO kv (key, value, expires_at) VALUES (?, ?, ?)') const delStmt = db.prepare('DELETE FROM kv WHERE key = ?') const prefixStmt = db.prepare(PREFIX_QUERY) return { get: (key) => getStmt.get(key) as KvRow | undefined, set: (key, value, expiresAt) => { setStmt.run(key, value, expiresAt) }, del: (key) => { delStmt.run(key) }, clear: () => db.exec('DELETE FROM kv'), entriesByPrefix: (prefix) => liveEntries(prefixStmt.all(likePrefix(prefix)) as unknown as KvEntryRow[]), } }}
export function kvGet(kv: KvAdapter, key: string): string | undefined { const row = kv.get(key) if (!row) return undefined if (row.expires_at !== null && row.expires_at <= Date.now()) { kv.del(key) return undefined } return row.value}
export function kvSet(kv: KvAdapter, key: string, value: string, ttlSeconds?: number): void { const expiresAt = ttlSeconds !== undefined ? Date.now() + ttlSeconds * 1000 : null kv.set(key, value, expiresAt)}
// ---------------------------------------------------------------------------// Account records// ---------------------------------------------------------------------------
export function normalizeHandle(handle: string): string { return handle.trim().toLowerCase().replace(/^@/, '')}
function parseStoredAccount(raw: string): StoredAccount | undefined { let value: unknown try { value = JSON.parse(raw) } catch { return undefined } if (!value || typeof value !== 'object') return undefined
const account = value as Partial<StoredAccount> if ( typeof account.did !== 'string' || !account.did.startsWith('did:') || (account.handle !== undefined && typeof account.handle !== 'string') || (account.pdsUrl !== undefined && typeof account.pdsUrl !== 'string') || (account.method !== 'oauth' && account.method !== 'app-password') || typeof account.addedAt !== 'number' || !Number.isFinite(account.addedAt) || typeof account.lastUsedAt !== 'number' || !Number.isFinite(account.lastUsedAt) || (account.handleCheckedAt !== undefined && (typeof account.handleCheckedAt !== 'number' || !Number.isFinite(account.handleCheckedAt))) ) { return undefined }
return account as StoredAccount}
export function readAccount(kv: KvAdapter, did: string): StoredAccount | undefined { const raw = kvGet(kv, `${ACCOUNT_PREFIX}${did}`) return raw ? parseStoredAccount(raw) : undefined}
export function writeAccount(kv: KvAdapter, account: StoredAccount): void { kvSet(kv, `${ACCOUNT_PREFIX}${account.did}`, JSON.stringify(account)) if (account.handle) { kvSet(kv, `${HANDLE_PREFIX}${account.handle}`, account.did) }}
export function listAccounts(kv: KvAdapter): StoredAccount[] { return kv .entriesByPrefix(ACCOUNT_PREFIX) .map(({ value }) => parseStoredAccount(value)) .filter((account): account is StoredAccount => account !== undefined) .sort((a, b) => (a.handle ?? a.did).localeCompare(b.handle ?? b.did))}function detachHandle(kv: KvAdapter, did: string, handle: string): void { const aliasKey = `${HANDLE_PREFIX}${handle}` if (kvGet(kv, aliasKey) === did) { kv.del(aliasKey) }
const account = readAccount(kv, did) if (account?.handle !== handle) return
const updated = { ...account } delete updated.handle delete updated.handleCheckedAt kvSet(kv, `${ACCOUNT_PREFIX}${did}`, JSON.stringify(updated))}
/** * Create or refresh the account record for `did`. Non-secret bookkeeping only — * credentials themselves live in the keychain. */export function upsertAccount( kv: KvAdapter, did: string, updates: { handle?: string; method?: AuthMethod; pdsUrl?: string; handleChecked?: boolean } = {},): StoredAccount { const now = Date.now() const existing = readAccount(kv, did) const handle = updates.handle ? normalizeHandle(updates.handle) : existing?.handle
if (existing?.handle && existing.handle !== handle) { detachHandle(kv, did, existing.handle) } if (handle) { const ownerDid = kvGet(kv, `${HANDLE_PREFIX}${handle}`) if (ownerDid && ownerDid !== did) { detachHandle(kv, ownerDid, handle) } }
const account: StoredAccount = { did, handle, method: updates.method ?? existing?.method ?? 'oauth', pdsUrl: updates.pdsUrl ?? existing?.pdsUrl, addedAt: existing?.addedAt ?? now, lastUsedAt: now, handleCheckedAt: updates.handleChecked ? now : handle !== existing?.handle ? undefined : existing?.handleCheckedAt, } writeAccount(kv, account) return account}
export function deleteAccount(kv: KvAdapter, did: string): void { const account = readAccount(kv, did) if (account?.handle) { kv.del(`${HANDLE_PREFIX}${account.handle}`) } // Any other alias rows still pointing at this DID. for (const { key, value } of kv.entriesByPrefix(HANDLE_PREFIX)) { if (value === did) kv.del(key) } for (const { key, value } of kv.entriesByPrefix(DIR_PREFIX)) { if (value === did) kv.del(key) } if (kvGet(kv, DEFAULT_ACCOUNT_KEY) === did) { kv.del(DEFAULT_ACCOUNT_KEY) } kv.del(`${ACCOUNT_PREFIX}${did}`)}
// ---------------------------------------------------------------------------// Directory + default account mappings// ---------------------------------------------------------------------------
export function dirKey(dir: string = cwd()): string { return `${DIR_PREFIX}${dir}`}
export function getDirDid(kv: KvAdapter): string | undefined { return kvGet(kv, dirKey())}
export function setDirDid(kv: KvAdapter, did: string): void { kvSet(kv, dirKey(), did)}
export function clearDirDid(kv: KvAdapter): void { kv.del(dirKey())}
export function getDefaultDid(kv: KvAdapter): string | undefined { return kvGet(kv, DEFAULT_ACCOUNT_KEY)}
export function setDefaultDid(kv: KvAdapter, did: string): void { kvSet(kv, DEFAULT_ACCOUNT_KEY, did)}
export function listDirsForDid(kv: KvAdapter, did: string): string[] { return kv .entriesByPrefix(DIR_PREFIX) .filter(({ value }) => value === did) .map(({ key }) => key.slice(DIR_PREFIX.length))}
/** * Which account a bare command in this directory should use. * * Directory mapping wins, then an explicitly chosen default (`wispctl accounts * use`), then a lone stored account. With several accounts and no explicit * choice we return nothing so the caller prompts rather than guessing an * identity to deploy as. */export function resolveAccountForDir(kv: KvAdapter): StoredAccount | undefined { const dirDid = getDirDid(kv) if (dirDid) { return readAccount(kv, dirDid) ?? upsertAccount(kv, dirDid) }
const defaultDid = getDefaultDid(kv) if (defaultDid) { const account = readAccount(kv, defaultDid) if (account) return account }
const accounts = listAccounts(kv) return accounts.length === 1 ? accounts[0] : undefined}
// ---------------------------------------------------------------------------// Identifier resolution// ---------------------------------------------------------------------------
function isAliasFresh(account: StoredAccount | undefined, handle: string): boolean { if (!account || account.handle !== handle) return false return account.handleCheckedAt !== undefined && Date.now() - account.handleCheckedAt < HANDLE_ALIAS_TTL_MS}
/** * Resolve a handle or DID to a DID, preferring the local alias cache so the * common case costs no network round trip. A stale or missing alias falls * through to a real resolve; a failed resolve falls back to the cache so the * CLI still works offline against an already-known account. */export async function resolveIdentifierToDid(kv: KvAdapter, identifier: string): Promise<string | null> { if (identifier.startsWith('did:')) { return identifier }
const handle = normalizeHandle(identifier) const cachedDid = kvGet(kv, `${HANDLE_PREFIX}${handle}`) if (cachedDid && isAliasFresh(readAccount(kv, cachedDid), handle)) { return cachedDid }
const resolved = await resolveDid(handle).catch(() => null) if (!resolved) { return cachedDid ?? null }
if (cachedDid && cachedDid !== resolved) { detachHandle(kv, cachedDid, handle) } if (readAccount(kv, resolved)) { upsertAccount(kv, resolved, { handle, handleChecked: true }) } else { kvSet(kv, `${HANDLE_PREFIX}${handle}`, resolved) } return resolved}
/** Fill in a missing handle for an account we only know by DID. */export async function backfillHandle(kv: KvAdapter, did: string): Promise<string | undefined> { const account = readAccount(kv, did) if (account?.handle) return account.handle const handle = await getHandleForDid(did).catch(() => null) if (!handle) return undefined return upsertAccount(kv, did, { handle: handle, handleChecked: true }).handle}
// ---------------------------------------------------------------------------// Store bootstrap// ---------------------------------------------------------------------------
/** * Promote pre-existing per-directory mappings into account records. Handles are * filled in lazily on first use, so this stays offline and cheap. */function migrateDirMappings(kv: KvAdapter): void { if (kvGet(kv, STORE_VERSION_KEY) === STORE_VERSION) return for (const { value: did } of kv.entriesByPrefix(DIR_PREFIX)) { if (!did.startsWith('did:')) continue if (!readAccount(kv, did)) { upsertAccount(kv, did, { method: 'oauth' }) } } kvSet(kv, STORE_VERSION_KEY, STORE_VERSION)}
export async function openAccountStore(dbPath?: string): Promise<KvAdapter> { const kv = await openKv(dbPath || DEFAULT_DB_PATH) migrateDirMappings(kv) return kv}12344567817 unmodified lines2627282930303132333469 unmodified lines104105106107108109110108109110111112113114115116117118119119120121122import type { Agent } from '@atproto/api'import { cancel, isCancel, text } from '@clack/prompts'import type { Command } from 'commander'import { authenticate, hasDirSession } from './auth.ts'import { authenticate, resolveAccountForCwd } from './auth.ts'import { createSpinner, pc, type SpinnerLike } from './progress.ts'import { parseServiceDid } from './wisp-service.ts'
export interface XrpcCommandOptions {17 unmodified lines
export function addXrpcAuthOptions<T extends Command>(command: T): T { return command .option('--password <password>', 'App password for headless authentication') .option('--db <path>', 'OAuth session database path') .option('--db <path>', 'Account database path') .option('--service <did:...>', 'Service DID to proxy through') .option('--json', 'Output raw JSON')}
69 unmodified lines '`--password` appears to have consumed the handle argument. Provide a password value and pass the handle separately.', ) }
// Skip the handle prompt if the current directory already has a stored session const resolvedIdentifier = identifier ?? ((await hasDirSession(options.db)) ? undefined : await resolveIdentifier(undefined)) // Skip the handle prompt if a stored account already applies here const knownAccount = identifier || options.password ? undefined : await resolveAccountForCwd(options.db) const resolvedIdentifier = identifier ?? (knownAccount ? undefined : await resolveIdentifier(undefined)) const serviceDid = parseServiceDid(options.service)
const spinner = createSpinner('Authenticating...').start() const { agent, did } = await authenticate(resolvedIdentifier, { appPassword: options.password, dbPath: options.db, onStatus: bindAuthStatusToSpinner(spinner), }) spinner.succeed(`Authenticated as ${did}`) spinner.succeed(`Authenticated as ${resolvedIdentifier ?? knownAccount?.handle ?? did}`)
return { agent, serviceDid, did }}123345671 unmodified line910111213131415{ "name": "create-wisp", "version": "1.1.4", "version": "1.1.5", "description": "CLI for wisp.place - deploy static sites to the AT Protocol", "type": "module", "bin": { "create-wisp": "bin.js"1 unmodified line "files": [ "bin.js" ], "dependencies": { "wispctl": "^1.2.1" "wispctl": "^1.3.0" }}