Automerge backed Markdown editor for the Atmosphere
Something went wrong. Try again.
11 kB · 300 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301import { execSync } from 'node:child_process'import { existsSync, readFileSync } from 'node:fs'import { join, resolve } from 'node:path'import { fileURLToPath } from 'node:url'
/** * Configuration for the pushwork-sync integration harness. * * Resolved fresh on every call so env-var overrides take effect without a * restart. Path defaults are resolved with the following precedence: * * 1. Explicit env var (e.g. `SYNC_SERVER_BIN`). * 2. `which`-style lookup on `$PATH` (sync server only; pushwork uses * a direct source-build path). * 3. Empty-string sentinel (`''`); the `existsSync` guards in * `server.ts` and `dev.ts` catch it and print a clear "enter the * Nix dev shell" message. * * Runtime state lives under `.dev-state/pushwork-sync/` (gitignored). */export interface HarnessConfig { /** WebSocket URL the sync server listens on. */ syncServerUrl: string /** Port the sync server binds to. Mirrors syncServerUrl. */ syncServerPort: number /** Vite dev-server URL Reams is expected on. Reported in `status`, not started by us. */ devServerUrl: string /** Path to the `automerge-repo-sync-server` binary. */ syncServerBin: string /** Path to the `pushwork` CLI. */ pushworkBin: string /** Root for all runtime state (pid, data, workspaces). */ stateRoot: string}
const DEFAULT_SYNC_SERVER_PORT = 3030const DEFAULT_DEV_SERVER_PORT = 7002// Pushwork 2.x is published via the source tree at ~/usr/pushwork; the// `dist/cli.js` is a node script (#!/usr/bin/env node) we can execute// directly. We do NOT fall back to ~/.npm-global/bin/pushwork — that// path used to hold the 1.x release, which expects the now-removed// `--sync-server` flag and connects to a different default server.// Mixing the two silently produces broken sync.const FALLBACK_PUSHWORK_BIN = `${process.env.HOME ?? ''}/usr/pushwork/dist/cli.js`
/** Repo root, resolved relative to this file so scripts can run from any cwd. */export function repoRoot(): string { const here = fileURLToPath(import.meta.url) // tests/integration/pushwork-sync/support/env.ts // .. -> support/ // ../.. -> pushwork-sync/ // ../../.. -> integration/ // ../../../.. -> tests/ // ../../../../.. -> repo root return resolve(here, '..', '..', '..', '..', '..')}
/** * Resolve a binary by name on `$PATH`, returning null if not found. * Wraps `which` so errors don't leak. Cross-platform-ish: bun/node ship * with `Bun.which` but we keep this dependency-free by shelling out. */function which(name: string): string | null { try { const result = execSync(`command -v ${name}`, { stdio: ['ignore', 'pipe', 'ignore'] }) .toString() .trim() return result.length > 0 ? result : null } catch { return null }}
/** * Pull an environment variable, treating `undefined`, `null`, and the * empty string as "unset". A bare `??` on `process.env.X` only guards * against `undefined` and would silently accept `SYNC_SERVER_PORT=` (an * exported-but-empty shell variable) as "0", which corrupts URL/port * derivation downstream. */function envOrDefault<T>(name: string, fallback: T, parse: (raw: string) => T): T { const raw = process.env[name] if (raw === undefined || raw === null || raw === '') return fallback return parse(raw)}
function parsePort(name: string, raw: string): number { const port = Number(raw) if (!Number.isFinite(port) || port <= 0 || port > 65_535 || !Number.isInteger(port)) { throw new Error(`${name} must be an integer from 1 to 65535, got '${raw}'.`) } return port}
function worktreePort(name: string, fallback: number): number { return envOrDefault(name, fallback, (raw) => parsePort(name, raw))}
export function getHarnessConfig(): HarnessConfig { const root = repoRoot() // The sync server binary is provided by the Reams flake's devShell // (automerge-repo-nix input). When not on $PATH and no explicit // SYNC_SERVER_BIN override is set, '' is the sentinel -- the // existsSync guards in server.ts and dev.ts catch it and print a // clear "enter the Nix dev shell" message. const syncServerBin = process.env.SYNC_SERVER_BIN ?? which('automerge-repo-sync-server') ?? '' // Pushwork: explicit PUSHWORK_BIN wins; otherwise we go straight to // the local source build at ~/usr/pushwork/dist/cli.js. We do NOT // consult `which('pushwork')` — historical npm-global installs hold // the 1.x release which is incompatible with the harness (different // CLI shape, no `--legacy` / `--shape`, different default sync // server). A failing `existsSync` check at runPushwork-time will // surface a clear "build pushwork first" error rather than a // confusing 1.x-vs-2.x flag mismatch. const pushworkBin = process.env.PUSHWORK_BIN ?? FALLBACK_PUSHWORK_BIN
// Port + URL must agree. Resolution rules: // - If SYNC_SERVER_URL is set: derive port from it (the URL is the // more specific signal; trust it). `SYNC_SERVER_PORT`, if also // set, is ignored UNLESS it matches. // - Else if SYNC_SERVER_PORT is set: derive URL from `ws://localhost:<port>`. // - Else: the worktree test port (`REAMS_TEST_SYNC_PORT`) or the // standalone fallback (`ws://localhost:3030`, 3030). // The previous behavior pulled port and URL independently from env // and could produce silently-inconsistent pairs (e.g. SYNC_SERVER_PORT=4040 // alone → server bound to 4040, URL stayed 3030 → Vite told the wrong // place to connect). This shape rejects that footgun loudly. const rawUrl = envOrDefault<string | null>('SYNC_SERVER_URL', null, (s) => s) const rawPort = envOrDefault<number | null>( 'SYNC_SERVER_PORT', null, (raw) => parsePort('SYNC_SERVER_PORT', raw), )
let syncServerUrl: string let syncServerPort: number if (rawUrl !== null) { syncServerUrl = rawUrl const parsed = (() => { try { return new URL(rawUrl) } catch { throw new Error(`SYNC_SERVER_URL is not a valid URL: '${rawUrl}'.`) } })() if (!parsed.port) { throw new Error( `SYNC_SERVER_URL '${rawUrl}' has no port. Specify a port explicitly ` + `(e.g. ws://localhost:3030) so SYNC_SERVER_PORT can be derived.`, ) } syncServerPort = Number(parsed.port) if (rawPort !== null && rawPort !== syncServerPort) { throw new Error( `SYNC_SERVER_URL='${rawUrl}' (port ${syncServerPort}) and ` + `SYNC_SERVER_PORT=${rawPort} disagree. Set exactly one, or set ` + `both to the same port.`, ) } } else if (rawPort !== null) { syncServerPort = rawPort syncServerUrl = `ws://localhost:${rawPort}` } else { syncServerPort = worktreePort('REAMS_TEST_SYNC_PORT', DEFAULT_SYNC_SERVER_PORT) syncServerUrl = `ws://localhost:${syncServerPort}` }
return { syncServerUrl, syncServerPort, devServerUrl: envOrDefault( 'DEV_SERVER_URL', `http://localhost:${worktreePort('REAMS_DEV_PORT', DEFAULT_DEV_SERVER_PORT)}`, (s) => s, ), syncServerBin, pushworkBin, stateRoot: envOrDefault( 'PUSHWORK_SYNC_STATE_ROOT', join(root, '.dev-state', 'pushwork-sync'), (s) => s, ), }}
// --- Path helpers ---------------------------------------------------------
export function syncServerPidFile(config: HarnessConfig): string { return join(config.stateRoot, 'server.pid')}
/** * /proc/<pid>/cmdline contents captured at startup. Used by `readRunningPid` * to defend against PID recycling: if the recorded cmdline doesn't match * the live process's cmdline, the PID file is stale. */export function syncServerCmdlineFile(config: HarnessConfig): string { return join(config.stateRoot, 'server.cmdline')}
export function syncServerStorageIdFile(config: HarnessConfig): string { return join(config.stateRoot, 'storage-id')}
export function syncServerLogFile(config: HarnessConfig): string { return join(config.stateRoot, 'server.log')}
export function syncServerDataDir(config: HarnessConfig): string { return join(config.stateRoot, 'data')}
export function fixtureWorkspaceDir(config: HarnessConfig, fixtureName: string): string { return join(config.stateRoot, 'workspaces', fixtureName)}
export function fixtureSourceDir(fixtureName: string): string { return join(repoRoot(), 'tests', 'integration', 'pushwork-sync', 'fixtures', fixtureName)}
export function fixtureRootUrlFile(config: HarnessConfig, fixtureName: string): string { // Sibling to the workspace, so the workspace dir stays a pure pushwork dir. return join(config.stateRoot, 'workspaces', `${fixtureName}.root-url`)}
// --- State accessors ------------------------------------------------------
/** * Read the storage ID written by `server.ts up`. Throws with a clear hint * if the server isn't running. */export function readStorageId(config: HarnessConfig): string { const path = syncServerStorageIdFile(config) if (!existsSync(path)) { throw new Error( `No storage-id file at ${path}. Start the sync server first:\n` + ` bun run test:pushwork-sync:server:up`, ) } return readFileSync(path, 'utf8').trim()}
/** * Read the PID of the running sync server. Returns null if: * - the PID file is missing, OR * - the recorded PID is no longer alive, OR * - the live process's cmdline doesn't match the recorded cmdline (PID- * recycling guard: the OS reused the PID for an unrelated process). * * The cmdline fingerprint catches the rare-but-real case where a long- * running box recycles PIDs into a different binary; without it, `status` * would lie and `down` could `kill` the wrong process. */export function readRunningPid(config: HarnessConfig): number | null { const pidPath = syncServerPidFile(config) if (!existsSync(pidPath)) return null
const pid = Number(readFileSync(pidPath, 'utf8').trim()) if (!Number.isFinite(pid) || pid <= 0) return null
// Liveness check: signal 0 = "is this PID alive?", does not kill. try { process.kill(pid, 0) } catch { return null }
// Cmdline fingerprint check. We require a fingerprint to be present // (startSyncServer writes it; absence means corrupted state). We also // require /proc to be readable (harness is Linux-only by design). // // Either missing-file or unreadable-cmdline is treated as "this PID is // not trustworthy" — return null. The user can `wipe` to recover. const cmdlinePath = syncServerCmdlineFile(config) if (!existsSync(cmdlinePath)) return null
const expected = readFileSync(cmdlinePath, 'utf8') const actual = readProcCmdline(pid) if (actual === null) return null if (actual !== expected) { // PID has been recycled into a different process. return null } return pid}
/** * Read `/proc/<pid>/cmdline` and return its raw bytes as a string. Returns * null if `/proc` isn't accessible. Note: this string contains NUL bytes * between argv elements — that's fine for equality comparison. */export function readProcCmdline(pid: number): string | null { try { return readFileSync(`/proc/${pid}/cmdline`, 'utf8') } catch { return null }}