the voice-memos commander was asked for a notification and reached for its harness's own push tool — a different channel, which refuses to interrupt a live terminal. it did that because nothing had told it Officer has a route of its own. that rule is the harness's; the platform's route has none, and had already pushed to a phone twice with the owner sitting at the terminal. so the notify part is its own section now, appendable on its own, and the backfill lands it on a commander that already has a credential — which is every commander that predates this, i.e. the ones that need it. the backfill also stopped being all-or-nothing. it skipped everything after the credential check, so a commander with an officer.env could never receive anything added later. each section is now appended independently, keyed on a marker the section itself contains. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
106 lines
4.2 KiB
TypeScript
106 lines
4.2 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, notifySection } 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 claudeMd = join(dir, 'CLAUDE.md');
|
|
|
|
/**
|
|
* Append a section only when it is not already described.
|
|
*
|
|
* The marker is a string the section itself contains, so a commander whose CLAUDE.md was hand-edited to
|
|
* describe the same thing does not end up with it twice.
|
|
*/
|
|
const appendIfMissing = async (marker: string, lines: string[]): Promise<boolean> => {
|
|
const existing = existsSync(claudeMd) ? readFileSync(claudeMd, 'utf8') : '';
|
|
if (existing.includes(marker)) return false;
|
|
appendFileSync(claudeMd, `\n${lines.join('\n')}`);
|
|
await giveToAccount(row.osUser, claudeMd);
|
|
return true;
|
|
};
|
|
|
|
const envPath = commanderEnvPath(row.email, row.dirName);
|
|
|
|
if (!existsSync(envPath)) {
|
|
const { path } = await writeCommanderCredential({ userId: row.userId, email: row.email, dirName: row.dirName });
|
|
chmodSync(path, 0o600);
|
|
await giveToAccount(row.osUser, path);
|
|
await appendIfMissing('officer.env', credentialSection(path));
|
|
console.log(`MINT ${label} — ${path}`);
|
|
} else {
|
|
console.log(`ok ${label} — already has a credential`);
|
|
}
|
|
|
|
/*
|
|
Separately, because it arrived later and the commanders that need it most already have a credential.
|
|
Without it a commander does not know Officer HAS a notification route, so it reaches for whatever its
|
|
harness offers — a different channel, with its own rules about when it may interrupt.
|
|
*/
|
|
if (await appendIfMissing('/api/notify/_officer/notify', notifySection())) {
|
|
console.log(`NOTIFY ${label} — told how to reach the owner`);
|
|
}
|
|
}
|
|
|
|
process.exit(0);
|