Files
platform/scripts/backfill-commander-credentials.ts
T
pastilhasandClaude Opus 5 a3a8516e66 backfill: a credential for a commander that predates them
a commander made before credentials existed has no officer.env and no key
row, so it cannot reach the platform at all — the state that made the first
one curl whisper 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 one is
left alone. CLAUDE.md is APPENDED to, never rewritten — it is a living
document its commander has been extending, and surviving is the whole
point of not recreating it — and only when it does not already mention
officer.env, so a hand-edited one does not end up with the section twice.

the credential section moved out of commanderClaudeMd for this: it is now
written in two places, and the backfill needs a self-contained section
rather than a slice of one file's layout.

verified locally: minted for the one commander here, second run reported
"already has a credential" and changed nothing, file is 0600.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-24 13:57:01 +00:00

90 lines
3.5 KiB
TypeScript

/*
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:<dirName>`, 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<void> {
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);