Automerge backed Markdown editor for the Atmosphere
Something went wrong. Try again.
13 kB · 350 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351import { execFileSync } from 'node:child_process'import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'import { dirname, resolve as pathResolve } from 'node:path'
import { fixtureRootUrlFile, fixtureSourceDir, fixtureWorkspaceDir, type HarnessConfig,} from './env.ts'
/** * Thin wrappers over the `pushwork` CLI. We use `execFileSync` rather than * a JS API because pushwork's surface is its CLI — using it directly is * the truest interop test, and a JS API would drift from the real path. * * All operations refuse to touch workspaces inside `tests/` or any path * the harness didn't create itself, to defend against accidental * `cp -r`-into-fixture mistakes that would commit a `.pushwork/` to git. */
/** * Restrict fixture names to a conservative character class. Prevents * `../../etc` shenanigans in CLI args and ensures workspace paths stay * inside the state root. */const FIXTURE_NAME_RE = /^[A-Za-z0-9_-]+$/
function assertValidFixtureName(name: string): void { if (!FIXTURE_NAME_RE.test(name)) { throw new Error( `Invalid fixture name '${name}'. Allowed: letters, digits, _ and -.`, ) }}
/** Result of `initFixture`. */export interface InitResult { workspaceDir: string rootUrl: string /** Actual workspace name; may be suffixed when the requested path exists. */ fixtureName: string}
/** * Copy the fixture source into a fresh workspace and run `pushwork init` * against the local sync server. Captures the root Automerge URL via * `pushwork url` and persists it next to the workspace. * * Refuses to clobber an existing workspace. With `force === true`, allocates * a suffixed workspace instead. */export function initFixture( config: HarnessConfig, fixtureName: string, options: { force?: boolean } = {},): InitResult { assertValidFixtureName(fixtureName) const sourceDir = fixtureSourceDir(fixtureName) if (!existsSync(sourceDir)) { throw new Error( `No fixture named '${fixtureName}' at ${sourceDir}.\n` + `Available fixtures: ${listFixtureNames().join(', ') || '(none)'}`, ) }
const actualFixtureName = options.force && fixturePathExists(config, fixtureName) ? nextAvailableFixtureName(config, fixtureName) : fixtureName const workspaceDir = fixtureWorkspaceDir(config, actualFixtureName) if (existsSync(workspaceDir)) { if (!options.force) { throw new Error( `Workspace already exists at ${workspaceDir}.\n` + `Use --force (or 'reset' verb) to allocate a fresh suffixed workspace.`, ) } throw new Error(`Unable to allocate a non-destructive workspace for '${fixtureName}'`) }
// Guardrail: never touch anything outside our state root or fixtures dir. assertInsideStateRoot(workspaceDir, config)
// Copy the fixture into the workspace. We use cpSync recursively. mtimes // are preserved by default, which is fine: pushwork's change detection // is content-based per its README, not mtime-based. mkdirSync(dirname(workspaceDir), { recursive: true }) cpSync(sourceDir, workspaceDir, { recursive: true })
// `pushwork init <dir>` initializes the workspace. // // Pushwork 2.x changes that affect the harness: // // - The `--sync-server <url> <storage-id>` flag is gone. Configure // the legacy WebSocket URL via the `PUSHWORK_LEGACY_SERVER` env // var (passed by `runPushwork` below). There is no longer a // storage-id concept in the legacy backend — the classic // `automerge-repo-sync-server` still prints one at startup but // pushwork 2.x ignores it. // // - `--legacy` (alias `--no-sub`) opts into the classic WebSocket // backend; pushwork 2.x defaults to Subduction, which Reams does // not host. Without `--legacy` the new pushwork would silently // connect to `wss://subduction.sync.inkandswitch.com` and skip // our local sync server entirely. // // - `--shape patchwork-folder` preserves the `@patchwork:{type:...}` // schema Reams expects. The new default `vfs` shape uses a // different `@patchwork:{type:'directory'}` discriminator that // `isPushworkDirectory` / `isPushworkFile` reject; without this // flag, Reams cannot hydrate the file tree. runPushwork( config, ['init', workspaceDir, '--legacy', '--shape', 'patchwork-folder'], )
// `pushwork url` prints the root Automerge URL. In 2.x it takes no // arguments and operates on `process.cwd()`, so we invoke from // inside the workspace. const rootUrl = runPushwork(config, ['url'], { cwd: workspaceDir }).trim() if (!rootUrl.startsWith('automerge:')) { throw new Error( `Expected pushwork url to print an automerge: URL, got: ${rootUrl}`, ) } writeFileSync(fixtureRootUrlFile(config, actualFixtureName), rootUrl)
return { workspaceDir, rootUrl, fixtureName: actualFixtureName }}
/** * Run `pushwork sync` in an initialized workspace. * Throws if the workspace doesn't exist or isn't pushwork-initialized. */export function syncFixture(config: HarnessConfig, fixtureName: string): void { assertValidFixtureName(fixtureName) const workspaceDir = fixtureWorkspaceDir(config, fixtureName) assertInsideStateRoot(workspaceDir, config) if (!existsSync(workspaceDir)) { throw new Error( `Workspace ${workspaceDir} does not exist. Initialize it first:\n` + ` bun run test:pushwork-sync:fixture init ${fixtureName}`, ) } if (!existsSync(`${workspaceDir}/.pushwork`)) { throw new Error( `Workspace ${workspaceDir} exists but is not pushwork-initialized ` + `(missing .pushwork/). Use 'reset' to recreate.`, ) } // Pushwork 2.x `sync` takes no arguments and operates on `process.cwd()`. runPushwork(config, ['sync'], { cwd: workspaceDir })}
/** * Read the persisted root URL for an initialized workspace. Throws if the * workspace has not been initialized. */export function readFixtureRootUrl(config: HarnessConfig, fixtureName: string): string { assertValidFixtureName(fixtureName) const path = fixtureRootUrlFile(config, fixtureName) assertInsideStateRoot(path, config) if (!existsSync(path)) { throw new Error( `No root URL recorded for fixture '${fixtureName}'. Initialize it first:\n` + ` bun run test:pushwork-sync:fixture init ${fixtureName}`, ) } return readFileSync(path, 'utf8').trim()}
/** * List names of fixtures available under fixtures/. */export function listFixtureNames(): string[] { const dir = fixtureSourceDir('') // empty name returns the fixtures dir itself if (!existsSync(dir)) return [] return readdirSync(dir, { withFileTypes: true }) .filter((d) => d.isDirectory()) .map((d) => d.name) .sort()}
/** * Status of every fixture: whether its workspace exists, whether it's * pushwork-initialized, and (if so) its root URL. */export interface FixtureStatus { name: string hasWorkspace: boolean initialized: boolean rootUrl: string | null}
export function listFixtureStatuses(config: HarnessConfig): FixtureStatus[] { return listFixtureNames().map((name) => { const workspaceDir = fixtureWorkspaceDir(config, name) const hasWorkspace = existsSync(workspaceDir) const initialized = hasWorkspace && existsSync(`${workspaceDir}/.pushwork`) const urlPath = fixtureRootUrlFile(config, name) const rootUrl = existsSync(urlPath) ? readFileSync(urlPath, 'utf8').trim() : null return { name, hasWorkspace, initialized, rootUrl } })}
// --- Internals ------------------------------------------------------------
interface RunPushworkOptions { /** Working directory to invoke pushwork from. Defaults to `process.cwd()`. */ cwd?: string}
function runPushwork( config: HarnessConfig, args: string[], options: RunPushworkOptions = {},): string { if (!existsSync(config.pushworkBin)) { throw new Error( `pushwork binary not found at ${config.pushworkBin}.\n` + `Build the local pushwork 2.x source:\n` + ` cd ~/usr/pushwork && bun install && bun run build\n` + `Or override PUSHWORK_BIN=<path-to-cli.js-or-wrapper>.\n` + `The CLI is a node script; if 'node' is not on $PATH the\n` + `harness will resolve it from process.execPath as a fallback.`, ) }
// Pushwork's dist/cli.js has a `#!/usr/bin/env node` shebang. In a // Nix shell where the node binary isn't on $PATH (e.g. the Reams // dev shell where bun is the canonical runtime), exec'ing the .js // directly fails with ENOENT. Detect a `.js` file and explicitly // spawn node — preferring `process.execPath` when the parent IS // node, falling back to a `which`-style PATH lookup otherwise. // // Direct-exec is still preferred when the user points // PUSHWORK_BIN at a wrapper script (e.g. /tmp/pushwork-local.sh in // the README), so we only swap to node-via-args for the .js case. const isJsBin = config.pushworkBin.endsWith('.js') let file: string let fullArgs: string[] if (isJsBin) { const node = resolveNodeBin() if (node === null) { throw new Error( `pushwork at ${config.pushworkBin} is a node script (.js) and no\n` + `'node' binary is available on $PATH. Set PUSHWORK_BIN to a\n` + `wrapper script that exec's node explicitly, e.g.:\n` + ` #!/usr/bin/env bash\n` + ` exec /nix/store/.../bin/node ~/usr/pushwork/dist/cli.js "$@"`, ) } file = node fullArgs = [config.pushworkBin, ...args] } else { file = config.pushworkBin fullArgs = args }
try { // PUSHWORK_LEGACY_SERVER is how pushwork 2.x learns the WebSocket // URL for the legacy (`--no-sub`) backend; the per-invocation // `--sync-server` flag is gone. We export it here for every CLI // call so subsequent commands (`sync`, `url`, `save`) all use the // same local server. We do NOT clobber the caller's environment: // if the user already exported PUSHWORK_LEGACY_SERVER, that wins. const env = { ...process.env, PUSHWORK_LEGACY_SERVER: process.env.PUSHWORK_LEGACY_SERVER ?? config.syncServerUrl, } const out = execFileSync(file, fullArgs, { stdio: ['ignore', 'pipe', 'pipe'], env, cwd: options.cwd, }) return out.toString() } catch (err) { const e = err as { stdout?: Buffer; stderr?: Buffer; status?: number } const stdout = e.stdout?.toString() ?? '' const stderr = e.stderr?.toString() ?? '' throw new Error( `pushwork ${args.join(' ')} failed (exit ${e.status ?? '?'}).\n` + (stdout ? `stdout:\n${stdout}\n` : '') + (stderr ? `stderr:\n${stderr}` : ''), ) }}
/** * Locate a `node` binary suitable for executing the pushwork CLI. * * Resolution order: * 1. `process.execPath` if it looks like a node binary. When the * harness runs under `node` (e.g. via Vitest in node mode), this * is the cleanest answer — same node that's running us. * 2. `command -v node` via the existing PATH. Works when the user * is in a dev shell with node available. * 3. The harness's recorded node path under the Nix store. Reams's * dev shell exposes `Node version: v22.14.0` at shell entry; we * cannot interrogate that programmatically without re-running * direnv, but in practice (1) or (2) covers every case we ship * to. If neither hits, return null and let `runPushwork` print a * clear error. */function resolveNodeBin(): string | null { const exec = process.execPath // `process.execPath` for bun is `/path/to/bun`, for node it ends in // 'node'. The `endsWith('/node')` check is a fast positive without // shelling out for the bun case. if (exec && /\/node(?:\d+)?$/.test(exec)) return exec try { const found = execFileSync('sh', ['-c', 'command -v node'], { stdio: ['ignore', 'pipe', 'ignore'], }) .toString() .trim() if (found.length > 0) return found } catch { // Fall through. } return null}
/** * Safety: refuse to operate on paths outside the configured state root. * Cheap insurance against typos in fixture names or env var overrides * sending us at, say, `/`. */function assertInsideStateRoot(path: string, config: HarnessConfig): void { const normalizedPath = pathResolve(path) const normalizedRoot = pathResolve(config.stateRoot) if (!normalizedPath.startsWith(normalizedRoot + '/') && normalizedPath !== normalizedRoot) { throw new Error( `Refusing to operate on ${normalizedPath} — outside state root ${normalizedRoot}. ` + `Set PUSHWORK_SYNC_STATE_ROOT explicitly if you really mean this.`, ) }}
function nextAvailableFixtureName(config: HarnessConfig, requestedName: string): string { for (let suffix = 1; ; suffix += 1) { const candidate = `${requestedName}-${suffix}` if (!fixturePathExists(config, candidate)) return candidate }}
function fixturePathExists(config: HarnessConfig, fixtureName: string): boolean { return existsSync(fixtureWorkspaceDir(config, fixtureName)) || existsSync(fixtureRootUrlFile(config, fixtureName))}