/* Give a credential to every commander that predates them. A commander created before `credential.ts` existed has no `officer.env` and no key row, so it cannot reach the platform at all — which is the state that made the first one curl the whisper container directly. Deleting and recreating it would fix that and throw away the conversation, which is the one thing a commander is: a long-running agreement about a job. So this is additive and re-runnable: - a commander that already has `officer.env` is left alone, whole; - `CLAUDE.md` is APPENDED to, never rewritten — it is a living document its commander has been extending, and the whole point of not recreating it is that it survives; - the key is named `commander:`, so it revokes with the commander like any other. Run it after a deploy that introduces credentials, on each host: bun run --env-file=.env scripts/backfill-commander-credentials.ts */ import { readFileSync, existsSync, appendFileSync, chmodSync } from 'node:fs'; import { join } from 'node:path'; import { getUsers, listCommandHqSessions } from 'officerdb'; import { writeCommanderCredential, commanderEnvPath } from '../src/servers/api/command-hq/credential'; import { credentialSection } from '../src/servers/api/command-hq/directory'; import { getCommanderDir } from '../src/servers/data-path'; import { lookupOsUser } from '../src/servers/os-user'; /** Best effort, and only for a member: the owner already owns everything under DATA_PATH. */ async function giveToAccount(osUser: string | null, path: string): Promise { if (!osUser) return; const ids = await lookupOsUser(osUser); if (!ids) return; Bun.spawnSync(['sudo', '-n', 'chown', `${ids.uid}:${ids.gid}`, path]); } // Per user, because `listCommandHqSessions` is scoped by one — which is the right shape everywhere else // and only awkward here. There are few accounts and few commanders; this runs once per deploy. const rows: Array<{ dirName: string | null; name: string | null; userId: number; email: string; osUser: string | null; }> = []; for (const user of await getUsers()) { for (const row of await listCommandHqSessions(user.id)) { rows.push({ dirName: row.dirName, name: row.name, userId: user.id, email: user.email, osUser: user.osUser }); } } if (rows.length === 0) console.log('no commanders on this host'); for (const row of rows) { const label = `${row.dirName} (${row.name})`; if (!row.dirName) { console.log(`skip ${label} — no directory name`); continue; } const dir = getCommanderDir(row.email, row.dirName); if (!existsSync(dir)) { console.log(`skip ${label} — no directory on disk`); continue; } const envPath = commanderEnvPath(row.email, row.dirName); if (existsSync(envPath)) { console.log(`ok ${label} — already has a credential`); continue; } const { path } = await writeCommanderCredential({ userId: row.userId, email: row.email, dirName: row.dirName }); chmodSync(path, 0o600); await giveToAccount(row.osUser, path); // Appended only when it is not already described. A commander whose CLAUDE.md was hand-edited to // mention its credential must not end up with the section twice. const claudeMd = join(dir, 'CLAUDE.md'); const existing = existsSync(claudeMd) ? readFileSync(claudeMd, 'utf8') : ''; if (!existing.includes('officer.env')) { appendFileSync(claudeMd, `\n${credentialSection(path).join('\n')}`); await giveToAccount(row.osUser, claudeMd); } console.log(`MINT ${label} — ${path}`); } process.exit(0);