From f2b57c2efa0488ff9735a156ce5ff9e4156abb83 Mon Sep 17 00:00:00 2001 From: John Mumm Date: Tue, 7 Apr 2026 09:46:40 +0100 Subject: [PATCH] Add dev suffix sync --- README.md | 10 ++++ src/cli.ts | 41 +++++++++------ src/commands.ts | 105 ++++++++++++++++++++++---------------- src/core/config.ts | 8 ++- src/core/snapshot.ts | 8 +-- src/core/sync-engine.ts | 5 +- src/utils/repo-factory.ts | 5 +- 7 files changed, 112 insertions(+), 70 deletions(-) diff --git a/README.md b/README.md index dfe1718..733fd4d 100644 --- a/README.md +++ b/README.md @@ -99,6 +99,16 @@ pushwork url **`checkout [path]`** - Restore to previous sync _(not yet implemented)_ +### Dev Mode + +The `--dev ` flag uses `.pushwork/dev-/` instead of `.pushwork/`, giving you a separate Automerge URL for development. The id lets you maintain multiple dev configurations. + +```bash +pushwork --dev init # Create dev config with a new URL +pushwork --dev sync # Sync to the dev URL +pushwork --dev watch # Watch, build, and sync to dev URL +``` + ## Configuration Configuration is stored in `.pushwork/config.json`: diff --git a/src/cli.ts b/src/cli.ts index 67d4f08..8c55242 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -24,7 +24,14 @@ const version = require("../package.json").version; const program = new Command() .name("pushwork") .description("Bidirectional directory synchronization using Automerge CRDTs") - .version(version, "-V, --version", "output the version number"); + .version(version, "-V, --version", "output the version number") + .option("--dev ", "Use a separate .pushwork/dev- directory for development"); + +/** Derive the config directory from the global --dev flag */ +function getConfigDir(): string { + const devId = program.opts().dev; + return devId ? `.pushwork/dev-${devId}` : ".pushwork"; +} // Init command program @@ -43,12 +50,12 @@ program const [syncServer, syncServerStorageId] = validateSyncServer( opts.syncServer ); - await init(path, { syncServer, syncServerStorageId }); + await init(path, { syncServer, syncServerStorageId }, getConfigDir()); }); // Track command (set root directory URL without full initialization) const trackAction = async (url: string, path: string, opts: { force: boolean }) => { - await root(url, path, { force: opts.force }); + await root(url, path, { force: opts.force }, getConfigDir()); }; program @@ -102,7 +109,7 @@ program verbose: opts.verbose, syncServer, syncServerStorageId, - }); + }, getConfigDir()); }); // Commit command @@ -115,7 +122,7 @@ program "." ) .action(async (path, _opts) => { - await commit(path); + await commit(path, {}, getConfigDir()); }); // Sync command @@ -151,7 +158,7 @@ program gentle: opts.gentle, nuclear: opts.nuclear, verbose: opts.verbose, - }); + }, getConfigDir()); }); // Diff command @@ -167,7 +174,7 @@ program .action(async (path, opts) => { await diff(path, { nameOnly: opts.nameOnly, - }); + }, getConfigDir()); }); // Status command @@ -183,7 +190,7 @@ program .action(async (path, opts) => { await status(path, { verbose: opts.verbose, - }); + }, getConfigDir()); }); // Log command @@ -203,7 +210,7 @@ program oneline: opts.oneline, since: opts.since, limit: parseInt(opts.limit), - }); + }, getConfigDir()); }); // Checkout command @@ -224,7 +231,7 @@ program .action(async (syncId, path, opts) => { await checkout(syncId, path, { force: opts.force, - }); + }, getConfigDir()); }); // URL command @@ -233,7 +240,7 @@ program .summary("Show the Automerge root URL") .argument("[path]", "Directory path (default: current directory)", ".") .action(async (path) => { - await url(path); + await url(path, getConfigDir()); }); // Remove command @@ -242,7 +249,7 @@ program .summary("Remove local pushwork data") .argument("[path]", "Directory path (default: current directory)", ".") .action(async (path) => { - await rm(path); + await rm(path, getConfigDir()); }); // List command @@ -254,7 +261,7 @@ program .action(async (path, opts) => { await ls(path, { verbose: opts.verbose, - }); + }, getConfigDir()); }); // Config command @@ -271,7 +278,7 @@ program await config(path, { list: opts.list, get: opts.get, - }); + }, getConfigDir()); }); // Watch command @@ -299,7 +306,7 @@ program script: opts.script, watchDir: opts.dir, verbose: opts.verbose, - }); + }, getConfigDir()); }); // Completion command (hidden from help) @@ -361,11 +368,11 @@ program.command("completion", { hidden: true }).action(() => { _pushwork() { local -a commands commands=(${commands}) - + _arguments -C \\ '1: :->command' \\ '*::arg:->args' - + case $state in command) _describe 'command' commands diff --git a/src/commands.ts b/src/commands.ts index 0ef1b5d..76a0d72 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -42,20 +42,21 @@ interface CommandContext { */ async function initializeRepository( resolvedPath: string, - overrides: Partial + overrides: Partial, + configDir: string = ConfigManager.CONFIG_DIR ): Promise<{ config: DirectoryConfig; repo: Repo; syncEngine: SyncEngine }> { - // Create .pushwork directory structure - const syncToolDir = path.join(resolvedPath, ConfigManager.CONFIG_DIR); + // Create config directory structure + const syncToolDir = path.join(resolvedPath, configDir); await ensureDirectoryExists(syncToolDir); await ensureDirectoryExists(path.join(syncToolDir, "automerge")); // Create configuration with overrides - const configManager = new ConfigManager(resolvedPath); + const configManager = new ConfigManager(resolvedPath, configDir); const config = await configManager.initializeWithOverrides(overrides); // Create repository and sync engine - const repo = await createRepo(resolvedPath, config); - const syncEngine = new SyncEngine(repo, resolvedPath, config); + const repo = await createRepo(resolvedPath, config, configDir); + const syncEngine = new SyncEngine(repo, resolvedPath, config, configDir); return { config, repo, syncEngine }; } @@ -66,20 +67,21 @@ async function initializeRepository( */ async function setupCommandContext( workingDir: string = process.cwd(), - options?: { syncEnabled?: boolean; forceDefaults?: boolean } + options?: { syncEnabled?: boolean; forceDefaults?: boolean; configDir?: string } ): Promise { const resolvedPath = path.resolve(workingDir); + const configDir = options?.configDir || ConfigManager.CONFIG_DIR; // Check if initialized - const syncToolDir = path.join(resolvedPath, ConfigManager.CONFIG_DIR); + const syncToolDir = path.join(resolvedPath, configDir); if (!(await pathExists(syncToolDir))) { throw new Error( - 'Directory not initialized for sync. Run "pushwork init" first.' + `Directory not initialized for sync. Run "pushwork${configDir !== ConfigManager.CONFIG_DIR ? " --dev" : ""} init" first.` ); } // Load configuration - const configManager = new ConfigManager(resolvedPath); + const configManager = new ConfigManager(resolvedPath, configDir); let config: DirectoryConfig; if (options?.forceDefaults) { @@ -99,10 +101,10 @@ async function setupCommandContext( } // Create repo with config - const repo = await createRepo(resolvedPath, config); + const repo = await createRepo(resolvedPath, config, configDir); // Create sync engine - const syncEngine = new SyncEngine(repo, resolvedPath, config); + const syncEngine = new SyncEngine(repo, resolvedPath, config, configDir); return { repo, @@ -153,7 +155,8 @@ async function safeRepoShutdown(repo: Repo): Promise { */ export async function init( targetPath: string, - options: InitOptions = {} + options: InitOptions = {}, + configDir: string = ConfigManager.CONFIG_DIR ): Promise { const resolvedPath = path.resolve(targetPath); @@ -162,7 +165,7 @@ export async function init( await ensureDirectoryExists(resolvedPath); // Check if already initialized - const syncToolDir = path.join(resolvedPath, ConfigManager.CONFIG_DIR); + const syncToolDir = path.join(resolvedPath, configDir); if (await pathExists(syncToolDir)) { out.error("Directory already initialized for sync"); out.exit(1); @@ -173,7 +176,7 @@ export async function init( const { repo, syncEngine, config } = await initializeRepository(resolvedPath, { sync_server: options.syncServer, sync_server_storage_id: options.syncServerStorageId, - }); + }, configDir); // Create new root directory document out.update("Creating root directory"); @@ -222,7 +225,8 @@ export async function init( */ export async function sync( targetPath = ".", - options: SyncOptions + options: SyncOptions, + configDir: string = ConfigManager.CONFIG_DIR ): Promise { out.task( options.nuclear @@ -234,6 +238,7 @@ export async function sync( const { repo, syncEngine } = await setupCommandContext(targetPath, { forceDefaults: !options.gentle, + configDir, }); if (options.nuclear) { @@ -339,11 +344,12 @@ export async function sync( */ export async function diff( targetPath = ".", - options: DiffOptions + options: DiffOptions, + configDir: string = ConfigManager.CONFIG_DIR ): Promise { out.task("Analyzing changes"); - const { repo, syncEngine } = await setupCommandContext(targetPath, { syncEnabled: false }); + const { repo, syncEngine } = await setupCommandContext(targetPath, { syncEnabled: false, configDir }); const preview = await syncEngine.previewChanges(); out.done(); @@ -443,11 +449,12 @@ export async function diff( */ export async function status( targetPath: string = ".", - options: StatusOptions = {} + options: StatusOptions = {}, + configDir: string = ConfigManager.CONFIG_DIR ): Promise { const { repo, syncEngine, config } = await setupCommandContext( targetPath, - { syncEnabled: false } + { syncEnabled: false, configDir } ); const syncStatus = await syncEngine.getStatus(); @@ -524,17 +531,18 @@ export async function status( */ export async function log( targetPath = ".", - _options: LogOptions + _options: LogOptions, + configDir: string = ConfigManager.CONFIG_DIR ): Promise { const { repo: logRepo, workingDir } = await setupCommandContext( targetPath, - { syncEnabled: false } + { syncEnabled: false, configDir } ); // TODO: Implement history tracking const snapshotPath = path.join( workingDir, - ConfigManager.CONFIG_DIR, + configDir, "snapshot.json" ); if (await pathExists(snapshotPath)) { @@ -554,9 +562,10 @@ export async function log( export async function checkout( syncId: string, targetPath = ".", - _options: CheckoutOptions + _options: CheckoutOptions, + configDir: string = ConfigManager.CONFIG_DIR ): Promise { - const { workingDir } = await setupCommandContext(targetPath); + const { workingDir } = await setupCommandContext(targetPath, { configDir }); // TODO: Implement checkout functionality out.warnBlock("NOT IMPLEMENTED", "Checkout not yet implemented"); @@ -572,7 +581,8 @@ export async function checkout( export async function clone( rootUrl: string, targetPath: string, - options: CloneOptions + options: CloneOptions, + configDir: string = ConfigManager.CONFIG_DIR ): Promise { // Validate that rootUrl is actually an Automerge URL if (!rootUrl.startsWith("automerge:")) { @@ -601,7 +611,7 @@ export async function clone( } // Check if already initialized - const syncToolDir = path.join(resolvedPath, ConfigManager.CONFIG_DIR); + const syncToolDir = path.join(resolvedPath, configDir); if (await pathExists(syncToolDir)) { if (!options.force) { out.error("Directory already initialized. Use --force to overwrite"); @@ -617,7 +627,8 @@ export async function clone( { sync_server: options.syncServer, sync_server_storage_id: options.syncServerStorageId, - } + }, + configDir ); // Connect to existing root directory and download files @@ -642,9 +653,9 @@ export async function clone( /** * Get the root URL for the current pushwork repository */ -export async function url(targetPath: string = "."): Promise { +export async function url(targetPath: string = ".", configDir: string = ConfigManager.CONFIG_DIR): Promise { const resolvedPath = path.resolve(targetPath); - const syncToolDir = path.join(resolvedPath, ConfigManager.CONFIG_DIR); + const syncToolDir = path.join(resolvedPath, configDir); if (!(await pathExists(syncToolDir))) { out.error("Directory not initialized for sync"); @@ -672,9 +683,9 @@ export async function url(targetPath: string = "."): Promise { /** * Remove local pushwork data and log URL for recovery */ -export async function rm(targetPath: string = "."): Promise { +export async function rm(targetPath: string = ".", configDir: string = ConfigManager.CONFIG_DIR): Promise { const resolvedPath = path.resolve(targetPath); - const syncToolDir = path.join(resolvedPath, ConfigManager.CONFIG_DIR); + const syncToolDir = path.join(resolvedPath, configDir); if (!(await pathExists(syncToolDir))) { out.error("Directory not initialized for sync"); @@ -706,11 +717,12 @@ export async function rm(targetPath: string = "."): Promise { export async function commit( targetPath: string, - _options: CommandOptions = {} + _options: CommandOptions = {}, + configDir: string = ConfigManager.CONFIG_DIR ): Promise { out.task("Committing local changes"); - const { repo, syncEngine } = await setupCommandContext(targetPath, { syncEnabled: false }); + const { repo, syncEngine } = await setupCommandContext(targetPath, { syncEnabled: false, configDir }); const result = await syncEngine.commitLocal(); await safeRepoShutdown(repo); @@ -740,9 +752,10 @@ export async function commit( */ export async function ls( targetPath: string = ".", - options: CommandOptions = {} + options: CommandOptions = {}, + configDir: string = ConfigManager.CONFIG_DIR ): Promise { - const { repo, syncEngine } = await setupCommandContext(targetPath, { syncEnabled: false }); + const { repo, syncEngine } = await setupCommandContext(targetPath, { syncEnabled: false, configDir }); const syncStatus = await syncEngine.getStatus(); if (!syncStatus.snapshot) { @@ -783,17 +796,18 @@ export async function ls( */ export async function config( targetPath: string = ".", - options: ConfigOptions = {} + options: ConfigOptions = {}, + configDir: string = ConfigManager.CONFIG_DIR ): Promise { const resolvedPath = path.resolve(targetPath); - const syncToolDir = path.join(resolvedPath, ConfigManager.CONFIG_DIR); + const syncToolDir = path.join(resolvedPath, configDir); if (!(await pathExists(syncToolDir))) { out.error("Directory not initialized for sync"); out.exit(1); } - const configManager = new ConfigManager(resolvedPath); + const configManager = new ConfigManager(resolvedPath, configDir); const config = await configManager.getMerged(); if (options.list) { @@ -833,13 +847,15 @@ export async function config( */ export async function watch( targetPath: string = ".", - options: WatchOptions = {} + options: WatchOptions = {}, + configDir: string = ConfigManager.CONFIG_DIR ): Promise { const script = options.script || "pnpm build"; const watchDir = options.watchDir || "src"; // Default to watching 'src' directory const verbose = options.verbose || false; const { repo, syncEngine, workingDir } = await setupCommandContext( - targetPath + targetPath, + { configDir } ); const absoluteWatchDir = path.resolve(workingDir, watchDir); @@ -1023,7 +1039,8 @@ async function runScript( export async function root( rootUrl: string, targetPath: string = ".", - options: { force?: boolean } = {} + options: { force?: boolean } = {}, + configDir: string = ConfigManager.CONFIG_DIR ): Promise { if (!rootUrl.startsWith("automerge:")) { out.error( @@ -1034,7 +1051,7 @@ export async function root( } const resolvedPath = path.resolve(targetPath); - const syncToolDir = path.join(resolvedPath, ConfigManager.CONFIG_DIR); + const syncToolDir = path.join(resolvedPath, configDir); if (await pathExists(syncToolDir)) { if (!options.force) { @@ -1058,7 +1075,7 @@ export async function root( await fs.writeFile(snapshotPath, JSON.stringify(snapshot, null, 2), "utf-8"); // Ensure config exists - const configManager = new ConfigManager(resolvedPath); + const configManager = new ConfigManager(resolvedPath, configDir); await configManager.initializeWithOverrides({}); out.successBlock("ROOT SET", rootUrl); diff --git a/src/core/config.ts b/src/core/config.ts index 41db973..52d889f 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -18,7 +18,11 @@ export class ConfigManager { static readonly CONFIG_DIR = ".pushwork"; - constructor(private workingDir?: string) {} + readonly configDir: string; + + constructor(private workingDir?: string, configDir: string = ".pushwork") { + this.configDir = configDir; + } /** * Get global configuration path @@ -40,7 +44,7 @@ export class ConfigManager { } return path.join( this.workingDir, - ConfigManager.CONFIG_DIR, + this.configDir, ConfigManager.CONFIG_FILENAME ); } diff --git a/src/core/snapshot.ts b/src/core/snapshot.ts index 21f2ead..251260a 100644 --- a/src/core/snapshot.ts +++ b/src/core/snapshot.ts @@ -18,15 +18,17 @@ import { out } from "../utils/output"; */ export class SnapshotManager { private static readonly SNAPSHOT_FILENAME = "snapshot.json"; - private static readonly SYNC_TOOL_DIR = ".pushwork"; + private readonly syncToolDir: string; - constructor(private rootPath: string) {} + constructor(private rootPath: string, configDir: string = ".pushwork") { + this.syncToolDir = configDir; + } /** * Get path to sync tool directory */ private getSyncToolDir(): string { - return path.join(this.rootPath, SnapshotManager.SYNC_TOOL_DIR); + return path.join(this.rootPath, this.syncToolDir); } /** diff --git a/src/core/sync-engine.ts b/src/core/sync-engine.ts index 73d383a..435a0e6 100644 --- a/src/core/sync-engine.ts +++ b/src/core/sync-engine.ts @@ -138,10 +138,11 @@ export class SyncEngine { constructor( private repo: Repo, private rootPath: string, - config: DirectoryConfig + config: DirectoryConfig, + configDir: string = ".pushwork" ) { this.config = config - this.snapshotManager = new SnapshotManager(rootPath) + this.snapshotManager = new SnapshotManager(rootPath, configDir) this.changeDetector = new ChangeDetector( repo, rootPath, diff --git a/src/utils/repo-factory.ts b/src/utils/repo-factory.ts index 36f23c1..efe39ce 100644 --- a/src/utils/repo-factory.ts +++ b/src/utils/repo-factory.ts @@ -9,9 +9,10 @@ import { DirectoryConfig } from "../types"; */ export async function createRepo( workingDir: string, - config: DirectoryConfig + config: DirectoryConfig, + configDir: string = ".pushwork" ): Promise { - const syncToolDir = path.join(workingDir, ".pushwork"); + const syncToolDir = path.join(workingDir, configDir); const storage = new NodeFSStorageAdapter(path.join(syncToolDir, "automerge")); const repoConfig: any = { storage };