Files
platform/src/servers/api/server-settings/server-settings.ts
T
pastilhasandClaude Opus 5 e35ff5c7ad stt leaves the platform for the transcribe plugin
Speech-to-text is now plugins/transcribe — sidecar, its own config, its own
skill and a task for the file browser's context menu. The transcription logic
moved BYTE FOR BYTE: 416 lines whose every decision has a reason attached, from
the two-pass detect-then-transcribe to the 25 language priming prompts to
timeout:false. Verified identical rather than assumed.

Removed here: api/stt/, server-settings/stt.ts and its mount, POST /chat/stt,
POST /file-browser/transcribe, the FileViewer handler behind the button hidden
last release, the Settings screen, the dead useAudioRecording hook and the
orphaned locale keys across all six locales.

blobToWav stayed. It lived in the same file as the dead hook and is still used
by DictateDialog — unrelated to STT, and deleting by file rather than by use is
how that would have gone.

No permission entry to remove: STT never had one, living under server-admin,
chat and files. The registry count is unchanged and docs:check is green.

The plugin ships a task that will not appear in the menu yet — the platform
loads a plugin's skills, affordances and notifications from its own directory
but not its tasks. That loader is the remaining piece of docs/plugin-capabilities.md
and is deliberately not built here.

1426 pass, tsgo clean, 18 plugins discovered with none broken.

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

214 lines
9.4 KiB
TypeScript

