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>
101 lines
4.3 KiB
TypeScript
101 lines
4.3 KiB
TypeScript
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`);
|