From 069829fc1d15b1415150b7317b4aa03be730cb23 Mon Sep 17 00:00:00 2001 From: Brooklyn Zelenka Date: Wed, 22 Apr 2026 22:50:54 +0100 Subject: [PATCH 1/2] Opus! --- ARCHITECTURE-ACCORDING-TO-CLAUDE.md | 19 +- CLAUDE.md | 35 +++- README.md | 77 +++++--- package.json | 2 +- src/cli.ts | 18 +- src/commands.ts | 214 +++++++++++--------- src/core/config.ts | 281 +++++++++++++++++++++++--- src/types/config.ts | 51 ++++- test/integration/legacy-flag.test.ts | 199 +++++++++++++++++++ test/integration/sub-flag.test.ts | 187 ------------------ test/unit/config-migration.test.ts | 286 +++++++++++++++++++++++++++ test/unit/repo-factory.test.ts | 94 +++++++-- test/unit/subduction-config.test.ts | 98 ++++++--- 13 files changed, 1148 insertions(+), 413 deletions(-) create mode 100644 test/integration/legacy-flag.test.ts delete mode 100644 test/integration/sub-flag.test.ts create mode 100644 test/unit/config-migration.test.ts diff --git a/ARCHITECTURE-ACCORDING-TO-CLAUDE.md b/ARCHITECTURE-ACCORDING-TO-CLAUDE.md index bc23cda..9f25315 100644 --- a/ARCHITECTURE-ACCORDING-TO-CLAUDE.md +++ b/ARCHITECTURE-ACCORDING-TO-CLAUDE.md @@ -2,7 +2,7 @@ > This document was generated by Claude from reading the source code. -Pushwork is a CLI tool for bidirectional file synchronization using **Automerge CRDTs**. It maps a local filesystem directory tree to a mirror tree of Automerge documents and syncs them through either a WebSocket relay server (default) or the Subduction backend (opt-in via `--sub`). Multiple peers can edit the same files and changes merge automatically. +Pushwork is a CLI tool for bidirectional file synchronization using **Automerge CRDTs**. It maps a local filesystem directory tree to a mirror tree of Automerge documents and syncs them through either the Subduction backend (default) or a legacy WebSocket relay server (opt-in via `--legacy`). Multiple peers can edit the same files and changes merge automatically. ## Module Diagram @@ -28,8 +28,8 @@ Pushwork is a CLI tool for bidirectional file synchronization using **Automerge │ defaults < │ │ Automerge │ │ Orchestrates the │ │ global < │ │ Repo with │ │ entire sync cycle │ │ local │ │ storage + │ │ │ -└──────────────┘ │ WebSocket or │ │ ┌────────────────┐ │ - │ Subduction │ │ │ChangeDetector │ │ +└──────────────┘ │ Subduction or│ │ ┌────────────────┐ │ + │ WebSocket │ │ │ChangeDetector │ │ └──────────────┘ │ │ FS vs snapshot │ │ │ │ vs remote docs │ │ │ └────────────────┘ │ @@ -60,12 +60,13 @@ Pushwork is a CLI tool for bidirectional file synchronization using **Automerge ┌───────────────────────────────┐ │ Sync backend (one of) │ │ │ - │ • WebSocket relay (default) │ - │ sync3.automerge.org │ - │ │ - │ • Subduction (--sub opt-in) │ + │ • Subduction (default) │ │ subduction.sync │ │ .inkandswitch.com │ + │ │ + │ • Legacy WebSocket relay │ + │ (--legacy opt-in) │ + │ sync3.automerge.org │ └───────────────────────────────┘ ``` @@ -136,7 +137,7 @@ DirectoryDocument (root) 1. Verifies the `.pushwork/` directory exists 2. Loads and merges config (defaults < global `~/.pushwork/config.json` < local `.pushwork/config.json`) -3. Creates an Automerge `Repo` via `createRepo()` — sets up `NodeFSStorageAdapter` for local persistence, and either a `BrowserWebSocketClientAdapter` (default) or the Subduction backend (`subductionWebsocketEndpoints`, when `config.subduction === true`) for network sync +3. Creates an Automerge `Repo` via `createRepo()` — sets up `NodeFSStorageAdapter` for local persistence, and either the Subduction backend (default, via `subductionWebsocketEndpoints`) or a `BrowserWebSocketClientAdapter` (legacy, when `config.protocol === "legacy"`) for network sync 4. Instantiates a `SyncEngine` with the repo, working directory, and config Every command (sync, commit, status, diff, ls, etc.) calls `setupCommandContext()`, uses the `SyncEngine`, then calls `safeRepoShutdown()`. @@ -246,7 +247,7 @@ your-project/ | `@automerge/automerge` | Core CRDT engine: splice, changeAt, RawString | | `@automerge/automerge-repo` | Repo, DocHandle, document lifecycle management | | `@automerge/automerge-repo-network-websocket` | WebSocket transport to relay server (default backend) | -| `@automerge/automerge-subduction` | Subduction Wasm bindings (opt-in backend via `--sub`) | +| `@automerge/automerge-subduction` | Subduction Wasm bindings (default backend) | | `@automerge/automerge-repo-storage-nodefs` | Local filesystem persistence for Automerge docs | | `@commander-js/extra-typings` | CLI command framework | | `diff` | Character-level diffing to feed `A.splice()` | diff --git a/CLAUDE.md b/CLAUDE.md index 86d4806..01d0903 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -139,21 +139,23 @@ Used throughout sync-engine: if heads are available, calls `handle.changeAt(head - **`waitForBidirectionalSync` on large trees.** Full tree traversal (`getAllDocumentHeads`) is expensive because it `repo.find()`s every document. For post-push stabilization, pass the `handles` option to only check documents that actually changed. The initial pre-pull call still needs the full scan to discover remote changes. The dynamic timeout adds the first scan's duration on top of the base timeout, since the first scan is just establishing baseline — its duration shouldn't count against stability-wait time. - **Versioned URLs and `repo.find()`.** `repo.find(versionedUrl)` returns a view handle whose `.heads()` returns the VERSION heads, not the current document heads. Always use `getPlainUrl()` when you need the current/mutable state. The snapshot head update loop at the end of `sync()` must use `getPlainUrl(snapshotEntry.url)` — without this, artifact directories (which store versioned URLs) get stale heads written to the snapshot, causing `changeAt()` to fork from the wrong point on the next sync. This was the root cause of the artifact deletion resurrection bug: `batchUpdateDirectory` would `changeAt` from an empty directory state where the file entry didn't exist yet, so the splice found nothing to delete. -## Subduction sync backend (`--sub`) +## Sync backends (default: Subduction) -The `--sub` flag switches from the default WebSocket sync adapter to the Subduction backend built into `automerge-repo@2.6.0-subduction.14`. The Repo manages a `SubductionSource` internally — pushwork just passes `subductionWebsocketEndpoints` and the Repo handles connection management, sync, and retries. +Pushwork supports two sync backends. Subduction is the default; legacy WebSocket is opt-in via `--legacy` on `init`/`clone`/`track`. + +The Repo manages a `SubductionSource` internally — pushwork just passes `subductionWebsocketEndpoints` (Subduction mode) or constructs a `BrowserWebSocketClientAdapter` (legacy mode), and the Repo handles connection management, sync, and retries. ### How it works -- `repo-factory.ts`: Initializes Subduction Wasm via ESM dynamic import, then creates Repo. When `sub: true`, passes `subductionWebsocketEndpoints: [syncServer]` and the Repo handles sync cadence internally. When `sub: false`, uses the traditional WebSocket network adapter instead. -- Default server: `wss://subduction.sync.inkandswitch.com` (vs `wss://sync3.automerge.org` for WebSocket) +- `repo-factory.ts`: Initializes Subduction Wasm via ESM dynamic import, then creates Repo. When `sub: true` (Subduction, default), passes `subductionWebsocketEndpoints: [syncServer]` and the Repo handles sync cadence internally. When `sub: false` (legacy), uses the traditional WebSocket network adapter instead. +- Default Subduction server: `wss://subduction.sync.inkandswitch.com`; legacy server: `wss://sync3.automerge.org` - `network-sync.ts`: When no `StorageId` is provided (Subduction mode), `waitForSync` falls back to head-stability polling (3 consecutive stable checks at 100ms intervals) instead of `getSyncInfo`-based verification -- `sync-engine.ts`: In sub mode, skips `recreateFailedDocuments` — SubductionSource has its own heal-sync retry logic -- Everything else (push/pull phases, artifact handling, `nukeAndRebuildDocs`, change detection) is identical +- `sync-engine.ts`: In Subduction mode, skips `recreateFailedDocuments` — SubductionSource has its own heal-sync retry logic +- Everything else (push/pull phases, artifact handling, `nukeAndRebuildDocs`, change detection) is identical across backends ### Wasm initialization -As of `automerge-repo@2.6.0-subduction.14`, the Repo constructor _always_ creates a `SubductionSource` internally (even without Subduction endpoints), which imports `MemorySigner` and `set_subduction_logger` from `@automerge/automerge-subduction/slim`. The `/slim` entry does NOT auto-init the Wasm — so Wasm must be initialized before _any_ `new Repo()` call, including the default WebSocket path. +As of `automerge-repo@2.6.0-subduction.14`, the Repo constructor _always_ creates a `SubductionSource` internally (even without Subduction endpoints), which imports `MemorySigner` and `set_subduction_logger` from `@automerge/automerge-subduction/slim`. The `/slim` entry does NOT auto-init the Wasm — so Wasm must be initialized before _any_ `new Repo()` call, including the legacy WebSocket path. `automerge-repo` exports `initSubduction()` which dynamically imports `@automerge/automerge-subduction` (the non-`/slim` entry that auto-inits Wasm). Pushwork calls this via `repoMod.initSubduction()` after loading the Repo module — no direct dependency on `@automerge/automerge-subduction` is needed. @@ -168,14 +170,27 @@ The Repo class itself is also loaded via this ESM dynamic import (cached after f - The `automerge-repo-network-websocket` adapter's `NetworkAdapter` types are slightly behind the repo's `NetworkAdapterInterface` (missing `state()` method in declarations). The adapter works at runtime; the type mismatch is worked around with `as unknown as NetworkAdapterInterface`. - New `"heal-exhausted"` event on Repo fires when self-healing sync gives up after all retry attempts for a document. Not currently used by pushwork but available for better error reporting. -### Subduction mode persistence +### Backend persistence in config -`--sub` is only accepted on `init` and `clone`. It persists `subduction: true` in `.pushwork/config.json`. All subsequent commands (`sync`, `watch`, etc.) read it from config via `config.subduction ?? false`. The force-defaults path in `setupCommandContext` preserves `subduction` alongside `root_directory_url`. +`--legacy` is only accepted on `init`, `clone`, and `track`. It persists `"protocol": "legacy"` in `.pushwork/config.json`. Default (Subduction) installs persist `"protocol": "subduction"`. All subsequent commands (`sync`, `watch`, etc.) read it from config via `resolveProtocol(localConfig)`. The force-defaults path in `setupCommandContext` preserves the protocol alongside `root_directory_url` and any user-configured `sync_server`. -When Subduction mode is active, commands print a banner: "Using Subduction sync backend (from config)". +When legacy mode is active, commands print a banner: "Using legacy WebSocket sync backend (from config)". No banner is printed for default Subduction operation. Every `sync` run prints the root Automerge URL at the end. +### Config schema version and migration + +The `config_version` field in `.pushwork/config.json` tracks schema version. Current: `CONFIG_VERSION = 1` (exported from `src/types/config.ts`). + +- **v0** (no `config_version` field): pre-flip configs. Had a `subduction?: boolean` opt-in flag. Absence of that flag ⇒ classic WebSocket install. +- **v1**: Subduction is the default. Uses `"protocol": "subduction" | "legacy"` instead of `subduction: boolean`. Always written explicitly. + +Migration is in `ConfigManager.migrateIfNeeded()`: + +- Write-ish commands (`init`, `clone`, `track`, `sync`, `watch`, `commit`) call `migrateConfigIfNeeded()` at the top. Read-only commands (`status`, `diff`, `log`, `ls`, `url`) do not — they read v0 configs transparently via `resolveProtocol` in memory without touching disk. +- Migration reads the raw v0 config, infers protocol (`subduction: true` → `"subduction"`; `false` or absent → `"legacy"`), writes a backup to `config.json.bak` (or `.bak.1`, `.bak.2`, ... if prior backups exist), and rewrites the file in v1 shape. Prints a multi-line banner so the user sees what happened. +- `resolveProtocol(config)` is the single source of truth for backend selection across all paths. Given `null`/`undefined` it returns `"subduction"` (new-install default). + ### Corrupt storage recovery `repo-factory.ts` scans `.pushwork/automerge/` for 0-byte files before creating the Repo. These indicate incomplete writes from a previous run (process exited before storage flushed). If any are found, the entire automerge cache is wiped and recreated — data will re-download from the sync server. The snapshot (`.pushwork/snapshot.json`) is preserved so all document URLs are retained. diff --git a/README.md b/README.md index 3c48191..5c9289a 100644 --- a/README.md +++ b/README.md @@ -45,15 +45,15 @@ pushwork url **`init [path]`** - Initialize sync in a directory -- `--sync-server ` - Custom sync server URL and storage ID -- `--sub` - Use the Subduction sync backend (opt-in, persisted in config) +- `--sync-server ` - Custom sync server URL and storage ID +- `--legacy` - Use the legacy WebSocket sync backend (Subduction is default) - `--debug` - Export performance flame graphs **`clone `** - Clone an existing synced directory - `--force` - Overwrite existing directory -- `--sync-server ` - Custom sync server URL and storage ID -- `--sub` - Use the Subduction sync backend (opt-in, persisted in config) +- `--sync-server ` - Custom sync server URL and storage ID +- `--legacy` - Use the legacy WebSocket sync backend (Subduction is default) **`sync [path]`** - Run bidirectional synchronization @@ -105,38 +105,63 @@ Configuration is stored in `.pushwork/config.json`: ```json { + "config_version": 1, + "protocol": "subduction", + "sync_server": "wss://subduction.sync.inkandswitch.com", + "sync_enabled": true, + "exclude_patterns": [".git", "node_modules", "*.tmp", ".pushwork"], + "artifact_directories": ["dist"], + "sync": { + "move_detection_threshold": 0.7 + } +} +``` + +A legacy-backend config looks like: + +```json +{ + "config_version": 1, + "protocol": "legacy", "sync_server": "wss://sync3.automerge.org", "sync_server_storage_id": "3760df37-a4c6-4f66-9ecd-732039a9385d", "sync_enabled": true, - "defaults": { - "exclude_patterns": [".git", "node_modules", "*.tmp", ".pushwork"], - "large_file_threshold": "100MB" - }, - "diff": { - "show_binary": false - }, + "exclude_patterns": [".git", "node_modules", "*.tmp", ".pushwork"], + "artifact_directories": ["dist"], "sync": { - "move_detection_threshold": 0.8, - "prompt_threshold": 0.5, - "auto_sync": false, - "parallel_operations": 4 + "move_detection_threshold": 0.7 } } ``` ### Sync Backends -Pushwork supports two sync backends: - -- **WebSocket (default)** — talks to `wss://sync3.automerge.org` via the - standard Automerge sync protocol. Uses `sync_server_storage_id` to - verify delivery via `getSyncInfo`. -- **Subduction (opt-in)** — pass `--sub` on `init` or `clone` to select - the Subduction backend (default endpoint: - `wss://subduction.sync.inkandswitch.com`). The Subduction choice is - persisted in `.pushwork/config.json` as `"subduction": true`, so - subsequent `sync` / `watch` commands pick it up automatically. - `sync_server_storage_id` is not used in this mode. +Pushwork supports two sync backends. Subduction is the default. + +- **Subduction (default)** — `wss://subduction.sync.inkandswitch.com`. + The backend is selected at `init` / `clone` time and persisted in + `.pushwork/config.json` as `"protocol": "subduction"`. Subsequent + `sync` / `watch` runs read the choice from config. +- **Legacy WebSocket** — opt in via `--legacy` on `init` or `clone` to + use `wss://sync3.automerge.org` with `sync_server_storage_id` for + delivery verification. Persisted as `"protocol": "legacy"`. + +### Config schema version + +Configs written by current pushwork include `"config_version": 1`. +Older configs (without this field) are automatically migrated on the +next write-ish command (`sync`, `watch`, `commit`, `init`, `clone`, +`track`). The original v0 file is saved as `config.json.bak` (or +`config.json.bak.1`, `.bak.2`, ... if earlier backups exist) and a +notice is printed. + +Migration inference: + +- v0 config with `"subduction": true` → `"protocol": "subduction"` +- v0 config with `"subduction": false` → `"protocol": "legacy"` +- v0 config with no `subduction` key → `"protocol": "legacy"` (this + matches pre-Subduction installs that were already using the + WebSocket relay) ## How It Works diff --git a/package.json b/package.json index 3a94705..b8baa48 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pushwork", - "version": "1.2.2", + "version": "1.3.0", "description": "Bidirectional directory synchronization using Automerge CRDTs", "main": "dist/index.js", "exports": { diff --git a/src/cli.ts b/src/cli.ts index 2b03195..8f35427 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -77,21 +77,21 @@ program "--sync-server ", "Custom sync server URL and storage ID" ) - .option("--sub", "Use Subduction sync backend", false) + .option("--legacy", "Use legacy WebSocket sync backend", false) .action(async (path, opts) => { const [syncServer, syncServerStorageId] = validateSyncServer( opts.syncServer ); - await init(path, { syncServer, syncServerStorageId, sub: opts.sub }); + await init(path, { syncServer, syncServerStorageId, legacy: opts.legacy }); }); // Track command (set root directory URL without full initialization) const trackAction = async ( url: string, path: string, - opts: { force: boolean; sub: boolean } + opts: { force: boolean; legacy: boolean } ) => { - await root(url, path, { force: opts.force, sub: opts.sub }); + await root(url, path, { force: opts.force, legacy: opts.legacy }); }; program @@ -107,7 +107,7 @@ program "." ) .option("-f, --force", "Overwrite existing pushwork setup", false) - .option("--sub", "Use Subduction sync backend", false) + .option("--legacy", "Use legacy WebSocket sync backend", false) .action(async (url, path, opts) => { await trackAction(url, path, opts); }); @@ -118,8 +118,8 @@ program .argument("") .argument("[path]", "", ".") .option("-f, --force", "", false) - .option("--sub", "", false) - .action(async (url: string, path: string, opts: { force: boolean; sub: boolean }) => { + .option("--legacy", "", false) + .action(async (url: string, path: string, opts: { force: boolean; legacy: boolean }) => { await trackAction(url, path, opts); }); @@ -137,7 +137,7 @@ program "--sync-server ", "Custom sync server URL and storage ID" ) - .option("--sub", "Use Subduction sync backend", false) + .option("--legacy", "Use legacy WebSocket sync backend", false) .option("-v, --verbose", "Verbose output", false) .action(async (url, path, opts) => { const [syncServer, syncServerStorageId] = validateSyncServer( @@ -148,7 +148,7 @@ program verbose: opts.verbose, syncServer, syncServerStorageId, - sub: opts.sub, + legacy: opts.legacy, }); }); diff --git a/src/commands.ts b/src/commands.ts index 805ca0e..7f5c583 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -18,10 +18,10 @@ import { DirectoryDocument, CommandOptions, } from "./types"; -import { DEFAULT_SUBDUCTION_SERVER } from "./types/config"; +import { DEFAULT_SUBDUCTION_SERVER, SyncProtocol } from "./types/config"; import { SyncEngine } from "./core"; import { pathExists, ensureDirectoryExists, formatRelativePath } from "./utils"; -import { ConfigManager } from "./core/config"; +import { ConfigManager, resolveProtocol } from "./core/config"; import { createRepo } from "./utils/repo-factory"; import { out } from "./utils/output"; import { waitForSync } from "./utils/network-sync"; @@ -38,48 +38,68 @@ interface CommandContext { } /** - * Initialize repository directory structure and configuration - * Shared logic for init and clone commands + * Initialize repository directory structure and configuration. + * Shared logic for `init` and `clone`. + * + * `protocol` selects the sync backend: + * - "subduction" (default) + * - "legacy" */ async function initializeRepository( resolvedPath: string, overrides: Partial, - sub: boolean = false + protocol: SyncProtocol = "subduction" ): Promise<{ config: DirectoryConfig; repo: Repo; syncEngine: SyncEngine }> { // Create .pushwork directory structure const syncToolDir = path.join(resolvedPath, ConfigManager.CONFIG_DIR); await ensureDirectoryExists(syncToolDir); await ensureDirectoryExists(path.join(syncToolDir, "automerge")); - // Persist Subduction mode + server in config so subsequent commands pick - // them up. Without persisting sync_server here, `.pushwork/config.json` - // would retain the default WebSocket server even in --sub mode, and - // `pushwork config` / `status` would misreport the endpoint. - if (sub) { - const { sync_server_storage_id: _discarded, ...rest } = overrides; - overrides = { - ...rest, - subduction: true, - sync_server: rest.sync_server ?? DEFAULT_SUBDUCTION_SERVER, - }; + // Build the overrides that `initializeWithOverrides` will merge over + // the protocol-appropriate defaults. + const effectiveOverrides: Partial = { ...overrides, protocol }; + + if (protocol === "subduction") { + // sync_server_storage_id is meaningless in Subduction mode; don't + // carry it through even if the caller passed one. + delete effectiveOverrides.sync_server_storage_id; } - // Create configuration with overrides const configManager = new ConfigManager(resolvedPath); - let config = await configManager.initializeWithOverrides(overrides); - - if (sub && config.sync_server_storage_id !== undefined) { - config = { ...config, sync_server_storage_id: undefined }; - await configManager.save(config); - } + const config = await configManager.initializeWithOverrides(effectiveOverrides); - // Create repository and sync engine + // The Subduction backend takes a boolean in repo-factory today. + const sub = protocol === "subduction"; const repo = await createRepo(resolvedPath, config, sub); const syncEngine = new SyncEngine(repo, resolvedPath, config); return { config, repo, syncEngine }; } +/** + * If the local config is v0, migrate it in place and print a + * multi-line info banner describing what happened. Called from the + * top of write-ish commands (init/clone/track don't need it — they + * create fresh configs; but sync/watch/commit do). + */ +async function migrateConfigIfNeeded( + configManager: ConfigManager +): Promise { + let result: Awaited>; + try { + result = await configManager.migrateIfNeeded(); + } catch (error) { + out.error(`Config migration failed: ${error}`); + out.exit(1); + return; + } + if (result.migrated) { + out.info(`Upgraded ${result.configPath} to version 1`); + out.info(` Detected backend: ${result.protocol}`); + out.info(` Previous config backed up to: ${result.backupPath}`); + } +} + /** * Shared pre-action that ensures repository and sync engine are properly initialized * This function always works, with or without network connectivity @@ -102,33 +122,25 @@ async function setupCommandContext( const configManager = new ConfigManager(resolvedPath); let config: DirectoryConfig; + // Resolve protocol from the raw local config (handles both v0 and v1 + // shapes). This is the single source of truth for backend selection. + const localConfig = await configManager.load(); + const protocol = resolveProtocol(localConfig); + if (options?.forceDefaults) { - // Force mode: use defaults, only preserving backend-selection keys from - // local config (root_directory_url, subduction flag, and the sync - // endpoint the user originally chose). Everything else (exclude - // patterns, artifact dirs, move threshold, etc.) is reset to defaults. - const localConfig = await configManager.load(); - config = configManager.getDefaultDirectoryConfig(); + // Force mode: start from protocol-appropriate defaults, then + // preserve only backend-selection keys (root_directory_url, custom + // sync_server/storage_id the user configured). Everything else + // (exclude patterns, artifact dirs, move threshold, etc.) is reset. + config = configManager.getDefaultDirectoryConfigForProtocol(protocol); if (localConfig?.root_directory_url) { config.root_directory_url = localConfig.root_directory_url; } - if (localConfig?.subduction) { - config.subduction = localConfig.subduction; - config.sync_server = localConfig.sync_server ?? DEFAULT_SUBDUCTION_SERVER; - // sync_server_storage_id is meaningless in Subduction mode; drop it - // so the in-memory config reflects reality. - config.sync_server_storage_id = undefined; - } else { - // WebSocket mode: preserve the user's custom server + storage id - // if they configured one. Without this, `pushwork sync` (default - // force mode) would silently reset a custom --sync-server back to - // DEFAULT_SYNC_SERVER on every run. - if (localConfig?.sync_server) { - config.sync_server = localConfig.sync_server; - } - if (localConfig?.sync_server_storage_id) { - config.sync_server_storage_id = localConfig.sync_server_storage_id; - } + if (localConfig?.sync_server) { + config.sync_server = localConfig.sync_server; + } + if (protocol === "legacy" && localConfig?.sync_server_storage_id) { + config.sync_server_storage_id = localConfig.sync_server_storage_id; } } else { config = await configManager.getMerged(); @@ -139,20 +151,20 @@ async function setupCommandContext( config = { ...config, sync_enabled: options.syncEnabled }; } - const sub = config.subduction ?? false; - if (sub) { - // Default to the Subduction endpoint only if the user hasn't - // configured one. Respect any explicit sync_server value (including - // custom Subduction endpoints set via `init --sub --sync-server ...`). + // Enforce protocol-consistent in-memory shape. + config.protocol = protocol; + if (protocol === "subduction") { + // Storage-id is irrelevant for Subduction — strip it to keep the + // in-memory config honest about what waitForSync will actually use + // (head-stability polling, not getSyncInfo verification). + config.sync_server_storage_id = undefined; if (!config.sync_server) { config.sync_server = DEFAULT_SUBDUCTION_SERVER; } - // sync_server_storage_id is a WebSocket-mode concept; clear it so - // the in-memory config reflects what waitForSync actually uses - // (head-stability polling, not getSyncInfo verification). - config.sync_server_storage_id = undefined; } + const sub = protocol === "subduction"; + // Create repo with config const repo = await createRepo(resolvedPath, config, sub); @@ -224,11 +236,11 @@ export async function init( ): Promise { const resolvedPath = path.resolve(targetPath); - const sub = options.sub ?? false; + const protocol: SyncProtocol = options.legacy ? "legacy" : "subduction"; out.task(`Initializing`); - if (sub) { - out.taskLine("Using Subduction sync backend", true); + if (protocol === "legacy") { + out.taskLine("Using legacy WebSocket sync backend", true); } await ensureDirectoryExists(resolvedPath); @@ -242,10 +254,16 @@ export async function init( // Initialize repository with optional CLI overrides out.update("Setting up repository"); - const { repo, syncEngine, config } = await initializeRepository(resolvedPath, { - sync_server: options.syncServer, - sync_server_storage_id: options.syncServerStorageId, - }, sub); + const { repo, syncEngine, config } = await initializeRepository( + resolvedPath, + { + sync_server: options.syncServer, + sync_server_storage_id: options.syncServerStorageId, + }, + protocol + ); + + const sub = protocol === "subduction"; // Create new root directory document out.update("Creating root directory"); @@ -314,13 +332,18 @@ export async function sync( : "Syncing" ); + // Opportunistically migrate v0 configs on the most common + // command. Prints a banner if anything happened. + const resolvedPath = path.resolve(targetPath); + await migrateConfigIfNeeded(new ConfigManager(resolvedPath)); + const { repo, syncEngine, config } = await setupCommandContext(targetPath, { forceDefaults: !options.gentle, }); - const sub = config.subduction ?? false; - if (sub) { - out.taskLine("Using Subduction sync backend (from config)", true); + const sub = config.protocol === "subduction"; + if (config.protocol === "legacy") { + out.taskLine("Using legacy WebSocket sync backend (from config)", true); } if (options.nuclear) { @@ -553,7 +576,7 @@ export async function status( statusInfo["Files"] = syncStatus.snapshot ? `${fileCount} tracked` : undefined; - statusInfo["Backend"] = config?.subduction ? "subduction" : "websocket"; + statusInfo["Backend"] = config?.protocol ?? "subduction"; statusInfo["Sync"] = config?.sync_server; // Add more detailed info in verbose mode @@ -681,11 +704,11 @@ export async function clone( const resolvedPath = path.resolve(targetPath); - const sub = options.sub ?? false; + const protocol: SyncProtocol = options.legacy ? "legacy" : "subduction"; out.task(`Cloning ${rootUrl}`); - if (sub) { - out.taskLine("Using Subduction sync backend", true); + if (protocol === "legacy") { + out.taskLine("Using legacy WebSocket sync backend", true); } // Check if directory exists and handle --force @@ -717,9 +740,11 @@ export async function clone( sync_server: options.syncServer, sync_server_storage_id: options.syncServerStorageId, }, - sub + protocol ); + const sub = protocol === "subduction"; + // Connect to existing root directory and download files out.update("Downloading files"); await syncEngine.setRootDirectoryUrl(rootUrl as AutomergeUrl); @@ -733,7 +758,7 @@ export async function clone( out.obj({ Path: resolvedPath, Files: `${result.filesChanged} downloaded`, - Backend: config.subduction ? "subduction" : "websocket", + Backend: config.protocol ?? "subduction", Sync: config.sync_server, }); out.successBlock("CLONED", rootUrl); @@ -811,6 +836,9 @@ export async function commit( ): Promise { out.task("Committing local changes"); + const resolvedCommitPath = path.resolve(targetPath); + await migrateConfigIfNeeded(new ConfigManager(resolvedCommitPath)); + const { repo, syncEngine } = await setupCommandContext(targetPath, { syncEnabled: false }); const result = await syncEngine.commitLocal(); @@ -920,7 +948,8 @@ export async function config( // Show basic config info out.infoBlock("CONFIGURATION"); out.obj({ - Backend: config.subduction ? "subduction" : "websocket", + "Config version": config.config_version ?? "0 (pre-migration)", + Backend: config.protocol ?? "subduction", "Sync server": config.sync_server || "default", "Sync enabled": config.sync_enabled ? "yes" : "no", Exclusions: config.exclude_patterns?.length, @@ -940,11 +969,16 @@ export async function watch( const script = options.script || "pnpm build"; const watchDir = options.watchDir || "src"; // Default to watching 'src' directory const verbose = options.verbose || false; + + // Migrate v0 configs if present. + const resolvedTargetPath = path.resolve(targetPath); + await migrateConfigIfNeeded(new ConfigManager(resolvedTargetPath)); + const { repo, syncEngine, config, workingDir } = await setupCommandContext( targetPath, ); - const sub = config.subduction ?? false; + const sub = config.protocol === "subduction"; const absoluteWatchDir = path.resolve(workingDir, watchDir); @@ -960,8 +994,8 @@ export async function watch( "WATCHING", `${chalk.underline(formatRelativePath(watchDir))} for changes...` ); - if (sub) { - out.info("Using Subduction sync backend (from config)"); + if (config.protocol === "legacy") { + out.info("Using legacy WebSocket sync backend (from config)"); } out.info(`Build script: ${script}`); out.info(`Working directory: ${workingDir}`); @@ -1130,7 +1164,7 @@ async function runScript( export async function root( rootUrl: string, targetPath: string = ".", - options: { force?: boolean; sub?: boolean } = {} + options: { force?: boolean; legacy?: boolean } = {} ): Promise { if (!rootUrl.startsWith("automerge:")) { out.error( @@ -1142,7 +1176,7 @@ export async function root( const resolvedPath = path.resolve(targetPath); const syncToolDir = path.join(resolvedPath, ConfigManager.CONFIG_DIR); - const sub = options.sub ?? false; + const protocol: SyncProtocol = options.legacy ? "legacy" : "subduction"; if (await pathExists(syncToolDir)) { if (!options.force) { @@ -1165,26 +1199,16 @@ export async function root( }; await fs.writeFile(snapshotPath, JSON.stringify(snapshot, null, 2), "utf-8"); - // Ensure config exists. In Subduction mode, persist the backend choice - // and the correct server so subsequent `sync` runs use the right endpoint. + // Persist the backend choice so subsequent `sync` runs use the right + // endpoint. initializeWithOverrides builds protocol-appropriate + // defaults, so legacy gets storage_id + classic URL, Subduction gets + // the Subduction URL and nothing else. const configManager = new ConfigManager(resolvedPath); - if (sub) { - let cfg = await configManager.initializeWithOverrides({ - subduction: true, - sync_server: DEFAULT_SUBDUCTION_SERVER, - }); - // Strip dead-baggage storage_id that getDefaultDirectoryConfig seeded. - if (cfg.sync_server_storage_id !== undefined) { - cfg = { ...cfg, sync_server_storage_id: undefined }; - await configManager.save(cfg); - } - } else { - await configManager.initializeWithOverrides({}); - } + await configManager.initializeWithOverrides({ protocol }); out.successBlock("ROOT SET", rootUrl); - if (sub) { - out.info("Using Subduction sync backend"); + if (protocol === "legacy") { + out.info("Using legacy WebSocket sync backend"); } process.exit(); } diff --git a/src/core/config.ts b/src/core/config.ts index c0a6311..b0f50bb 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -6,9 +6,59 @@ import { DirectoryConfig, DEFAULT_SYNC_SERVER, DEFAULT_SYNC_SERVER_STORAGE_ID, + DEFAULT_SUBDUCTION_SERVER, + CONFIG_VERSION, + SyncProtocol, } from "../types"; import { pathExists, ensureDirectoryExists } from "../utils"; +/** + * Determine which sync protocol a (possibly v0) config specifies. + * + * Rules: + * 1. If `config.protocol` is set (v1), trust it. + * 2. Else if `config.subduction === true` (v0 opt-in Subduction user), + * return "subduction". + * 3. Else (absent or `false`) return "legacy" — pre-flip WebSocket + * install or an explicit v0 opt-out. + * + * Passing `undefined` returns `"subduction"` (the default for *new* + * installs). + */ +export function resolveProtocol( + config: Partial | null | undefined +): SyncProtocol { + if (!config) return "subduction"; + if (config.protocol) return config.protocol; + if (config.subduction === true) return "subduction"; + if (config.subduction === false) return "legacy"; + // v0 config with no subduction field → pre-flip WebSocket user + if (config.config_version === undefined) return "legacy"; + // v1 config that somehow lacks `protocol` — shouldn't happen for + // anything we wrote, but default defensively to the modern choice. + return "subduction"; +} + +/** + * Pick an available backup path for a v0 config we're about to migrate. + * + * Starts at `.bak`. If that already exists (a previous migration + * left one behind), append `.1`, `.2`, ... until a free slot is found. + * This preserves every historical backup instead of silently + * overwriting. + */ +export async function pickAvailableBackupPath(base: string): Promise { + const primary = `${base}.bak`; + if (!(await pathExists(primary))) return primary; + for (let n = 1; n < 1000; n++) { + const candidate = `${primary}.${n}`; + if (!(await pathExists(candidate))) return candidate; + } + // Astronomically unlikely. Fall through with a timestamp to avoid + // infinite loops if someone manually created 1000 backups. + return `${primary}.${Date.now()}`; +} + /** * Configuration manager for pushwork */ @@ -101,7 +151,10 @@ export class ConfigManager { } /** - * Save local/directory configuration + * Save local/directory configuration. + * + * Strips the deprecated v0 `subduction` field before writing so no + * v1 config on disk carries both `protocol` and the legacy flag. */ async save(config: DirectoryConfig): Promise { if (!this.workingDir) { @@ -112,7 +165,14 @@ export class ConfigManager { const configPath = this.getLocalConfigPath(); await ensureDirectoryExists(path.dirname(configPath)); - const content = JSON.stringify(config, null, 2); + // Normalize before serialization: enforce v1 invariants. + const { subduction: _legacy, ...clean } = config; + const toWrite: DirectoryConfig = { + ...clean, + config_version: clean.config_version ?? CONFIG_VERSION, + }; + + const content = JSON.stringify(toWrite, null, 2); await fs.writeFile(configPath, content, "utf8"); } catch (error) { throw new Error(`Failed to save local config: ${error}`); @@ -120,6 +180,9 @@ export class ConfigManager { } private getDefaultGlobalConfig(): GlobalConfig { + // Global config doesn't specify a backend. sync_server is left + // undefined; the per-directory config (or `resolveProtocol`'s + // defaults) decides the endpoint. We seed the other fields. return { exclude_patterns: [ ".git", @@ -129,8 +192,6 @@ export class ConfigManager { ".pushwork", ], artifact_directories: ["dist"], - sync_server: DEFAULT_SYNC_SERVER, - sync_server_storage_id: DEFAULT_SYNC_SERVER_STORAGE_ID, sync: { move_detection_threshold: 0.7, }, @@ -138,13 +199,28 @@ export class ConfigManager { } /** - * Get default configuration + * Get default directory configuration (v1, Subduction-by-default). + * + * Legacy-mode configs are constructed by callers via + * `getDefaultDirectoryConfigForProtocol("legacy")`. */ getDefaultDirectoryConfig(): DirectoryConfig { - return { + return this.getDefaultDirectoryConfigForProtocol("subduction"); + } + + /** + * Get default directory configuration for a specific protocol. + * + * - "subduction": default endpoint, no storage_id + * - "legacy": classic WebSocket endpoint + storage_id + */ + getDefaultDirectoryConfigForProtocol( + protocol: SyncProtocol + ): DirectoryConfig { + const base: DirectoryConfig = { + config_version: CONFIG_VERSION, + protocol, sync_enabled: true, - sync_server: DEFAULT_SYNC_SERVER, - sync_server_storage_id: DEFAULT_SYNC_SERVER_STORAGE_ID, exclude_patterns: [ ".git", "node_modules", @@ -157,17 +233,31 @@ export class ConfigManager { move_detection_threshold: 0.7, }, }; + + if (protocol === "subduction") { + return { ...base, sync_server: DEFAULT_SUBDUCTION_SERVER }; + } + return { + ...base, + sync_server: DEFAULT_SYNC_SERVER, + sync_server_storage_id: DEFAULT_SYNC_SERVER_STORAGE_ID, + }; } /** - * Get merged configuration (global + local) + * Get merged configuration (global + local). + * + * Picks the base defaults according to the effective protocol of the + * local config (if any). This keeps the merged shape consistent with + * the backend choice — a legacy local config gets a legacy base, so + * `sync_server_storage_id` appears in the merged result. */ async getMerged(): Promise { const globalConfig = await this.loadGlobal(); const localConfig = await this.load(); - // Merge configurations: default < global < local - let merged = this.getDefaultDirectoryConfig(); + const protocol = resolveProtocol(localConfig); + let merged = this.getDefaultDirectoryConfigForProtocol(protocol); if (globalConfig) { merged = this.mergeConfigs(merged, globalConfig); @@ -177,21 +267,137 @@ export class ConfigManager { merged = this.mergeConfigs(merged, localConfig); } + // Normalize: on v1, `protocol` is authoritative; strip the legacy + // `subduction` field from the in-memory shape so callers never see + // both fields. + if (merged.subduction !== undefined) { + delete merged.subduction; + } + merged.protocol = protocol; + return merged; } /** - * Initialize with CLI option overrides - * Creates a new config with defaults + CLI overrides and saves it + * Initialize with CLI option overrides. + * + * Creates a new v1 config with protocol-appropriate defaults and + * saves it. The `protocol` in `overrides` (if set) picks the base. */ async initializeWithOverrides( overrides: Partial = {} ): Promise { - const config = this.mergeConfigs(this.getDefaultDirectoryConfig(), overrides); + const protocol = + overrides.protocol ?? resolveProtocol(overrides) ?? "subduction"; + const base = this.getDefaultDirectoryConfigForProtocol(protocol); + const config = this.mergeConfigs(base, overrides); + + // Strip the legacy v0 field if it snuck in via overrides. `protocol` + // is the v1 source of truth. + if (config.subduction !== undefined) { + delete config.subduction; + } + config.config_version = CONFIG_VERSION; + config.protocol = protocol; + await this.save(config); return config; } + /** + * Migrate a v0 config to v1 on disk, if needed. + * + * - Reads the raw local config. + * - If absent or already v1, returns without action. + * - If v0: backs up the original to `config.json.bak` (collision-safe + * via `pickAvailableBackupPath`), rewrites to v1 shape, saves, and + * returns metadata so callers can print a migration message. + * + * Intended for write-ish commands (init/clone/track/sync/watch/ + * commit). Read-only commands should not call this; they instead + * read through `getMerged()` or `load()` + `resolveProtocol()`, which + * handle v0 configs transparently in memory. + */ + async migrateIfNeeded(): Promise< + | { migrated: false } + | { + migrated: true; + protocol: SyncProtocol; + backupPath: string; + configPath: string; + } + > { + if (!this.workingDir) return { migrated: false }; + const configPath = this.getLocalConfigPath(); + if (!(await pathExists(configPath))) return { migrated: false }; + + let raw: Partial; + try { + const content = await fs.readFile(configPath, "utf8"); + raw = JSON.parse(content) as Partial; + } catch { + // If the file is corrupt, don't try to migrate — let the load + // path handle the error in its usual way. + return { migrated: false }; + } + + // Forward compat: a future version we don't understand. + if ( + raw.config_version !== undefined && + raw.config_version > CONFIG_VERSION + ) { + throw new Error( + `Config schema version ${raw.config_version} is newer than this pushwork understands ` + + `(supports up to v${CONFIG_VERSION}). Upgrade pushwork.` + ); + } + + // Already current — nothing to do. + if (raw.config_version === CONFIG_VERSION) return { migrated: false }; + + // v0 → v1 migration. + const protocol = resolveProtocol(raw); + + // 1. Write backup of the v0 file verbatim. + const backupPath = await pickAvailableBackupPath(configPath); + const originalContent = await fs.readFile(configPath, "utf8"); + await fs.writeFile(backupPath, originalContent, "utf8"); + + // 2. Build the v1 shape. Start from protocol-appropriate defaults, + // then layer the user's v0 fields over top, then enforce v1 + // invariants (config_version, protocol, no `subduction`). + const migrated = this.mergeConfigs( + this.getDefaultDirectoryConfigForProtocol(protocol), + raw + ); + if (migrated.subduction !== undefined) { + delete migrated.subduction; + } + migrated.config_version = CONFIG_VERSION; + migrated.protocol = protocol; + + // For legacy protocol: ensure the WebSocket endpoint + storage_id + // survive. The user's explicit values take precedence; only fall + // back to defaults if they were absent. + if (protocol === "legacy") { + if (!migrated.sync_server) migrated.sync_server = DEFAULT_SYNC_SERVER; + if (!migrated.sync_server_storage_id) { + migrated.sync_server_storage_id = DEFAULT_SYNC_SERVER_STORAGE_ID; + } + } else { + // Subduction mode: storage_id is meaningless. Strip it. + if (migrated.sync_server_storage_id !== undefined) { + delete migrated.sync_server_storage_id; + } + if (!migrated.sync_server) { + migrated.sync_server = DEFAULT_SUBDUCTION_SERVER; + } + } + + await this.save(migrated); + return { migrated: true, protocol, backupPath, configPath }; + } + /** * Merge two configuration objects */ @@ -200,37 +406,52 @@ export class ConfigManager { override: Partial | GlobalConfig ): DirectoryConfig { const merged = { ...base }; + const ov = override as Partial; + + if ("config_version" in ov && ov.config_version !== undefined) { + merged.config_version = ov.config_version; + } - if ("sync_server" in override && override.sync_server !== undefined) { - merged.sync_server = override.sync_server; + if ("sync_server" in ov && ov.sync_server !== undefined) { + merged.sync_server = ov.sync_server; } if ( - "sync_server_storage_id" in override && - override.sync_server_storage_id !== undefined + "sync_server_storage_id" in ov && + ov.sync_server_storage_id !== undefined ) { - merged.sync_server_storage_id = override.sync_server_storage_id; + merged.sync_server_storage_id = ov.sync_server_storage_id; + } + + if ("protocol" in ov && ov.protocol !== undefined) { + merged.protocol = ov.protocol; + } + + // Legacy v0 field — still honored during merge so old configs + // read cleanly. Normalized to `protocol` by `migrateIfNeeded`. + if ("subduction" in ov && ov.subduction !== undefined) { + merged.subduction = ov.subduction; } - if ("subduction" in override && override.subduction !== undefined) { - merged.subduction = override.subduction; + if ("sync_enabled" in ov && ov.sync_enabled !== undefined) { + merged.sync_enabled = ov.sync_enabled; } - if ("sync_enabled" in override && override.sync_enabled !== undefined) { - merged.sync_enabled = override.sync_enabled; + if ("root_directory_url" in ov && ov.root_directory_url !== undefined) { + merged.root_directory_url = ov.root_directory_url; } - // Handle GlobalConfig structure - if ("exclude_patterns" in override && override.exclude_patterns) { - merged.exclude_patterns = override.exclude_patterns; + // Handle GlobalConfig-ish fields + if ("exclude_patterns" in ov && ov.exclude_patterns) { + merged.exclude_patterns = ov.exclude_patterns; } - if ("artifact_directories" in override && override.artifact_directories) { - merged.artifact_directories = override.artifact_directories; + if ("artifact_directories" in ov && ov.artifact_directories) { + merged.artifact_directories = ov.artifact_directories; } - if ("sync" in override && override.sync) { - merged.sync = { ...merged.sync, ...override.sync }; + if ("sync" in ov && ov.sync) { + merged.sync = { ...merged.sync, ...ov.sync }; } return merged; diff --git a/src/types/config.ts b/src/types/config.ts index 5ac1e32..ec268f7 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -8,6 +8,29 @@ export const DEFAULT_SYNC_SERVER_STORAGE_ID = "3760df37-a4c6-4f66-9ecd-732039a9385d" as StorageId; export const DEFAULT_SUBDUCTION_SERVER = "wss://subduction.sync.inkandswitch.com"; +/** + * Current schema version for persisted `.pushwork/config.json`. + * + * Bumped whenever the on-disk format changes in a way that needs + * explicit migration. The migration logic lives in `core/config.ts` + * (`resolveProtocol`, `migrateIfNeeded`). See CLAUDE.md for history. + * + * Versions: + * - v0 (absent field): pre-Subduction-default configs. Had a + * `subduction?: boolean` field (opt-in flag). Absence of that + * field meant legacy WebSocket sync. + * - v1: Subduction is the default backend. The field is now + * `protocol: "subduction" | "legacy"`, always written explicitly. + * `--legacy` opts into classic WebSocket sync. + */ +export const CONFIG_VERSION = 1; + +/** + * Sync protocol identifier. Extensible: future protocols can be added + * as additional string literals (e.g. `"bluesky"`). + */ +export type SyncProtocol = "subduction" | "legacy"; + /** * Global configuration options */ @@ -25,7 +48,23 @@ export interface GlobalConfig { * Per-directory configuration */ export interface DirectoryConfig extends GlobalConfig { + /** + * Config schema version. Absent ⇒ v0 (pre-flip). Written explicitly + * as `CONFIG_VERSION` on any config this pushwork creates. + */ + config_version?: number; root_directory_url?: string; + /** + * Which sync backend this directory uses. Always present on v1 + * configs. On v0 configs this is absent — use `resolveProtocol()` + * (which also inspects the legacy `subduction` field) to derive it. + */ + protocol?: SyncProtocol; + /** + * @deprecated v0-only field. On v1 configs, use `protocol` instead. + * Kept in the type only to let `resolveProtocol()` inspect v0 + * configs during migration. + */ subduction?: boolean; sync_enabled: boolean; } @@ -44,7 +83,11 @@ export interface CloneOptions extends CommandOptions { force?: boolean; // Overwrite existing directory syncServer?: string; // Custom sync server URL syncServerStorageId?: StorageId; // Custom sync server storage ID - sub?: boolean; + /** + * Use the legacy WebSocket sync backend. When absent or false, + * Subduction (the default) is used. + */ + legacy?: boolean; } /** @@ -86,7 +129,11 @@ export interface CheckoutOptions extends CommandOptions { export interface InitOptions extends CommandOptions { syncServer?: string; syncServerStorageId?: StorageId; - sub?: boolean; + /** + * Use the legacy WebSocket sync backend. When absent or false, + * Subduction (the default) is used. + */ + legacy?: boolean; } /** diff --git a/test/integration/legacy-flag.test.ts b/test/integration/legacy-flag.test.ts new file mode 100644 index 0000000..77fd13e --- /dev/null +++ b/test/integration/legacy-flag.test.ts @@ -0,0 +1,199 @@ +import * as fs from "fs/promises"; +import * as path from "path"; +import * as tmp from "tmp"; +import { execSync, execFile as execFileCb } from "child_process"; +import { promisify } from "util"; +import { SnapshotManager } from "../../src/core"; +import { ConfigManager } from "../../src/core/config"; + +const execFile = promisify(execFileCb); + +describe("backend selection integration (default: Subduction, --legacy opts out)", () => { + let tmpDir: string; + let cleanup: () => void; + const cliPath = path.join(__dirname, "../../dist/cli.js"); + + beforeAll(() => { + execSync("pnpm build", { + cwd: path.join(__dirname, "../.."), + stdio: "pipe", + }); + }); + + beforeEach(() => { + const tmpObj = tmp.dirSync({ unsafeCleanup: true }); + tmpDir = tmpObj.name; + cleanup = tmpObj.removeCallback; + }); + + afterEach(() => { + cleanup(); + }); + + async function pushwork(args: string[], timeoutMs = 30000): Promise { + const { stdout } = await execFile("node", [cliPath, ...args], { + timeout: timeoutMs, + env: { ...process.env, NO_COLOR: "1" }, + }); + return stdout; + } + + describe("init (default, no flag → Subduction)", () => { + it("initializes a directory with default Subduction backend", async () => { + await fs.writeFile(path.join(tmpDir, "hello.txt"), "Hello, world!"); + + await pushwork(["init", tmpDir]); + + const pushworkDir = path.join(tmpDir, ".pushwork"); + const stat = await fs.stat(pushworkDir); + expect(stat.isDirectory()).toBe(true); + + const snapshotManager = new SnapshotManager(tmpDir); + const snapshot = await snapshotManager.load(); + expect(snapshot).not.toBeNull(); + expect(snapshot!.rootDirectoryUrl).toMatch(/^automerge:/); + expect(snapshot!.files.has("hello.txt")).toBe(true); + + const cfg = await new ConfigManager(tmpDir).load(); + expect(cfg?.protocol).toBe("subduction"); + expect(cfg?.config_version).toBe(1); + expect(cfg?.sync_server_storage_id).toBeUndefined(); + }, 60000); + + it("tracks files in subdirectories", async () => { + await fs.mkdir(path.join(tmpDir, "src"), { recursive: true }); + await fs.writeFile( + path.join(tmpDir, "src", "index.ts"), + "export default {}" + ); + await fs.writeFile(path.join(tmpDir, "package.json"), '{"name":"t"}'); + + await pushwork(["init", tmpDir]); + + const snapshot = await new SnapshotManager(tmpDir).load(); + expect(snapshot!.files.has("src/index.ts")).toBe(true); + expect(snapshot!.files.has("package.json")).toBe(true); + }, 60000); + + it("respects default exclude patterns", async () => { + await fs.writeFile(path.join(tmpDir, "included.txt"), "keep me"); + await fs.mkdir(path.join(tmpDir, "node_modules")); + await fs.writeFile( + path.join(tmpDir, "node_modules", "dep.js"), + "module" + ); + await fs.mkdir(path.join(tmpDir, ".git")); + await fs.writeFile( + path.join(tmpDir, ".git", "HEAD"), + "ref: refs/heads/main" + ); + + await pushwork(["init", tmpDir]); + + const snapshot = await new SnapshotManager(tmpDir).load(); + expect(snapshot!.files.has("included.txt")).toBe(true); + expect(snapshot!.files.has("node_modules/dep.js")).toBe(false); + expect(snapshot!.files.has(".git/HEAD")).toBe(false); + }, 60000); + }); + + describe("init --legacy", () => { + it("initializes with legacy WebSocket backend and storage_id", async () => { + await fs.writeFile(path.join(tmpDir, "classic.txt"), "legacy sync"); + + await pushwork(["init", "--legacy", tmpDir]); + + const snapshot = await new SnapshotManager(tmpDir).load(); + expect(snapshot).not.toBeNull(); + expect(snapshot!.files.has("classic.txt")).toBe(true); + + const cfg = await new ConfigManager(tmpDir).load(); + expect(cfg?.protocol).toBe("legacy"); + expect(cfg?.config_version).toBe(1); + expect(cfg?.sync_server_storage_id).toBeDefined(); + expect(cfg?.sync_server).toContain("sync3.automerge.org"); + }, 60000); + }); + + describe("sync (reads backend from persisted config)", () => { + it("syncs after default init (Subduction)", async () => { + await fs.writeFile(path.join(tmpDir, "file1.txt"), "initial"); + + await pushwork(["init", tmpDir]); + await fs.writeFile(path.join(tmpDir, "file2.txt"), "new file"); + await pushwork(["sync", tmpDir]); + + const snapshot = await new SnapshotManager(tmpDir).load(); + expect(snapshot!.files.has("file1.txt")).toBe(true); + expect(snapshot!.files.has("file2.txt")).toBe(true); + }, 60000); + + it("syncs after init --legacy (WebSocket)", async () => { + await fs.writeFile(path.join(tmpDir, "a.txt"), "one"); + + await pushwork(["init", "--legacy", tmpDir]); + await fs.writeFile(path.join(tmpDir, "b.txt"), "two"); + await pushwork(["sync", tmpDir]); + + const snapshot = await new SnapshotManager(tmpDir).load(); + expect(snapshot!.files.has("a.txt")).toBe(true); + expect(snapshot!.files.has("b.txt")).toBe(true); + + // Config still reports legacy protocol after sync. + const cfg = await new ConfigManager(tmpDir).load(); + expect(cfg?.protocol).toBe("legacy"); + }, 60000); + + it("detects file modifications on sync", async () => { + await fs.writeFile(path.join(tmpDir, "mutable.txt"), "v1"); + + await pushwork(["init", tmpDir]); + + const snap1 = await new SnapshotManager(tmpDir).load(); + const initialHead = snap1!.files.get("mutable.txt")!.head; + + await fs.writeFile(path.join(tmpDir, "mutable.txt"), "v2"); + await pushwork(["sync", tmpDir]); + + const snap2 = await new SnapshotManager(tmpDir).load(); + const updatedHead = snap2!.files.get("mutable.txt")!.head; + expect(updatedHead).not.toEqual(initialHead); + }, 60000); + + it("handles file deletions on sync", async () => { + await fs.writeFile(path.join(tmpDir, "ephemeral.txt"), "bye"); + await fs.writeFile(path.join(tmpDir, "keeper.txt"), "stay"); + + await pushwork(["init", tmpDir]); + + await fs.unlink(path.join(tmpDir, "ephemeral.txt")); + await pushwork(["sync", tmpDir]); + + const snapshot = await new SnapshotManager(tmpDir).load(); + expect(snapshot!.files.has("ephemeral.txt")).toBe(false); + expect(snapshot!.files.has("keeper.txt")).toBe(true); + }, 60000); + }); + + describe("url / status / diff", () => { + it("url prints a valid automerge URL", async () => { + await pushwork(["init", tmpDir]); + const stdout = await pushwork(["url", tmpDir]); + expect(stdout.trim()).toMatch(/^automerge:/); + }, 60000); + + it("status reports without errors", async () => { + await fs.writeFile(path.join(tmpDir, "t.txt"), "ok"); + await pushwork(["init", tmpDir]); + const stdout = await pushwork(["status", tmpDir]); + expect(stdout).toBeDefined(); + }, 60000); + + it("diff shows no changes immediately after init", async () => { + await fs.writeFile(path.join(tmpDir, "stable.txt"), "no changes"); + await pushwork(["init", tmpDir]); + const stdout = await pushwork(["diff", tmpDir]); + expect(stdout).not.toContain("modified"); + }, 60000); + }); +}); diff --git a/test/integration/sub-flag.test.ts b/test/integration/sub-flag.test.ts deleted file mode 100644 index f36cccd..0000000 --- a/test/integration/sub-flag.test.ts +++ /dev/null @@ -1,187 +0,0 @@ -import * as fs from "fs/promises"; -import * as path from "path"; -import * as tmp from "tmp"; -import { execSync, execFile as execFileCb } from "child_process"; -import { promisify } from "util"; -import { SnapshotManager } from "../../src/core"; - -const execFile = promisify(execFileCb); - -describe("--sub flag integration", () => { - let tmpDir: string; - let cleanup: () => void; - const cliPath = path.join(__dirname, "../../dist/cli.js"); - - beforeAll(() => { - execSync("pnpm build", { cwd: path.join(__dirname, "../.."), stdio: "pipe" }); - }); - - beforeEach(() => { - const tmpObj = tmp.dirSync({ unsafeCleanup: true }); - tmpDir = tmpObj.name; - cleanup = tmpObj.removeCallback; - }); - - afterEach(() => { - cleanup(); - }); - - /** - * Run pushwork CLI command and return stdout. - * Throws on non-zero exit code. - */ - async function pushwork(args: string[], timeoutMs = 30000): Promise { - const { stdout } = await execFile("node", [cliPath, ...args], { - timeout: timeoutMs, - env: { ...process.env, NO_COLOR: "1" }, - }); - return stdout; - } - - describe("init --sub", () => { - it("should initialize a directory with --sub flag", async () => { - await fs.writeFile(path.join(tmpDir, "hello.txt"), "Hello from sub!"); - - await pushwork(["init", "--sub", tmpDir]); - - // Verify .pushwork was created - const pushworkDir = path.join(tmpDir, ".pushwork"); - const stat = await fs.stat(pushworkDir); - expect(stat.isDirectory()).toBe(true); - - // Verify snapshot exists and tracks the file - const snapshotManager = new SnapshotManager(tmpDir); - const snapshot = await snapshotManager.load(); - expect(snapshot).not.toBeNull(); - expect(snapshot!.rootDirectoryUrl).toBeDefined(); - expect(snapshot!.rootDirectoryUrl).toMatch(/^automerge:/); - expect(snapshot!.files.has("hello.txt")).toBe(true); - }, 60000); - - it("should track files in subdirectories", async () => { - await fs.mkdir(path.join(tmpDir, "src"), { recursive: true }); - await fs.writeFile(path.join(tmpDir, "src", "index.ts"), "export default {}"); - await fs.writeFile(path.join(tmpDir, "package.json"), '{"name": "test"}'); - - await pushwork(["init", "--sub", tmpDir]); - - const snapshotManager = new SnapshotManager(tmpDir); - const snapshot = await snapshotManager.load(); - expect(snapshot).not.toBeNull(); - expect(snapshot!.files.has("src/index.ts")).toBe(true); - expect(snapshot!.files.has("package.json")).toBe(true); - }, 60000); - - it("should respect default exclude patterns with --sub", async () => { - await fs.writeFile(path.join(tmpDir, "included.txt"), "keep me"); - await fs.mkdir(path.join(tmpDir, "node_modules")); - await fs.writeFile(path.join(tmpDir, "node_modules", "dep.js"), "module"); - await fs.mkdir(path.join(tmpDir, ".git")); - await fs.writeFile(path.join(tmpDir, ".git", "HEAD"), "ref: refs/heads/main"); - - await pushwork(["init", "--sub", tmpDir]); - - const snapshotManager = new SnapshotManager(tmpDir); - const snapshot = await snapshotManager.load(); - expect(snapshot).not.toBeNull(); - expect(snapshot!.files.has("included.txt")).toBe(true); - expect(snapshot!.files.has("node_modules/dep.js")).toBe(false); - expect(snapshot!.files.has(".git/HEAD")).toBe(false); - }, 60000); - }); - - describe("sync --sub", () => { - it("should sync after init --sub", async () => { - await fs.writeFile(path.join(tmpDir, "file1.txt"), "initial content"); - - // Init with --sub - await pushwork(["init", "--sub", tmpDir]); - - // Add a new file - await fs.writeFile(path.join(tmpDir, "file2.txt"), "new file"); - - // Sync with --sub - await pushwork(["sync", "--sub", tmpDir]); - - // Verify the new file is now tracked - const snapshotManager = new SnapshotManager(tmpDir); - const snapshot = await snapshotManager.load(); - expect(snapshot).not.toBeNull(); - expect(snapshot!.files.has("file1.txt")).toBe(true); - expect(snapshot!.files.has("file2.txt")).toBe(true); - }, 60000); - - it("should detect file modifications on sync --sub", async () => { - await fs.writeFile(path.join(tmpDir, "mutable.txt"), "version 1"); - - await pushwork(["init", "--sub", tmpDir]); - - // Record initial heads - const snapshotManager = new SnapshotManager(tmpDir); - const snapshot1 = await snapshotManager.load(); - const initialHead = snapshot1!.files.get("mutable.txt")!.head; - - // Modify the file - await fs.writeFile(path.join(tmpDir, "mutable.txt"), "version 2"); - - // Sync - await pushwork(["sync", "--sub", tmpDir]); - - // Heads should have changed - const snapshot2 = await snapshotManager.load(); - const updatedHead = snapshot2!.files.get("mutable.txt")!.head; - expect(updatedHead).not.toEqual(initialHead); - }, 60000); - - it("should handle file deletions on sync --sub", async () => { - await fs.writeFile(path.join(tmpDir, "ephemeral.txt"), "delete me"); - await fs.writeFile(path.join(tmpDir, "keeper.txt"), "keep me"); - - await pushwork(["init", "--sub", tmpDir]); - - // Delete a file - await fs.unlink(path.join(tmpDir, "ephemeral.txt")); - - // Sync - await pushwork(["sync", "--sub", tmpDir]); - - // Deleted file should be gone from snapshot - const snapshotManager = new SnapshotManager(tmpDir); - const snapshot = await snapshotManager.load(); - expect(snapshot).not.toBeNull(); - expect(snapshot!.files.has("ephemeral.txt")).toBe(false); - expect(snapshot!.files.has("keeper.txt")).toBe(true); - }, 60000); - }); - - describe("url after init --sub", () => { - it("should print a valid automerge URL", async () => { - await pushwork(["init", "--sub", tmpDir]); - - const stdout = await pushwork(["url", tmpDir]); - expect(stdout.trim()).toMatch(/^automerge:/); - }, 60000); - }); - - describe("status after init --sub", () => { - it("should report status without errors", async () => { - await fs.writeFile(path.join(tmpDir, "test.txt"), "status check"); - await pushwork(["init", "--sub", tmpDir]); - - // status should not throw - const stdout = await pushwork(["status", tmpDir]); - expect(stdout).toBeDefined(); - }, 60000); - }); - - describe("diff after init --sub", () => { - it("should show no changes immediately after init", async () => { - await fs.writeFile(path.join(tmpDir, "stable.txt"), "no changes"); - await pushwork(["init", "--sub", tmpDir]); - - const stdout = await pushwork(["diff", tmpDir]); - // After a fresh init+sync, there should be no pending changes - expect(stdout).not.toContain("modified"); - }, 60000); - }); -}); diff --git a/test/unit/config-migration.test.ts b/test/unit/config-migration.test.ts new file mode 100644 index 0000000..7577c2b --- /dev/null +++ b/test/unit/config-migration.test.ts @@ -0,0 +1,286 @@ +import * as fs from "fs/promises"; +import * as path from "path"; +import * as tmp from "tmp"; +import { + ConfigManager, + resolveProtocol, + pickAvailableBackupPath, +} from "../../src/core/config"; +import { + CONFIG_VERSION, + DEFAULT_SUBDUCTION_SERVER, + DEFAULT_SYNC_SERVER, +} from "../../src/types/config"; + +describe("resolveProtocol", () => { + it("returns 'subduction' for null/undefined (new-install default)", () => { + expect(resolveProtocol(null)).toBe("subduction"); + expect(resolveProtocol(undefined)).toBe("subduction"); + }); + + it("trusts explicit v1 protocol field", () => { + expect(resolveProtocol({ protocol: "subduction" })).toBe("subduction"); + expect(resolveProtocol({ protocol: "legacy" })).toBe("legacy"); + }); + + it("maps v0 subduction: true to 'subduction'", () => { + expect(resolveProtocol({ subduction: true })).toBe("subduction"); + }); + + it("maps v0 subduction: false to 'legacy'", () => { + expect(resolveProtocol({ subduction: false })).toBe("legacy"); + }); + + it("treats v0 with absent subduction field as 'legacy' (pre-flip WebSocket user)", () => { + // v0 config, sync_enabled present but no `subduction` key and no + // `config_version` — this is the classic pre-PR-21 shape. + expect(resolveProtocol({ sync_enabled: true })).toBe("legacy"); + }); + + it("defaults to 'subduction' for a v1 config missing protocol (defensive)", () => { + expect(resolveProtocol({ config_version: CONFIG_VERSION })).toBe( + "subduction" + ); + }); +}); + +describe("pickAvailableBackupPath", () => { + let tmpDir: string; + let cleanup: () => void; + + beforeEach(() => { + const tmpObj = tmp.dirSync({ unsafeCleanup: true }); + tmpDir = tmpObj.name; + cleanup = tmpObj.removeCallback; + }); + + afterEach(() => { + cleanup(); + }); + + it("returns .bak when no backup exists", async () => { + const base = path.join(tmpDir, "config.json"); + expect(await pickAvailableBackupPath(base)).toBe(`${base}.bak`); + }); + + it("appends .1 when .bak already exists", async () => { + const base = path.join(tmpDir, "config.json"); + await fs.writeFile(`${base}.bak`, "first"); + expect(await pickAvailableBackupPath(base)).toBe(`${base}.bak.1`); + }); + + it("keeps counting up through collisions", async () => { + const base = path.join(tmpDir, "config.json"); + await fs.writeFile(`${base}.bak`, "a"); + await fs.writeFile(`${base}.bak.1`, "b"); + await fs.writeFile(`${base}.bak.2`, "c"); + expect(await pickAvailableBackupPath(base)).toBe(`${base}.bak.3`); + }); +}); + +describe("ConfigManager.migrateIfNeeded", () => { + let tmpDir: string; + let cleanup: () => void; + + beforeEach(async () => { + const tmpObj = tmp.dirSync({ unsafeCleanup: true }); + tmpDir = tmpObj.name; + cleanup = tmpObj.removeCallback; + await fs.mkdir(path.join(tmpDir, ".pushwork"), { recursive: true }); + }); + + afterEach(() => { + cleanup(); + }); + + async function writeConfig(raw: Record): Promise { + await fs.writeFile( + path.join(tmpDir, ".pushwork", "config.json"), + JSON.stringify(raw, null, 2), + "utf8" + ); + } + + async function readConfig(): Promise> { + const s = await fs.readFile( + path.join(tmpDir, ".pushwork", "config.json"), + "utf8" + ); + return JSON.parse(s); + } + + it("no-op when config doesn't exist", async () => { + const mgr = new ConfigManager(tmpDir); + const result = await mgr.migrateIfNeeded(); + expect(result.migrated).toBe(false); + }); + + it("no-op when config is already v1", async () => { + await writeConfig({ + config_version: 1, + protocol: "subduction", + sync_enabled: true, + sync_server: DEFAULT_SUBDUCTION_SERVER, + exclude_patterns: [], + artifact_directories: [], + sync: { move_detection_threshold: 0.7 }, + }); + const mgr = new ConfigManager(tmpDir); + const result = await mgr.migrateIfNeeded(); + expect(result.migrated).toBe(false); + }); + + it("migrates v0 subduction: true to v1 protocol: 'subduction'", async () => { + await writeConfig({ + subduction: true, + sync_enabled: true, + sync_server: DEFAULT_SUBDUCTION_SERVER, + exclude_patterns: [], + artifact_directories: [], + sync: { move_detection_threshold: 0.7 }, + }); + + const mgr = new ConfigManager(tmpDir); + const result = await mgr.migrateIfNeeded(); + if (!result.migrated) throw new Error("expected migration"); + + expect(result.protocol).toBe("subduction"); + expect(result.backupPath).toMatch(/config\.json\.bak$/); + + const migrated = await readConfig(); + expect(migrated.config_version).toBe(1); + expect(migrated.protocol).toBe("subduction"); + expect(migrated.subduction).toBeUndefined(); + }); + + it("migrates v0 with no subduction field to v1 protocol: 'legacy'", async () => { + await writeConfig({ + sync_enabled: true, + sync_server: DEFAULT_SYNC_SERVER, + sync_server_storage_id: "3760df37-a4c6-4f66-9ecd-732039a9385d", + exclude_patterns: [], + artifact_directories: [], + sync: { move_detection_threshold: 0.7 }, + }); + + const mgr = new ConfigManager(tmpDir); + const result = await mgr.migrateIfNeeded(); + if (!result.migrated) throw new Error("expected migration"); + + expect(result.protocol).toBe("legacy"); + + const migrated = await readConfig(); + expect(migrated.config_version).toBe(1); + expect(migrated.protocol).toBe("legacy"); + expect(migrated.sync_server_storage_id).toBe( + "3760df37-a4c6-4f66-9ecd-732039a9385d" + ); + }); + + it("writes a backup of the original v0 config", async () => { + const v0 = { + subduction: true, + sync_enabled: true, + sync_server: DEFAULT_SUBDUCTION_SERVER, + exclude_patterns: ["foo"], + artifact_directories: [], + sync: { move_detection_threshold: 0.7 }, + }; + await writeConfig(v0); + + const mgr = new ConfigManager(tmpDir); + const result = await mgr.migrateIfNeeded(); + if (!result.migrated) throw new Error("expected migration"); + + const backup = await fs.readFile(result.backupPath, "utf8"); + expect(JSON.parse(backup)).toEqual(v0); + }); + + it("picks .bak.1 when .bak already exists from prior migration", async () => { + // Simulate: someone manually reverted to v0, left the prior backup + // in place, then re-ran pushwork. + await writeConfig({ + subduction: true, + sync_enabled: true, + sync_server: DEFAULT_SUBDUCTION_SERVER, + exclude_patterns: [], + artifact_directories: [], + sync: { move_detection_threshold: 0.7 }, + }); + await fs.writeFile( + path.join(tmpDir, ".pushwork", "config.json.bak"), + "old backup", + "utf8" + ); + + const mgr = new ConfigManager(tmpDir); + const result = await mgr.migrateIfNeeded(); + if (!result.migrated) throw new Error("expected migration"); + + expect(result.backupPath).toMatch(/config\.json\.bak\.1$/); + + // Original .bak untouched. + const original = await fs.readFile( + path.join(tmpDir, ".pushwork", "config.json.bak"), + "utf8" + ); + expect(original).toBe("old backup"); + }); + + it("throws on a config_version newer than we understand", async () => { + await writeConfig({ + config_version: 999, + protocol: "subduction", + sync_enabled: true, + sync_server: DEFAULT_SUBDUCTION_SERVER, + exclude_patterns: [], + artifact_directories: [], + sync: { move_detection_threshold: 0.7 }, + }); + const mgr = new ConfigManager(tmpDir); + await expect(mgr.migrateIfNeeded()).rejects.toThrow(/newer/); + }); + + it("legacy migration ensures sync_server + storage_id are present", async () => { + // v0 config that somehow lacks a sync_server (corner case). Should + // fall back to DEFAULT_SYNC_SERVER during migration. + await writeConfig({ + subduction: false, + sync_enabled: true, + exclude_patterns: [], + artifact_directories: [], + sync: { move_detection_threshold: 0.7 }, + }); + + const mgr = new ConfigManager(tmpDir); + const result = await mgr.migrateIfNeeded(); + if (!result.migrated) throw new Error("expected migration"); + + const migrated = await readConfig(); + expect(migrated.protocol).toBe("legacy"); + expect(migrated.sync_server).toBe(DEFAULT_SYNC_SERVER); + expect(migrated.sync_server_storage_id).toBeDefined(); + }); + + it("subduction migration strips any stale sync_server_storage_id", async () => { + // A v0 config where `subduction: true` was set but the storage_id + // field leaked through from the defaults. + await writeConfig({ + subduction: true, + sync_enabled: true, + sync_server: DEFAULT_SUBDUCTION_SERVER, + sync_server_storage_id: "stale-id", + exclude_patterns: [], + artifact_directories: [], + sync: { move_detection_threshold: 0.7 }, + }); + + const mgr = new ConfigManager(tmpDir); + const result = await mgr.migrateIfNeeded(); + if (!result.migrated) throw new Error("expected migration"); + + const migrated = await readConfig(); + expect(migrated.protocol).toBe("subduction"); + expect(migrated.sync_server_storage_id).toBeUndefined(); + }); +}); diff --git a/test/unit/repo-factory.test.ts b/test/unit/repo-factory.test.ts index 2eb78f2..421513c 100644 --- a/test/unit/repo-factory.test.ts +++ b/test/unit/repo-factory.test.ts @@ -1,12 +1,11 @@ /** - * Tests for repo-factory.ts Subduction configuration. + * Tests for repo-factory.ts backend selection. * * The actual Repo construction requires Wasm initialization via real ESM * dynamic imports. We test by invoking the CLI as a subprocess (which runs * in a real Node.js context) and inspecting the results. * - * Non-sub (WebSocket) init is tested elsewhere (init-sync.test.ts). - * These tests focus on the --sub path. + * Covers both the default Subduction backend and the `--legacy` path. */ import * as path from "path"; @@ -14,13 +13,16 @@ import * as fs from "fs/promises"; import * as tmp from "tmp"; import { execSync } from "child_process"; -describe("createRepo with --sub", () => { +describe("createRepo (default Subduction)", () => { let tmpDir: string; let cleanup: () => void; const cliPath = path.join(__dirname, "../../dist/cli.js"); beforeAll(() => { - execSync("pnpm build", { cwd: path.join(__dirname, "../.."), stdio: "pipe" }); + execSync("pnpm build", { + cwd: path.join(__dirname, "../.."), + stdio: "pipe", + }); }); beforeEach(async () => { @@ -33,10 +35,10 @@ describe("createRepo with --sub", () => { cleanup(); }); - it("should create a working repo with --sub flag", async () => { + it("creates a working repo with default (Subduction) backend", async () => { await fs.writeFile(path.join(tmpDir, "test.txt"), "hello"); - execSync(`node "${cliPath}" init --sub "${tmpDir}"`, { + execSync(`node "${cliPath}" init "${tmpDir}"`, { stdio: "pipe", timeout: 30000, }); @@ -46,10 +48,10 @@ describe("createRepo with --sub", () => { expect(stat.isFile()).toBe(true); }); - it("should produce a valid automerge URL", async () => { + it("produces a valid automerge URL", async () => { await fs.writeFile(path.join(tmpDir, "test.txt"), "hello"); - execSync(`node "${cliPath}" init --sub "${tmpDir}"`, { + execSync(`node "${cliPath}" init "${tmpDir}"`, { stdio: "pipe", timeout: 30000, }); @@ -62,12 +64,12 @@ describe("createRepo with --sub", () => { expect(url).toMatch(/^automerge:/); }); - it("should track files in the snapshot", async () => { + it("tracks files in the snapshot", async () => { await fs.writeFile(path.join(tmpDir, "a.txt"), "aaa"); await fs.mkdir(path.join(tmpDir, "sub"), { recursive: true }); await fs.writeFile(path.join(tmpDir, "sub", "b.txt"), "bbb"); - execSync(`node "${cliPath}" init --sub "${tmpDir}"`, { + execSync(`node "${cliPath}" init "${tmpDir}"`, { stdio: "pipe", timeout: 30000, }); @@ -81,20 +83,17 @@ describe("createRepo with --sub", () => { expect(ls).toContain("b.txt"); }); - it("should be able to sync after init", async () => { + it("can sync after init (persisted Subduction config)", async () => { await fs.writeFile(path.join(tmpDir, "initial.txt"), "first"); - execSync(`node "${cliPath}" init --sub "${tmpDir}"`, { + execSync(`node "${cliPath}" init "${tmpDir}"`, { stdio: "pipe", timeout: 30000, }); - // Add a new file await fs.writeFile(path.join(tmpDir, "added.txt"), "second"); - // Sync should not throw. The `sync` command has no --sub flag — it - // reads the backend choice from .pushwork/config.json (persisted by - // the init --sub above). + // Sync reads the backend from .pushwork/config.json. execSync(`node "${cliPath}" sync "${tmpDir}"`, { stdio: "pipe", timeout: 30000, @@ -109,3 +108,64 @@ describe("createRepo with --sub", () => { expect(ls).toContain("added.txt"); }); }); + +describe("createRepo with --legacy", () => { + let tmpDir: string; + let cleanup: () => void; + const cliPath = path.join(__dirname, "../../dist/cli.js"); + + beforeEach(async () => { + const tmpObj = tmp.dirSync({ unsafeCleanup: true }); + tmpDir = tmpObj.name; + cleanup = tmpObj.removeCallback; + }); + + afterEach(() => { + cleanup(); + }); + + /** + * Run `pushwork init --legacy` and tolerate network timeouts. + * + * `init --legacy` calls `waitForSync` against the classic WebSocket + * server to verify root delivery. In CI/sandboxes without outbound + * network access, this hangs until the 60s timeout. We only care + * that the config and snapshot were written before the network call, + * so we invoke with a short SIGKILL timeout and ignore the exit code. + */ + function initLegacy(dir: string): void { + try { + execSync(`node "${cliPath}" init --legacy "${dir}"`, { + stdio: "pipe", + timeout: 10000, + killSignal: "SIGKILL", + }); + } catch { + // Timeouts, non-zero exits, etc. are fine — we assert on disk + // state, which is written before the blocking network call. + } + } + + it("creates a working repo with --legacy flag", async () => { + await fs.writeFile(path.join(tmpDir, "test.txt"), "hello"); + initLegacy(tmpDir); + + const snapshotPath = path.join(tmpDir, ".pushwork", "snapshot.json"); + const stat = await fs.stat(snapshotPath); + expect(stat.isFile()).toBe(true); + }, 30000); + + it("persists protocol: 'legacy' in config", async () => { + await fs.writeFile(path.join(tmpDir, "test.txt"), "hello"); + initLegacy(tmpDir); + + const cfgRaw = await fs.readFile( + path.join(tmpDir, ".pushwork", "config.json"), + "utf8" + ); + const cfg = JSON.parse(cfgRaw); + expect(cfg.protocol).toBe("legacy"); + expect(cfg.config_version).toBe(1); + expect(cfg.sync_server_storage_id).toBeDefined(); + }, 30000); +}); diff --git a/test/unit/subduction-config.test.ts b/test/unit/subduction-config.test.ts index d7745c6..af91463 100644 --- a/test/unit/subduction-config.test.ts +++ b/test/unit/subduction-config.test.ts @@ -2,9 +2,13 @@ import * as path from "path"; import * as fs from "fs/promises"; import * as tmp from "tmp"; import { ConfigManager } from "../../src/core/config"; -import { DEFAULT_SUBDUCTION_SERVER, DEFAULT_SYNC_SERVER } from "../../src/types/config"; +import { + CONFIG_VERSION, + DEFAULT_SUBDUCTION_SERVER, + DEFAULT_SYNC_SERVER, +} from "../../src/types/config"; -describe("Subduction configuration", () => { +describe("Sync backend configuration", () => { let tmpDir: string; let cleanup: () => void; @@ -13,57 +17,97 @@ describe("Subduction configuration", () => { tmpDir = tmpObj.name; cleanup = tmpObj.removeCallback; - // Set up .pushwork directory structure - await fs.mkdir(path.join(tmpDir, ".pushwork", "automerge"), { recursive: true }); + await fs.mkdir(path.join(tmpDir, ".pushwork", "automerge"), { + recursive: true, + }); }); afterEach(() => { cleanup(); }); - describe("DEFAULT_SUBDUCTION_SERVER", () => { - it("should be the subduction sync endpoint", () => { - expect(DEFAULT_SUBDUCTION_SERVER).toBe("wss://subduction.sync.inkandswitch.com"); + describe("Default servers", () => { + it("Subduction default endpoint", () => { + expect(DEFAULT_SUBDUCTION_SERVER).toBe( + "wss://subduction.sync.inkandswitch.com" + ); }); - it("should differ from the default WebSocket sync server", () => { + it("Subduction differs from legacy WebSocket server", () => { expect(DEFAULT_SUBDUCTION_SERVER).not.toBe(DEFAULT_SYNC_SERVER); }); }); - describe("ConfigManager defaults", () => { - it("should use the WebSocket server as default sync_server", async () => { + describe("ConfigManager defaults (Subduction is default)", () => { + it("default config uses the Subduction server", () => { const configManager = new ConfigManager(tmpDir); const config = configManager.getDefaultDirectoryConfig(); - expect(config.sync_server).toBe(DEFAULT_SYNC_SERVER); + expect(config.sync_server).toBe(DEFAULT_SUBDUCTION_SERVER); + }); + + it("default config marks protocol as 'subduction'", () => { + const configManager = new ConfigManager(tmpDir); + const config = configManager.getDefaultDirectoryConfig(); + expect(config.protocol).toBe("subduction"); }); - it("should not default to the subduction server", async () => { + it("default config has no sync_server_storage_id (Subduction doesn't use one)", () => { const configManager = new ConfigManager(tmpDir); const config = configManager.getDefaultDirectoryConfig(); - expect(config.sync_server).not.toBe(DEFAULT_SUBDUCTION_SERVER); + expect(config.sync_server_storage_id).toBeUndefined(); + }); + + it("default config stamps CONFIG_VERSION", () => { + const configManager = new ConfigManager(tmpDir); + const config = configManager.getDefaultDirectoryConfig(); + expect(config.config_version).toBe(CONFIG_VERSION); + }); + }); + + describe("ConfigManager legacy defaults", () => { + it("legacy default uses the classic WebSocket server", () => { + const configManager = new ConfigManager(tmpDir); + const config = + configManager.getDefaultDirectoryConfigForProtocol("legacy"); + expect(config.sync_server).toBe(DEFAULT_SYNC_SERVER); + }); + + it("legacy default includes sync_server_storage_id", () => { + const configManager = new ConfigManager(tmpDir); + const config = + configManager.getDefaultDirectoryConfigForProtocol("legacy"); + expect(config.sync_server_storage_id).toBeDefined(); + }); + + it("legacy default marks protocol as 'legacy'", () => { + const configManager = new ConfigManager(tmpDir); + const config = + configManager.getDefaultDirectoryConfigForProtocol("legacy"); + expect(config.protocol).toBe("legacy"); }); }); - describe("sub flag option types", () => { - // These tests verify that the option interfaces accept `sub` on the - // commands that actually have a --sub flag (init and clone). The flag - // is NOT on SyncOptions or WatchOptions because sync/watch read the - // backend choice from persisted config (see `setupCommandContext`). - // If the type definitions are wrong, these will fail at compile time. - it("should accept sub on InitOptions", () => { - const opts: import("../../src/types/config").InitOptions = { sub: true }; - expect(opts.sub).toBe(true); + describe("legacy flag option types", () => { + // The `--legacy` flag lives on init and clone only. sync/watch read + // the backend choice from persisted config (see setupCommandContext). + // These tests fail at compile time if the type definitions drift. + it("InitOptions accepts legacy: true", () => { + const opts: import("../../src/types/config").InitOptions = { + legacy: true, + }; + expect(opts.legacy).toBe(true); }); - it("should accept sub on CloneOptions", () => { - const opts: import("../../src/types/config").CloneOptions = { sub: true }; - expect(opts.sub).toBe(true); + it("CloneOptions accepts legacy: true", () => { + const opts: import("../../src/types/config").CloneOptions = { + legacy: true, + }; + expect(opts.legacy).toBe(true); }); - it("should default sub to undefined (not required) on InitOptions", () => { + it("InitOptions.legacy is optional (defaults to undefined)", () => { const opts: import("../../src/types/config").InitOptions = {}; - expect(opts.sub).toBeUndefined(); + expect(opts.legacy).toBeUndefined(); }); }); }); From ec6cea05b86f60621ee68cac276f22e54812972d Mon Sep 17 00:00:00 2001 From: Brooklyn Zelenka Date: Thu, 23 Apr 2026 01:41:31 -0700 Subject: [PATCH 2/2] WIP --- src/commands.ts | 48 +++++++++++++++++----------------- src/core/sync-engine.ts | 17 +++++++++--- src/utils/repo-factory.ts | 22 ++++++++-------- test/unit/repo-factory.test.ts | 8 ++++++ 4 files changed, 57 insertions(+), 38 deletions(-) diff --git a/src/commands.ts b/src/commands.ts index 7f5c583..e671d30 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -68,9 +68,7 @@ async function initializeRepository( const configManager = new ConfigManager(resolvedPath); const config = await configManager.initializeWithOverrides(effectiveOverrides); - // The Subduction backend takes a boolean in repo-factory today. - const sub = protocol === "subduction"; - const repo = await createRepo(resolvedPath, config, sub); + const repo = await createRepo(resolvedPath, config, protocol); const syncEngine = new SyncEngine(repo, resolvedPath, config); return { config, repo, syncEngine }; @@ -89,8 +87,13 @@ async function migrateConfigIfNeeded( try { result = await configManager.migrateIfNeeded(); } catch (error) { - out.error(`Config migration failed: ${error}`); - out.exit(1); + // Migration is a convenience — `resolveProtocol` handles v0 configs + // transparently in memory on every command, so a failed migration + // (disk full, read-only filesystem, permission denied, etc.) is + // non-fatal. Warn loudly and carry on with in-memory v0 handling. + out.warn( + `Config migration failed (continuing with in-memory v0 handling): ${error}` + ); return; } if (result.migrated) { @@ -163,10 +166,8 @@ async function setupCommandContext( } } - const sub = protocol === "subduction"; - // Create repo with config - const repo = await createRepo(resolvedPath, config, sub); + const repo = await createRepo(resolvedPath, config, protocol); // Create sync engine const syncEngine = new SyncEngine(repo, resolvedPath, config); @@ -263,8 +264,6 @@ export async function init( protocol ); - const sub = protocol === "subduction"; - // Create new root directory document out.update("Creating root directory"); const dirName = path.basename(resolvedPath); @@ -282,7 +281,7 @@ export async function init( // Wait for root document to sync to server if sync is enabled. // With Subduction, we skip StorageId-based sync verification — // the SubductionSource handles sync internally. - if (config.sync_enabled && !sub) { + if (config.sync_enabled && protocol === "legacy") { if (config.sync_server_storage_id) { out.update("Syncing to server"); const { failed } = await waitForSync([rootHandle], config.sync_server_storage_id); @@ -291,8 +290,8 @@ export async function init( // Continue anyway - the document is created locally and will sync later } } else { - // WebSocket mode without a storage id can't verify delivery via - // getSyncInfo. Warn loudly so users don't silently end up with + // Legacy WebSocket mode without a storage id can't verify delivery + // via getSyncInfo. Warn loudly so users don't silently end up with // data that never reached the server. out.taskLine( "Warning: sync_server_storage_id is not set; skipping post-init sync verification", @@ -303,7 +302,7 @@ export async function init( // Run initial sync to capture existing files out.update("Running initial sync"); - const result = await syncEngine.sync({ sub }); + const result = await syncEngine.sync({ protocol }); out.update("Writing to disk"); await safeRepoShutdown(repo); @@ -341,7 +340,6 @@ export async function sync( forceDefaults: !options.gentle, }); - const sub = config.protocol === "subduction"; if (config.protocol === "legacy") { out.taskLine("Using legacy WebSocket sync backend (from config)", true); } @@ -396,7 +394,7 @@ export async function sync( out.log(""); out.log("Run without --dry-run to apply these changes"); } else { - const result = await syncEngine.sync({ sub }); + const result = await syncEngine.sync({ protocol: config.protocol }); out.taskLine("Writing to disk"); await safeRepoShutdown(repo); @@ -743,12 +741,10 @@ export async function clone( protocol ); - const sub = protocol === "subduction"; - // Connect to existing root directory and download files out.update("Downloading files"); await syncEngine.setRootDirectoryUrl(rootUrl as AutomergeUrl); - const result = await syncEngine.sync({ sub }); + const result = await syncEngine.sync({ protocol }); out.update("Writing to disk"); await safeRepoShutdown(repo); @@ -945,10 +941,16 @@ export async function config( out.exit(1); } } else { - // Show basic config info + // Show basic config info. For the schema version display we read + // the *raw* local config so users can see whether their on-disk + // config has been migrated yet. `config.config_version` comes from + // `getMerged()`, which layers defaults (always v1) over the local + // file — so it would always report "1" even for an unmigrated v0 + // file on disk. + const rawLocal = await configManager.load(); out.infoBlock("CONFIGURATION"); out.obj({ - "Config version": config.config_version ?? "0 (pre-migration)", + "Config version": rawLocal?.config_version ?? "0 (pre-migration)", Backend: config.protocol ?? "subduction", "Sync server": config.sync_server || "default", "Sync enabled": config.sync_enabled ? "yes" : "no", @@ -978,8 +980,6 @@ export async function watch( targetPath, ); - const sub = config.protocol === "subduction"; - const absoluteWatchDir = path.resolve(workingDir, watchDir); // Check if watch directory exists @@ -1035,7 +1035,7 @@ export async function watch( // Run sync out.task("Syncing"); - const result = await syncEngine.sync({ sub }); + const result = await syncEngine.sync({ protocol: config.protocol }); if (result.success) { if (result.filesChanged === 0 && result.directoriesChanged === 0) { diff --git a/src/core/sync-engine.ts b/src/core/sync-engine.ts index 6a2dff5..ed854db 100644 --- a/src/core/sync-engine.ts +++ b/src/core/sync-engine.ts @@ -18,6 +18,7 @@ import { MoveCandidate, DirectoryConfig, DetectedChange, + SyncProtocol, } from "../types" import { writeFileContent, @@ -430,9 +431,17 @@ export class SyncEngine { } /** - * Run full bidirectional sync + * Run full bidirectional sync. + * + * `protocol` selects which sync-verification strategy to use after + * push. In "subduction" mode (default), `waitForSync` falls back to + * head-stability polling. In "legacy" mode, it uses `getSyncInfo` + * against the configured `sync_server_storage_id`. + * + * Typically derived from `this.config.protocol` by the caller and + * passed through so Repo backend and sync-verification agree. */ - async sync(options?: {sub?: boolean}): Promise { + async sync(options?: { protocol?: SyncProtocol }): Promise { const result: SyncResult = { success: false, filesChanged: 0, @@ -534,7 +543,9 @@ export class SyncEngine { // Wait for network sync (important for clone scenarios) if (this.config.sync_enabled) { - const sub = options?.sub ?? false + const protocol: SyncProtocol = + options?.protocol ?? this.config.protocol ?? "subduction" + const sub = protocol === "subduction" // In Subduction mode, pass no StorageId so waitForSync // falls back to head-stability polling. In WebSocket mode, // pass the StorageId for precise getSyncInfo-based verification. diff --git a/src/utils/repo-factory.ts b/src/utils/repo-factory.ts index 4ec1697..d4a3224 100644 --- a/src/utils/repo-factory.ts +++ b/src/utils/repo-factory.ts @@ -2,7 +2,7 @@ import { type Repo, type RepoConfig, type NetworkAdapterInterface } from "@autom import { NodeFSStorageAdapter } from "@automerge/automerge-repo-storage-nodefs"; import * as fs from "fs/promises"; import * as path from "path"; -import { DirectoryConfig } from "../types"; +import { DirectoryConfig, SyncProtocol } from "../types"; /** * Perform a real ESM dynamic import that tsc won't rewrite to require(). @@ -79,18 +79,18 @@ async function hasCorruptStorage(dir: string): Promise { /** * Create an Automerge repository with configuration-based setup. * - * When `sub` is true, uses the Subduction sync backend built into - * automerge-repo. The Repo manages its own SubductionSource internally — - * we just pass `subductionWebsocketEndpoints` and the Repo handles - * connection management, sync, and retries. - * - * When `sub` is false (default), uses the traditional WebSocket network - * adapter for sync via the automerge sync server. + * `protocol` selects the sync backend: + * - "subduction" (default) — uses the Subduction sync backend built + * into automerge-repo. The Repo manages its own SubductionSource + * internally; we just pass `subductionWebsocketEndpoints` and the + * Repo handles connection management, sync, and retries. + * - "legacy" — uses the traditional WebSocket network adapter for + * sync via the classic automerge sync server. */ export async function createRepo( workingDir: string, config: DirectoryConfig, - sub: boolean = false + protocol: SyncProtocol = "subduction" ): Promise { const RepoClass = await getRepoClass(); @@ -108,7 +108,7 @@ export async function createRepo( const storage = new NodeFSStorageAdapter(automergeDir); - if (sub) { + if (protocol === "subduction") { const endpoints: string[] = []; if (config.sync_enabled && config.sync_server) { endpoints.push(config.sync_server); @@ -120,7 +120,7 @@ export async function createRepo( }); } - // Default: WebSocket sync adapter + // Legacy: classic WebSocket sync adapter. const repoConfig: RepoConfig = { storage }; if (config.sync_enabled && config.sync_server) { diff --git a/test/unit/repo-factory.test.ts b/test/unit/repo-factory.test.ts index 421513c..56e699e 100644 --- a/test/unit/repo-factory.test.ts +++ b/test/unit/repo-factory.test.ts @@ -132,6 +132,14 @@ describe("createRepo with --legacy", () => { * network access, this hangs until the 60s timeout. We only care * that the config and snapshot were written before the network call, * so we invoke with a short SIGKILL timeout and ignore the exit code. + * + * CONTRACT: this test depends on `init` in `src/commands.ts` writing + * (1) `.pushwork/config.json` via `initializeRepository`, and + * (2) `.pushwork/snapshot.json` via `setRootDirectoryUrl` + * BEFORE it calls `waitForSync` (the blocking network step). If that + * ordering ever changes — e.g. sync moves earlier in init — this + * test becomes a false positive and will need updating (ideally by + * stubbing the network adapter in `repo-factory.ts`). */ function initLegacy(dir: string): void { try {