diff --git a/src/server.tsx b/src/server.tsx index 0983ed52..fc486e03 100644 --- a/src/server.tsx +++ b/src/server.tsx @@ -4,6 +4,7 @@ import { serve } from 'bun'; import { join } from 'node:path'; import { honoServer, PROTECTED_API_PREFIXES, UNPROTECTED_API_PREFIXES } from './servers/hono'; import { assertPermissionTotality } from './servers/permissions/totality'; +import { bootstrapCaldav } from './servers/caldav-bootstrap'; import { refreshPluginMounts, snapshotPlugins } from './servers/plugins/mount'; import { listPluginRootClaims } from './servers/plugins/public-paths'; import { generatePluginsModule, rebuildFrontend, BUILD_DIR, SHELL_FILE } from './servers/plugins/generate'; @@ -551,4 +552,11 @@ void (async () => { // the provider names — and now lives in `plugins/music/cliamp/`, parked rather than working. Bringing it // back means a plugin owning a websocket, which is a platform gap, not a music one. +// Calendar and contacts became CORE on 2026-08-19, having been an app-store entry since 2026-08-13. +// A fresh install gets radicale and the officer-caldav entry from setup; an EXISTING one gets neither +// from a `git pull`, so it would come up with /calendar mounted and nothing behind it. Best-effort, +// after serve(), never blocking: a held apt lock or a machine without passwordless sudo must not stop +// Officer from starting, and when it cannot install it says what to run instead. +void bootstrapCaldav(); + // Pi check/install is handled by bootstrap.ts (imported above) diff --git a/src/servers/caldav-bootstrap.test.ts b/src/servers/caldav-bootstrap.test.ts new file mode 100644 index 00000000..0ee9444d --- /dev/null +++ b/src/servers/caldav-bootstrap.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, test } from 'bun:test'; +import { radicaleInstalled } from './caldav-bootstrap'; + +// What this guard exists for: calendar and contacts moved from an app-store entry to CORE on +// 2026-08-19. A fresh install gets `radicale` from machine-setup and `officer-caldav` from +// CORE_PROCESSES. An existing one gets NEITHER from `git pull`, because a pull changes nothing outside +// the repository — so /calendar would mount, render, and talk to nothing. +// +// The install side is deliberately not tested here: it shells out to a package manager, and a test that +// really ran `apt-get install` would be a test that modifies the machine running it. What IS worth +// pinning is that the detector asks the same question the sidecar will ask. + +describe('radicale detection', () => { + test('answers the same question the sidecar asks — is `radicale` on PATH', () => { + // `radicale.ts` spawns the bare name `radicale`, so PATH lookup is the whole contract. Asserting the + // boolean matches Bun.which rather than asserting a fixed value, because this machine may or may not + // have it and a test that only passes on one of those is worse than no test. + expect(radicaleInstalled()).toBe(Bun.which('radicale') !== null); + }); + + test('is a boolean, never a throw — it runs on the boot path', () => { + expect(typeof radicaleInstalled()).toBe('boolean'); + }); +}); diff --git a/src/servers/caldav-bootstrap.ts b/src/servers/caldav-bootstrap.ts new file mode 100644 index 00000000..3ea18f42 --- /dev/null +++ b/src/servers/caldav-bootstrap.ts @@ -0,0 +1,100 @@ +import { existsSync } from 'node:fs'; +import { PLATFORM_DIR } from './data-path'; +import { readEcosystemApps, addCoreProcess, type Pm2App } from './plugins/ecosystem'; +import { detectPackageManager, installCommand, manualInstallHint, sudoAvailable } from './plugins/os-deps'; + +// Bringing an EXISTING server up to the 2026-08-19 layout, where calendar and contacts are core. +// +// A fresh install needs none of this: `officer-setup` writes officer-caldav into the ecosystem from +// CORE_PROCESSES, and machine-setup installs `radicale` with the other packages. But a server set up +// before today has neither, and `git pull` changes no state outside the repository — so without this a +// pull would leave `/calendar` mounted, working, and talking to nothing, which is precisely the silent +// half-working failure this codebase keeps being bitten by. +// +// ── Why it does not block the boot ── +// +// Everything here is best-effort and runs AFTER `serve()`. A package manager holding a lock, an +// unreachable mirror or a machine with no passwordless sudo must not stop Officer from starting. The +// worst outcome is that calendar does not work and the log says why, which is the same outcome as doing +// nothing — only louder. + +const RADICALE_ENTRY: Pm2App = { + name: 'officer-caldav', + script: 'bun', + args: 'run src/servers/sidecar/caldav/index.ts', + cwd: PLATFORM_DIR, + watch: false, +}; + +/** Is Radicale on PATH? The sidecar spawns it by bare name, so this is the same question it will ask. */ +export function radicaleInstalled(): boolean { + return Bun.which('radicale') !== null; +} + +/** + * Add `officer-caldav` to the ecosystem file if a pre-2026-08-19 install lacks it. + * + * Returns true when it wrote. PM2 still has to be told — `pm2 start ecosystem.config.cjs + * --only officer-caldav` — because this process cannot restart its own supervisor's view of the file. + */ +export function ensureCaldavProcess(): boolean { + const apps = readEcosystemApps(); + if (apps.some((a) => a.name === RADICALE_ENTRY.name)) return false; + addCoreProcess(RADICALE_ENTRY); + return true; +} + +/** + * Install Radicale when it is missing and the platform is allowed to. + * + * `sudo -n` only, never a prompt: this runs unattended under PM2, where a password prompt is not a slow + * path but a hang. When it cannot, it prints the exact command for a person to run rather than failing + * silently — the whole point is that the operator finds out from the log rather than from a phone that + * stopped syncing. + */ +export async function ensureRadicale(): Promise<'present' | 'installed' | 'blocked' | 'failed'> { + if (radicaleInstalled()) return 'present'; + + const manager = detectPackageManager(); + if (!manager || (manager !== 'brew' && !sudoAvailable())) { + const hint = manualInstallHint(manager, ['radicale']); + console.warn( + `[caldav] radicale is not installed and the platform cannot install it` + + `${manager ? '' : ' (no supported package manager found)'}.` + + `${hint ? ` Run: ${hint}` : ''}`, + ); + return 'blocked'; + } + + console.log('[caldav] radicale missing — installing'); + const argv = installCommand(manager, ['radicale']); + try { + const proc = Bun.spawn(argv, { stdout: 'pipe', stderr: 'pipe' }); + const code = await proc.exited; + if (code !== 0 || !radicaleInstalled()) { + const err = await new Response(proc.stderr).text(); + console.error(`[caldav] radicale install failed (exit ${code}): ${err.trim().split('\n').slice(-3).join(' ')}`); + return 'failed'; + } + } catch (err) { + console.error(`[caldav] radicale install failed: ${err instanceof Error ? err.message : err}`); + return 'failed'; + } + console.log('[caldav] radicale installed'); + return 'installed'; +} + +/** Both halves, in the order that makes the log read sensibly. Never throws. */ +export async function bootstrapCaldav(): Promise { + try { + if (ensureCaldavProcess()) { + console.log('[caldav] added officer-caldav to the ecosystem — run `pm2 start ecosystem.config.cjs`'); + } + await ensureRadicale(); + } catch (err) { + console.error('[caldav] bootstrap skipped —', err instanceof Error ? err.message : err); + } +} + +/** Exported for the test, which must not depend on this machine having an ecosystem file. */ +export const _ecosystemPathExists = (): boolean => existsSync(`${PLATFORM_DIR}/ecosystem.config.cjs`); diff --git a/src/servers/plugins/ecosystem.ts b/src/servers/plugins/ecosystem.ts index cc90cea9..468aae0d 100644 --- a/src/servers/plugins/ecosystem.ts +++ b/src/servers/plugins/ecosystem.ts @@ -129,6 +129,23 @@ export function addPluginToEcosystem(plugin: DiscoveredPlugin): boolean { return true; } +/** + * Add a CORE process the running platform knows it needs, when the file predates it. + * + * Normally core entries come from `officer-setup`'s CORE_PROCESSES and the platform never writes one — + * it does not know the core list and duplicating it here would be a second copy to drift. This is the + * exception, and it is narrow on purpose: an existing install that pulls a commit which MOVED something + * into the core set has no other way to learn about it, because `git pull` changes nothing outside the + * repository. Caller supplies the whole entry; nothing here has an opinion about what belongs. + * + * Idempotent. Replaces an entry of the same name so a changed script path is picked up. + */ +export function addCoreProcess(entry: Pm2App): void { + const apps = readEcosystemApps().filter((a) => a.name !== entry.name); + apps.push(entry); + writeEcosystemApps(apps); +} + /** Drop the plugin's entry. Safe to call when it was never there. */ export function removePluginFromEcosystem(appName: string): void { const name = pluginProcessName(appName); diff --git a/src/servers/plugins/os-deps.ts b/src/servers/plugins/os-deps.ts index 814091ae..2e1792b1 100644 --- a/src/servers/plugins/os-deps.ts +++ b/src/servers/plugins/os-deps.ts @@ -97,7 +97,7 @@ export type DependencyReport = { * Homebrew is the exception and needs no escalation: it refuses to run as root outright, which is also * why machine-setup never escalates on macOS. */ -function sudoAvailable(): boolean { +export function sudoAvailable(): boolean { if (!Bun.which('sudo')) return false; try { return Bun.spawnSync(['sudo', '-n', 'true'], { stdout: 'ignore', stderr: 'ignore' }).exitCode === 0;