import * as FS from 'node:fs'; import * as Path from 'node:path'; import { spawn } from 'node:child_process'; import { configDir, readAccounts, type Account } from './config.ts'; import { listDeclaredRepos, resolveRepo } from './repo.ts'; // What completion offers from the network, kept on disk so that Tab never // waits for it: each signed-in account's repositories, and the knots they // live on. // // The repositories are the account's own declarations // (`org.tangled.repo.declaration`, and legacy `sh.tangled.repo` records that // name a repo DID) in its PDS, which is authoritative and current, unlike an // index that catches up later; each is then looked up for its name, knot and // manifest. The cache is refreshed by the commands that change it, by // `auth login`, by the quiet check at shell start, and by completion itself, // in the background, when it finds the cache stale. export interface CachedRepo { name: string; repoDid?: string | undefined; knot: string; spindle?: string | undefined; description?: string | undefined; } export interface AccountCache { did: string; handle: string | undefined; repos: CachedRepo[]; /** The knots this account's repositories live on. */ knots: string[]; refreshed: number; } interface CacheFile { accounts: Record; } /** How old the cache may get before completion asks for a refresh. */ const STALE = 6 * 3600_000; function file(): string { return Path.join(configDir(), 'cache.json'); } export function readCache(): CacheFile { try { return JSON.parse(FS.readFileSync(file(), 'utf8')) as CacheFile; } catch { return { accounts: {} }; } } function writeCache(cache: CacheFile): void { FS.mkdirSync(Path.dirname(file()), { recursive: true, mode: 0o700 }); const temporary = `${file()}.${process.pid}.tmp`; FS.writeFileSync(temporary, `${JSON.stringify(cache, null, 2)}\n`); FS.renameSync(temporary, file()); } /** Read an account's repositories from its PDS, and look each up. */ async function fetchAccount(account: Account): Promise { const declared = await listDeclaredRepos(account.pds, account.did); const found = await Promise.all(declared.map(({ repoDid }) => resolveRepo({ kind: 'did', did: repoDid }).catch(() => undefined))); const repos: CachedRepo[] = []; for (const repo of found) { if (!repo || repo.ownerDid !== account.did) continue; repos.push({ name: repo.name, repoDid: repo.repoDid, knot: repo.knot, spindle: repo.manifest.spindle, description: repo.manifest.description }); } const knots = [...new Set(repos.map((repo) => repo.knot))]; return { did: account.did, handle: account.handle, repos, knots, refreshed: Date.now() }; } /** Refresh the cache of every signed-in account (or of `only`). Failures keep what was cached. */ export async function refreshCache(only?: string): Promise { const { accounts } = readAccounts(); const fresh = await Promise.all(Object.values(accounts) .filter((account) => !only || account.did === only) .map((account) => fetchAccount(account).catch(() => undefined))); const cache = readCache(); // Accounts signed out of since are dropped. for (const did of Object.keys(cache.accounts)) if (!accounts[did]) delete cache.accounts[did]; for (const entry of fresh) if (entry) cache.accounts[entry.did] = entry; writeCache(cache); } /** Refresh in the background, never failing the command that asked. */ export async function refreshQuietly(only?: string): Promise { try { await refreshCache(only); } catch { // Completion just offers less. } } /** The cached accounts — and, when they are stale, a refresh started in the background. */ export function cachedAccounts(): AccountCache[] { const cache = readCache(); const signedIn = Object.keys(readAccounts().accounts); const stale = signedIn.some((did) => !cache.accounts[did] || Date.now() - cache.accounts[did]!.refreshed > STALE); if (stale) refreshInBackground(); return signedIn.map((did) => cache.accounts[did]).filter((entry): entry is AccountCache => Boolean(entry)); } // Start `tangled __refresh` detached, at most once a minute: completion has // to answer now, and the next Tab gets what this one could not. function refreshInBackground(): void { const stamp = Path.join(configDir(), 'cache.refreshing'); try { if (Date.now() - FS.statSync(stamp).mtimeMs < 60_000) return; } catch { // Never started. } try { FS.mkdirSync(configDir(), { recursive: true, mode: 0o700 }); FS.writeFileSync(stamp, ''); const [command, args] = selfCommand(['__refresh']); spawn(command, args, { detached: true, stdio: 'ignore' }).unref(); } catch { // Offering less beats failing. } } // How to run this program again: through the bundle's mount when bundled (a // second --vfs-load is refused, and under `bundle run` the inherited node // flags are bundle's), else as it was run. const SELF = Path.resolve(process.argv[1] ?? ''); function selfCommand(args: string[]): [string, string[]] { const sea = Boolean((process.getBuiltinModule('node:sea') as { isSea?: () => boolean } | undefined)?.isSea?.()); if (sea) return [process.execPath, args]; const bundled = process.execArgv.some((arg) => arg === '--vfs-load' || arg.startsWith('--vfs-load=')); if (bundled) return [process.execPath, ['--no-warnings', '--experimental-vfs', `--vfs-load=${SELF}`, '--', ...args]]; return [process.execPath, [...process.execArgv, SELF, ...args]]; }