import * 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; export type SettingKey = keyof typeof SETTINGS; export type Settings = Partial>; export interface Config { /** The DID of the account commands act as when no --account is given. */ default?: string | undefined; accounts: Record; 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; 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(name: string, fn: () => Promise, env?: NodeJS.ProcessEnv): Promise { 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 }); } }