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.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313import * as FS from 'node:fs';import * as PATH from 'node:path';import { spawnSync, type SpawnSyncReturns } from 'node:child_process';import { configDir, setting } from './config.ts';import { CliError } from './errors.ts';
// Session secrets — access and refresh tokens, OAuth DPoP keys — kept in the// system's own credential store, never in a file of ours://// secret-service Linux and the BSDs: GNOME Keyring, KWallet (Plasma 5.97+),// KeePassXC and anything else speaking the Secret Service// D-Bus API, through `secret-tool` (libsecret)// kwallet KWallet directly, through `kwallet-query`, for KDE setups// without the Secret Service bridge// keychain the macOS login keychain, through `security`// wincred Windows Credential Manager, through PowerShell and the// Win32 credential API// file a 0600 JSON file in the config directory. Never chosen on// its own: for containers and CI, where there is no// keyring, it has to be asked for.//// The secret-store setting picks one explicitly (`atcli config set secret-store// file`); otherwise the platform's native one is used. Each secret is stored// under the service "atcli" and the account's DID, and is base64 of its JSON,// so no store ever has to quote a character it might mangle.//// Secrets go in on stdin, never on a command line, where any other user on the// machine could read them from the process table.
export type StoreName = 'secret-service' | 'kwallet' | 'keychain' | 'wincred' | 'file';export const STORE_NAMES: readonly StoreName[] = ['secret-service', 'kwallet', 'keychain', 'wincred', 'file'];
export interface SecretStore { name: StoreName; get(key: string): string | undefined; set(key: string, value: string): void; delete(key: string): void;}
const SERVICE = 'atcli';
let chosen: { key: string; store: SecretStore } | undefined;
/** The store this machine uses. */export function secretStore(): SecretStore { const wanted = setting('secret-store'); const key = `${configDir()}\0${wanted}`; if (chosen?.key === key) return chosen.store; let store: SecretStore; if (wanted !== 'auto') store = make(wanted as StoreName); else if (process.platform === 'darwin') store = make('keychain'); else if (process.platform === 'win32') store = make('wincred'); else if (has('secret-tool') || !has('kwallet-query')) store = make('secret-service'); else store = make('kwallet'); chosen = { key, store }; return store;}
/** For tests: forget which store was chosen. */export function resetSecretStore(): void { chosen = undefined;}
function make(name: StoreName): SecretStore { switch (name) { case 'secret-service': return secretService(SERVICE); case 'kwallet': return kwallet(SERVICE); case 'keychain': return keychain(SERVICE); case 'wincred': return wincred(SERVICE); case 'file': return fileStore(PATH.join(configDir(), 'secrets.json')); }}
/** Read and decode a JSON secret. */export function readSecret<T>(key: string): T | undefined { const raw = secretStore().get(key); if (raw === undefined) return undefined; try { return JSON.parse(Buffer.from(raw, 'base64').toString('utf-8')) as T; } catch { return undefined; }}
export function writeSecret(key: string, value: unknown): void { secretStore().set(key, Buffer.from(JSON.stringify(value)).toString('base64'));}
export function deleteSecret(key: string): void { secretStore().delete(key);}
// ---------------------------------------------------------------- helpers ---
function has(command: string): boolean { const probe = process.platform === 'win32' ? spawnSync('where', [command], { stdio: 'ignore' }) : spawnSync('sh', ['-c', `command -v ${command}`], { stdio: 'ignore' }); return probe.status === 0;}
function run(command: string, args: string[], input?: string): SpawnSyncReturns<string> { const result = spawnSync(command, args, { input: input ?? '', encoding: 'utf-8', timeout: 30_000, windowsHide: true }); if (result.error) { const missing = (result.error as NodeJS.ErrnoException).code === 'ENOENT'; throw new CliError(missing ? `the credential store needs '${command}', which is not installed. Install it, or choose another store: atcli config set secret-store <${STORE_NAMES.join('|')}>` : `${command} failed: ${result.error.message}`, 'SecretStoreUnavailable'); } return result;}
function failed(store: string, result: SpawnSyncReturns<string>, what: string): never { const detail = (result.stderr || result.stdout || '').trim(); throw new CliError(`${store}: could not ${what}${detail ? `: ${detail}` : ` (exit ${result.status})`}. ` + "Is the keyring unlocked? Where there is none (a container, CI), 'atcli config set secret-store file' keeps secrets in a 0600 file instead.", 'SecretStoreUnavailable');}
// ----------------------------------------------------------------- stores ---
function secretService(service: string): SecretStore { const attributes = (key: string) => ['service', service, 'account', key]; return { name: 'secret-service', get(key) { const result = run('secret-tool', ['lookup', ...attributes(key)]); // `lookup` exits 1, saying nothing, when there is no such secret. if (result.status !== 0) { if (!result.stderr.trim()) return undefined; failed('secret-service', result, 'read a secret'); } return result.stdout.trim() || undefined; }, set(key, value) { const result = run('secret-tool', ['store', `--label=atcli: ${key}`, ...attributes(key)], value); if (result.status !== 0) failed('secret-service', result, 'store a secret'); }, delete(key) { run('secret-tool', ['clear', ...attributes(key)]); }, };}
function kwallet(service: string): SecretStore { const wallet = setting('kwallet-wallet'); return { name: 'kwallet', get(key) { const result = run('kwallet-query', ['--folder', service, '--read-password', key, wallet]); if (result.status !== 0) return undefined; return result.stdout.trim() || undefined; }, set(key, value) { const result = run('kwallet-query', ['--folder', service, '--write-password', key, wallet], value); if (result.status !== 0) failed('kwallet', result, 'store a secret'); }, // kwallet-query cannot remove an entry; an empty one reads as none. delete(key) { run('kwallet-query', ['--folder', service, '--write-password', key, wallet], ''); }, };}
function keychain(service: string): SecretStore { return { name: 'keychain', get(key) { const result = run('security', ['find-generic-password', '-s', service, '-a', key, '-w']); if (result.status === 44) return undefined; // errSecItemNotFound if (result.status !== 0) failed('keychain', result, 'read a secret'); return result.stdout.trim() || undefined; }, set(key, value) { // `security -i` reads commands from stdin, so the secret is never an // argument of a process. Values are base64, which needs no quoting. const result = run('security', ['-i'], `add-generic-password -U -s ${quote(service)} -a ${quote(key)} -l ${quote(`atcli: ${key}`)} -w ${value}\n`); if (result.status !== 0 || /error/i.test(result.stderr)) failed('keychain', result, 'store a secret'); }, delete(key) { run('security', ['delete-generic-password', '-s', service, '-a', key]); }, };}
function quote(text: string): string { return `"${text.replace(/["\\]/g, '\\$&')}"`;}
// Windows Credential Manager, through the Win32 API from PowerShell. A// generic credential holds at most 2560 bytes, and an OAuth session (tokens// and a key) can be more; so a value is stored in numbered parts, `<key>#0`,// `<key>#1`…, with the count in the first.const WINCRED = String.raw`$ErrorActionPreference = 'Stop'Add-Type -TypeDefinition @'using System;using System.Runtime.InteropServices;public static class AtcliCred { [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] public struct CREDENTIAL { public int Flags; public int Type; public string TargetName; public string Comment; public System.Runtime.InteropServices.ComTypes.FILETIME LastWritten; public int CredentialBlobSize; public IntPtr CredentialBlob; public int Persist; public int AttributeCount; public IntPtr Attributes; public string TargetAlias; public string UserName; } [DllImport("advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true)] public static extern bool CredReadW(string target, int type, int flags, out IntPtr credential); [DllImport("advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true)] public static extern bool CredWriteW(ref CREDENTIAL credential, int flags); [DllImport("advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true)] public static extern bool CredDeleteW(string target, int type, int flags); [DllImport("advapi32.dll")] public static extern void CredFree(IntPtr buffer); public static string Read(string target) { IntPtr p; if (!CredReadW(target, 1, 0, out p)) return null; try { var c = (CREDENTIAL)Marshal.PtrToStructure(p, typeof(CREDENTIAL)); var bytes = new byte[c.CredentialBlobSize]; Marshal.Copy(c.CredentialBlob, bytes, 0, c.CredentialBlobSize); return System.Text.Encoding.UTF8.GetString(bytes); } finally { CredFree(p); } } public static void Write(string target, string value) { var bytes = System.Text.Encoding.UTF8.GetBytes(value); var c = new CREDENTIAL(); c.Type = 1; c.TargetName = target; c.Persist = 2; c.UserName = "atcli"; c.CredentialBlobSize = bytes.Length; c.CredentialBlob = Marshal.AllocHGlobal(bytes.Length); try { Marshal.Copy(bytes, 0, c.CredentialBlob, bytes.Length); if (!CredWriteW(ref c, 0)) throw new System.ComponentModel.Win32Exception(Marshal.GetLastWin32Error()); } finally { Marshal.FreeHGlobal(c.CredentialBlob); } } public static void Delete(string target) { CredDeleteW(target, 1, 0); }}'@$op, $target = $args[0], $args[1]$chunk = 2000switch ($op) { 'get' { $head = [AtcliCred]::Read("$target#0") if ($null -eq $head) { exit 3 } $count, $value = $head -split ':', 2 for ($i = 1; $i -lt [int]$count; $i++) { $value += [AtcliCred]::Read("$target#$i") } [Console]::Out.Write($value) } 'set' { $value = [Console]::In.ReadToEnd() $parts = [Math]::Max(1, [Math]::Ceiling($value.Length / $chunk)) for ($i = 0; $i -lt $parts; $i++) { $part = $value.Substring($i * $chunk, [Math]::Min($chunk, $value.Length - $i * $chunk)) if ($i -eq 0) { $part = "$($parts):$part" } [AtcliCred]::Write("$target#$i", $part) } for ($i = $parts; $i -lt 64; $i++) { [AtcliCred]::Delete("$target#$i") } } 'delete' { for ($i = 0; $i -lt 64; $i++) { [AtcliCred]::Delete("$target#$i") } }}`;
function wincred(service: string): SecretStore { const shell = has('pwsh') ? 'pwsh' : 'powershell'; const invoke = (op: string, key: string, input?: string) => run(shell, [ '-NoLogo', '-NoProfile', '-NonInteractive', '-Command', `& { ${WINCRED} } ${op} '${`${service}:${key}`.replace(/'/g, "''")}'`, ], input); return { name: 'wincred', get(key) { const result = invoke('get', key); if (result.status === 3) return undefined; if (result.status !== 0) failed('wincred', result, 'read a secret'); return result.stdout.trim() || undefined; }, set(key, value) { const result = invoke('set', key, value); if (result.status !== 0) failed('wincred', result, 'store a secret'); }, delete(key) { invoke('delete', key); }, };}
function fileStore(file: string): SecretStore { const read = (): Record<string, string> => { try { return JSON.parse(FS.readFileSync(file, 'utf-8')) as Record<string, string>; } catch (err) { if ((err as NodeJS.ErrnoException).code === 'ENOENT') return {}; throw err; } }; const write = (data: Record<string, string>) => { FS.mkdirSync(PATH.dirname(file), { recursive: true, mode: 0o700 }); const temp = `${file}.${process.pid}.tmp`; FS.writeFileSync(temp, `${JSON.stringify(data, null, 2)}\n`, { mode: 0o600 }); FS.renameSync(temp, file); }; return { name: 'file', get: (key) => read()[key], set(key, value) { write({ ...read(), [key]: value }); }, delete(key) { const data = read(); delete data[key]; write(data); }, };}