Something went wrong. Try again.
tgl: manage Tangled (tangled.org) from the terminal www.npmjs.com/package/tangled-cli
atproto git cli pull-request
Something went wrong. Try again.
8.9 kB · 218 lines
JavaScript
at master
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219// Stores the login outside any project, in the user's profile. The app password// goes to the operating system's secret store:// - Windows: encrypted with DPAPI (only this Windows user on this computer can decrypt it).// - macOS: the login Keychain.// - Linux: the desktop secret service (GNOME Keyring, KWallet...) through secret-tool;// if there is none, a file readable only by the user, with a warning.// The secret always travels through stdin, never as a command-line argument,// so it does not show up in the list of running processes.import { spawnSync } from 'node:child_process';import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';import { homedir } from 'node:os';import { join } from 'node:path';import { TglError } from './errors.js';import { t } from './i18n.js';
const SERVICE = 'tgl';
export function configDir() { if (process.env.APPDATA) return join(process.env.APPDATA, 'tgl'); return join(process.env.XDG_CONFIG_HOME || join(homedir(), '.config'), 'tgl');}
function loginFile() { return join(configDir(), 'login.json');}
function run(cmd, args, input) { const res = spawnSync(cmd, args, { input, encoding: 'utf8' }); return { ok: !res.error && res.status === 0, out: res.stdout ?? '', err: (res.stderr || res.error?.message || '').trim() };}
// --- Windows: DPAPI ---// Calls DPAPI through .NET directly rather than the SecureString cmdlets: when started// from PowerShell 7, the inherited PSModulePath stops Windows PowerShell from loading them.const LOAD_DPAPI = "[void][Reflection.Assembly]::LoadWithPartialName('System.Security'); " + '$scope = [Security.Cryptography.DataProtectionScope]::CurrentUser; ';
function powershell(script, input) { const env = { ...process.env }; delete env.PSModulePath; const res = spawnSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', LOAD_DPAPI + script], { input, env, encoding: 'utf8' }); if (res.status !== 0) { throw new TglError(t(`Could not use Windows encryption: ${res.stderr.trim() || res.error?.message}`, `No se pudo usar el cifrado de Windows: ${res.stderr.trim() || res.error?.message}`)); } return res.stdout.trim();}
// --- macOS: Keychain. `security -i` reads its command from stdin. ---function keychainQuote(s) { return `"${s.replace(/[\\"]/g, '\\$&')}"`;}
const STORES = { dpapi: { save: (_did, secret) => powershell( '$b = [Text.Encoding]::UTF8.GetBytes([Console]::In.ReadToEnd()); ' + '[Convert]::ToBase64String([Security.Cryptography.ProtectedData]::Protect($b, $null, $scope))', secret, ), load: (_did, value) => powershell( '$e = [Convert]::FromBase64String([Console]::In.ReadToEnd().Trim()); ' + '[Console]::Out.Write([Text.Encoding]::UTF8.GetString([Security.Cryptography.ProtectedData]::Unprotect($e, $null, $scope)))', value, ), remove: () => {}, }, keychain: { save: (did, secret) => { const r = run('security', ['-i'], `add-generic-password -U -a ${keychainQuote(did)} -s ${SERVICE} -w ${keychainQuote(secret)}\n`); if (!r.ok) throw new TglError(t(`Could not save to the Keychain: ${r.err}`, `No se pudo guardar en el Llavero: ${r.err}`)); return null; }, load: (did) => { const r = run('security', ['find-generic-password', '-a', did, '-s', SERVICE, '-w']); if (!r.ok) throw new TglError(t('The app password is not in the Keychain. Run "tgl auth login".', 'La contraseña de aplicación no está en el Llavero. Ejecuta "tgl auth login".')); return r.out.replace(/\n$/, ''); }, remove: (did) => run('security', ['delete-generic-password', '-a', did, '-s', SERVICE]), }, 'secret-service': { save: (did, secret) => { const r = run('secret-tool', ['store', `--label=tgl (${did})`, 'service', SERVICE, 'account', did], secret); if (!r.ok) throw new Error(r.err); return null; }, load: (did) => { const r = run('secret-tool', ['lookup', 'service', SERVICE, 'account', did]); if (!r.ok || !r.out) throw new TglError(t('The app password is not in the secret store. Run "tgl auth login".', 'La contraseña de aplicación no está en el almacén de secretos. Ejecuta "tgl auth login".')); return r.out.replace(/\n$/, ''); }, remove: (did) => run('secret-tool', ['clear', 'service', SERVICE, 'account', did]), }, // Last resort on Linux without a secret service; the file is created with mode 600. file: { save: (_did, secret) => secret, load: (_did, value) => value, remove: () => {}, },};
function saveSecret(did, secret) { if (process.platform === 'win32') return { scheme: 'dpapi', value: STORES.dpapi.save(did, secret) }; if (process.platform === 'darwin') return { scheme: 'keychain', value: STORES.keychain.save(did, secret) }; try { return { scheme: 'secret-service', value: STORES['secret-service'].save(did, secret) }; } catch { console.error(t( 'Warning: no system secret store found (secret-tool). The app password is saved in a file only your user can read.', 'Aviso: no hay almacén de secretos del sistema (secret-tool). La contraseña se guarda en un archivo que solo tu usuario puede leer.', )); return { scheme: 'file', value: secret }; }}
function storeFor(scheme) { // "plain" was the name of the file scheme in tgl 0.1. const store = STORES[scheme === 'plain' ? 'file' : scheme]; if (!store) throw new TglError(t(`Unknown credentials format: ${scheme}`, `Formato de credenciales desconocido: ${scheme}`)); return store;}
function notLoggedIn() { return new TglError(t('You are not logged in. Run "tgl auth login" in your terminal.', 'No has iniciado sesión. Ejecuta "tgl auth login" en tu terminal.'));}
export function saveLogin({ handle, did, pds, password }) { mkdirSync(configDir(), { recursive: true }); const data = { handle, did, pds, password: saveSecret(did, password), savedAt: new Date().toISOString() }; writeFileSync(loginFile(), JSON.stringify(data, null, 2), { mode: 0o600 });}
// A login given in environment variables (TGL_ACCOUNT, TGL_APP_PASSWORD), for CI// pipelines where the app password is a secret of the pipeline. It replaces the saved// login for this run and is never written anywhere.let envLogin = null;
export function useEnvLogin(login) { envLogin = { ...login, store: 'env' };}
export function loadLogin() { if (envLogin) return envLogin; if (!existsSync(loginFile())) throw notLoggedIn(); const data = JSON.parse(readFileSync(loginFile(), 'utf8')); return { ...data, password: storeFor(data.password.scheme).load(data.did, data.password.value) };}
export function readLoginInfo() { if (envLogin) { const { password, ...info } = envLogin; return info; } if (!existsSync(loginFile())) return null; const { handle, did, pds, savedAt, password } = JSON.parse(readFileSync(loginFile(), 'utf8')); return { handle, did, pds, savedAt, store: password.scheme, file: loginFile() };}
// The logged-in account (no password needed): for reading public records.export function whoAmI() { const info = readLoginInfo(); if (!info) throw notLoggedIn(); return info;}
export function deleteLogin() { const info = readLoginInfo(); if (info) storeFor(info.store).remove(info.did); rmSync(loginFile(), { force: true });}
// Reads a line from the terminal without echoing what is typed.export function promptHidden(question) { const { stdin, stdout } = process; if (!stdin.isTTY) { throw new TglError(t('This command needs an interactive terminal: run it yourself in your own terminal.', 'Este comando necesita una terminal interactiva: ejecútalo tú en tu propia terminal.')); } return new Promise((resolve, reject) => { let value = ''; stdout.write(question); stdin.setRawMode(true); stdin.resume(); stdin.setEncoding('utf8'); const onData = (chunk) => { for (const ch of chunk) { if (ch === '\r' || ch === '\n') { cleanup(); stdout.write('\n'); resolve(value); return; } if (ch === '\u0003') { cleanup(); stdout.write('\n'); reject(new TglError(t('Cancelled.', 'Cancelado.'))); return; } if (ch === '\u007f' || ch === '\b') value = value.slice(0, -1); else value += ch; } }; const cleanup = () => { stdin.off('data', onData); stdin.setRawMode(false); stdin.pause(); }; stdin.on('data', onData); });}
export async function promptLine(question) { const { createInterface } = await import('node:readline/promises'); const rl = createInterface({ input: process.stdin, output: process.stdout }); try { return (await rl.question(question)).trim(); } finally { rl.close(); }}