install radicale and register officer-caldav on an existing server's next boot
Calendar and contacts became core in9c309c5a. A FRESH install gets radicale from machine-setup and officer-caldav from CORE_PROCESSES; an existing one gets neither from a git pull, because a pull changes nothing outside the repository. So without this, pulling that commit leaves /calendar mounted, rendering, and talking to nothing — the silent half-working failure this codebase keeps getting bitten by, and the same shape as the plugin permission collision fixed inee321f49. Best-effort and AFTER serve(), never blocking: a held apt lock, an unreachable mirror or a machine without passwordless sudo must not stop Officer starting. Uses the existing os-deps machinery rather than a second spelling of "install a package" — including `sudo -n`, which fails fast instead of hanging on a prompt no one can answer under PM2. When it cannot install, it logs the exact command to run by hand. addCoreProcess is new and deliberately narrow. The platform normally never writes a core entry — it does not know the core list, and duplicating CORE_PROCESSES here would be a second copy to drift. The exception is exactly this: a commit that MOVES something into the core set, which an existing install has no other way to learn about. Verified on this machine rather than assumed: radicale was absent, the guard installed it (3.1.8), and a second run reported `present` and wrote nothing. The install path is not unit-tested on purpose — a test that really ran apt-get would modify the machine running it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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<void> {
|
||||
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`);
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user