Something went wrong. Try again.
A fully featured CLI used as an alternative to AppView applications (Bsky.app/mu.social)
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185import * as FS from 'node:fs';import * as OS from 'node:os';import * as PATH from 'node:path';
// Where atcli keeps what is not secret: which accounts are signed in, which is// the default, how to reach each, and the user's settings. Secrets — session// tokens, OAuth keys — never come here; they live in the system's credential// store (secrets.ts).//// Linux & co. $XDG_CONFIG_HOME/atcli (~/.config/atcli)// macOS ~/Library/Application Support/atcli// Windows %APPDATA%\atcli//// Everything atcli can be told to do differently is a setting in this file,// changed with `atcli config`, rather than an environment variable. The one// exception is where the file is: ATCLI_CONFIG_DIR, since a setting cannot say// where the settings are. It is also how tests stay out of the real one.
export interface AccountRecord { did: string; handle: string; /** The PDS the account's repository lives on. */ pds: string; method: 'password' | 'oauth'; /** When this account was signed in, ISO 8601. */ added: string;}
export interface SettingSpec { description: string; /** What it is when not set; shown in `atcli config list`. */ fallback: string; choices?: readonly string[]; /** Says what is wrong with a value, or nothing when it is fine. */ check?: (value: string) => string | undefined;}
const url = (value: string) => (/^https?:\/\/[^/\s]+/.test(value) ? undefined : 'an http(s) URL');const service = (value: string) => (/^did:[a-z]+:[^#\s]+#[\w-]+$/.test(value) ? undefined : 'a service reference like did:web:api.bsky.app#bsky_appview');
/** Every setting, what it does, and what it may be. */export const SETTINGS = { format: { description: 'how results are printed when no --format (--json, --markdown, --html) is given', fallback: 'text', choices: ['text', 'json', 'markdown', 'html'] }, lang: { description: 'the language(s) posts are tagged with when no --lang is given, e.g. en or en,de', fallback: '(none)', check: (value: string) => (/^[a-zA-Z]{2,3}(-[a-zA-Z0-9]{2,8})*(,\s*[a-zA-Z]{2,3}(-[a-zA-Z0-9]{2,8})*)*$/.test(value) ? undefined : 'language codes like en, de or pt-BR, comma-separated'), }, 'secret-store': { description: "where sessions are kept: the system's store (auto), or a 0600 file, for machines without one", fallback: 'auto', choices: ['auto', 'secret-service', 'kwallet', 'keychain', 'wincred', 'file'], }, 'kwallet-wallet': { description: 'the KWallet wallet sessions are kept in', fallback: 'kdewallet' }, 'oauth-client': { description: "the OAuth client to sign in as: a client metadata URL to try first, auto (atcli's own hosted clients, else loopback), or loopback", fallback: 'auto', check: (value: string) => (value === 'auto' || value === 'loopback' ? undefined : url(value) && 'auto, loopback, or an https URL'), }, browser: { description: 'whether login opens a browser: auto (when there plausibly is one) or never', fallback: 'auto', choices: ['auto', 'never'] }, 'rate-limit-wait': { description: 'the longest to wait out a rate limit, in seconds, before giving up', fallback: '60', check: (value: string) => (/^\d+$/.test(value) ? undefined : 'a whole number of seconds'), }, appview: { description: 'the app view app.bsky.* calls are proxied to', fallback: 'did:web:api.bsky.app#bsky_appview', check: service }, 'public-appview': { description: 'the app view asked when signed out', fallback: 'https://public.api.bsky.app', check: url }, chat: { description: 'the chat service chat.bsky.* calls are proxied to', fallback: 'did:web:api.bsky.chat#bsky_chat', check: service }, 'video-service': { description: 'the service videos are uploaded to', fallback: 'https://video.bsky.app', check: url }, 'plc-directory': { description: 'where did:plc identities are resolved', fallback: 'https://plc.directory', check: url },} satisfies Record<string, SettingSpec>;
export type SettingKey = keyof typeof SETTINGS;export type Settings = Partial<Record<SettingKey, string>>;
export interface Config { /** The DID of the account commands act as when no --account is given. */ default?: string | undefined; accounts: Record<string, AccountRecord>; settings?: Settings | undefined;}
export function configDir(env: NodeJS.ProcessEnv = process.env): string { if (env['ATCLI_CONFIG_DIR']) return env['ATCLI_CONFIG_DIR']; const home = OS.homedir(); if (process.platform === 'win32') return PATH.join(env['APPDATA'] ?? PATH.join(home, 'AppData', 'Roaming'), 'atcli'); if (process.platform === 'darwin') return PATH.join(home, 'Library', 'Application Support', 'atcli'); return PATH.join(env['XDG_CONFIG_HOME'] || PATH.join(home, '.config'), 'atcli');}
function configFile(env?: NodeJS.ProcessEnv): string { return PATH.join(configDir(env), 'config.json');}
export function readConfig(env?: NodeJS.ProcessEnv): Config { try { const parsed = JSON.parse(FS.readFileSync(configFile(env), 'utf-8')) as Partial<Config>; return { default: parsed.default, accounts: parsed.accounts ?? {}, ...(parsed.settings ? { settings: parsed.settings } : {}) }; } catch (err) { if ((err as NodeJS.ErrnoException).code === 'ENOENT') return { accounts: {} }; throw new Error(`${configFile(env)} could not be read: ${(err as Error).message}`); }}
let cachedSettings: { dir: string; settings: Settings } | undefined;
/** A setting's value, or its fallback when it is not set. Read once per process. */export function setting(key: SettingKey): string { const dir = configDir(); if (cachedSettings?.dir !== dir) cachedSettings = { dir, settings: readConfig().settings ?? {} }; return cachedSettings.settings[key] ?? SETTINGS[key].fallback;}
/** A setting's value when one has been set, else nothing. */export function configured(key: SettingKey): string | undefined { setting(key); return cachedSettings!.settings[key];}
/** What is wrong with a value for a setting, if anything. */export function checkSetting(key: SettingKey, value: string): string | undefined { const spec: SettingSpec = SETTINGS[key]; if (spec.choices && !spec.choices.includes(value)) return `one of ${spec.choices.join(', ')}`; return spec.check?.(value);}
/** Write the config atomically: a reader never sees half a file. */export function writeConfig(config: Config, env?: NodeJS.ProcessEnv): void { cachedSettings = undefined; const file = configFile(env); FS.mkdirSync(PATH.dirname(file), { recursive: true, mode: 0o700 }); const temp = `${file}.${process.pid}.tmp`; FS.writeFileSync(temp, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 }); FS.renameSync(temp, file);}
/** * Find a signed-in account by DID or handle (with or without the `@`), or * the default one when nothing is named. */export function findAccount(config: Config, name?: string): AccountRecord | undefined { if (!name) return config.default ? config.accounts[config.default] : undefined; const wanted = name.replace(/^@/, '').toLowerCase(); return config.accounts[wanted] ?? Object.values(config.accounts).find((account) => account.handle.toLowerCase() === wanted);}
/** * Run `fn` holding an exclusive lock named `name` in the config directory. * * Refresh tokens are single-use: two processes refreshing one session at once * leave one of them holding a token the server has already retired. Agents * run commands in parallel, so refreshing happens under this lock, and the * second process re-reads the session the first one stored instead. */export async function withLock<T>(name: string, fn: () => Promise<T>, env?: NodeJS.ProcessEnv): Promise<T> { const dir = configDir(env); FS.mkdirSync(dir, { recursive: true, mode: 0o700 }); const file = PATH.join(dir, `${name.replace(/[^A-Za-z0-9._-]/g, '_')}.lock`); const deadline = Date.now() + 30_000; let fd: number | undefined; while (fd === undefined) { try { fd = FS.openSync(file, 'wx', 0o600); FS.writeSync(fd, String(process.pid)); } catch (err) { if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err; // A lock left by a process that died is taken over after a while. try { const age = Date.now() - FS.statSync(file).mtimeMs; if (age > 60_000) FS.rmSync(file, { force: true }); } catch { // It went away between the two calls: try again. } if (Date.now() > deadline) throw new Error(`timed out waiting for ${file}`); await new Promise((resolve) => setTimeout(resolve, 50 + Math.random() * 100)); } } try { return await fn(); } finally { FS.closeSync(fd); FS.rmSync(file, { force: true }); }}