import { createRouter } from '../../create-router';
import { rebuildFrontend } from '../../plugins/generate';
import { isKnownToPm2, restartProcess } from '../../app-store/pm2';
import { fixEnvFile } from '../../env-prefix';
import { PLATFORM_DIR } from '../../data-path';
import { readdirSync, existsSync } from 'node:fs';
import { join } from 'node:path';
import { readServerSettings, writeServerSettings } from 'officerdb';
import { chatProvidersRouter } from './chat-providers';
import { smtpRouter } from './smtp';
import { ttsRouter } from './tts';
import { ocrRouter } from './ocr';
export const serverSettingsRouter = createRouter();
serverSettingsRouter.route('/chat-providers', chatProvidersRouter);
serverSettingsRouter.route('/smtp', smtpRouter);
serverSettingsRouter.route('/tts', ttsRouter);
serverSettingsRouter.route('/ocr', ocrRouter);
export { readServerSettings as readSettings };
/**
* Rebuild the SPA from source, without restarting anything.
*
* This is the same `rebuildFrontend` a plugin install runs, exposed as a button. It works — and it is
* worth being precise about WHY, because its neighbour below does not:
*
* `Bun.build` reads the source from disk on every call and writes `build/`, and the server serves that
* directory by reading each file per request. So a rebuild is visible on the next page load with no
* process restart, no dropped websocket, and no chat session lost.
*
* Frontend changes only. Nothing here can pick up an edit to server code — see `/restart`.
*/
serverSettingsRouter.post('/rebuild-frontend', async (ctx) => {
const result = await rebuildFrontend();
if (!result.ok) return ctx.json({ ok: false, error: result.error }, 500);
return ctx.json({ ok: true, ms: result.ms, outputs: result.outputs });
});
/**
* Pull the platform's own repository.
*
* The button that was missing between the two below: `/rebuild-frontend` compiles what is on disk and
* `/restart` reloads it, but neither CHANGES what is on disk. Doing that meant a terminal, on every
* server, every time.
*
* ── What it refuses, and why ──
*
* A dirty tree, and a non-fast-forward. Same two refusals as `pullPlugin`, for the same reason: this
* checkout is somebody's working copy on at least one machine, and a button that discards uncommitted
* work is worse than a button that does not exist. `--ff-only` so a divergence is reported rather than
* merged by a machine.
*
* ── What it does NOT do ──
*
* It does not rebuild, restart, install dependencies or push the schema. Those are separate buttons and
* separate decisions — `bun install` after a lockfile change is a supply-chain moment the frozen-lockfile
* rule exists to make deliberate, and `db:push` alters a live database. So this REPORTS when the pull
* touched `package.json`, `bun.lock` or a schema file, and leaves the choice with whoever pressed it.
*/
serverSettingsRouter.post('/pull', async (ctx) => {
const git = async (args: string[]) => {
const proc = Bun.spawn(['git', ...args], { cwd: PLATFORM_DIR, stdout: 'pipe', stderr: 'pipe' });
const [out, err] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()]);
return { code: await proc.exited, out: out.trim(), err: err.trim() };
};
const dirty = await git(['status', '--porcelain']);
if (dirty.out) {
const files = dirty.out.split('\n').length;
return ctx.json(
{
ok: false,
error: `${files} uncommitted change(s) — commit or discard them first, refusing to overwrite local work`,
},
400,
);
}
const before = (await git(['rev-parse', 'HEAD'])).out;
// `origin HEAD`, not a branch name: a checkout made before the default branch was renamed is on one
// the remote no longer has, and `git pull origin <local-name>` fails on exactly the installs that most
// need updating. Learned in `plugins/update.ts`; the same trap applies here.
const fetched = await git(['fetch', 'origin', 'HEAD']);
if (fetched.code !== 0) return ctx.json({ ok: false, error: fetched.err || 'git fetch failed' }, 500);
const merged = await git(['merge', '--ff-only', 'FETCH_HEAD']);
if (merged.code !== 0) {
return ctx.json(
{ ok: false, error: merged.err || merged.out || 'not a fast-forward — this checkout has diverged' },
409,
);
}
const after = (await git(['rev-parse', 'HEAD'])).out;
if (before === after) return ctx.json({ ok: true, updated: false, commits: [], needs: [] });
const log = await git(['log', '--oneline', '--no-decorate', `${before}..${after}`]);
const changed = (await git(['diff', '--name-only', `${before}..${after}`])).out.split('\n');
// What the pull implies but must not do on its own.
const needs: string[] = [];
if (changed.some((f) => f === 'package.json' || f === 'bun.lock')) needs.push('bun install');
if (changed.some((f) => f.includes('/schema.ts') || f.endsWith('drizzle-schema.ts'))) needs.push('bun db:push');
if (changed.some((f) => f.startsWith('src/servers/') || f.startsWith('src/databases/'))) needs.push('restart');
else if (changed.some((f) => f.startsWith('src/'))) needs.push('rebuild-frontend');
return ctx.json({
ok: true,
updated: true,
from: before.slice(0, 7),
to: after.slice(0, 7),
commits: log.out ? log.out.split('\n') : [],
files: changed.length,
needs,
});
});
/**
* Restart the platform process. The only way to load changed SERVER code.
*
* ── Why there is no gentler option ──
*
* Bun caches modules for the life of the process. Measured rather than assumed: editing a file and
* re-importing it returns the ORIGINAL export, and a cache-busting query picks up only that one
* specifier while everything it imports stays cached. Rebuilding the Hono app — which install already
* does — therefore re-wires the same module instances into a new router tree and cannot see an edit.
*
* Busting the whole graph would not be a fix either: singletons would be duplicated, giving two database
* clients and two in-memory session maps in one process.
*
* So this is honest about what it costs. Websockets drop, in-memory chat sessions are lost, and the
* browser must reconnect. It saves a terminal, not a reconnection.
*
* The response is sent BEFORE the restart is scheduled, or the caller would never learn it worked: the
* process serving this request is the one about to be replaced.
*/
serverSettingsRouter.post('/restart', async (ctx) => {
// Refuse when PM2 does not know this process, rather than reporting a restart that cannot happen. In
// development the platform runs under `bun --watch`, where `pm2 restart officer` would exit non-zero
// into a promise nobody awaits — and the button would say it worked.
if (!(await isKnownToPm2('officer', PLATFORM_DIR))) {
return ctx.json({ ok: false, error: 'not running under PM2 — restart it the way you started it' }, 400);
}
// Convert `.env` to the `OFFICERDEV_` prefix on the way past.
//
// Here rather than at boot, because a restart is the one moment the file can be rewritten and the
// change take effect in the same breath — the process about to start is the one that reads it. Doing
// it at boot would rewrite a file the running process had already loaded.
//
// Idempotent and non-fatal: an already-converted file is not even written, and a failure must not stop
// a restart the owner asked for. Every consumer still reads the unprefixed name as a fallback, so the
// worst case is that nothing changed.
let envFixed: string[] = [];
try {
envFixed = fixEnvFile(join(PLATFORM_DIR, '.env')).changed;
if (envFixed.length) console.log(`[env] prefixed on restart: ${envFixed.join(', ')}`);
} catch (err) {
console.error('[env] could not prefix .env —', err instanceof Error ? err.message : err);
}
setTimeout(() => {
void restartProcess('officer', PLATFORM_DIR);
}, 250);
return ctx.json({ ok: true, restartingIn: 250, envFixed });
});
serverSettingsRouter.get('/settings', async (ctx) => {
return ctx.json(await readServerSettings());
});
serverSettingsRouter.put('/', async (ctx) => {
const body = await ctx.req.json();
const settings = await readServerSettings();
const updated = { ...settings, ...body };
await writeServerSettings(updated);
return ctx.json(updated);
});
serverSettingsRouter.get('/plugins', async (ctx) => {
const pluginsDir = join(import.meta.dir, '../../../workspaces/plugins');
const settings = await readServerSettings();
const pluginSettings: Record<string, boolean> = (settings.plugins as Record<string, boolean>) ?? {};
const plugins: { id: string; name: string; description: string; enabled: boolean }[] = [];
if (!existsSync(pluginsDir)) return ctx.json(plugins);
const dirs = readdirSync(pluginsDir, { withFileTypes: true }).filter((d) => d.isDirectory());
for (const dir of dirs) {
const hasServer = existsSync(join(pluginsDir, dir.name, 'server', 'index.ts'));
const hasClient = existsSync(join(pluginsDir, dir.name, 'client', 'index.ts'));
if (!hasServer && !hasClient) continue;
const mainIndex = join(pluginsDir, dir.name, 'index.ts');
if (!existsSync(mainIndex)) continue;
const mod = await import(mainIndex);
const meta = mod.plugin ?? { id: dir.name, name: dir.name, description: '' };
plugins.push({
id: meta.id,
name: meta.name,
description: meta.description,
enabled: pluginSettings[meta.id] !== false,
});
}
return ctx.json(plugins);
});