From 7b1a6cd2658e6cbaba0fe19fb5314ba8d41ed2fe Mon Sep 17 00:00:00 2001 From: chee Date: Tue, 28 Apr 2026 15:20:11 +0100 Subject: [PATCH 01/21] update stale artie --- CLAUDE.md | 2 ++ src/core/sync-engine.ts | 79 ++++++++++++++++++++++++++++++++++++++--- 2 files changed, 76 insertions(+), 5 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 86d4806..2043f74 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -124,6 +124,8 @@ Key fields: `pushLocalChanges()` processes directories deepest-first via `batchUpdateDirectory()`, propagating subdirectory URL updates as it walks up toward the root. This ensures directory entries always point to the latest version of their children. +**Invariant: any change to dist's heads must update parents recursively, leaf-first.** Local file changes are caught by the loop above. Heads can also drift from remote merges that land during `waitForBidirectionalSync` — the artifact directory advances locally but no file-level change is detected, so leaf-first propagation never kicks in and the parent's versioned URL goes stale. `findStaleArtifactDirs()` scans every artifact dir in the snapshot, compares its live `handle.heads()` against the heads encoded in its parent's stored URL entry, and returns paths that have drifted. `pushLocalChanges()` then folds these into `allDirsToProcess` and pre-populates `modifiedDirs` so the existing leaf-first machinery emits a `subdirUpdates` entry for each stale dir's parent. This is self-healing — even if drift happens after a sync exits, the next sync catches it. + ## The `changeWithOptionalHeads` helper Used throughout sync-engine: if heads are available, calls `handle.changeAt(heads, cb)` to branch from a known version; otherwise falls back to `handle.change(cb)`. This is important for conflict-free merging when multiple peers are editing. diff --git a/src/core/sync-engine.ts b/src/core/sync-engine.ts index 6a2dff5..a1bac34 100644 --- a/src/core/sync-engine.ts +++ b/src/core/sync-engine.ts @@ -208,6 +208,61 @@ export class SyncEngine { return getPlainUrl(handle.url) } + /** + * Find artifact directories whose live heads don't match the heads + * encoded in their parent's stored URL entry. This drift happens when + * remote changes land via bidirectional sync — the directory advances + * locally but no file-level change is detected, so leaf-first + * propagation never kicks in. Returning these here lets pushLocalChanges + * treat them as modified and refresh parent URLs all the way to the root. + */ + private async findStaleArtifactDirs(snapshot: SyncSnapshot): Promise { + if (!snapshot.rootDirectoryUrl) return [] + + const stale: string[] = [] + for (const [dirPath, entry] of snapshot.directories.entries()) { + if (!dirPath) continue + if (!this.isArtifactPath(dirPath)) continue + + const parts = dirPath.split("/") + const dirName = parts.pop()! + const parentPath = parts.join("/") + const parentUrl = !parentPath + ? snapshot.rootDirectoryUrl + : snapshot.directories.get(parentPath)?.url + if (!parentUrl) continue + + try { + const parentHandle = await this.repo.find( + getPlainUrl(parentUrl) + ) + const parentDoc = parentHandle.doc() + if (!parentDoc) continue + + const entryInParent = parentDoc.docs.find( + (e: DirectoryEntry) => e.name === dirName && e.type === "folder" + ) + if (!entryInParent) continue + + const dirHandle = await this.repo.find( + getPlainUrl(entry.url) + ) + const liveHeads = dirHandle.heads() + const urlHeadsInParent = parseAutomergeUrl(entryInParent.url).heads + + if ( + !urlHeadsInParent || + !A.equals(urlHeadsInParent as unknown as UrlHeads, liveHeads) + ) { + stale.push(dirPath) + } + } catch (err) { + debug(`findStaleArtifactDirs: ${dirPath}: ${err}`) + } + } + return stale + } + /** * Set the root directory URL in the snapshot */ @@ -792,11 +847,21 @@ export class SyncEngine { c.changeType === ChangeType.BOTH_CHANGED ) - if (localChanges.length === 0) { + // Detect artifact directories whose heads have drifted from what's + // encoded in their parent's URL (typically from remote merges during + // bidirectional sync). Treat them as modified so the existing + // leaf-first propagation refreshes parent URLs all the way up. + const staleArtifactDirs = await this.findStaleArtifactDirs(snapshot) + + if (localChanges.length === 0 && staleArtifactDirs.length === 0) { debug("push: no local changes to push") return result } + if (staleArtifactDirs.length > 0) { + debug(`push: ${staleArtifactDirs.length} stale artifact dirs need parent URL refresh: ${staleArtifactDirs.join(", ")}`) + } + const newFiles = localChanges.filter(c => !snapshot.files.has(c.path) && c.localContent !== null) const modifiedFiles = localChanges.filter(c => snapshot.files.has(c.path) && c.localContent !== null) const deletedFiles = localChanges.filter(c => c.localContent === null && snapshot.files.has(c.path)) @@ -816,9 +881,9 @@ export class SyncEngine { } // Collect all directory paths that need processing: - // directories with file changes + all ancestors up to root + // directories with file changes + stale artifact dirs + all ancestors const allDirsToProcess = new Set() - for (const dirPath of changesByDir.keys()) { + const addWithAncestors = (dirPath: string) => { allDirsToProcess.add(dirPath) // Add ancestors so subdirectory URL updates propagate to root let current = dirPath @@ -829,6 +894,8 @@ export class SyncEngine { allDirsToProcess.add(current) } } + for (const dirPath of changesByDir.keys()) addWithAncestors(dirPath) + for (const dirPath of staleArtifactDirs) addWithAncestors(dirPath) // Sort deepest-first const sortedDirPaths = Array.from(allDirsToProcess).sort((a, b) => { @@ -839,8 +906,10 @@ export class SyncEngine { debug(`push: processing ${sortedDirPaths.length} directories (deepest first)`) - // Track which directories were modified (for subdirectory URL propagation) - const modifiedDirs = new Set() + // Track which directories were modified (for subdirectory URL propagation). + // Pre-populate with stale artifact dirs so their parents emit a + // subdirUpdate even if no local file change touches them. + const modifiedDirs = new Set(staleArtifactDirs) let filesProcessed = 0 const totalFiles = localChanges.length From 87acefbaf242c01490f93157812b4aa1cbf04858 Mon Sep 17 00:00:00 2001 From: chee Date: Sun, 3 May 2026 22:37:09 +0100 Subject: [PATCH 02/21] switch to vitest --- CLAUDE.md | 6 +- babel.config.js | 5 - package.json | 46 +- pnpm-lock.yaml | 5218 ++++++----------------- src/commands.ts | 24 +- src/core/sync-engine.ts | 20 +- src/utils/network-sync.ts | 265 +- src/utils/repo-factory.ts | 9 +- test/integration/fuzzer.test.ts | 4 +- test/integration/in-memory-sync.test.ts | 18 +- test/integration/sub-flag.test.ts | 18 +- test/jest.setup.ts | 34 - test/setup.ts | 29 + test/unit/network-sync-sub.test.ts | 252 +- tsconfig.json | 2 +- vitest.config.ts | 14 + 16 files changed, 1598 insertions(+), 4366 deletions(-) delete mode 100644 babel.config.js delete mode 100644 test/jest.setup.ts create mode 100644 test/setup.ts create mode 100644 vitest.config.ts diff --git a/CLAUDE.md b/CLAUDE.md index 2043f74..020543d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -112,7 +112,7 @@ Key fields: ## Network sync details -- Uses `waitForSync()` to verify documents reach the server by comparing local and remote heads +- Uses `waitForSync()` to wait until each handle has positive sync confirmation. Two signals can resolve a handle: (1) a `remote-heads` event whose heads match local heads — emitted by `SyncStateTracker` in WebSocket mode when the server reports its sync state; (2) head stability — heads unchanged for 3 consecutive 100ms polls. Subduction direct-peer connections take path (2) because `handleImmediateRemoteHeadsChanged` in `RemoteHeadsSubscriptions` stores received heads but does not currently emit `remote-heads-changed`, so no `remote-heads` event reaches the handle for the directly-connected sync server (only indirect/gossip updates do). `enableRemoteHeadsGossiping: true` is set in `repo-factory.ts` so the path is wired up; if upstream emits the strict signal for direct peers we'll start getting it for free. We don't filter on `storageId` — pushwork configures one upstream peer. No batching: all handles are awaited concurrently. The `sync_server_storage_id` config field is no longer used for verification. - Uses `waitForBidirectionalSync()` to poll until document heads stabilize (no more incoming changes) - Accepts optional `handles` param to check only specific handles instead of full tree traversal (used post-push in `sync()`) - Timeout scales dynamically: `max(timeoutMs, 5000 + docCount * 50)` so large trees don't prematurely time out @@ -137,7 +137,7 @@ Used throughout sync-engine: if heads are available, calls `handle.changeAt(head - **Artifact directories are always nuked.** `batchUpdateDirectory` uses a plain `dirHandle.change()` (not `changeWithOptionalHeads`) for artifact directory paths and rebuilds the entire `docs` array from scratch. This avoids `changeAt` forking from stale heads, which previously caused bugs like deleted entries resurrecting. The rebuild reads the current entries, applies all changes (deletes, updates, additions, subdir URL updates), then splices out the old array and pushes the computed entries. - **Sync timeout recovery.** `waitForSync()` returns `{ failed: DocHandle[] }` instead of throwing. When documents fail to sync (timeout or unavailable), `recreateFailedDocuments()` creates new Automerge docs with the same content, updates snapshot entries and parent directory references, then retries once. If documents still fail after recreation, it's reported as an error (not a warning) so the sync shows as "PARTIAL" rather than "SYNCED". - **Document availability during clone.** `repo.find()` rejects with "Document X is unavailable" if the sync server doesn't have the document yet. `DocHandle.doc()` is synchronous and throws if the handle isn't ready. For clone scenarios, `sync()` retries `repo.find()` for the root document with exponential backoff (up to 6 attempts). `ChangeDetector.findDocument()` wraps `repo.find()` + `doc()` with retry logic for all document fetches during change detection. -- **Server load.** `enableRemoteHeadsGossiping` is disabled — pushwork syncs directly with the server so the gossip protocol is unnecessary overhead. `waitForSync` processes documents in batches of 10 (`SYNC_BATCH_SIZE`) to avoid flooding the server with concurrent sync messages. Without batching, syncing 100+ documents simultaneously can overwhelm the sync server (which is single-threaded with no backpressure). +- **Server load.** `enableRemoteHeadsGossiping` is now enabled — `waitForSync` requires the `remote-heads` events that gossip wires up. Previously batched concurrent waits via `SYNC_BATCH_SIZE`; that was removed because head-stability polling falsely reported "synced" before the server actually had the data, and the batching wasn't doing useful work once we switched to event-based confirmation. - **`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. @@ -149,7 +149,7 @@ The `--sub` flag switches from the default WebSocket sync adapter to the Subduct - `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) -- `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 +- `network-sync.ts`: `waitForSync` is the same in both modes — listen for `remote-heads` events on each handle and resolve when reported heads match local heads. Subduction's gossip callback fires these events because `enableRemoteHeadsGossiping: true` is now set in `repo-factory.ts` - `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 diff --git a/babel.config.js b/babel.config.js deleted file mode 100644 index 52eca3a..0000000 --- a/babel.config.js +++ /dev/null @@ -1,5 +0,0 @@ -module.exports = { - presets: [ - ['@babel/preset-env', { targets: { node: 'current' } }] - ] -}; diff --git a/package.json b/package.json index 3a94705..f400383 100644 --- a/package.json +++ b/package.json @@ -12,10 +12,10 @@ "scripts": { "build": "tsc", "dev": "tsc --watch", - "test": "jest", - "test:bail": "jest --bail", - "test:watch": "jest --watch", - "test:coverage": "jest --coverage", + "test": "vitest run", + "test:bail": "vitest run --bail 1", + "test:watch": "vitest", + "test:coverage": "vitest run --coverage", "lint": "eslint src --ext .ts", "lint:fix": "eslint src --ext .ts --fix", "clean": "rm -rf dist", @@ -53,47 +53,15 @@ "ora": "^7.0.1" }, "devDependencies": { - "@babel/core": "^7.28.6", - "@babel/preset-env": "^7.28.6", "@types/diff": "^5.0.3", - "@types/jest": "^29.5.0", "@types/mime-types": "^2.1.1", "@types/node": "^20.0.0", "@types/tmp": "^0.2.4", - "babel-jest": "^30.2.0", + "@vitest/coverage-v8": "^2.1.0", "fast-check": "^4.3.0", - "jest": "^29.7.0", "tmp": "^0.2.1", - "ts-jest": "^29.1.0", "tsx": "^4.19.2", - "typescript": "^5.2.0" - }, - "jest": { - "preset": "ts-jest", - "testEnvironment": "node", - "roots": [ - "/src", - "/test" - ], - "testMatch": [ - "**/__tests__/**/*.ts", - "**/*.(test|spec).ts" - ], - "collectCoverageFrom": [ - "src/**/*.ts", - "!src/**/*.d.ts" - ], - "setupFilesAfterEnv": [ - "/test/jest.setup.ts" - ], - "maxWorkers": "75%", - "maxConcurrency": 10, - "transformIgnorePatterns": [ - "node_modules/(?!.*@automerge)" - ], - "transform": { - "^.+\\.tsx?$": "ts-jest", - "^.+\\.jsx?$": "babel-jest" - } + "typescript": "^5.2.0", + "vitest": "^2.1.0" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ea234ef..2d125e3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -48,18 +48,9 @@ importers: specifier: ^7.0.1 version: 7.0.1 devDependencies: - '@babel/core': - specifier: ^7.28.6 - version: 7.29.0 - '@babel/preset-env': - specifier: ^7.28.6 - version: 7.29.2(@babel/core@7.29.0) '@types/diff': specifier: ^5.0.3 version: 5.2.3 - '@types/jest': - specifier: ^29.5.0 - version: 29.5.14 '@types/mime-types': specifier: ^2.1.1 version: 2.1.4 @@ -69,30 +60,31 @@ importers: '@types/tmp': specifier: ^0.2.4 version: 0.2.6 - babel-jest: - specifier: ^30.2.0 - version: 30.3.0(@babel/core@7.29.0) + '@vitest/coverage-v8': + specifier: ^2.1.0 + version: 2.1.9(vitest@2.1.9(@types/node@20.19.39)) fast-check: specifier: ^4.3.0 version: 4.6.0 - jest: - specifier: ^29.7.0 - version: 29.7.0(@types/node@20.19.39) tmp: specifier: ^0.2.1 version: 0.2.5 - ts-jest: - specifier: ^29.1.0 - version: 29.4.9(@babel/core@7.29.0)(@jest/transform@30.3.0)(@jest/types@30.3.0)(babel-jest@30.3.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@29.7.0(@types/node@20.19.39))(typescript@5.9.3) tsx: specifier: ^4.19.2 version: 4.21.0 typescript: specifier: ^5.2.0 version: 5.9.3 + vitest: + specifier: ^2.1.0 + version: 2.1.9(@types/node@20.19.39) packages: + '@ampproject/remapping@2.3.0': + resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} + engines: {node: '>=6.0.0'} + '@automerge/automerge-repo-network-websocket@2.6.0-subduction.15': resolution: {integrity: sha512-vbNTG/jHetrjkPV97W1yA81kQJVv/TAoo1bbrh1poerZ+zuE/EptwqrREwf0BRfg9qWFbABjgcasSayxygRUgg==} @@ -108,89 +100,6 @@ packages: '@automerge/automerge@3.2.6': resolution: {integrity: sha512-9/GXXfYYWNVGpnbRrGQzTNU4fWZ3XaEMeEg0OrpK4pvlQSpkmUBoirEb/4TMK6BwMysZGV5Yeneq3wwc7RNGfg==} - '@babel/code-frame@7.29.0': - resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} - engines: {node: '>=6.9.0'} - - '@babel/compat-data@7.29.0': - resolution: {integrity: sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==} - engines: {node: '>=6.9.0'} - - '@babel/core@7.29.0': - resolution: {integrity: sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==} - engines: {node: '>=6.9.0'} - - '@babel/generator@7.29.1': - resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==} - engines: {node: '>=6.9.0'} - - '@babel/helper-annotate-as-pure@7.27.3': - resolution: {integrity: sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==} - engines: {node: '>=6.9.0'} - - '@babel/helper-compilation-targets@7.28.6': - resolution: {integrity: sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==} - engines: {node: '>=6.9.0'} - - '@babel/helper-create-class-features-plugin@7.28.6': - resolution: {integrity: sha512-dTOdvsjnG3xNT9Y0AUg1wAl38y+4Rl4sf9caSQZOXdNqVn+H+HbbJ4IyyHaIqNR6SW9oJpA/RuRjsjCw2IdIow==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0 - - '@babel/helper-create-regexp-features-plugin@7.28.5': - resolution: {integrity: sha512-N1EhvLtHzOvj7QQOUCCS3NrPJP8c5W6ZXCHDn7Yialuy1iu4r5EmIYkXlKNqT99Ciw+W0mDqWoR6HWMZlFP3hw==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0 - - '@babel/helper-define-polyfill-provider@0.6.8': - resolution: {integrity: sha512-47UwBLPpQi1NoWzLuHNjRoHlYXMwIJoBf7MFou6viC/sIHWYygpvr0B6IAyh5sBdA2nr2LPIRww8lfaUVQINBA==} - peerDependencies: - '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0 - - '@babel/helper-globals@7.28.0': - resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} - engines: {node: '>=6.9.0'} - - '@babel/helper-member-expression-to-functions@7.28.5': - resolution: {integrity: sha512-cwM7SBRZcPCLgl8a7cY0soT1SptSzAlMH39vwiRpOQkJlh53r5hdHwLSCZpQdVLT39sZt+CRpNwYG4Y2v77atg==} - engines: {node: '>=6.9.0'} - - '@babel/helper-module-imports@7.28.6': - resolution: {integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==} - engines: {node: '>=6.9.0'} - - '@babel/helper-module-transforms@7.28.6': - resolution: {integrity: sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0 - - '@babel/helper-optimise-call-expression@7.27.1': - resolution: {integrity: sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw==} - engines: {node: '>=6.9.0'} - - '@babel/helper-plugin-utils@7.28.6': - resolution: {integrity: sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==} - engines: {node: '>=6.9.0'} - - '@babel/helper-remap-async-to-generator@7.27.1': - resolution: {integrity: sha512-7fiA521aVw8lSPeI4ZOD3vRFkoqkJcS+z4hFo82bFSH/2tNd6eJ5qCVMS5OzDmZh/kaHQeBaeyxK6wljcPtveA==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0 - - '@babel/helper-replace-supers@7.28.6': - resolution: {integrity: sha512-mq8e+laIk94/yFec3DxSjCRD2Z0TAjhVbEJY3UQrlwVo15Lmt7C2wAUbK4bjnTs4APkwsYLTahXRraQXhb1WCg==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0 - - '@babel/helper-skip-transparent-expression-wrappers@7.27.1': - resolution: {integrity: sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==} - engines: {node: '>=6.9.0'} - '@babel/helper-string-parser@7.27.1': resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} engines: {node: '>=6.9.0'} @@ -199,2826 +108,1178 @@ packages: resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} engines: {node: '>=6.9.0'} - '@babel/helper-validator-option@7.27.1': - resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} - engines: {node: '>=6.9.0'} - - '@babel/helper-wrap-function@7.28.6': - resolution: {integrity: sha512-z+PwLziMNBeSQJonizz2AGnndLsP2DeGHIxDAn+wdHOGuo4Fo1x1HBPPXeE9TAOPHNNWQKCSlA2VZyYyyibDnQ==} - engines: {node: '>=6.9.0'} - - '@babel/helpers@7.29.2': - resolution: {integrity: sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==} - engines: {node: '>=6.9.0'} - '@babel/parser@7.29.2': resolution: {integrity: sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==} engines: {node: '>=6.0.0'} hasBin: true - '@babel/plugin-bugfix-firefox-class-in-computed-class-key@7.28.5': - resolution: {integrity: sha512-87GDMS3tsmMSi/3bWOte1UblL+YUTFMV8SZPZ2eSEL17s74Cw/l63rR6NmGVKMYW2GYi85nE+/d6Hw5N0bEk2Q==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0 - - '@babel/plugin-bugfix-safari-class-field-initializer-scope@7.27.1': - resolution: {integrity: sha512-qNeq3bCKnGgLkEXUuFry6dPlGfCdQNZbn7yUAPCInwAJHMU7THJfrBSozkcWq5sNM6RcF3S8XyQL2A52KNR9IA==} + '@babel/types@7.29.0': + resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0 - '@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@7.27.1': - resolution: {integrity: sha512-g4L7OYun04N1WyqMNjldFwlfPCLVkgB54A/YCXICZYBsvJJE3kByKv9c9+R/nAfmIfjl2rKYLNyMHboYbZaWaA==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0 + '@bcoe/v8-coverage@0.2.3': + resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==} - '@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining@7.27.1': - resolution: {integrity: sha512-oO02gcONcD5O1iTLi/6frMJBIwWEHceWGSGqrpCmEL8nogiS6J9PBlE48CaK20/Jx1LuRml9aDftLgdjXT8+Cw==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.13.0 + '@cbor-extract/cbor-extract-darwin-arm64@2.2.2': + resolution: {integrity: sha512-ZKZ/F8US7JR92J4DMct6cLW/Y66o2K576+zjlEN/MevH70bFIsB10wkZEQPLzl2oNh2SMGy55xpJ9JoBRl5DOA==} + cpu: [arm64] + os: [darwin] - '@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly@7.28.6': - resolution: {integrity: sha512-a0aBScVTlNaiUe35UtfxAN7A/tehvvG4/ByO6+46VPKTRSlfnAFsgKy0FUh+qAkQrDTmhDkT+IBOKlOoMUxQ0g==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0 + '@cbor-extract/cbor-extract-darwin-x64@2.2.2': + resolution: {integrity: sha512-32b1mgc+P61Js+KW9VZv/c+xRw5EfmOcPx990JbCBSkYJFY0l25VinvyyWfl+3KjibQmAcYwmyzKF9J4DyKP/Q==} + cpu: [x64] + os: [darwin] - '@babel/plugin-proposal-private-property-in-object@7.21.0-placeholder-for-preset-env.2': - resolution: {integrity: sha512-SOSkfJDddaM7mak6cPEpswyTRnuRltl429hMraQEglW+OkovnCzsiszTmsrlY//qLFjCpQDFRvjdm2wA5pPm9w==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 + '@cbor-extract/cbor-extract-linux-arm64@2.2.2': + resolution: {integrity: sha512-wfqgzqCAy/Vn8i6WVIh7qZd0DdBFaWBjPdB6ma+Wihcjv0gHqD/mw3ouVv7kbbUNrab6dKEx/w3xQZEdeXIlzg==} + cpu: [arm64] + os: [linux] - '@babel/plugin-syntax-async-generators@7.8.4': - resolution: {integrity: sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==} - peerDependencies: - '@babel/core': ^7.0.0-0 + '@cbor-extract/cbor-extract-linux-arm@2.2.2': + resolution: {integrity: sha512-tNg0za41TpQfkhWjptD+0gSD2fggMiDCSacuIeELyb2xZhr7PrhPe5h66Jc67B/5dmpIhI2QOUtv4SBsricyYQ==} + cpu: [arm] + os: [linux] - '@babel/plugin-syntax-bigint@7.8.3': - resolution: {integrity: sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==} - peerDependencies: - '@babel/core': ^7.0.0-0 + '@cbor-extract/cbor-extract-linux-x64@2.2.2': + resolution: {integrity: sha512-rpiLnVEsqtPJ+mXTdx1rfz4RtUGYIUg2rUAZgd1KjiC1SehYUSkJN7Yh+aVfSjvCGtVP0/bfkQkXpPXKbmSUaA==} + cpu: [x64] + os: [linux] - '@babel/plugin-syntax-class-properties@7.12.13': - resolution: {integrity: sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==} - peerDependencies: - '@babel/core': ^7.0.0-0 + '@cbor-extract/cbor-extract-win32-x64@2.2.2': + resolution: {integrity: sha512-dI+9P7cfWxkTQ+oE+7Aa6onEn92PHgfWXZivjNheCRmTBDBf2fx6RyTi0cmgpYLnD1KLZK9ZYrMxaPZ4oiXhGA==} + cpu: [x64] + os: [win32] - '@babel/plugin-syntax-class-static-block@7.14.5': - resolution: {integrity: sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==} - engines: {node: '>=6.9.0'} + '@commander-js/extra-typings@14.0.0': + resolution: {integrity: sha512-hIn0ncNaJRLkZrxBIp5AsW/eXEHNKYQBh0aPdoUqNgD+Io3NIykQqpKFyKcuasZhicGaEZJX/JBSIkZ4e5x8Dg==} peerDependencies: - '@babel/core': ^7.0.0-0 + commander: ~14.0.0 - '@babel/plugin-syntax-import-assertions@7.28.6': - resolution: {integrity: sha512-pSJUpFHdx9z5nqTSirOCMtYVP2wFgoWhP0p3g8ONK/4IHhLIBd0B9NYqAvIUAhq+OkhO4VM1tENCt0cjlsNShw==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 + '@esbuild/aix-ppc64@0.21.5': + resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [aix] - '@babel/plugin-syntax-import-attributes@7.28.6': - resolution: {integrity: sha512-jiLC0ma9XkQT3TKJ9uYvlakm66Pamywo+qwL+oL8HJOvc6TWdZXVfhqJr8CCzbSGUAbDOzlGHJC1U+vRfLQDvw==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 + '@esbuild/aix-ppc64@0.27.7': + resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] - '@babel/plugin-syntax-import-meta@7.10.4': - resolution: {integrity: sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==} - peerDependencies: - '@babel/core': ^7.0.0-0 + '@esbuild/android-arm64@0.21.5': + resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] - '@babel/plugin-syntax-json-strings@7.8.3': - resolution: {integrity: sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==} - peerDependencies: - '@babel/core': ^7.0.0-0 + '@esbuild/android-arm64@0.27.7': + resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] - '@babel/plugin-syntax-jsx@7.28.6': - resolution: {integrity: sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 + '@esbuild/android-arm@0.21.5': + resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] - '@babel/plugin-syntax-logical-assignment-operators@7.10.4': - resolution: {integrity: sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==} - peerDependencies: - '@babel/core': ^7.0.0-0 + '@esbuild/android-arm@0.27.7': + resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] - '@babel/plugin-syntax-nullish-coalescing-operator@7.8.3': - resolution: {integrity: sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==} - peerDependencies: - '@babel/core': ^7.0.0-0 + '@esbuild/android-x64@0.21.5': + resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] - '@babel/plugin-syntax-numeric-separator@7.10.4': - resolution: {integrity: sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==} - peerDependencies: - '@babel/core': ^7.0.0-0 + '@esbuild/android-x64@0.27.7': + resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] - '@babel/plugin-syntax-object-rest-spread@7.8.3': - resolution: {integrity: sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==} - peerDependencies: - '@babel/core': ^7.0.0-0 + '@esbuild/darwin-arm64@0.21.5': + resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] - '@babel/plugin-syntax-optional-catch-binding@7.8.3': - resolution: {integrity: sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==} - peerDependencies: - '@babel/core': ^7.0.0-0 + '@esbuild/darwin-arm64@0.27.7': + resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] - '@babel/plugin-syntax-optional-chaining@7.8.3': - resolution: {integrity: sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==} - peerDependencies: - '@babel/core': ^7.0.0-0 + '@esbuild/darwin-x64@0.21.5': + resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] - '@babel/plugin-syntax-private-property-in-object@7.14.5': - resolution: {integrity: sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 + '@esbuild/darwin-x64@0.27.7': + resolution: {integrity: sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] - '@babel/plugin-syntax-top-level-await@7.14.5': - resolution: {integrity: sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 + '@esbuild/freebsd-arm64@0.21.5': + resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] - '@babel/plugin-syntax-typescript@7.28.6': - resolution: {integrity: sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 + '@esbuild/freebsd-arm64@0.27.7': + resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] - '@babel/plugin-syntax-unicode-sets-regex@7.18.6': - resolution: {integrity: sha512-727YkEAPwSIQTv5im8QHz3upqp92JTWhidIC81Tdx4VJYIte/VndKf1qKrfnnhPLiPghStWfvC/iFaMCQu7Nqg==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0 + '@esbuild/freebsd-x64@0.21.5': + resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] - '@babel/plugin-transform-arrow-functions@7.27.1': - resolution: {integrity: sha512-8Z4TGic6xW70FKThA5HYEKKyBpOOsucTOD1DjU3fZxDg+K3zBJcXMFnt/4yQiZnf5+MiOMSXQ9PaEK/Ilh1DeA==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 + '@esbuild/freebsd-x64@0.27.7': + resolution: {integrity: sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] - '@babel/plugin-transform-async-generator-functions@7.29.0': - resolution: {integrity: sha512-va0VdWro4zlBr2JsXC+ofCPB2iG12wPtVGTWFx2WLDOM3nYQZZIGP82qku2eW/JR83sD+k2k+CsNtyEbUqhU6w==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 + '@esbuild/linux-arm64@0.21.5': + resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] - '@babel/plugin-transform-async-to-generator@7.28.6': - resolution: {integrity: sha512-ilTRcmbuXjsMmcZ3HASTe4caH5Tpo93PkTxF9oG2VZsSWsahydmcEHhix9Ik122RcTnZnUzPbmux4wh1swfv7g==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 + '@esbuild/linux-arm64@0.27.7': + resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] - '@babel/plugin-transform-block-scoped-functions@7.27.1': - resolution: {integrity: sha512-cnqkuOtZLapWYZUYM5rVIdv1nXYuFVIltZ6ZJ7nIj585QsjKM5dhL2Fu/lICXZ1OyIAFc7Qy+bvDAtTXqGrlhg==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 + '@esbuild/linux-arm@0.21.5': + resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] - '@babel/plugin-transform-block-scoping@7.28.6': - resolution: {integrity: sha512-tt/7wOtBmwHPNMPu7ax4pdPz6shjFrmHDghvNC+FG9Qvj7D6mJcoRQIF5dy4njmxR941l6rgtvfSB2zX3VlUIw==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 + '@esbuild/linux-arm@0.27.7': + resolution: {integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] - '@babel/plugin-transform-class-properties@7.28.6': - resolution: {integrity: sha512-dY2wS3I2G7D697VHndN91TJr8/AAfXQNt5ynCTI/MpxMsSzHp+52uNivYT5wCPax3whc47DR8Ba7cmlQMg24bw==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 + '@esbuild/linux-ia32@0.21.5': + resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] - '@babel/plugin-transform-class-static-block@7.28.6': - resolution: {integrity: sha512-rfQ++ghVwTWTqQ7w8qyDxL1XGihjBss4CmTgGRCTAC9RIbhVpyp4fOeZtta0Lbf+dTNIVJer6ych2ibHwkZqsQ==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.12.0 + '@esbuild/linux-ia32@0.27.7': + resolution: {integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] - '@babel/plugin-transform-classes@7.28.6': - resolution: {integrity: sha512-EF5KONAqC5zAqT783iMGuM2ZtmEBy+mJMOKl2BCvPZ2lVrwvXnB6o+OBWCS+CoeCCpVRF2sA2RBKUxvT8tQT5Q==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 + '@esbuild/linux-loong64@0.21.5': + resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] - '@babel/plugin-transform-computed-properties@7.28.6': - resolution: {integrity: sha512-bcc3k0ijhHbc2lEfpFHgx7eYw9KNXqOerKWfzbxEHUGKnS3sz9C4CNL9OiFN1297bDNfUiSO7DaLzbvHQQQ1BQ==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 + '@esbuild/linux-loong64@0.27.7': + resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] - '@babel/plugin-transform-destructuring@7.28.5': - resolution: {integrity: sha512-Kl9Bc6D0zTUcFUvkNuQh4eGXPKKNDOJQXVyyM4ZAQPMveniJdxi8XMJwLo+xSoW3MIq81bD33lcUe9kZpl0MCw==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 + '@esbuild/linux-mips64el@0.21.5': + resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] - '@babel/plugin-transform-dotall-regex@7.28.6': - resolution: {integrity: sha512-SljjowuNKB7q5Oayv4FoPzeB74g3QgLt8IVJw9ADvWy3QnUb/01aw8I4AVv8wYnPvQz2GDDZ/g3GhcNyDBI4Bg==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 + '@esbuild/linux-mips64el@0.27.7': + resolution: {integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] - '@babel/plugin-transform-duplicate-keys@7.27.1': - resolution: {integrity: sha512-MTyJk98sHvSs+cvZ4nOauwTTG1JeonDjSGvGGUNHreGQns+Mpt6WX/dVzWBHgg+dYZhkC4X+zTDfkTU+Vy9y7Q==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 + '@esbuild/linux-ppc64@0.21.5': + resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] - '@babel/plugin-transform-duplicate-named-capturing-groups-regex@7.29.0': - resolution: {integrity: sha512-zBPcW2lFGxdiD8PUnPwJjag2J9otbcLQzvbiOzDxpYXyCuYX9agOwMPGn1prVH0a4qzhCKu24rlH4c1f7yA8rw==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0 + '@esbuild/linux-ppc64@0.27.7': + resolution: {integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] - '@babel/plugin-transform-dynamic-import@7.27.1': - resolution: {integrity: sha512-MHzkWQcEmjzzVW9j2q8LGjwGWpG2mjwaaB0BNQwst3FIjqsg8Ct/mIZlvSPJvfi9y2AC8mi/ktxbFVL9pZ1I4A==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-transform-explicit-resource-management@7.28.6': - resolution: {integrity: sha512-Iao5Konzx2b6g7EPqTy40UZbcdXE126tTxVFr/nAIj+WItNxjKSYTEw3RC+A2/ZetmdJsgueL1KhaMCQHkLPIg==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-transform-exponentiation-operator@7.28.6': - resolution: {integrity: sha512-WitabqiGjV/vJ0aPOLSFfNY1u9U3R7W36B03r5I2KoNix+a3sOhJ3pKFB3R5It9/UiK78NiO0KE9P21cMhlPkw==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-transform-export-namespace-from@7.27.1': - resolution: {integrity: sha512-tQvHWSZ3/jH2xuq/vZDy0jNn+ZdXJeM8gHvX4lnJmsc3+50yPlWdZXIc5ay+umX+2/tJIqHqiEqcJvxlmIvRvQ==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-transform-for-of@7.27.1': - resolution: {integrity: sha512-BfbWFFEJFQzLCQ5N8VocnCtA8J1CLkNTe2Ms2wocj75dd6VpiqS5Z5quTYcUoo4Yq+DN0rtikODccuv7RU81sw==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-transform-function-name@7.27.1': - resolution: {integrity: sha512-1bQeydJF9Nr1eBCMMbC+hdwmRlsv5XYOMu03YSWFwNs0HsAmtSxxF1fyuYPqemVldVyFmlCU7w8UE14LupUSZQ==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-transform-json-strings@7.28.6': - resolution: {integrity: sha512-Nr+hEN+0geQkzhbdgQVPoqr47lZbm+5fCUmO70722xJZd0Mvb59+33QLImGj6F+DkK3xgDi1YVysP8whD6FQAw==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-transform-literals@7.27.1': - resolution: {integrity: sha512-0HCFSepIpLTkLcsi86GG3mTUzxV5jpmbv97hTETW3yzrAij8aqlD36toB1D0daVFJM8NK6GvKO0gslVQmm+zZA==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-transform-logical-assignment-operators@7.28.6': - resolution: {integrity: sha512-+anKKair6gpi8VsM/95kmomGNMD0eLz1NQ8+Pfw5sAwWH9fGYXT50E55ZpV0pHUHWf6IUTWPM+f/7AAff+wr9A==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-transform-member-expression-literals@7.27.1': - resolution: {integrity: sha512-hqoBX4dcZ1I33jCSWcXrP+1Ku7kdqXf1oeah7ooKOIiAdKQ+uqftgCFNOSzA5AMS2XIHEYeGFg4cKRCdpxzVOQ==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-transform-modules-amd@7.27.1': - resolution: {integrity: sha512-iCsytMg/N9/oFq6n+gFTvUYDZQOMK5kEdeYxmxt91fcJGycfxVP9CnrxoliM0oumFERba2i8ZtwRUCMhvP1LnA==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-transform-modules-commonjs@7.28.6': - resolution: {integrity: sha512-jppVbf8IV9iWWwWTQIxJMAJCWBuuKx71475wHwYytrRGQ2CWiDvYlADQno3tcYpS/T2UUWFQp3nVtYfK/YBQrA==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-transform-modules-systemjs@7.29.0': - resolution: {integrity: sha512-PrujnVFbOdUpw4UHiVwKvKRLMMic8+eC0CuNlxjsyZUiBjhFdPsewdXCkveh2KqBA9/waD0W1b4hXSOBQJezpQ==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-transform-modules-umd@7.27.1': - resolution: {integrity: sha512-iQBE/xC5BV1OxJbp6WG7jq9IWiD+xxlZhLrdwpPkTX3ydmXdvoCpyfJN7acaIBZaOqTfr76pgzqBJflNbeRK+w==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-transform-named-capturing-groups-regex@7.29.0': - resolution: {integrity: sha512-1CZQA5KNAD6ZYQLPw7oi5ewtDNxH/2vuCh+6SmvgDfhumForvs8a1o9n0UrEoBD8HU4djO2yWngTQlXl1NDVEQ==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0 - - '@babel/plugin-transform-new-target@7.27.1': - resolution: {integrity: sha512-f6PiYeqXQ05lYq3TIfIDu/MtliKUbNwkGApPUvyo6+tc7uaR4cPjPe7DFPr15Uyycg2lZU6btZ575CuQoYh7MQ==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-transform-nullish-coalescing-operator@7.28.6': - resolution: {integrity: sha512-3wKbRgmzYbw24mDJXT7N+ADXw8BC/imU9yo9c9X9NKaLF1fW+e5H1U5QjMUBe4Qo4Ox/o++IyUkl1sVCLgevKg==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-transform-numeric-separator@7.28.6': - resolution: {integrity: sha512-SJR8hPynj8outz+SlStQSwvziMN4+Bq99it4tMIf5/Caq+3iOc0JtKyse8puvyXkk3eFRIA5ID/XfunGgO5i6w==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-transform-object-rest-spread@7.28.6': - resolution: {integrity: sha512-5rh+JR4JBC4pGkXLAcYdLHZjXudVxWMXbB6u6+E9lRL5TrGVbHt1TjxGbZ8CkmYw9zjkB7jutzOROArsqtncEA==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-transform-object-super@7.27.1': - resolution: {integrity: sha512-SFy8S9plRPbIcxlJ8A6mT/CxFdJx/c04JEctz4jf8YZaVS2px34j7NXRrlGlHkN/M2gnpL37ZpGRGVFLd3l8Ng==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-transform-optional-catch-binding@7.28.6': - resolution: {integrity: sha512-R8ja/Pyrv0OGAvAXQhSTmWyPJPml+0TMqXlO5w+AsMEiwb2fg3WkOvob7UxFSL3OIttFSGSRFKQsOhJ/X6HQdQ==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-transform-optional-chaining@7.28.6': - resolution: {integrity: sha512-A4zobikRGJTsX9uqVFdafzGkqD30t26ck2LmOzAuLL8b2x6k3TIqRiT2xVvA9fNmFeTX484VpsdgmKNA0bS23w==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-transform-parameters@7.27.7': - resolution: {integrity: sha512-qBkYTYCb76RRxUM6CcZA5KRu8K4SM8ajzVeUgVdMVO9NN9uI/GaVmBg/WKJJGnNokV9SY8FxNOVWGXzqzUidBg==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-transform-private-methods@7.28.6': - resolution: {integrity: sha512-piiuapX9CRv7+0st8lmuUlRSmX6mBcVeNQ1b4AYzJxfCMuBfB0vBXDiGSmm03pKJw1v6cZ8KSeM+oUnM6yAExg==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-transform-private-property-in-object@7.28.6': - resolution: {integrity: sha512-b97jvNSOb5+ehyQmBpmhOCiUC5oVK4PMnpRvO7+ymFBoqYjeDHIU9jnrNUuwHOiL9RpGDoKBpSViarV+BU+eVA==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-transform-property-literals@7.27.1': - resolution: {integrity: sha512-oThy3BCuCha8kDZ8ZkgOg2exvPYUlprMukKQXI1r1pJ47NCvxfkEy8vK+r/hT9nF0Aa4H1WUPZZjHTFtAhGfmQ==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-transform-regenerator@7.29.0': - resolution: {integrity: sha512-FijqlqMA7DmRdg/aINBSs04y8XNTYw/lr1gJ2WsmBnnaNw1iS43EPkJW+zK7z65auG3AWRFXWj+NcTQwYptUog==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-transform-regexp-modifiers@7.28.6': - resolution: {integrity: sha512-QGWAepm9qxpaIs7UM9FvUSnCGlb8Ua1RhyM4/veAxLwt3gMat/LSGrZixyuj4I6+Kn9iwvqCyPTtbdxanYoWYg==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0 - - '@babel/plugin-transform-reserved-words@7.27.1': - resolution: {integrity: sha512-V2ABPHIJX4kC7HegLkYoDpfg9PVmuWy/i6vUM5eGK22bx4YVFD3M5F0QQnWQoDs6AGsUWTVOopBiMFQgHaSkVw==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-transform-shorthand-properties@7.27.1': - resolution: {integrity: sha512-N/wH1vcn4oYawbJ13Y/FxcQrWk63jhfNa7jef0ih7PHSIHX2LB7GWE1rkPrOnka9kwMxb6hMl19p7lidA+EHmQ==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-transform-spread@7.28.6': - resolution: {integrity: sha512-9U4QObUC0FtJl05AsUcodau/RWDytrU6uKgkxu09mLR9HLDAtUMoPuuskm5huQsoktmsYpI+bGmq+iapDcriKA==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-transform-sticky-regex@7.27.1': - resolution: {integrity: sha512-lhInBO5bi/Kowe2/aLdBAawijx+q1pQzicSgnkB6dUPc1+RC8QmJHKf2OjvU+NZWitguJHEaEmbV6VWEouT58g==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-transform-template-literals@7.27.1': - resolution: {integrity: sha512-fBJKiV7F2DxZUkg5EtHKXQdbsbURW3DZKQUWphDum0uRP6eHGGa/He9mc0mypL680pb+e/lDIthRohlv8NCHkg==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-transform-typeof-symbol@7.27.1': - resolution: {integrity: sha512-RiSILC+nRJM7FY5srIyc4/fGIwUhyDuuBSdWn4y6yT6gm652DpCHZjIipgn6B7MQ1ITOUnAKWixEUjQRIBIcLw==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-transform-unicode-escapes@7.27.1': - resolution: {integrity: sha512-Ysg4v6AmF26k9vpfFuTZg8HRfVWzsh1kVfowA23y9j/Gu6dOuahdUVhkLqpObp3JIv27MLSii6noRnuKN8H0Mg==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-transform-unicode-property-regex@7.28.6': - resolution: {integrity: sha512-4Wlbdl/sIZjzi/8St0evF0gEZrgOswVO6aOzqxh1kDZOl9WmLrHq2HtGhnOJZmHZYKP8WZ1MDLCt5DAWwRo57A==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-transform-unicode-regex@7.27.1': - resolution: {integrity: sha512-xvINq24TRojDuyt6JGtHmkVkrfVV3FPT16uytxImLeBZqW3/H52yN+kM1MGuyPkIQxrzKwPHs5U/MP3qKyzkGw==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-transform-unicode-sets-regex@7.28.6': - resolution: {integrity: sha512-/wHc/paTUmsDYN7SZkpWxogTOBNnlx7nBQYfy6JJlCT7G3mVhltk3e++N7zV0XfgGsrqBxd4rJQt9H16I21Y1Q==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0 - - '@babel/preset-env@7.29.2': - resolution: {integrity: sha512-DYD23veRYGvBFhcTY1iUvJnDNpuqNd/BzBwCvzOTKUnJjKg5kpUBh3/u9585Agdkgj+QuygG7jLfOPWMa2KVNw==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/preset-modules@0.1.6-no-external-plugins': - resolution: {integrity: sha512-HrcgcIESLm9aIR842yhJ5RWan/gebQUJ6E/E5+rf0y9o6oj7w0Br+sWuL6kEQ/o/AdfvR1Je9jG18/gnpwjEyA==} - peerDependencies: - '@babel/core': ^7.0.0-0 || ^8.0.0-0 <8.0.0 - - '@babel/template@7.28.6': - resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} - engines: {node: '>=6.9.0'} - - '@babel/traverse@7.29.0': - resolution: {integrity: sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==} - engines: {node: '>=6.9.0'} - - '@babel/types@7.29.0': - resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} - engines: {node: '>=6.9.0'} - - '@bcoe/v8-coverage@0.2.3': - resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==} - - '@cbor-extract/cbor-extract-darwin-arm64@2.2.2': - resolution: {integrity: sha512-ZKZ/F8US7JR92J4DMct6cLW/Y66o2K576+zjlEN/MevH70bFIsB10wkZEQPLzl2oNh2SMGy55xpJ9JoBRl5DOA==} - cpu: [arm64] - os: [darwin] - - '@cbor-extract/cbor-extract-darwin-x64@2.2.2': - resolution: {integrity: sha512-32b1mgc+P61Js+KW9VZv/c+xRw5EfmOcPx990JbCBSkYJFY0l25VinvyyWfl+3KjibQmAcYwmyzKF9J4DyKP/Q==} - cpu: [x64] - os: [darwin] - - '@cbor-extract/cbor-extract-linux-arm64@2.2.2': - resolution: {integrity: sha512-wfqgzqCAy/Vn8i6WVIh7qZd0DdBFaWBjPdB6ma+Wihcjv0gHqD/mw3ouVv7kbbUNrab6dKEx/w3xQZEdeXIlzg==} - cpu: [arm64] + '@esbuild/linux-riscv64@0.21.5': + resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} + engines: {node: '>=12'} + cpu: [riscv64] os: [linux] - '@cbor-extract/cbor-extract-linux-arm@2.2.2': - resolution: {integrity: sha512-tNg0za41TpQfkhWjptD+0gSD2fggMiDCSacuIeELyb2xZhr7PrhPe5h66Jc67B/5dmpIhI2QOUtv4SBsricyYQ==} - cpu: [arm] + '@esbuild/linux-riscv64@0.27.7': + resolution: {integrity: sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==} + engines: {node: '>=18'} + cpu: [riscv64] os: [linux] - '@cbor-extract/cbor-extract-linux-x64@2.2.2': - resolution: {integrity: sha512-rpiLnVEsqtPJ+mXTdx1rfz4RtUGYIUg2rUAZgd1KjiC1SehYUSkJN7Yh+aVfSjvCGtVP0/bfkQkXpPXKbmSUaA==} - cpu: [x64] + '@esbuild/linux-s390x@0.21.5': + resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} + engines: {node: '>=12'} + cpu: [s390x] os: [linux] - '@cbor-extract/cbor-extract-win32-x64@2.2.2': - resolution: {integrity: sha512-dI+9P7cfWxkTQ+oE+7Aa6onEn92PHgfWXZivjNheCRmTBDBf2fx6RyTi0cmgpYLnD1KLZK9ZYrMxaPZ4oiXhGA==} - cpu: [x64] - os: [win32] - - '@commander-js/extra-typings@14.0.0': - resolution: {integrity: sha512-hIn0ncNaJRLkZrxBIp5AsW/eXEHNKYQBh0aPdoUqNgD+Io3NIykQqpKFyKcuasZhicGaEZJX/JBSIkZ4e5x8Dg==} - peerDependencies: - commander: ~14.0.0 - - '@esbuild/aix-ppc64@0.27.7': - resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [aix] - - '@esbuild/android-arm64@0.27.7': - resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==} + '@esbuild/linux-s390x@0.27.7': + resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==} engines: {node: '>=18'} - cpu: [arm64] - os: [android] + cpu: [s390x] + os: [linux] - '@esbuild/android-arm@0.27.7': - resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==} - engines: {node: '>=18'} - cpu: [arm] - os: [android] + '@esbuild/linux-x64@0.21.5': + resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] - '@esbuild/android-x64@0.27.7': - resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==} + '@esbuild/linux-x64@0.27.7': + resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==} engines: {node: '>=18'} cpu: [x64] - os: [android] + os: [linux] - '@esbuild/darwin-arm64@0.27.7': - resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==} + '@esbuild/netbsd-arm64@0.27.7': + resolution: {integrity: sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==} engines: {node: '>=18'} cpu: [arm64] - os: [darwin] + os: [netbsd] - '@esbuild/darwin-x64@0.27.7': - resolution: {integrity: sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==} - engines: {node: '>=18'} + '@esbuild/netbsd-x64@0.21.5': + resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} + engines: {node: '>=12'} cpu: [x64] - os: [darwin] - - '@esbuild/freebsd-arm64@0.27.7': - resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==} - engines: {node: '>=18'} - cpu: [arm64] - os: [freebsd] + os: [netbsd] - '@esbuild/freebsd-x64@0.27.7': - resolution: {integrity: sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==} + '@esbuild/netbsd-x64@0.27.7': + resolution: {integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==} engines: {node: '>=18'} cpu: [x64] - os: [freebsd] + os: [netbsd] - '@esbuild/linux-arm64@0.27.7': - resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==} + '@esbuild/openbsd-arm64@0.27.7': + resolution: {integrity: sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==} engines: {node: '>=18'} cpu: [arm64] - os: [linux] - - '@esbuild/linux-arm@0.27.7': - resolution: {integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==} - engines: {node: '>=18'} - cpu: [arm] - os: [linux] - - '@esbuild/linux-ia32@0.27.7': - resolution: {integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==} - engines: {node: '>=18'} - cpu: [ia32] - os: [linux] - - '@esbuild/linux-loong64@0.27.7': - resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==} - engines: {node: '>=18'} - cpu: [loong64] - os: [linux] + os: [openbsd] - '@esbuild/linux-mips64el@0.27.7': - resolution: {integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==} - engines: {node: '>=18'} - cpu: [mips64el] - os: [linux] + '@esbuild/openbsd-x64@0.21.5': + resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] - '@esbuild/linux-ppc64@0.27.7': - resolution: {integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [linux] - - '@esbuild/linux-riscv64@0.27.7': - resolution: {integrity: sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==} - engines: {node: '>=18'} - cpu: [riscv64] - os: [linux] - - '@esbuild/linux-s390x@0.27.7': - resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==} - engines: {node: '>=18'} - cpu: [s390x] - os: [linux] - - '@esbuild/linux-x64@0.27.7': - resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==} - engines: {node: '>=18'} - cpu: [x64] - os: [linux] - - '@esbuild/netbsd-arm64@0.27.7': - resolution: {integrity: sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==} - engines: {node: '>=18'} - cpu: [arm64] - os: [netbsd] - - '@esbuild/netbsd-x64@0.27.7': - resolution: {integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==} - engines: {node: '>=18'} - cpu: [x64] - os: [netbsd] - - '@esbuild/openbsd-arm64@0.27.7': - resolution: {integrity: sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openbsd] - - '@esbuild/openbsd-x64@0.27.7': - resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==} + '@esbuild/openbsd-x64@0.27.7': + resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==} engines: {node: '>=18'} cpu: [x64] os: [openbsd] - '@esbuild/openharmony-arm64@0.27.7': - resolution: {integrity: sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openharmony] - - '@esbuild/sunos-x64@0.27.7': - resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==} - engines: {node: '>=18'} - cpu: [x64] - os: [sunos] - - '@esbuild/win32-arm64@0.27.7': - resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==} - engines: {node: '>=18'} - cpu: [arm64] - os: [win32] - - '@esbuild/win32-ia32@0.27.7': - resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==} - engines: {node: '>=18'} - cpu: [ia32] - os: [win32] - - '@esbuild/win32-x64@0.27.7': - resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==} - engines: {node: '>=18'} - cpu: [x64] - os: [win32] - - '@isaacs/cliui@8.0.2': - resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} - engines: {node: '>=12'} - - '@istanbuljs/load-nyc-config@1.1.0': - resolution: {integrity: sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==} - engines: {node: '>=8'} - - '@istanbuljs/schema@0.1.3': - resolution: {integrity: sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==} - engines: {node: '>=8'} - - '@jest/console@29.7.0': - resolution: {integrity: sha512-5Ni4CU7XHQi32IJ398EEP4RrB8eV09sXP2ROqD4bksHrnTree52PsxvX8tpL8LvTZ3pFzXyPbNQReSN41CAhOg==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - '@jest/core@29.7.0': - resolution: {integrity: sha512-n7aeXWKMnGtDA48y8TLWJPJmLmmZ642Ceo78cYWEpiD7FzDgmNDV/GCVRorPABdXLJZ/9wzzgZAlHjXjxDHGsg==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - peerDependencies: - node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 - peerDependenciesMeta: - node-notifier: - optional: true - - '@jest/environment@29.7.0': - resolution: {integrity: sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - '@jest/expect-utils@29.7.0': - resolution: {integrity: sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - '@jest/expect@29.7.0': - resolution: {integrity: sha512-8uMeAMycttpva3P1lBHB8VciS9V0XAr3GymPpipdyQXbBcuhkLQOSe8E/p92RyAdToS6ZD1tFkX+CkhoECE0dQ==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - '@jest/fake-timers@29.7.0': - resolution: {integrity: sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - '@jest/globals@29.7.0': - resolution: {integrity: sha512-mpiz3dutLbkW2MNFubUGUEVLkTGiqW6yLVTA+JbP6fI6J5iL9Y0Nlg8k95pcF8ctKwCS7WVxteBs29hhfAotzQ==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - '@jest/pattern@30.0.1': - resolution: {integrity: sha512-gWp7NfQW27LaBQz3TITS8L7ZCQ0TLvtmI//4OwlQRx4rnWxcPNIYjxZpDcN4+UlGxgm3jS5QPz8IPTCkb59wZA==} - engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - - '@jest/reporters@29.7.0': - resolution: {integrity: sha512-DApq0KJbJOEzAFYjHADNNxAE3KbhxQB1y5Kplb5Waqw6zVbuWatSnMjE5gs8FUgEPmNsnZA3NCWl9NG0ia04Pg==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - peerDependencies: - node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 - peerDependenciesMeta: - node-notifier: - optional: true - - '@jest/schemas@29.6.3': - resolution: {integrity: sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - '@jest/schemas@30.0.5': - resolution: {integrity: sha512-DmdYgtezMkh3cpU8/1uyXakv3tJRcmcXxBOcO0tbaozPwpmh4YMsnWrQm9ZmZMfa5ocbxzbFk6O4bDPEc/iAnA==} - engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - - '@jest/source-map@29.6.3': - resolution: {integrity: sha512-MHjT95QuipcPrpLM+8JMSzFx6eHp5Bm+4XeFDJlwsvVBjmKNiIAvasGK2fxz2WbGRlnvqehFbh07MMa7n3YJnw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - '@jest/test-result@29.7.0': - resolution: {integrity: sha512-Fdx+tv6x1zlkJPcWXmMDAG2HBnaR9XPSd5aDWQVsfrZmLVT3lU1cwyxLgRmXR9yrq4NBoEm9BMsfgFzTQAbJYA==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - '@jest/test-sequencer@29.7.0': - resolution: {integrity: sha512-GQwJ5WZVrKnOJuiYiAF52UNUJXgTZx1NHjFSEB0qEMmSZKAkdMoIzw/Cj6x6NF4AvV23AUqDpFzQkN/eYCYTxw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - '@jest/transform@29.7.0': - resolution: {integrity: sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - '@jest/transform@30.3.0': - resolution: {integrity: sha512-TLKY33fSLVd/lKB2YI1pH69ijyUblO/BQvCj566YvnwuzoTNr648iE0j22vRvVNk2HsPwByPxATg3MleS3gf5A==} - engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - - '@jest/types@29.6.3': - resolution: {integrity: sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - '@jest/types@30.3.0': - resolution: {integrity: sha512-JHm87k7bA33hpBngtU8h6UBub/fqqA9uXfw+21j5Hmk7ooPHlboRNxHq0JcMtC+n8VJGP1mcfnD3Mk+XKe1oSw==} - engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - - '@jridgewell/gen-mapping@0.3.13': - resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} - - '@jridgewell/remapping@2.3.5': - resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} - - '@jridgewell/resolve-uri@3.1.2': - resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} - engines: {node: '>=6.0.0'} - - '@jridgewell/sourcemap-codec@1.5.5': - resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} - - '@jridgewell/trace-mapping@0.3.31': - resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} - - '@noble/hashes@1.8.0': - resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} - engines: {node: ^14.21.3 || >=16} - - '@pkgjs/parseargs@0.11.0': - resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} - engines: {node: '>=14'} - - '@sinclair/typebox@0.27.10': - resolution: {integrity: sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==} - - '@sinclair/typebox@0.34.49': - resolution: {integrity: sha512-brySQQs7Jtn0joV8Xh9ZV/hZb9Ozb0pmazDIASBkYKCjXrXU3mpcFahmK/z4YDhGkQvP9mWJbVyahdtU5wQA+A==} - - '@sinonjs/commons@3.0.1': - resolution: {integrity: sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==} - - '@sinonjs/fake-timers@10.3.0': - resolution: {integrity: sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==} - - '@types/babel__core@7.20.5': - resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} - - '@types/babel__generator@7.27.0': - resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} - - '@types/babel__template@7.4.4': - resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} - - '@types/babel__traverse@7.28.0': - resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} - - '@types/diff@5.2.3': - resolution: {integrity: sha512-K0Oqlrq3kQMaO2RhfrNQX5trmt+XLyom88zS0u84nnIcLvFnRUMRRHmrGny5GSM+kNO9IZLARsdQHDzkhAgmrQ==} - - '@types/graceful-fs@4.1.9': - resolution: {integrity: sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==} - - '@types/istanbul-lib-coverage@2.0.6': - resolution: {integrity: sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==} - - '@types/istanbul-lib-report@3.0.3': - resolution: {integrity: sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==} - - '@types/istanbul-reports@3.0.4': - resolution: {integrity: sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==} - - '@types/jest@29.5.14': - resolution: {integrity: sha512-ZN+4sdnLUbo8EVvVc2ao0GFW6oVrQRPn4K2lglySj7APvSrgzxHiNNK99us4WDMi57xxA2yggblIAMNhXOotLQ==} - - '@types/mime-types@2.1.4': - resolution: {integrity: sha512-lfU4b34HOri+kAY5UheuFMWPDOI+OPceBSHZKp69gEyTL/mmJ4cnU6Y/rlme3UL3GyOn6Y42hyIEw0/q8sWx5w==} - - '@types/node@20.19.39': - resolution: {integrity: sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw==} - - '@types/stack-utils@2.0.3': - resolution: {integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==} - - '@types/tmp@0.2.6': - resolution: {integrity: sha512-chhaNf2oKHlRkDGt+tiKE2Z5aJ6qalm7Z9rlLdBwmOiAAf09YQvvoLXjWK4HWPF1xU/fqvMgfNfpVoBscA/tKA==} - - '@types/yargs-parser@21.0.3': - resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==} - - '@types/yargs@17.0.35': - resolution: {integrity: sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==} - - '@ungap/structured-clone@1.3.0': - resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} - - ansi-escapes@4.3.2: - resolution: {integrity: sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==} - engines: {node: '>=8'} - - ansi-regex@5.0.1: - resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} - engines: {node: '>=8'} - - ansi-regex@6.2.2: - resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} - engines: {node: '>=12'} - - ansi-styles@4.3.0: - resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} - engines: {node: '>=8'} - - ansi-styles@5.2.0: - resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} - engines: {node: '>=10'} - - ansi-styles@6.2.3: - resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} - engines: {node: '>=12'} - - anymatch@3.1.3: - resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} - engines: {node: '>= 8'} - - argparse@1.0.10: - resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} - - babel-jest@29.7.0: - resolution: {integrity: sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - peerDependencies: - '@babel/core': ^7.8.0 - - babel-jest@30.3.0: - resolution: {integrity: sha512-gRpauEU2KRrCox5Z296aeVHR4jQ98BCnu0IO332D/xpHNOsIH/bgSRk9k6GbKIbBw8vFeN6ctuu6tV8WOyVfYQ==} - engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - peerDependencies: - '@babel/core': ^7.11.0 || ^8.0.0-0 - - babel-plugin-istanbul@6.1.1: - resolution: {integrity: sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==} - engines: {node: '>=8'} - - babel-plugin-istanbul@7.0.1: - resolution: {integrity: sha512-D8Z6Qm8jCvVXtIRkBnqNHX0zJ37rQcFJ9u8WOS6tkYOsRdHBzypCstaxWiu5ZIlqQtviRYbgnRLSoCEvjqcqbA==} - engines: {node: '>=12'} - - babel-plugin-jest-hoist@29.6.3: - resolution: {integrity: sha512-ESAc/RJvGTFEzRwOTT4+lNDk/GNHMkKbNzsvT0qKRfDyyYTskxB5rnU2njIDYVxXCBHHEI1c0YwHob3WaYujOg==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - babel-plugin-jest-hoist@30.3.0: - resolution: {integrity: sha512-+TRkByhsws6sfPjVaitzadk1I0F5sPvOVUH5tyTSzhePpsGIVrdeunHSw/C36QeocS95OOk8lunc4rlu5Anwsg==} - engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - - babel-plugin-polyfill-corejs2@0.4.17: - resolution: {integrity: sha512-aTyf30K/rqAsNwN76zYrdtx8obu0E4KoUME29B1xj+B3WxgvWkp943vYQ+z8Mv3lw9xHXMHpvSPOBxzAkIa94w==} - peerDependencies: - '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0 - - babel-plugin-polyfill-corejs3@0.14.2: - resolution: {integrity: sha512-coWpDLJ410R781Npmn/SIBZEsAetR4xVi0SxLMXPaMO4lSf1MwnkGYMtkFxew0Dn8B3/CpbpYxN0JCgg8mn67g==} - peerDependencies: - '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0 - - babel-plugin-polyfill-regenerator@0.6.8: - resolution: {integrity: sha512-M762rNHfSF1EV3SLtnCJXFoQbbIIz0OyRwnCmV0KPC7qosSfCO0QLTSuJX3ayAebubhE6oYBAYPrBA5ljowaZg==} - peerDependencies: - '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0 - - babel-preset-current-node-syntax@1.2.0: - resolution: {integrity: sha512-E/VlAEzRrsLEb2+dv8yp3bo4scof3l9nR4lrld+Iy5NyVqgVYUJnDAmunkhPMisRI32Qc4iRiz425d8vM++2fg==} - peerDependencies: - '@babel/core': ^7.0.0 || ^8.0.0-0 - - babel-preset-jest@29.6.3: - resolution: {integrity: sha512-0B3bhxR6snWXJZtR/RliHTDPRgn1sNHOR0yVtq/IiQFyuOVjFS+wuio/R4gSNkyYmKmJB4wGZv2NZanmKmTnNA==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - peerDependencies: - '@babel/core': ^7.0.0 - - babel-preset-jest@30.3.0: - resolution: {integrity: sha512-6ZcUbWHC+dMz2vfzdNwi87Z1gQsLNK2uLuK1Q89R11xdvejcivlYYwDlEv0FHX3VwEXpbBQ9uufB/MUNpZGfhQ==} - engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - peerDependencies: - '@babel/core': ^7.11.0 || ^8.0.0-beta.1 - - balanced-match@1.0.2: - resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} - - base-x@4.0.1: - resolution: {integrity: sha512-uAZ8x6r6S3aUM9rbHGVOIsR15U/ZSc82b3ymnCPsT45Gk1DDvhDPdIgB5MrhirZWt+5K0EEPQH985kNqZgNPFw==} - - base64-js@1.5.1: - resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} - - baseline-browser-mapping@2.10.16: - resolution: {integrity: sha512-Lyf3aK28zpsD1yQMiiHD4RvVb6UdMoo8xzG2XzFIfR9luPzOpcBlAsT/qfB1XWS1bxWT+UtE4WmQgsp297FYOA==} - engines: {node: '>=6.0.0'} - hasBin: true - - bl@5.1.0: - resolution: {integrity: sha512-tv1ZJHLfTDnXE6tMHv73YgSJaWR2AFuPwMntBe7XL/GBFHnT0CLnsHMogfk5+GzCDC5ZWarSCYaIGATZt9dNsQ==} - - brace-expansion@1.1.13: - resolution: {integrity: sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==} - - brace-expansion@2.0.3: - resolution: {integrity: sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==} - - braces@3.0.3: - resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} - engines: {node: '>=8'} - - browserslist@4.28.2: - resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==} - engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} - hasBin: true - - bs-logger@0.2.6: - resolution: {integrity: sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog==} - engines: {node: '>= 6'} - - bs58@5.0.0: - resolution: {integrity: sha512-r+ihvQJvahgYT50JD05dyJNKlmmSlMoOGwn1lCcEzanPglg7TxYjioQUYehQ9mAR/+hOSd2jRc/Z2y5UxBymvQ==} - - bs58check@3.0.1: - resolution: {integrity: sha512-hjuuJvoWEybo7Hn/0xOrczQKKEKD63WguEjlhLExYs2wUBcebDC1jDNK17eEAD2lYfw82d5ASC1d7K3SWszjaQ==} - - bser@2.1.1: - resolution: {integrity: sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==} - - buffer-from@1.1.2: - resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} - - buffer@6.0.3: - resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} - - callsites@3.1.0: - resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} - engines: {node: '>=6'} - - camelcase@5.3.1: - resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==} - engines: {node: '>=6'} - - camelcase@6.3.0: - resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==} - engines: {node: '>=10'} - - caniuse-lite@1.0.30001787: - resolution: {integrity: sha512-mNcrMN9KeI68u7muanUpEejSLghOKlVhRqS/Za2IeyGllJ9I9otGpR9g3nsw7n4W378TE/LyIteA0+/FOZm4Kg==} - - cbor-extract@2.2.2: - resolution: {integrity: sha512-hlSxxI9XO2yQfe9g6msd3g4xCfDqK5T5P0fRMLuaLHhxn4ViPrm+a+MUfhrvH2W962RGxcBwEGzLQyjbDG1gng==} - hasBin: true - - cbor-x@1.6.4: - resolution: {integrity: sha512-UGKHjp6RHC6QuZ2yy5LCKm7MojM4716DwoSaqwQpaH4DvZvbBTGcoDNTiG9Y2lByXZYFEs9WRkS5tLl96IrF1Q==} - - chalk@4.1.2: - resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} - engines: {node: '>=10'} - - chalk@5.6.2: - resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} - engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} - - char-regex@1.0.2: - resolution: {integrity: sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==} - engines: {node: '>=10'} - - ci-info@3.9.0: - resolution: {integrity: sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==} - engines: {node: '>=8'} - - ci-info@4.4.0: - resolution: {integrity: sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==} - engines: {node: '>=8'} - - cjs-module-lexer@1.4.3: - resolution: {integrity: sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==} - - cli-cursor@4.0.0: - resolution: {integrity: sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - - cli-spinners@2.9.2: - resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==} - engines: {node: '>=6'} - - cliui@8.0.1: - resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} - engines: {node: '>=12'} - - co@4.6.0: - resolution: {integrity: sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==} - engines: {iojs: '>= 1.0.0', node: '>= 0.12.0'} - - collect-v8-coverage@1.0.3: - resolution: {integrity: sha512-1L5aqIkwPfiodaMgQunkF1zRhNqifHBmtbbbxcr6yVxxBnliw4TDOW6NxpO8DJLgJ16OT+Y4ztZqP6p/FtXnAw==} - - color-convert@2.0.1: - resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} - engines: {node: '>=7.0.0'} - - color-name@1.1.4: - resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} - - commander@14.0.3: - resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} - engines: {node: '>=20'} - - concat-map@0.0.1: - resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} - - convert-source-map@2.0.0: - resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} - - core-js-compat@3.49.0: - resolution: {integrity: sha512-VQXt1jr9cBz03b331DFDCCP90b3fanciLkgiOoy8SBHy06gNf+vQ1A3WFLqG7I8TipYIKeYK9wxd0tUrvHcOZA==} - - create-jest@29.7.0: - resolution: {integrity: sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - hasBin: true - - cross-spawn@7.0.6: - resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} - engines: {node: '>= 8'} - - debug@4.4.3: - resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} - engines: {node: '>=6.0'} - peerDependencies: - supports-color: '*' - peerDependenciesMeta: - supports-color: - optional: true - - dedent@1.7.2: - resolution: {integrity: sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA==} - peerDependencies: - babel-plugin-macros: ^3.1.0 - peerDependenciesMeta: - babel-plugin-macros: - optional: true - - deepmerge@4.3.1: - resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} - engines: {node: '>=0.10.0'} - - detect-libc@2.1.2: - resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} - engines: {node: '>=8'} - - detect-newline@3.1.0: - resolution: {integrity: sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==} - engines: {node: '>=8'} - - diff-sequences@29.6.3: - resolution: {integrity: sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - diff@8.0.4: - resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==} - engines: {node: '>=0.3.1'} - - eastasianwidth@0.2.0: - resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} - - electron-to-chromium@1.5.334: - resolution: {integrity: sha512-mgjZAz7Jyx1SRCwEpy9wefDS7GvNPazLthHg8eQMJ76wBdGQQDW33TCrUTvQ4wzpmOrv2zrFoD3oNufMdyMpog==} - - emittery@0.13.1: - resolution: {integrity: sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==} - engines: {node: '>=12'} - - emoji-regex@10.6.0: - resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} - - emoji-regex@8.0.0: - resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} - - emoji-regex@9.2.2: - resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} - - error-ex@1.3.4: - resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} - - esbuild@0.27.7: - resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==} - engines: {node: '>=18'} - hasBin: true - - escalade@3.2.0: - resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} - engines: {node: '>=6'} - - escape-string-regexp@2.0.0: - resolution: {integrity: sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==} - engines: {node: '>=8'} - - esprima@4.0.1: - resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} - engines: {node: '>=4'} - hasBin: true - - esutils@2.0.3: - resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} - engines: {node: '>=0.10.0'} - - eventemitter3@5.0.4: - resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} - - execa@5.1.1: - resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} - engines: {node: '>=10'} - - exit@0.1.2: - resolution: {integrity: sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ==} - engines: {node: '>= 0.8.0'} - - expect@29.7.0: - resolution: {integrity: sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - fast-check@4.6.0: - resolution: {integrity: sha512-h7H6Dm0Fy+H4ciQYFxFjXnXkzR2kr9Fb22c0UBpHnm59K2zpr2t13aPTHlltFiNT6zuxp6HMPAVVvgur4BLdpA==} - engines: {node: '>=12.17.0'} - - fast-json-stable-stringify@2.1.0: - resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} - - fast-sha256@1.3.0: - resolution: {integrity: sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==} - - fb-watchman@2.0.2: - resolution: {integrity: sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==} - - fill-range@7.1.1: - resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} - engines: {node: '>=8'} - - find-up@4.1.0: - resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} - engines: {node: '>=8'} - - foreground-child@3.3.1: - resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} - engines: {node: '>=14'} - - fs.realpath@1.0.0: - resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} - - fsevents@2.3.3: - resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} - engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} - os: [darwin] - - function-bind@1.1.2: - resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} - - gensync@1.0.0-beta.2: - resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} - engines: {node: '>=6.9.0'} - - get-caller-file@2.0.5: - resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} - engines: {node: 6.* || 8.* || >= 10.*} - - get-package-type@0.1.0: - resolution: {integrity: sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==} - engines: {node: '>=8.0.0'} - - get-stream@6.0.1: - resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} - engines: {node: '>=10'} - - get-tsconfig@4.13.7: - resolution: {integrity: sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==} - - glob@10.5.0: - resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} - deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me - hasBin: true - - glob@7.2.3: - resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} - deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me - - graceful-fs@4.2.11: - resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} - - handlebars@4.7.9: - resolution: {integrity: sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==} - engines: {node: '>=0.4.7'} - hasBin: true - - has-flag@4.0.0: - resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} - engines: {node: '>=8'} - - hasown@2.0.2: - resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} - engines: {node: '>= 0.4'} - - html-escaper@2.0.2: - resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} - - human-signals@2.1.0: - resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} - engines: {node: '>=10.17.0'} - - ieee754@1.2.1: - resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} - - ignore@5.3.2: - resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} - engines: {node: '>= 4'} - - import-local@3.2.0: - resolution: {integrity: sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==} - engines: {node: '>=8'} - hasBin: true - - imurmurhash@0.1.4: - resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} - engines: {node: '>=0.8.19'} - - inflight@1.0.6: - resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} - deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. - - inherits@2.0.4: - resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} - - is-arrayish@0.2.1: - resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} - - is-core-module@2.16.1: - resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==} - engines: {node: '>= 0.4'} - - is-fullwidth-code-point@3.0.0: - resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} - engines: {node: '>=8'} - - is-generator-fn@2.1.0: - resolution: {integrity: sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==} - engines: {node: '>=6'} - - is-interactive@2.0.0: - resolution: {integrity: sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==} - engines: {node: '>=12'} - - is-number@7.0.0: - resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} - engines: {node: '>=0.12.0'} - - is-stream@2.0.1: - resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} - engines: {node: '>=8'} - - is-unicode-supported@1.3.0: - resolution: {integrity: sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ==} - engines: {node: '>=12'} - - isexe@2.0.0: - resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} - - isomorphic-ws@5.0.0: - resolution: {integrity: sha512-muId7Zzn9ywDsyXgTIafTry2sV3nySZeUDe6YedVd1Hvuuep5AsIlqK+XefWpYTyJG5e503F2xIuT2lcU6rCSw==} - peerDependencies: - ws: '*' - - istanbul-lib-coverage@3.2.2: - resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} - engines: {node: '>=8'} - - istanbul-lib-instrument@5.2.1: - resolution: {integrity: sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==} - engines: {node: '>=8'} - - istanbul-lib-instrument@6.0.3: - resolution: {integrity: sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==} - engines: {node: '>=10'} - - istanbul-lib-report@3.0.1: - resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} - engines: {node: '>=10'} - - istanbul-lib-source-maps@4.0.1: - resolution: {integrity: sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==} - engines: {node: '>=10'} - - istanbul-reports@3.2.0: - resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} - engines: {node: '>=8'} - - jackspeak@3.4.3: - resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} - - jest-changed-files@29.7.0: - resolution: {integrity: sha512-fEArFiwf1BpQ+4bXSprcDc3/x4HSzL4al2tozwVpDFpsxALjLYdyiIK4e5Vz66GQJIbXJ82+35PtysofptNX2w==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - jest-circus@29.7.0: - resolution: {integrity: sha512-3E1nCMgipcTkCocFwM90XXQab9bS+GMsjdpmPrlelaxwD93Ad8iVEjX/vvHPdLPnFf+L40u+5+iutRdA1N9myw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - jest-cli@29.7.0: - resolution: {integrity: sha512-OVVobw2IubN/GSYsxETi+gOe7Ka59EFMR/twOU3Jb2GnKKeMGJB5SGUUrEz3SFVmJASUdZUzy83sLNNQ2gZslg==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - hasBin: true - peerDependencies: - node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 - peerDependenciesMeta: - node-notifier: - optional: true - - jest-config@29.7.0: - resolution: {integrity: sha512-uXbpfeQ7R6TZBqI3/TxCU4q4ttk3u0PJeC+E0zbfSoSjq6bJ7buBPxzQPL0ifrkY4DNu4JUdk0ImlBUYi840eQ==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - peerDependencies: - '@types/node': '*' - ts-node: '>=9.0.0' - peerDependenciesMeta: - '@types/node': - optional: true - ts-node: - optional: true - - jest-diff@29.7.0: - resolution: {integrity: sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - jest-docblock@29.7.0: - resolution: {integrity: sha512-q617Auw3A612guyaFgsbFeYpNP5t2aoUNLwBUbc/0kD1R4t9ixDbyFTHd1nok4epoVFpr7PmeWHrhvuV3XaJ4g==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - jest-each@29.7.0: - resolution: {integrity: sha512-gns+Er14+ZrEoC5fhOfYCY1LOHHr0TI+rQUHZS8Ttw2l7gl+80eHc/gFf2Ktkw0+SIACDTeWvpFcv3B04VembQ==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - jest-environment-node@29.7.0: - resolution: {integrity: sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - jest-get-type@29.6.3: - resolution: {integrity: sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - jest-haste-map@29.7.0: - resolution: {integrity: sha512-fP8u2pyfqx0K1rGn1R9pyE0/KTn+G7PxktWidOBTqFPLYX0b9ksaMFkhK5vrS3DVun09pckLdlx90QthlW7AmA==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - jest-haste-map@30.3.0: - resolution: {integrity: sha512-mMi2oqG4KRU0R9QEtscl87JzMXfUhbKaFqOxmjb2CKcbHcUGFrJCBWHmnTiUqi6JcnzoBlO4rWfpdl2k/RfLCA==} - engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - - jest-leak-detector@29.7.0: - resolution: {integrity: sha512-kYA8IJcSYtST2BY9I+SMC32nDpBT3J2NvWJx8+JCuCdl/CR1I4EKUJROiP8XtCcxqgTTBGJNdbB1A8XRKbTetw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - jest-matcher-utils@29.7.0: - resolution: {integrity: sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - jest-message-util@29.7.0: - resolution: {integrity: sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - jest-mock@29.7.0: - resolution: {integrity: sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - jest-pnp-resolver@1.2.3: - resolution: {integrity: sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==} - engines: {node: '>=6'} - peerDependencies: - jest-resolve: '*' - peerDependenciesMeta: - jest-resolve: - optional: true - - jest-regex-util@29.6.3: - resolution: {integrity: sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - jest-regex-util@30.0.1: - resolution: {integrity: sha512-jHEQgBXAgc+Gh4g0p3bCevgRCVRkB4VB70zhoAE48gxeSr1hfUOsM/C2WoJgVL7Eyg//hudYENbm3Ne+/dRVVA==} - engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - - jest-resolve-dependencies@29.7.0: - resolution: {integrity: sha512-un0zD/6qxJ+S0et7WxeI3H5XSe9lTBBR7bOHCHXkKR6luG5mwDDlIzVQ0V5cZCuoTgEdcdwzTghYkTWfubi+nA==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - jest-resolve@29.7.0: - resolution: {integrity: sha512-IOVhZSrg+UvVAshDSDtHyFCCBUl/Q3AAJv8iZ6ZjnZ74xzvwuzLXid9IIIPgTnY62SJjfuupMKZsZQRsCvxEgA==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - jest-runner@29.7.0: - resolution: {integrity: sha512-fsc4N6cPCAahybGBfTRcq5wFR6fpLznMg47sY5aDpsoejOcVYFb07AHuSnR0liMcPTgBsA3ZJL6kFOjPdoNipQ==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - jest-runtime@29.7.0: - resolution: {integrity: sha512-gUnLjgwdGqW7B4LvOIkbKs9WGbn+QLqRQQ9juC6HndeDiezIwhDP+mhMwHWCEcfQ5RUXa6OPnFF8BJh5xegwwQ==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - jest-snapshot@29.7.0: - resolution: {integrity: sha512-Rm0BMWtxBcioHr1/OX5YCP8Uov4riHvKPknOGs804Zg9JGZgmIBkbtlxJC/7Z4msKYVbIJtfU+tKb8xlYNfdkw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - jest-util@29.7.0: - resolution: {integrity: sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - jest-util@30.3.0: - resolution: {integrity: sha512-/jZDa00a3Sz7rdyu55NLrQCIrbyIkbBxareejQI315f/i8HjYN+ZWsDLLpoQSiUIEIyZF/R8fDg3BmB8AtHttg==} - engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - - jest-validate@29.7.0: - resolution: {integrity: sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - jest-watcher@29.7.0: - resolution: {integrity: sha512-49Fg7WXkU3Vl2h6LbLtMQ/HyB6rXSIX7SqvBLQmssRBGN9I0PNvPmAmCWSOY6SOvrjhI/F7/bGAv9RtnsPA03g==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - jest-worker@29.7.0: - resolution: {integrity: sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - jest-worker@30.3.0: - resolution: {integrity: sha512-DrCKkaQwHexjRUFTmPzs7sHQe0TSj9nvDALKGdwmK5mW9v7j90BudWirKAJHt3QQ9Dhrg1F7DogPzhChppkJpQ==} - engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - - jest@29.7.0: - resolution: {integrity: sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - hasBin: true - peerDependencies: - node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 - peerDependenciesMeta: - node-notifier: - optional: true - - js-tokens@4.0.0: - resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} - - js-yaml@3.14.2: - resolution: {integrity: sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==} - hasBin: true - - jsesc@3.1.0: - resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} - engines: {node: '>=6'} - hasBin: true - - json-parse-even-better-errors@2.3.1: - resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} - - json5@2.2.3: - resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} - engines: {node: '>=6'} - hasBin: true - - kleur@3.0.3: - resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==} - engines: {node: '>=6'} - - leven@3.1.0: - resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==} - engines: {node: '>=6'} - - lines-and-columns@1.2.4: - resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} - - locate-path@5.0.0: - resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} - engines: {node: '>=8'} - - lodash.debounce@4.0.8: - resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==} - - lodash.memoize@4.1.2: - resolution: {integrity: sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==} - - log-symbols@5.1.0: - resolution: {integrity: sha512-l0x2DvrW294C9uDCoQe1VSU4gf529FkSZ6leBl4TiqZH/e+0R7hSfHQBNut2mNygDgHwvYHfFLn6Oxb3VWj2rA==} - engines: {node: '>=12'} - - lru-cache@10.4.3: - resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} - - lru-cache@5.1.1: - resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} - - make-dir@4.0.0: - resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} - engines: {node: '>=10'} - - make-error@1.3.6: - resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==} - - makeerror@1.0.12: - resolution: {integrity: sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==} - - merge-stream@2.0.0: - resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} - - micromatch@4.0.8: - resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} - engines: {node: '>=8.6'} - - mime-db@1.52.0: - resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} - engines: {node: '>= 0.6'} - - mime-types@2.1.35: - resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} - engines: {node: '>= 0.6'} - - mimic-fn@2.1.0: - resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} - engines: {node: '>=6'} - - minimatch@3.1.5: - resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} - - minimatch@9.0.9: - resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==} - engines: {node: '>=16 || 14 >=14.17'} - - minimist@1.2.8: - resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} - - minipass@7.1.3: - resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} - engines: {node: '>=16 || 14 >=14.17'} - - ms@2.1.3: - resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - - natural-compare@1.4.0: - resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} - - neo-async@2.6.2: - resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} - - node-gyp-build-optional-packages@5.1.1: - resolution: {integrity: sha512-+P72GAjVAbTxjjwUmwjVrqrdZROD4nf8KgpBoDxqXXTiYZZt/ud60dE5yvCSr9lRO8e8yv6kgJIC0K0PfZFVQw==} - hasBin: true - - node-int64@0.4.0: - resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==} - - node-releases@2.0.37: - resolution: {integrity: sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==} - - normalize-path@3.0.0: - resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} - engines: {node: '>=0.10.0'} - - npm-run-path@4.0.1: - resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} - engines: {node: '>=8'} - - once@1.4.0: - resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} - - onetime@5.1.2: - resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} - engines: {node: '>=6'} + '@esbuild/openharmony-arm64@0.27.7': + resolution: {integrity: sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] - ora@7.0.1: - resolution: {integrity: sha512-0TUxTiFJWv+JnjWm4o9yvuskpEJLXTcng8MJuKd+SzAzp2o+OP3HWqNhB4OdJRt1Vsd9/mR0oyaEYlOnL7XIRw==} - engines: {node: '>=16'} + '@esbuild/sunos-x64@0.21.5': + resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] - p-limit@2.3.0: - resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} - engines: {node: '>=6'} + '@esbuild/sunos-x64@0.27.7': + resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] - p-limit@3.1.0: - resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} - engines: {node: '>=10'} + '@esbuild/win32-arm64@0.21.5': + resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] - p-locate@4.1.0: - resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} - engines: {node: '>=8'} + '@esbuild/win32-arm64@0.27.7': + resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] - p-try@2.2.0: - resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} - engines: {node: '>=6'} + '@esbuild/win32-ia32@0.21.5': + resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] - package-json-from-dist@1.0.1: - resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + '@esbuild/win32-ia32@0.27.7': + resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] - parse-json@5.2.0: - resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} - engines: {node: '>=8'} + '@esbuild/win32-x64@0.21.5': + resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] - path-exists@4.0.0: - resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} - engines: {node: '>=8'} + '@esbuild/win32-x64@0.27.7': + resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] - path-is-absolute@1.0.1: - resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} - engines: {node: '>=0.10.0'} + '@isaacs/cliui@8.0.2': + resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} + engines: {node: '>=12'} - path-key@3.1.1: - resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + '@istanbuljs/schema@0.1.3': + resolution: {integrity: sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==} engines: {node: '>=8'} - path-parse@1.0.7: - resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} - - path-scurry@1.11.1: - resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} - engines: {node: '>=16 || 14 >=14.18'} + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} - picocolors@1.1.1: - resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} - picomatch@2.3.2: - resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} - engines: {node: '>=8.6'} + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} - picomatch@4.0.4: - resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} - engines: {node: '>=12'} + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} - pirates@4.0.7: - resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} - engines: {node: '>= 6'} + '@noble/hashes@1.8.0': + resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} + engines: {node: ^14.21.3 || >=16} - pkg-dir@4.2.0: - resolution: {integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==} - engines: {node: '>=8'} + '@pkgjs/parseargs@0.11.0': + resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} + engines: {node: '>=14'} - pretty-format@29.7.0: - resolution: {integrity: sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + '@rollup/rollup-android-arm-eabi@4.60.2': + resolution: {integrity: sha512-dnlp69efPPg6Uaw2dVqzWRfAWRnYVb1XJ8CyyhIbZeaq4CA5/mLeZ1IEt9QqQxmbdvagjLIm2ZL8BxXv5lH4Yw==} + cpu: [arm] + os: [android] - prompts@2.4.2: - resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==} - engines: {node: '>= 6'} + '@rollup/rollup-android-arm64@4.60.2': + resolution: {integrity: sha512-OqZTwDRDchGRHHm/hwLOL7uVPB9aUvI0am/eQuWMNyFHf5PSEQmyEeYYheA0EPPKUO/l0uigCp+iaTjoLjVoHg==} + cpu: [arm64] + os: [android] - pure-rand@6.1.0: - resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==} + '@rollup/rollup-darwin-arm64@4.60.2': + resolution: {integrity: sha512-UwRE7CGpvSVEQS8gUMBe1uADWjNnVgP3Iusyda1nSRwNDCsRjnGc7w6El6WLQsXmZTbLZx9cecegumcitNfpmA==} + cpu: [arm64] + os: [darwin] - pure-rand@8.4.0: - resolution: {integrity: sha512-IoM8YF/jY0hiugFo/wOWqfmarlE6J0wc6fDK1PhftMk7MGhVZl88sZimmqBBFomLOCSmcCCpsfj7wXASCpvK9A==} + '@rollup/rollup-darwin-x64@4.60.2': + resolution: {integrity: sha512-gjEtURKLCC5VXm1I+2i1u9OhxFsKAQJKTVB8WvDAHF+oZlq0GTVFOlTlO1q3AlCTE/DF32c16ESvfgqR7343/g==} + cpu: [x64] + os: [darwin] - react-is@18.3.1: - resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} + '@rollup/rollup-freebsd-arm64@4.60.2': + resolution: {integrity: sha512-Bcl6CYDeAgE70cqZaMojOi/eK63h5Me97ZqAQoh77VPjMysA/4ORQBRGo3rRy45x4MzVlU9uZxs8Uwy7ZaKnBw==} + cpu: [arm64] + os: [freebsd] - readable-stream@3.6.2: - resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} - engines: {node: '>= 6'} + '@rollup/rollup-freebsd-x64@4.60.2': + resolution: {integrity: sha512-LU+TPda3mAE2QB0/Hp5VyeKJivpC6+tlOXd1VMoXV/YFMvk/MNk5iXeBfB4MQGRWyOYVJ01625vjkr0Az98OJQ==} + cpu: [x64] + os: [freebsd] - regenerate-unicode-properties@10.2.2: - resolution: {integrity: sha512-m03P+zhBeQd1RGnYxrGyDAPpWX/epKirLrp8e3qevZdVkKtnCrjjWczIbYc8+xd6vcTStVlqfycTx1KR4LOr0g==} - engines: {node: '>=4'} + '@rollup/rollup-linux-arm-gnueabihf@4.60.2': + resolution: {integrity: sha512-2QxQrM+KQ7DAW4o22j+XZ6RKdxjLD7BOWTP0Bv0tmjdyhXSsr2Ul1oJDQqh9Zf5qOwTuTc7Ek83mOFaKnodPjg==} + cpu: [arm] + os: [linux] + libc: [glibc] - regenerate@1.4.2: - resolution: {integrity: sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==} + '@rollup/rollup-linux-arm-musleabihf@4.60.2': + resolution: {integrity: sha512-TbziEu2DVsTEOPif2mKWkMeDMLoYjx95oESa9fkQQK7r/Orta0gnkcDpzwufEcAO2BLBsD7mZkXGFqEdMRRwfw==} + cpu: [arm] + os: [linux] + libc: [musl] - regexpu-core@6.4.0: - resolution: {integrity: sha512-0ghuzq67LI9bLXpOX/ISfve/Mq33a4aFRzoQYhnnok1JOFpmE/A2TBGkNVenOGEeSBCjIiWcc6MVOG5HEQv0sA==} - engines: {node: '>=4'} + '@rollup/rollup-linux-arm64-gnu@4.60.2': + resolution: {integrity: sha512-bO/rVDiDUuM2YfuCUwZ1t1cP+/yqjqz+Xf2VtkdppefuOFS2OSeAfgafaHNkFn0t02hEyXngZkxtGqXcXwO8Rg==} + cpu: [arm64] + os: [linux] + libc: [glibc] - regjsgen@0.8.0: - resolution: {integrity: sha512-RvwtGe3d7LvWiDQXeQw8p5asZUmfU1G/l6WbUXeHta7Y2PEIvBTwH6E2EfmYUK8pxcxEdEmaomqyp0vZZ7C+3Q==} + '@rollup/rollup-linux-arm64-musl@4.60.2': + resolution: {integrity: sha512-hr26p7e93Rl0Za+JwW7EAnwAvKkehh12BU1Llm9Ykiibg4uIr2rbpxG9WCf56GuvidlTG9KiiQT/TXT1yAWxTA==} + cpu: [arm64] + os: [linux] + libc: [musl] - regjsparser@0.13.1: - resolution: {integrity: sha512-dLsljMd9sqwRkby8zhO1gSg3PnJIBFid8f4CQj/sXx+7cKx+E7u0PKhZ+U4wmhx7EfmtvnA318oVaIkAB1lRJw==} - hasBin: true + '@rollup/rollup-linux-loong64-gnu@4.60.2': + resolution: {integrity: sha512-pOjB/uSIyDt+ow3k/RcLvUAOGpysT2phDn7TTUB3n75SlIgZzM6NKAqlErPhoFU+npgY3/n+2HYIQVbF70P9/A==} + cpu: [loong64] + os: [linux] + libc: [glibc] - require-directory@2.1.1: - resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} - engines: {node: '>=0.10.0'} + '@rollup/rollup-linux-loong64-musl@4.60.2': + resolution: {integrity: sha512-2/w+q8jszv9Ww1c+6uJT3OwqhdmGP2/4T17cu8WuwyUuuaCDDJ2ojdyYwZzCxx0GcsZBhzi3HmH+J5pZNXnd+Q==} + cpu: [loong64] + os: [linux] + libc: [musl] - resolve-cwd@3.0.0: - resolution: {integrity: sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==} - engines: {node: '>=8'} + '@rollup/rollup-linux-ppc64-gnu@4.60.2': + resolution: {integrity: sha512-11+aL5vKheYgczxtPVVRhdptAM2H7fcDR5Gw4/bTcteuZBlH4oP9f5s9zYO9aGZvoGeBpqXI/9TZZihZ609wKw==} + cpu: [ppc64] + os: [linux] + libc: [glibc] - resolve-from@5.0.0: - resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} - engines: {node: '>=8'} + '@rollup/rollup-linux-ppc64-musl@4.60.2': + resolution: {integrity: sha512-i16fokAGK46IVZuV8LIIwMdtqhin9hfYkCh8pf8iC3QU3LpwL+1FSFGej+O7l3E/AoknL6Dclh2oTdnRMpTzFQ==} + cpu: [ppc64] + os: [linux] + libc: [musl] - resolve-pkg-maps@1.0.0: - resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + '@rollup/rollup-linux-riscv64-gnu@4.60.2': + resolution: {integrity: sha512-49FkKS6RGQoriDSK/6E2GkAsAuU5kETFCh7pG4yD/ylj9rKhTmO3elsnmBvRD4PgJPds5W2PkhC82aVwmUcJ7A==} + cpu: [riscv64] + os: [linux] + libc: [glibc] - resolve.exports@2.0.3: - resolution: {integrity: sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A==} - engines: {node: '>=10'} + '@rollup/rollup-linux-riscv64-musl@4.60.2': + resolution: {integrity: sha512-mjYNkHPfGpUR00DuM1ZZIgs64Hpf4bWcz9Z41+4Q+pgDx73UwWdAYyf6EG/lRFldmdHHzgrYyge5akFUW0D3mQ==} + cpu: [riscv64] + os: [linux] + libc: [musl] - resolve@1.22.11: - resolution: {integrity: sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==} - engines: {node: '>= 0.4'} - hasBin: true + '@rollup/rollup-linux-s390x-gnu@4.60.2': + resolution: {integrity: sha512-ALyvJz965BQk8E9Al/JDKKDLH2kfKFLTGMlgkAbbYtZuJt9LU8DW3ZoDMCtQpXAltZxwBHevXz5u+gf0yA0YoA==} + cpu: [s390x] + os: [linux] + libc: [glibc] - restore-cursor@4.0.0: - resolution: {integrity: sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + '@rollup/rollup-linux-x64-gnu@4.60.2': + resolution: {integrity: sha512-UQjrkIdWrKI626Du8lCQ6MJp/6V1LAo2bOK9OTu4mSn8GGXIkPXk/Vsp4bLHCd9Z9Iz2OTEaokUE90VweJgIYQ==} + cpu: [x64] + os: [linux] + libc: [glibc] - rimraf@5.0.10: - resolution: {integrity: sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==} - hasBin: true + '@rollup/rollup-linux-x64-musl@4.60.2': + resolution: {integrity: sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw==} + cpu: [x64] + os: [linux] + libc: [musl] - safe-buffer@5.2.1: - resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + '@rollup/rollup-openbsd-x64@4.60.2': + resolution: {integrity: sha512-6d4Z3534xitaA1FcMWP7mQPq5zGwBmGbhphh2DwaA1aNIXUu3KTOfwrWpbwI4/Gr0uANo7NTtaykFyO2hPuFLg==} + cpu: [x64] + os: [openbsd] - semver@6.3.1: - resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} - hasBin: true + '@rollup/rollup-openharmony-arm64@4.60.2': + resolution: {integrity: sha512-NetAg5iO2uN7eB8zE5qrZ3CSil+7IJt4WDFLcC75Ymywq1VZVD6qJ6EvNLjZ3rEm6gB7XW5JdT60c6MN35Z85Q==} + cpu: [arm64] + os: [openharmony] - semver@7.7.4: - resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} - engines: {node: '>=10'} - hasBin: true + '@rollup/rollup-win32-arm64-msvc@4.60.2': + resolution: {integrity: sha512-NCYhOotpgWZ5kdxCZsv6Iudx0wX8980Q/oW4pNFNihpBKsDbEA1zpkfxJGC0yugsUuyDZ7gL37dbzwhR0VI7pQ==} + cpu: [arm64] + os: [win32] - shebang-command@2.0.0: - resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} - engines: {node: '>=8'} + '@rollup/rollup-win32-ia32-msvc@4.60.2': + resolution: {integrity: sha512-RXsaOqXxfoUBQoOgvmmijVxJnW2IGB0eoMO7F8FAjaj0UTywUO/luSqimWBJn04WNgUkeNhh7fs7pESXajWmkg==} + cpu: [ia32] + os: [win32] - shebang-regex@3.0.0: - resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} - engines: {node: '>=8'} + '@rollup/rollup-win32-x64-gnu@4.60.2': + resolution: {integrity: sha512-qdAzEULD+/hzObedtmV6iBpdL5TIbKVztGiK7O3/KYSf+HIzU257+MX1EXJcyIiDbMAqmbwaufcYPvyRryeZtA==} + cpu: [x64] + os: [win32] - signal-exit@3.0.7: - resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + '@rollup/rollup-win32-x64-msvc@4.60.2': + resolution: {integrity: sha512-Nd/SgG27WoA9e+/TdK74KnHz852TLa94ovOYySo/yMPuTmpckK/jIF2jSwS3g7ELSKXK13/cVdmg1Z/DaCWKxA==} + cpu: [x64] + os: [win32] - signal-exit@4.1.0: - resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} - engines: {node: '>=14'} + '@types/diff@5.2.3': + resolution: {integrity: sha512-K0Oqlrq3kQMaO2RhfrNQX5trmt+XLyom88zS0u84nnIcLvFnRUMRRHmrGny5GSM+kNO9IZLARsdQHDzkhAgmrQ==} - sisteransi@1.0.5: - resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} - slash@3.0.0: - resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} - engines: {node: '>=8'} + '@types/mime-types@2.1.4': + resolution: {integrity: sha512-lfU4b34HOri+kAY5UheuFMWPDOI+OPceBSHZKp69gEyTL/mmJ4cnU6Y/rlme3UL3GyOn6Y42hyIEw0/q8sWx5w==} - source-map-support@0.5.13: - resolution: {integrity: sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==} + '@types/node@20.19.39': + resolution: {integrity: sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw==} - source-map@0.6.1: - resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} - engines: {node: '>=0.10.0'} + '@types/tmp@0.2.6': + resolution: {integrity: sha512-chhaNf2oKHlRkDGt+tiKE2Z5aJ6qalm7Z9rlLdBwmOiAAf09YQvvoLXjWK4HWPF1xU/fqvMgfNfpVoBscA/tKA==} - sprintf-js@1.0.3: - resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} + '@vitest/coverage-v8@2.1.9': + resolution: {integrity: sha512-Z2cOr0ksM00MpEfyVE8KXIYPEcBFxdbLSs56L8PO0QQMxt/6bDj45uQfxoc96v05KW3clk7vvgP0qfDit9DmfQ==} + peerDependencies: + '@vitest/browser': 2.1.9 + vitest: 2.1.9 + peerDependenciesMeta: + '@vitest/browser': + optional: true - stack-utils@2.0.6: - resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==} - engines: {node: '>=10'} + '@vitest/expect@2.1.9': + resolution: {integrity: sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==} - stdin-discarder@0.1.0: - resolution: {integrity: sha512-xhV7w8S+bUwlPTb4bAOUQhv8/cSS5offJuX8GQGq32ONF0ZtDWKfkdomM3HMRA+LhX6um/FZ0COqlwsjD53LeQ==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + '@vitest/mocker@2.1.9': + resolution: {integrity: sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true - string-length@4.0.2: - resolution: {integrity: sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==} - engines: {node: '>=10'} + '@vitest/pretty-format@2.1.9': + resolution: {integrity: sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==} - string-width@4.2.3: - resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} - engines: {node: '>=8'} + '@vitest/runner@2.1.9': + resolution: {integrity: sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==} - string-width@5.1.2: - resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} - engines: {node: '>=12'} + '@vitest/snapshot@2.1.9': + resolution: {integrity: sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==} - string-width@6.1.0: - resolution: {integrity: sha512-k01swCJAgQmuADB0YIc+7TuatfNvTBVOoaUWJjTB9R4VJzR5vNWzf5t42ESVZFPS8xTySF7CAdV4t/aaIm3UnQ==} - engines: {node: '>=16'} + '@vitest/spy@2.1.9': + resolution: {integrity: sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==} - string_decoder@1.3.0: - resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + '@vitest/utils@2.1.9': + resolution: {integrity: sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==} - strip-ansi@6.0.1: - resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} - strip-ansi@7.2.0: - resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} + ansi-regex@6.2.2: + resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} engines: {node: '>=12'} - strip-bom@4.0.0: - resolution: {integrity: sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==} + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} - strip-final-newline@2.0.0: - resolution: {integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==} - engines: {node: '>=6'} - - strip-json-comments@3.1.1: - resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} - engines: {node: '>=8'} + ansi-styles@6.2.3: + resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} + engines: {node: '>=12'} - supports-color@7.2.0: - resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} - engines: {node: '>=8'} + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} - supports-color@8.1.1: - resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} - engines: {node: '>=10'} + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} - supports-preserve-symlinks-flag@1.0.0: - resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} - engines: {node: '>= 0.4'} + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} - test-exclude@6.0.0: - resolution: {integrity: sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==} - engines: {node: '>=8'} + base-x@4.0.1: + resolution: {integrity: sha512-uAZ8x6r6S3aUM9rbHGVOIsR15U/ZSc82b3ymnCPsT45Gk1DDvhDPdIgB5MrhirZWt+5K0EEPQH985kNqZgNPFw==} - tmp@0.2.5: - resolution: {integrity: sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==} - engines: {node: '>=14.14'} + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} - tmpl@1.0.5: - resolution: {integrity: sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==} + bl@5.1.0: + resolution: {integrity: sha512-tv1ZJHLfTDnXE6tMHv73YgSJaWR2AFuPwMntBe7XL/GBFHnT0CLnsHMogfk5+GzCDC5ZWarSCYaIGATZt9dNsQ==} - to-regex-range@5.0.1: - resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} - engines: {node: '>=8.0'} + brace-expansion@2.0.3: + resolution: {integrity: sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==} - ts-jest@29.4.9: - resolution: {integrity: sha512-LTb9496gYPMCqjeDLdPrKuXtncudeV1yRZnF4Wo5l3SFi0RYEnYRNgMrFIdg+FHvfzjCyQk1cLncWVqiSX+EvQ==} - engines: {node: ^14.15.0 || ^16.10.0 || ^18.0.0 || >=20.0.0} - hasBin: true - peerDependencies: - '@babel/core': '>=7.0.0-beta.0 <8' - '@jest/transform': ^29.0.0 || ^30.0.0 - '@jest/types': ^29.0.0 || ^30.0.0 - babel-jest: ^29.0.0 || ^30.0.0 - esbuild: '*' - jest: ^29.0.0 || ^30.0.0 - jest-util: ^29.0.0 || ^30.0.0 - typescript: '>=4.3 <7' - peerDependenciesMeta: - '@babel/core': - optional: true - '@jest/transform': - optional: true - '@jest/types': - optional: true - babel-jest: - optional: true - esbuild: - optional: true - jest-util: - optional: true + brace-expansion@5.0.5: + resolution: {integrity: sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==} + engines: {node: 18 || 20 || >=22} - tsx@4.21.0: - resolution: {integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==} - engines: {node: '>=18.0.0'} - hasBin: true + bs58@5.0.0: + resolution: {integrity: sha512-r+ihvQJvahgYT50JD05dyJNKlmmSlMoOGwn1lCcEzanPglg7TxYjioQUYehQ9mAR/+hOSd2jRc/Z2y5UxBymvQ==} - type-detect@4.0.8: - resolution: {integrity: sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==} - engines: {node: '>=4'} + bs58check@3.0.1: + resolution: {integrity: sha512-hjuuJvoWEybo7Hn/0xOrczQKKEKD63WguEjlhLExYs2wUBcebDC1jDNK17eEAD2lYfw82d5ASC1d7K3SWszjaQ==} - type-fest@0.21.3: - resolution: {integrity: sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==} - engines: {node: '>=10'} + buffer@6.0.3: + resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} - type-fest@4.41.0: - resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} - engines: {node: '>=16'} + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} - typescript@5.9.3: - resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} - engines: {node: '>=14.17'} + cbor-extract@2.2.2: + resolution: {integrity: sha512-hlSxxI9XO2yQfe9g6msd3g4xCfDqK5T5P0fRMLuaLHhxn4ViPrm+a+MUfhrvH2W962RGxcBwEGzLQyjbDG1gng==} hasBin: true - uglify-js@3.19.3: - resolution: {integrity: sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==} - engines: {node: '>=0.8.0'} - hasBin: true + cbor-x@1.6.4: + resolution: {integrity: sha512-UGKHjp6RHC6QuZ2yy5LCKm7MojM4716DwoSaqwQpaH4DvZvbBTGcoDNTiG9Y2lByXZYFEs9WRkS5tLl96IrF1Q==} - undici-types@6.21.0: - resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + chai@5.3.3: + resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} + engines: {node: '>=18'} - unicode-canonical-property-names-ecmascript@2.0.1: - resolution: {integrity: sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==} - engines: {node: '>=4'} + chalk@5.6.2: + resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} + engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} - unicode-match-property-ecmascript@2.0.0: - resolution: {integrity: sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q==} - engines: {node: '>=4'} + check-error@2.1.3: + resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} + engines: {node: '>= 16'} - unicode-match-property-value-ecmascript@2.2.1: - resolution: {integrity: sha512-JQ84qTuMg4nVkx8ga4A16a1epI9H6uTXAknqxkGF/aFfRLw1xC/Bp24HNLaZhHSkWd3+84t8iXnp1J0kYcZHhg==} - engines: {node: '>=4'} + cli-cursor@4.0.0: + resolution: {integrity: sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - unicode-property-aliases-ecmascript@2.2.0: - resolution: {integrity: sha512-hpbDzxUY9BFwX+UeBnxv3Sh1q7HFxj48DTmXchNgRa46lO8uj3/1iEn3MiNUYTg1g9ctIqXCCERn8gYZhHC5lQ==} - engines: {node: '>=4'} + cli-spinners@2.9.2: + resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==} + engines: {node: '>=6'} - update-browserslist-db@1.2.3: - resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} - hasBin: true - peerDependencies: - browserslist: '>= 4.21.0' + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} - util-deprecate@1.0.2: - resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} - uuid@9.0.1: - resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} - hasBin: true + commander@14.0.3: + resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} + engines: {node: '>=20'} - v8-to-istanbul@9.3.0: - resolution: {integrity: sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==} - engines: {node: '>=10.12.0'} + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} - walker@1.0.8: - resolution: {integrity: sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==} + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true - which@2.0.2: - resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} - engines: {node: '>= 8'} - hasBin: true + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} - wordwrap@1.0.0: - resolution: {integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==} + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} - wrap-ansi@7.0.0: - resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} - engines: {node: '>=10'} + diff@8.0.4: + resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==} + engines: {node: '>=0.3.1'} - wrap-ansi@8.1.0: - resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} - engines: {node: '>=12'} + eastasianwidth@0.2.0: + resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} - wrappy@1.0.2: - resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + emoji-regex@10.6.0: + resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} - write-file-atomic@4.0.2: - resolution: {integrity: sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==} - engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} - write-file-atomic@5.0.1: - resolution: {integrity: sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==} - engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + emoji-regex@9.2.2: + resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} - ws@8.20.0: - resolution: {integrity: sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==} - engines: {node: '>=10.0.0'} - peerDependencies: - bufferutil: ^4.0.1 - utf-8-validate: '>=5.0.2' - peerDependenciesMeta: - bufferutil: - optional: true - utf-8-validate: - optional: true + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} - xstate@5.30.0: - resolution: {integrity: sha512-mIzIuMjtYVkqXq9dUzYQoag7b/dF1CBS/yhliuPLfR0FwKPC18HiUivb/crcqY2gknhR8gJEhnppLg6ubQ0gGw==} + esbuild@0.21.5: + resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} + engines: {node: '>=12'} + hasBin: true - y18n@5.0.8: - resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} - engines: {node: '>=10'} + esbuild@0.27.7: + resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==} + engines: {node: '>=18'} + hasBin: true - yallist@3.1.1: - resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} - yargs-parser@21.1.1: - resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} - engines: {node: '>=12'} + eventemitter3@5.0.4: + resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} - yargs@17.7.2: - resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} - engines: {node: '>=12'} + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} - yocto-queue@0.1.0: - resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} - engines: {node: '>=10'} + fast-check@4.6.0: + resolution: {integrity: sha512-h7H6Dm0Fy+H4ciQYFxFjXnXkzR2kr9Fb22c0UBpHnm59K2zpr2t13aPTHlltFiNT6zuxp6HMPAVVvgur4BLdpA==} + engines: {node: '>=12.17.0'} -snapshots: + fast-sha256@1.3.0: + resolution: {integrity: sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==} - '@automerge/automerge-repo-network-websocket@2.6.0-subduction.15': - dependencies: - '@automerge/automerge-repo': 2.6.0-subduction.15 - cbor-x: 1.6.4 - debug: 4.4.3 - eventemitter3: 5.0.4 - isomorphic-ws: 5.0.0(ws@8.20.0) - ws: 8.20.0 - transitivePeerDependencies: - - bufferutil - - supports-color - - utf-8-validate + foreground-child@3.3.1: + resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} + engines: {node: '>=14'} - '@automerge/automerge-repo-storage-nodefs@2.6.0-subduction.15': - dependencies: - '@automerge/automerge-repo': 2.6.0-subduction.15 - rimraf: 5.0.10 - transitivePeerDependencies: - - bufferutil - - supports-color - - utf-8-validate + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] - '@automerge/automerge-repo@2.6.0-subduction.15': - dependencies: - '@automerge/automerge': 3.2.6 - '@automerge/automerge-subduction': 0.8.1 - bs58check: 3.0.1 - cbor-x: 1.6.4 - debug: 4.4.3 - eventemitter3: 5.0.4 - fast-sha256: 1.3.0 - isomorphic-ws: 5.0.0(ws@8.20.0) - uuid: 9.0.1 - ws: 8.20.0 - xstate: 5.30.0 - transitivePeerDependencies: - - bufferutil - - supports-color - - utf-8-validate + get-tsconfig@4.13.7: + resolution: {integrity: sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==} - '@automerge/automerge-subduction@0.8.1': {} + glob@10.5.0: + resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + hasBin: true - '@automerge/automerge@3.2.6': {} + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} - '@babel/code-frame@7.29.0': - dependencies: - '@babel/helper-validator-identifier': 7.28.5 - js-tokens: 4.0.0 - picocolors: 1.1.1 + html-escaper@2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} - '@babel/compat-data@7.29.0': {} + ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} - '@babel/core@7.29.0': - dependencies: - '@babel/code-frame': 7.29.0 - '@babel/generator': 7.29.1 - '@babel/helper-compilation-targets': 7.28.6 - '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) - '@babel/helpers': 7.29.2 - '@babel/parser': 7.29.2 - '@babel/template': 7.28.6 - '@babel/traverse': 7.29.0 - '@babel/types': 7.29.0 - '@jridgewell/remapping': 2.3.5 - convert-source-map: 2.0.0 - debug: 4.4.3 - gensync: 1.0.0-beta.2 - json5: 2.2.3 - semver: 6.3.1 - transitivePeerDependencies: - - supports-color + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} - '@babel/generator@7.29.1': - dependencies: - '@babel/parser': 7.29.2 - '@babel/types': 7.29.0 - '@jridgewell/gen-mapping': 0.3.13 - '@jridgewell/trace-mapping': 0.3.31 - jsesc: 3.1.0 + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} - '@babel/helper-annotate-as-pure@7.27.3': - dependencies: - '@babel/types': 7.29.0 + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} - '@babel/helper-compilation-targets@7.28.6': - dependencies: - '@babel/compat-data': 7.29.0 - '@babel/helper-validator-option': 7.27.1 - browserslist: 4.28.2 - lru-cache: 5.1.1 - semver: 6.3.1 + is-interactive@2.0.0: + resolution: {integrity: sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==} + engines: {node: '>=12'} - '@babel/helper-create-class-features-plugin@7.28.6(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-annotate-as-pure': 7.27.3 - '@babel/helper-member-expression-to-functions': 7.28.5 - '@babel/helper-optimise-call-expression': 7.27.1 - '@babel/helper-replace-supers': 7.28.6(@babel/core@7.29.0) - '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 - '@babel/traverse': 7.29.0 - semver: 6.3.1 - transitivePeerDependencies: - - supports-color + is-unicode-supported@1.3.0: + resolution: {integrity: sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ==} + engines: {node: '>=12'} - '@babel/helper-create-regexp-features-plugin@7.28.5(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-annotate-as-pure': 7.27.3 - regexpu-core: 6.4.0 - semver: 6.3.1 + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} - '@babel/helper-define-polyfill-provider@0.6.8(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-compilation-targets': 7.28.6 - '@babel/helper-plugin-utils': 7.28.6 - debug: 4.4.3 - lodash.debounce: 4.0.8 - resolve: 1.22.11 - transitivePeerDependencies: - - supports-color + isomorphic-ws@5.0.0: + resolution: {integrity: sha512-muId7Zzn9ywDsyXgTIafTry2sV3nySZeUDe6YedVd1Hvuuep5AsIlqK+XefWpYTyJG5e503F2xIuT2lcU6rCSw==} + peerDependencies: + ws: '*' - '@babel/helper-globals@7.28.0': {} + istanbul-lib-coverage@3.2.2: + resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} + engines: {node: '>=8'} - '@babel/helper-member-expression-to-functions@7.28.5': - dependencies: - '@babel/traverse': 7.29.0 - '@babel/types': 7.29.0 - transitivePeerDependencies: - - supports-color + istanbul-lib-report@3.0.1: + resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} + engines: {node: '>=10'} - '@babel/helper-module-imports@7.28.6': - dependencies: - '@babel/traverse': 7.29.0 - '@babel/types': 7.29.0 - transitivePeerDependencies: - - supports-color + istanbul-lib-source-maps@5.0.6: + resolution: {integrity: sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==} + engines: {node: '>=10'} - '@babel/helper-module-transforms@7.28.6(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-module-imports': 7.28.6 - '@babel/helper-validator-identifier': 7.28.5 - '@babel/traverse': 7.29.0 - transitivePeerDependencies: - - supports-color + istanbul-reports@3.2.0: + resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} + engines: {node: '>=8'} - '@babel/helper-optimise-call-expression@7.27.1': - dependencies: - '@babel/types': 7.29.0 + jackspeak@3.4.3: + resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} - '@babel/helper-plugin-utils@7.28.6': {} + log-symbols@5.1.0: + resolution: {integrity: sha512-l0x2DvrW294C9uDCoQe1VSU4gf529FkSZ6leBl4TiqZH/e+0R7hSfHQBNut2mNygDgHwvYHfFLn6Oxb3VWj2rA==} + engines: {node: '>=12'} - '@babel/helper-remap-async-to-generator@7.27.1(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-annotate-as-pure': 7.27.3 - '@babel/helper-wrap-function': 7.28.6 - '@babel/traverse': 7.29.0 - transitivePeerDependencies: - - supports-color + loupe@3.2.1: + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} - '@babel/helper-replace-supers@7.28.6(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-member-expression-to-functions': 7.28.5 - '@babel/helper-optimise-call-expression': 7.27.1 - '@babel/traverse': 7.29.0 - transitivePeerDependencies: - - supports-color + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} - '@babel/helper-skip-transparent-expression-wrappers@7.27.1': - dependencies: - '@babel/traverse': 7.29.0 - '@babel/types': 7.29.0 - transitivePeerDependencies: - - supports-color + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} - '@babel/helper-string-parser@7.27.1': {} + magicast@0.3.5: + resolution: {integrity: sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==} - '@babel/helper-validator-identifier@7.28.5': {} + make-dir@4.0.0: + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} + engines: {node: '>=10'} - '@babel/helper-validator-option@7.27.1': {} + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} - '@babel/helper-wrap-function@7.28.6': - dependencies: - '@babel/template': 7.28.6 - '@babel/traverse': 7.29.0 - '@babel/types': 7.29.0 - transitivePeerDependencies: - - supports-color + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} - '@babel/helpers@7.29.2': - dependencies: - '@babel/template': 7.28.6 - '@babel/types': 7.29.0 + mimic-fn@2.1.0: + resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} + engines: {node: '>=6'} - '@babel/parser@7.29.2': - dependencies: - '@babel/types': 7.29.0 + minimatch@10.2.5: + resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} + engines: {node: 18 || 20 || >=22} - '@babel/plugin-bugfix-firefox-class-in-computed-class-key@7.28.5(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 - '@babel/traverse': 7.29.0 - transitivePeerDependencies: - - supports-color + minimatch@9.0.9: + resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==} + engines: {node: '>=16 || 14 >=14.17'} - '@babel/plugin-bugfix-safari-class-field-initializer-scope@7.27.1(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} + engines: {node: '>=16 || 14 >=14.17'} - '@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@7.27.1(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - '@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining@7.27.1(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 - '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 - '@babel/plugin-transform-optional-chaining': 7.28.6(@babel/core@7.29.0) - transitivePeerDependencies: - - supports-color + nanoid@3.3.11: + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true - '@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly@7.28.6(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 - '@babel/traverse': 7.29.0 - transitivePeerDependencies: - - supports-color + node-gyp-build-optional-packages@5.1.1: + resolution: {integrity: sha512-+P72GAjVAbTxjjwUmwjVrqrdZROD4nf8KgpBoDxqXXTiYZZt/ud60dE5yvCSr9lRO8e8yv6kgJIC0K0PfZFVQw==} + hasBin: true - '@babel/plugin-proposal-private-property-in-object@7.21.0-placeholder-for-preset-env.2(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 + onetime@5.1.2: + resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} + engines: {node: '>=6'} - '@babel/plugin-syntax-async-generators@7.8.4(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 + ora@7.0.1: + resolution: {integrity: sha512-0TUxTiFJWv+JnjWm4o9yvuskpEJLXTcng8MJuKd+SzAzp2o+OP3HWqNhB4OdJRt1Vsd9/mR0oyaEYlOnL7XIRw==} + engines: {node: '>=16'} - '@babel/plugin-syntax-bigint@7.8.3(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 + package-json-from-dist@1.0.1: + resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} - '@babel/plugin-syntax-class-properties@7.12.13(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} - '@babel/plugin-syntax-class-static-block@7.14.5(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 + path-scurry@1.11.1: + resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} + engines: {node: '>=16 || 14 >=14.18'} - '@babel/plugin-syntax-import-assertions@7.28.6(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 + pathe@1.1.2: + resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} - '@babel/plugin-syntax-import-attributes@7.28.6(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 + pathval@2.0.1: + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} + engines: {node: '>= 14.16'} - '@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} - '@babel/plugin-syntax-json-strings@7.8.3(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 + postcss@8.5.12: + resolution: {integrity: sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==} + engines: {node: ^10 || ^12 || >=14} - '@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 + pure-rand@8.4.0: + resolution: {integrity: sha512-IoM8YF/jY0hiugFo/wOWqfmarlE6J0wc6fDK1PhftMk7MGhVZl88sZimmqBBFomLOCSmcCCpsfj7wXASCpvK9A==} - '@babel/plugin-syntax-logical-assignment-operators@7.10.4(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 + readable-stream@3.6.2: + resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} + engines: {node: '>= 6'} - '@babel/plugin-syntax-nullish-coalescing-operator@7.8.3(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} - '@babel/plugin-syntax-numeric-separator@7.10.4(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 + restore-cursor@4.0.0: + resolution: {integrity: sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - '@babel/plugin-syntax-object-rest-spread@7.8.3(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 + rimraf@5.0.10: + resolution: {integrity: sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==} + hasBin: true - '@babel/plugin-syntax-optional-catch-binding@7.8.3(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 + rollup@4.60.2: + resolution: {integrity: sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true - '@babel/plugin-syntax-optional-chaining@7.8.3(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} - '@babel/plugin-syntax-private-property-in-object@7.14.5(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 + semver@7.7.4: + resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} + engines: {node: '>=10'} + hasBin: true - '@babel/plugin-syntax-top-level-await@7.14.5(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} - '@babel/plugin-syntax-typescript@7.28.6(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} - '@babel/plugin-syntax-unicode-sets-regex@7.18.6(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.0) - '@babel/helper-plugin-utils': 7.28.6 + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} - '@babel/plugin-transform-arrow-functions@7.27.1(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 + signal-exit@3.0.7: + resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} - '@babel/plugin-transform-async-generator-functions@7.29.0(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 - '@babel/helper-remap-async-to-generator': 7.27.1(@babel/core@7.29.0) - '@babel/traverse': 7.29.0 - transitivePeerDependencies: - - supports-color + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} - '@babel/plugin-transform-async-to-generator@7.28.6(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-module-imports': 7.28.6 - '@babel/helper-plugin-utils': 7.28.6 - '@babel/helper-remap-async-to-generator': 7.27.1(@babel/core@7.29.0) - transitivePeerDependencies: - - supports-color + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} - '@babel/plugin-transform-block-scoped-functions@7.27.1(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} - '@babel/plugin-transform-block-scoping@7.28.6(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} - '@babel/plugin-transform-class-properties@7.28.6(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-create-class-features-plugin': 7.28.6(@babel/core@7.29.0) - '@babel/helper-plugin-utils': 7.28.6 - transitivePeerDependencies: - - supports-color + stdin-discarder@0.1.0: + resolution: {integrity: sha512-xhV7w8S+bUwlPTb4bAOUQhv8/cSS5offJuX8GQGq32ONF0ZtDWKfkdomM3HMRA+LhX6um/FZ0COqlwsjD53LeQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - '@babel/plugin-transform-class-static-block@7.28.6(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-create-class-features-plugin': 7.28.6(@babel/core@7.29.0) - '@babel/helper-plugin-utils': 7.28.6 - transitivePeerDependencies: - - supports-color + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} - '@babel/plugin-transform-classes@7.28.6(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-annotate-as-pure': 7.27.3 - '@babel/helper-compilation-targets': 7.28.6 - '@babel/helper-globals': 7.28.0 - '@babel/helper-plugin-utils': 7.28.6 - '@babel/helper-replace-supers': 7.28.6(@babel/core@7.29.0) - '@babel/traverse': 7.29.0 - transitivePeerDependencies: - - supports-color + string-width@5.1.2: + resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} + engines: {node: '>=12'} - '@babel/plugin-transform-computed-properties@7.28.6(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 - '@babel/template': 7.28.6 + string-width@6.1.0: + resolution: {integrity: sha512-k01swCJAgQmuADB0YIc+7TuatfNvTBVOoaUWJjTB9R4VJzR5vNWzf5t42ESVZFPS8xTySF7CAdV4t/aaIm3UnQ==} + engines: {node: '>=16'} - '@babel/plugin-transform-destructuring@7.28.5(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 - '@babel/traverse': 7.29.0 - transitivePeerDependencies: - - supports-color + string_decoder@1.3.0: + resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} - '@babel/plugin-transform-dotall-regex@7.28.6(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.0) - '@babel/helper-plugin-utils': 7.28.6 + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} - '@babel/plugin-transform-duplicate-keys@7.27.1(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 + strip-ansi@7.2.0: + resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} + engines: {node: '>=12'} - '@babel/plugin-transform-duplicate-named-capturing-groups-regex@7.29.0(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.0) - '@babel/helper-plugin-utils': 7.28.6 + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} - '@babel/plugin-transform-dynamic-import@7.27.1(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 + test-exclude@7.0.2: + resolution: {integrity: sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw==} + engines: {node: '>=18'} - '@babel/plugin-transform-explicit-resource-management@7.28.6(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-destructuring': 7.28.5(@babel/core@7.29.0) - transitivePeerDependencies: - - supports-color + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} - '@babel/plugin-transform-exponentiation-operator@7.28.6(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} - '@babel/plugin-transform-export-namespace-from@7.27.1(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 + tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} + engines: {node: ^18.0.0 || >=20.0.0} - '@babel/plugin-transform-for-of@7.27.1(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 - '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 - transitivePeerDependencies: - - supports-color + tinyrainbow@1.2.0: + resolution: {integrity: sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==} + engines: {node: '>=14.0.0'} - '@babel/plugin-transform-function-name@7.27.1(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-compilation-targets': 7.28.6 - '@babel/helper-plugin-utils': 7.28.6 - '@babel/traverse': 7.29.0 - transitivePeerDependencies: - - supports-color + tinyspy@3.0.2: + resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==} + engines: {node: '>=14.0.0'} - '@babel/plugin-transform-json-strings@7.28.6(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 + tmp@0.2.5: + resolution: {integrity: sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==} + engines: {node: '>=14.14'} - '@babel/plugin-transform-literals@7.27.1(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 + tsx@4.21.0: + resolution: {integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==} + engines: {node: '>=18.0.0'} + hasBin: true - '@babel/plugin-transform-logical-assignment-operators@7.28.6(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true - '@babel/plugin-transform-member-expression-literals@7.27.1(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} - '@babel/plugin-transform-modules-amd@7.27.1(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) - '@babel/helper-plugin-utils': 7.28.6 - transitivePeerDependencies: - - supports-color + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} - '@babel/plugin-transform-modules-commonjs@7.28.6(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) - '@babel/helper-plugin-utils': 7.28.6 - transitivePeerDependencies: - - supports-color + uuid@9.0.1: + resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} + hasBin: true - '@babel/plugin-transform-modules-systemjs@7.29.0(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) - '@babel/helper-plugin-utils': 7.28.6 - '@babel/helper-validator-identifier': 7.28.5 - '@babel/traverse': 7.29.0 - transitivePeerDependencies: - - supports-color + vite-node@2.1.9: + resolution: {integrity: sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true - '@babel/plugin-transform-modules-umd@7.27.1(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) - '@babel/helper-plugin-utils': 7.28.6 - transitivePeerDependencies: - - supports-color + vite@5.4.21: + resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || >=20.0.0 + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.4.0 + peerDependenciesMeta: + '@types/node': + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true - '@babel/plugin-transform-named-capturing-groups-regex@7.29.0(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.0) - '@babel/helper-plugin-utils': 7.28.6 + vitest@2.1.9: + resolution: {integrity: sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/node': ^18.0.0 || >=20.0.0 + '@vitest/browser': 2.1.9 + '@vitest/ui': 2.1.9 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true - '@babel/plugin-transform-new-target@7.27.1(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true - '@babel/plugin-transform-nullish-coalescing-operator@7.28.6(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true - '@babel/plugin-transform-numeric-separator@7.28.6(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} - '@babel/plugin-transform-object-rest-spread@7.28.6(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-compilation-targets': 7.28.6 - '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-destructuring': 7.28.5(@babel/core@7.29.0) - '@babel/plugin-transform-parameters': 7.27.7(@babel/core@7.29.0) - '@babel/traverse': 7.29.0 - transitivePeerDependencies: - - supports-color + wrap-ansi@8.1.0: + resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} + engines: {node: '>=12'} - '@babel/plugin-transform-object-super@7.27.1(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 - '@babel/helper-replace-supers': 7.28.6(@babel/core@7.29.0) - transitivePeerDependencies: - - supports-color + ws@8.20.0: + resolution: {integrity: sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true - '@babel/plugin-transform-optional-catch-binding@7.28.6(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 + xstate@5.30.0: + resolution: {integrity: sha512-mIzIuMjtYVkqXq9dUzYQoag7b/dF1CBS/yhliuPLfR0FwKPC18HiUivb/crcqY2gknhR8gJEhnppLg6ubQ0gGw==} - '@babel/plugin-transform-optional-chaining@7.28.6(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 - '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 - transitivePeerDependencies: - - supports-color +snapshots: - '@babel/plugin-transform-parameters@7.27.7(@babel/core@7.29.0)': + '@ampproject/remapping@2.3.0': dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 - '@babel/plugin-transform-private-methods@7.28.6(@babel/core@7.29.0)': + '@automerge/automerge-repo-network-websocket@2.6.0-subduction.15': dependencies: - '@babel/core': 7.29.0 - '@babel/helper-create-class-features-plugin': 7.28.6(@babel/core@7.29.0) - '@babel/helper-plugin-utils': 7.28.6 + '@automerge/automerge-repo': 2.6.0-subduction.15 + cbor-x: 1.6.4 + debug: 4.4.3 + eventemitter3: 5.0.4 + isomorphic-ws: 5.0.0(ws@8.20.0) + ws: 8.20.0 transitivePeerDependencies: + - bufferutil - supports-color + - utf-8-validate - '@babel/plugin-transform-private-property-in-object@7.28.6(@babel/core@7.29.0)': + '@automerge/automerge-repo-storage-nodefs@2.6.0-subduction.15': dependencies: - '@babel/core': 7.29.0 - '@babel/helper-annotate-as-pure': 7.27.3 - '@babel/helper-create-class-features-plugin': 7.28.6(@babel/core@7.29.0) - '@babel/helper-plugin-utils': 7.28.6 + '@automerge/automerge-repo': 2.6.0-subduction.15 + rimraf: 5.0.10 transitivePeerDependencies: + - bufferutil - supports-color + - utf-8-validate - '@babel/plugin-transform-property-literals@7.27.1(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 - - '@babel/plugin-transform-regenerator@7.29.0(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 - - '@babel/plugin-transform-regexp-modifiers@7.28.6(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.0) - '@babel/helper-plugin-utils': 7.28.6 - - '@babel/plugin-transform-reserved-words@7.27.1(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 - - '@babel/plugin-transform-shorthand-properties@7.27.1(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 - - '@babel/plugin-transform-spread@7.28.6(@babel/core@7.29.0)': + '@automerge/automerge-repo@2.6.0-subduction.15': dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 - '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 + '@automerge/automerge': 3.2.6 + '@automerge/automerge-subduction': 0.8.1 + bs58check: 3.0.1 + cbor-x: 1.6.4 + debug: 4.4.3 + eventemitter3: 5.0.4 + fast-sha256: 1.3.0 + isomorphic-ws: 5.0.0(ws@8.20.0) + uuid: 9.0.1 + ws: 8.20.0 + xstate: 5.30.0 transitivePeerDependencies: + - bufferutil - supports-color + - utf-8-validate - '@babel/plugin-transform-sticky-regex@7.27.1(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 - - '@babel/plugin-transform-template-literals@7.27.1(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 - - '@babel/plugin-transform-typeof-symbol@7.27.1(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 - - '@babel/plugin-transform-unicode-escapes@7.27.1(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 - - '@babel/plugin-transform-unicode-property-regex@7.28.6(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.0) - '@babel/helper-plugin-utils': 7.28.6 - - '@babel/plugin-transform-unicode-regex@7.27.1(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.0) - '@babel/helper-plugin-utils': 7.28.6 - - '@babel/plugin-transform-unicode-sets-regex@7.28.6(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.0) - '@babel/helper-plugin-utils': 7.28.6 + '@automerge/automerge-subduction@0.8.1': {} - '@babel/preset-env@7.29.2(@babel/core@7.29.0)': - dependencies: - '@babel/compat-data': 7.29.0 - '@babel/core': 7.29.0 - '@babel/helper-compilation-targets': 7.28.6 - '@babel/helper-plugin-utils': 7.28.6 - '@babel/helper-validator-option': 7.27.1 - '@babel/plugin-bugfix-firefox-class-in-computed-class-key': 7.28.5(@babel/core@7.29.0) - '@babel/plugin-bugfix-safari-class-field-initializer-scope': 7.27.1(@babel/core@7.29.0) - '@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression': 7.27.1(@babel/core@7.29.0) - '@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining': 7.27.1(@babel/core@7.29.0) - '@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly': 7.28.6(@babel/core@7.29.0) - '@babel/plugin-proposal-private-property-in-object': 7.21.0-placeholder-for-preset-env.2(@babel/core@7.29.0) - '@babel/plugin-syntax-import-assertions': 7.28.6(@babel/core@7.29.0) - '@babel/plugin-syntax-import-attributes': 7.28.6(@babel/core@7.29.0) - '@babel/plugin-syntax-unicode-sets-regex': 7.18.6(@babel/core@7.29.0) - '@babel/plugin-transform-arrow-functions': 7.27.1(@babel/core@7.29.0) - '@babel/plugin-transform-async-generator-functions': 7.29.0(@babel/core@7.29.0) - '@babel/plugin-transform-async-to-generator': 7.28.6(@babel/core@7.29.0) - '@babel/plugin-transform-block-scoped-functions': 7.27.1(@babel/core@7.29.0) - '@babel/plugin-transform-block-scoping': 7.28.6(@babel/core@7.29.0) - '@babel/plugin-transform-class-properties': 7.28.6(@babel/core@7.29.0) - '@babel/plugin-transform-class-static-block': 7.28.6(@babel/core@7.29.0) - '@babel/plugin-transform-classes': 7.28.6(@babel/core@7.29.0) - '@babel/plugin-transform-computed-properties': 7.28.6(@babel/core@7.29.0) - '@babel/plugin-transform-destructuring': 7.28.5(@babel/core@7.29.0) - '@babel/plugin-transform-dotall-regex': 7.28.6(@babel/core@7.29.0) - '@babel/plugin-transform-duplicate-keys': 7.27.1(@babel/core@7.29.0) - '@babel/plugin-transform-duplicate-named-capturing-groups-regex': 7.29.0(@babel/core@7.29.0) - '@babel/plugin-transform-dynamic-import': 7.27.1(@babel/core@7.29.0) - '@babel/plugin-transform-explicit-resource-management': 7.28.6(@babel/core@7.29.0) - '@babel/plugin-transform-exponentiation-operator': 7.28.6(@babel/core@7.29.0) - '@babel/plugin-transform-export-namespace-from': 7.27.1(@babel/core@7.29.0) - '@babel/plugin-transform-for-of': 7.27.1(@babel/core@7.29.0) - '@babel/plugin-transform-function-name': 7.27.1(@babel/core@7.29.0) - '@babel/plugin-transform-json-strings': 7.28.6(@babel/core@7.29.0) - '@babel/plugin-transform-literals': 7.27.1(@babel/core@7.29.0) - '@babel/plugin-transform-logical-assignment-operators': 7.28.6(@babel/core@7.29.0) - '@babel/plugin-transform-member-expression-literals': 7.27.1(@babel/core@7.29.0) - '@babel/plugin-transform-modules-amd': 7.27.1(@babel/core@7.29.0) - '@babel/plugin-transform-modules-commonjs': 7.28.6(@babel/core@7.29.0) - '@babel/plugin-transform-modules-systemjs': 7.29.0(@babel/core@7.29.0) - '@babel/plugin-transform-modules-umd': 7.27.1(@babel/core@7.29.0) - '@babel/plugin-transform-named-capturing-groups-regex': 7.29.0(@babel/core@7.29.0) - '@babel/plugin-transform-new-target': 7.27.1(@babel/core@7.29.0) - '@babel/plugin-transform-nullish-coalescing-operator': 7.28.6(@babel/core@7.29.0) - '@babel/plugin-transform-numeric-separator': 7.28.6(@babel/core@7.29.0) - '@babel/plugin-transform-object-rest-spread': 7.28.6(@babel/core@7.29.0) - '@babel/plugin-transform-object-super': 7.27.1(@babel/core@7.29.0) - '@babel/plugin-transform-optional-catch-binding': 7.28.6(@babel/core@7.29.0) - '@babel/plugin-transform-optional-chaining': 7.28.6(@babel/core@7.29.0) - '@babel/plugin-transform-parameters': 7.27.7(@babel/core@7.29.0) - '@babel/plugin-transform-private-methods': 7.28.6(@babel/core@7.29.0) - '@babel/plugin-transform-private-property-in-object': 7.28.6(@babel/core@7.29.0) - '@babel/plugin-transform-property-literals': 7.27.1(@babel/core@7.29.0) - '@babel/plugin-transform-regenerator': 7.29.0(@babel/core@7.29.0) - '@babel/plugin-transform-regexp-modifiers': 7.28.6(@babel/core@7.29.0) - '@babel/plugin-transform-reserved-words': 7.27.1(@babel/core@7.29.0) - '@babel/plugin-transform-shorthand-properties': 7.27.1(@babel/core@7.29.0) - '@babel/plugin-transform-spread': 7.28.6(@babel/core@7.29.0) - '@babel/plugin-transform-sticky-regex': 7.27.1(@babel/core@7.29.0) - '@babel/plugin-transform-template-literals': 7.27.1(@babel/core@7.29.0) - '@babel/plugin-transform-typeof-symbol': 7.27.1(@babel/core@7.29.0) - '@babel/plugin-transform-unicode-escapes': 7.27.1(@babel/core@7.29.0) - '@babel/plugin-transform-unicode-property-regex': 7.28.6(@babel/core@7.29.0) - '@babel/plugin-transform-unicode-regex': 7.27.1(@babel/core@7.29.0) - '@babel/plugin-transform-unicode-sets-regex': 7.28.6(@babel/core@7.29.0) - '@babel/preset-modules': 0.1.6-no-external-plugins(@babel/core@7.29.0) - babel-plugin-polyfill-corejs2: 0.4.17(@babel/core@7.29.0) - babel-plugin-polyfill-corejs3: 0.14.2(@babel/core@7.29.0) - babel-plugin-polyfill-regenerator: 0.6.8(@babel/core@7.29.0) - core-js-compat: 3.49.0 - semver: 6.3.1 - transitivePeerDependencies: - - supports-color + '@automerge/automerge@3.2.6': {} - '@babel/preset-modules@0.1.6-no-external-plugins(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 - '@babel/types': 7.29.0 - esutils: 2.0.3 + '@babel/helper-string-parser@7.27.1': {} - '@babel/template@7.28.6': - dependencies: - '@babel/code-frame': 7.29.0 - '@babel/parser': 7.29.2 - '@babel/types': 7.29.0 + '@babel/helper-validator-identifier@7.28.5': {} - '@babel/traverse@7.29.0': + '@babel/parser@7.29.2': dependencies: - '@babel/code-frame': 7.29.0 - '@babel/generator': 7.29.1 - '@babel/helper-globals': 7.28.0 - '@babel/parser': 7.29.2 - '@babel/template': 7.28.6 '@babel/types': 7.29.0 - debug: 4.4.3 - transitivePeerDependencies: - - supports-color '@babel/types@7.29.0': dependencies: @@ -3049,81 +1310,150 @@ snapshots: dependencies: commander: 14.0.3 + '@esbuild/aix-ppc64@0.21.5': + optional: true + '@esbuild/aix-ppc64@0.27.7': optional: true + '@esbuild/android-arm64@0.21.5': + optional: true + '@esbuild/android-arm64@0.27.7': optional: true + '@esbuild/android-arm@0.21.5': + optional: true + '@esbuild/android-arm@0.27.7': optional: true + '@esbuild/android-x64@0.21.5': + optional: true + '@esbuild/android-x64@0.27.7': optional: true + '@esbuild/darwin-arm64@0.21.5': + optional: true + '@esbuild/darwin-arm64@0.27.7': optional: true + '@esbuild/darwin-x64@0.21.5': + optional: true + '@esbuild/darwin-x64@0.27.7': optional: true + '@esbuild/freebsd-arm64@0.21.5': + optional: true + '@esbuild/freebsd-arm64@0.27.7': optional: true + '@esbuild/freebsd-x64@0.21.5': + optional: true + '@esbuild/freebsd-x64@0.27.7': optional: true + '@esbuild/linux-arm64@0.21.5': + optional: true + '@esbuild/linux-arm64@0.27.7': optional: true + '@esbuild/linux-arm@0.21.5': + optional: true + '@esbuild/linux-arm@0.27.7': optional: true + '@esbuild/linux-ia32@0.21.5': + optional: true + '@esbuild/linux-ia32@0.27.7': optional: true + '@esbuild/linux-loong64@0.21.5': + optional: true + '@esbuild/linux-loong64@0.27.7': optional: true + '@esbuild/linux-mips64el@0.21.5': + optional: true + '@esbuild/linux-mips64el@0.27.7': optional: true + '@esbuild/linux-ppc64@0.21.5': + optional: true + '@esbuild/linux-ppc64@0.27.7': optional: true + '@esbuild/linux-riscv64@0.21.5': + optional: true + '@esbuild/linux-riscv64@0.27.7': optional: true + '@esbuild/linux-s390x@0.21.5': + optional: true + '@esbuild/linux-s390x@0.27.7': optional: true + '@esbuild/linux-x64@0.21.5': + optional: true + '@esbuild/linux-x64@0.27.7': optional: true '@esbuild/netbsd-arm64@0.27.7': optional: true + '@esbuild/netbsd-x64@0.21.5': + optional: true + '@esbuild/netbsd-x64@0.27.7': optional: true '@esbuild/openbsd-arm64@0.27.7': optional: true + '@esbuild/openbsd-x64@0.21.5': + optional: true + '@esbuild/openbsd-x64@0.27.7': optional: true '@esbuild/openharmony-arm64@0.27.7': optional: true + '@esbuild/sunos-x64@0.21.5': + optional: true + '@esbuild/sunos-x64@0.27.7': optional: true + '@esbuild/win32-arm64@0.21.5': + optional: true + '@esbuild/win32-arm64@0.27.7': optional: true + '@esbuild/win32-ia32@0.21.5': + optional: true + '@esbuild/win32-ia32@0.27.7': optional: true + '@esbuild/win32-x64@0.21.5': + optional: true + '@esbuild/win32-x64@0.27.7': optional: true @@ -3136,226 +1466,13 @@ snapshots: wrap-ansi: 8.1.0 wrap-ansi-cjs: wrap-ansi@7.0.0 - '@istanbuljs/load-nyc-config@1.1.0': - dependencies: - camelcase: 5.3.1 - find-up: 4.1.0 - get-package-type: 0.1.0 - js-yaml: 3.14.2 - resolve-from: 5.0.0 - '@istanbuljs/schema@0.1.3': {} - '@jest/console@29.7.0': - dependencies: - '@jest/types': 29.6.3 - '@types/node': 20.19.39 - chalk: 4.1.2 - jest-message-util: 29.7.0 - jest-util: 29.7.0 - slash: 3.0.0 - - '@jest/core@29.7.0': - dependencies: - '@jest/console': 29.7.0 - '@jest/reporters': 29.7.0 - '@jest/test-result': 29.7.0 - '@jest/transform': 29.7.0 - '@jest/types': 29.6.3 - '@types/node': 20.19.39 - ansi-escapes: 4.3.2 - chalk: 4.1.2 - ci-info: 3.9.0 - exit: 0.1.2 - graceful-fs: 4.2.11 - jest-changed-files: 29.7.0 - jest-config: 29.7.0(@types/node@20.19.39) - jest-haste-map: 29.7.0 - jest-message-util: 29.7.0 - jest-regex-util: 29.6.3 - jest-resolve: 29.7.0 - jest-resolve-dependencies: 29.7.0 - jest-runner: 29.7.0 - jest-runtime: 29.7.0 - jest-snapshot: 29.7.0 - jest-util: 29.7.0 - jest-validate: 29.7.0 - jest-watcher: 29.7.0 - micromatch: 4.0.8 - pretty-format: 29.7.0 - slash: 3.0.0 - strip-ansi: 6.0.1 - transitivePeerDependencies: - - babel-plugin-macros - - supports-color - - ts-node - - '@jest/environment@29.7.0': - dependencies: - '@jest/fake-timers': 29.7.0 - '@jest/types': 29.6.3 - '@types/node': 20.19.39 - jest-mock: 29.7.0 - - '@jest/expect-utils@29.7.0': - dependencies: - jest-get-type: 29.6.3 - - '@jest/expect@29.7.0': - dependencies: - expect: 29.7.0 - jest-snapshot: 29.7.0 - transitivePeerDependencies: - - supports-color - - '@jest/fake-timers@29.7.0': - dependencies: - '@jest/types': 29.6.3 - '@sinonjs/fake-timers': 10.3.0 - '@types/node': 20.19.39 - jest-message-util: 29.7.0 - jest-mock: 29.7.0 - jest-util: 29.7.0 - - '@jest/globals@29.7.0': - dependencies: - '@jest/environment': 29.7.0 - '@jest/expect': 29.7.0 - '@jest/types': 29.6.3 - jest-mock: 29.7.0 - transitivePeerDependencies: - - supports-color - - '@jest/pattern@30.0.1': - dependencies: - '@types/node': 20.19.39 - jest-regex-util: 30.0.1 - - '@jest/reporters@29.7.0': - dependencies: - '@bcoe/v8-coverage': 0.2.3 - '@jest/console': 29.7.0 - '@jest/test-result': 29.7.0 - '@jest/transform': 29.7.0 - '@jest/types': 29.6.3 - '@jridgewell/trace-mapping': 0.3.31 - '@types/node': 20.19.39 - chalk: 4.1.2 - collect-v8-coverage: 1.0.3 - exit: 0.1.2 - glob: 7.2.3 - graceful-fs: 4.2.11 - istanbul-lib-coverage: 3.2.2 - istanbul-lib-instrument: 6.0.3 - istanbul-lib-report: 3.0.1 - istanbul-lib-source-maps: 4.0.1 - istanbul-reports: 3.2.0 - jest-message-util: 29.7.0 - jest-util: 29.7.0 - jest-worker: 29.7.0 - slash: 3.0.0 - string-length: 4.0.2 - strip-ansi: 6.0.1 - v8-to-istanbul: 9.3.0 - transitivePeerDependencies: - - supports-color - - '@jest/schemas@29.6.3': - dependencies: - '@sinclair/typebox': 0.27.10 - - '@jest/schemas@30.0.5': - dependencies: - '@sinclair/typebox': 0.34.49 - - '@jest/source-map@29.6.3': - dependencies: - '@jridgewell/trace-mapping': 0.3.31 - callsites: 3.1.0 - graceful-fs: 4.2.11 - - '@jest/test-result@29.7.0': - dependencies: - '@jest/console': 29.7.0 - '@jest/types': 29.6.3 - '@types/istanbul-lib-coverage': 2.0.6 - collect-v8-coverage: 1.0.3 - - '@jest/test-sequencer@29.7.0': - dependencies: - '@jest/test-result': 29.7.0 - graceful-fs: 4.2.11 - jest-haste-map: 29.7.0 - slash: 3.0.0 - - '@jest/transform@29.7.0': - dependencies: - '@babel/core': 7.29.0 - '@jest/types': 29.6.3 - '@jridgewell/trace-mapping': 0.3.31 - babel-plugin-istanbul: 6.1.1 - chalk: 4.1.2 - convert-source-map: 2.0.0 - fast-json-stable-stringify: 2.1.0 - graceful-fs: 4.2.11 - jest-haste-map: 29.7.0 - jest-regex-util: 29.6.3 - jest-util: 29.7.0 - micromatch: 4.0.8 - pirates: 4.0.7 - slash: 3.0.0 - write-file-atomic: 4.0.2 - transitivePeerDependencies: - - supports-color - - '@jest/transform@30.3.0': - dependencies: - '@babel/core': 7.29.0 - '@jest/types': 30.3.0 - '@jridgewell/trace-mapping': 0.3.31 - babel-plugin-istanbul: 7.0.1 - chalk: 4.1.2 - convert-source-map: 2.0.0 - fast-json-stable-stringify: 2.1.0 - graceful-fs: 4.2.11 - jest-haste-map: 30.3.0 - jest-regex-util: 30.0.1 - jest-util: 30.3.0 - pirates: 4.0.7 - slash: 3.0.0 - write-file-atomic: 5.0.1 - transitivePeerDependencies: - - supports-color - - '@jest/types@29.6.3': - dependencies: - '@jest/schemas': 29.6.3 - '@types/istanbul-lib-coverage': 2.0.6 - '@types/istanbul-reports': 3.0.4 - '@types/node': 20.19.39 - '@types/yargs': 17.0.35 - chalk: 4.1.2 - - '@jest/types@30.3.0': - dependencies: - '@jest/pattern': 30.0.1 - '@jest/schemas': 30.0.5 - '@types/istanbul-lib-coverage': 2.0.6 - '@types/istanbul-reports': 3.0.4 - '@types/node': 20.19.39 - '@types/yargs': 17.0.35 - chalk: 4.1.2 - '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 '@jridgewell/trace-mapping': 0.3.31 - '@jridgewell/remapping@2.3.5': - dependencies: - '@jridgewell/gen-mapping': 0.3.13 - '@jridgewell/trace-mapping': 0.3.31 - '@jridgewell/resolve-uri@3.1.2': {} '@jridgewell/sourcemap-codec@1.5.5': {} @@ -3370,253 +1487,184 @@ snapshots: '@pkgjs/parseargs@0.11.0': optional: true - '@sinclair/typebox@0.27.10': {} + '@rollup/rollup-android-arm-eabi@4.60.2': + optional: true - '@sinclair/typebox@0.34.49': {} + '@rollup/rollup-android-arm64@4.60.2': + optional: true - '@sinonjs/commons@3.0.1': - dependencies: - type-detect: 4.0.8 + '@rollup/rollup-darwin-arm64@4.60.2': + optional: true - '@sinonjs/fake-timers@10.3.0': - dependencies: - '@sinonjs/commons': 3.0.1 + '@rollup/rollup-darwin-x64@4.60.2': + optional: true - '@types/babel__core@7.20.5': - dependencies: - '@babel/parser': 7.29.2 - '@babel/types': 7.29.0 - '@types/babel__generator': 7.27.0 - '@types/babel__template': 7.4.4 - '@types/babel__traverse': 7.28.0 + '@rollup/rollup-freebsd-arm64@4.60.2': + optional: true - '@types/babel__generator@7.27.0': - dependencies: - '@babel/types': 7.29.0 + '@rollup/rollup-freebsd-x64@4.60.2': + optional: true - '@types/babel__template@7.4.4': - dependencies: - '@babel/parser': 7.29.2 - '@babel/types': 7.29.0 + '@rollup/rollup-linux-arm-gnueabihf@4.60.2': + optional: true - '@types/babel__traverse@7.28.0': - dependencies: - '@babel/types': 7.29.0 + '@rollup/rollup-linux-arm-musleabihf@4.60.2': + optional: true - '@types/diff@5.2.3': {} + '@rollup/rollup-linux-arm64-gnu@4.60.2': + optional: true - '@types/graceful-fs@4.1.9': - dependencies: - '@types/node': 20.19.39 + '@rollup/rollup-linux-arm64-musl@4.60.2': + optional: true - '@types/istanbul-lib-coverage@2.0.6': {} + '@rollup/rollup-linux-loong64-gnu@4.60.2': + optional: true - '@types/istanbul-lib-report@3.0.3': - dependencies: - '@types/istanbul-lib-coverage': 2.0.6 + '@rollup/rollup-linux-loong64-musl@4.60.2': + optional: true - '@types/istanbul-reports@3.0.4': - dependencies: - '@types/istanbul-lib-report': 3.0.3 + '@rollup/rollup-linux-ppc64-gnu@4.60.2': + optional: true - '@types/jest@29.5.14': - dependencies: - expect: 29.7.0 - pretty-format: 29.7.0 + '@rollup/rollup-linux-ppc64-musl@4.60.2': + optional: true - '@types/mime-types@2.1.4': {} + '@rollup/rollup-linux-riscv64-gnu@4.60.2': + optional: true - '@types/node@20.19.39': - dependencies: - undici-types: 6.21.0 + '@rollup/rollup-linux-riscv64-musl@4.60.2': + optional: true - '@types/stack-utils@2.0.3': {} + '@rollup/rollup-linux-s390x-gnu@4.60.2': + optional: true - '@types/tmp@0.2.6': {} + '@rollup/rollup-linux-x64-gnu@4.60.2': + optional: true - '@types/yargs-parser@21.0.3': {} + '@rollup/rollup-linux-x64-musl@4.60.2': + optional: true - '@types/yargs@17.0.35': - dependencies: - '@types/yargs-parser': 21.0.3 + '@rollup/rollup-openbsd-x64@4.60.2': + optional: true - '@ungap/structured-clone@1.3.0': {} + '@rollup/rollup-openharmony-arm64@4.60.2': + optional: true - ansi-escapes@4.3.2: - dependencies: - type-fest: 0.21.3 + '@rollup/rollup-win32-arm64-msvc@4.60.2': + optional: true - ansi-regex@5.0.1: {} + '@rollup/rollup-win32-ia32-msvc@4.60.2': + optional: true - ansi-regex@6.2.2: {} + '@rollup/rollup-win32-x64-gnu@4.60.2': + optional: true - ansi-styles@4.3.0: - dependencies: - color-convert: 2.0.1 + '@rollup/rollup-win32-x64-msvc@4.60.2': + optional: true - ansi-styles@5.2.0: {} + '@types/diff@5.2.3': {} - ansi-styles@6.2.3: {} + '@types/estree@1.0.8': {} - anymatch@3.1.3: - dependencies: - normalize-path: 3.0.0 - picomatch: 2.3.2 + '@types/mime-types@2.1.4': {} - argparse@1.0.10: + '@types/node@20.19.39': dependencies: - sprintf-js: 1.0.3 + undici-types: 6.21.0 - babel-jest@29.7.0(@babel/core@7.29.0): - dependencies: - '@babel/core': 7.29.0 - '@jest/transform': 29.7.0 - '@types/babel__core': 7.20.5 - babel-plugin-istanbul: 6.1.1 - babel-preset-jest: 29.6.3(@babel/core@7.29.0) - chalk: 4.1.2 - graceful-fs: 4.2.11 - slash: 3.0.0 - transitivePeerDependencies: - - supports-color + '@types/tmp@0.2.6': {} - babel-jest@30.3.0(@babel/core@7.29.0): + '@vitest/coverage-v8@2.1.9(vitest@2.1.9(@types/node@20.19.39))': dependencies: - '@babel/core': 7.29.0 - '@jest/transform': 30.3.0 - '@types/babel__core': 7.20.5 - babel-plugin-istanbul: 7.0.1 - babel-preset-jest: 30.3.0(@babel/core@7.29.0) - chalk: 4.1.2 - graceful-fs: 4.2.11 - slash: 3.0.0 + '@ampproject/remapping': 2.3.0 + '@bcoe/v8-coverage': 0.2.3 + debug: 4.4.3 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-lib-source-maps: 5.0.6 + istanbul-reports: 3.2.0 + magic-string: 0.30.21 + magicast: 0.3.5 + std-env: 3.10.0 + test-exclude: 7.0.2 + tinyrainbow: 1.2.0 + vitest: 2.1.9(@types/node@20.19.39) transitivePeerDependencies: - supports-color - babel-plugin-istanbul@6.1.1: + '@vitest/expect@2.1.9': dependencies: - '@babel/helper-plugin-utils': 7.28.6 - '@istanbuljs/load-nyc-config': 1.1.0 - '@istanbuljs/schema': 0.1.3 - istanbul-lib-instrument: 5.2.1 - test-exclude: 6.0.0 - transitivePeerDependencies: - - supports-color + '@vitest/spy': 2.1.9 + '@vitest/utils': 2.1.9 + chai: 5.3.3 + tinyrainbow: 1.2.0 - babel-plugin-istanbul@7.0.1: + '@vitest/mocker@2.1.9(vite@5.4.21(@types/node@20.19.39))': dependencies: - '@babel/helper-plugin-utils': 7.28.6 - '@istanbuljs/load-nyc-config': 1.1.0 - '@istanbuljs/schema': 0.1.3 - istanbul-lib-instrument: 6.0.3 - test-exclude: 6.0.0 - transitivePeerDependencies: - - supports-color + '@vitest/spy': 2.1.9 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 5.4.21(@types/node@20.19.39) - babel-plugin-jest-hoist@29.6.3: + '@vitest/pretty-format@2.1.9': dependencies: - '@babel/template': 7.28.6 - '@babel/types': 7.29.0 - '@types/babel__core': 7.20.5 - '@types/babel__traverse': 7.28.0 + tinyrainbow: 1.2.0 - babel-plugin-jest-hoist@30.3.0: + '@vitest/runner@2.1.9': dependencies: - '@types/babel__core': 7.20.5 + '@vitest/utils': 2.1.9 + pathe: 1.1.2 - babel-plugin-polyfill-corejs2@0.4.17(@babel/core@7.29.0): + '@vitest/snapshot@2.1.9': dependencies: - '@babel/compat-data': 7.29.0 - '@babel/core': 7.29.0 - '@babel/helper-define-polyfill-provider': 0.6.8(@babel/core@7.29.0) - semver: 6.3.1 - transitivePeerDependencies: - - supports-color + '@vitest/pretty-format': 2.1.9 + magic-string: 0.30.21 + pathe: 1.1.2 - babel-plugin-polyfill-corejs3@0.14.2(@babel/core@7.29.0): + '@vitest/spy@2.1.9': dependencies: - '@babel/core': 7.29.0 - '@babel/helper-define-polyfill-provider': 0.6.8(@babel/core@7.29.0) - core-js-compat: 3.49.0 - transitivePeerDependencies: - - supports-color + tinyspy: 3.0.2 - babel-plugin-polyfill-regenerator@0.6.8(@babel/core@7.29.0): + '@vitest/utils@2.1.9': dependencies: - '@babel/core': 7.29.0 - '@babel/helper-define-polyfill-provider': 0.6.8(@babel/core@7.29.0) - transitivePeerDependencies: - - supports-color + '@vitest/pretty-format': 2.1.9 + loupe: 3.2.1 + tinyrainbow: 1.2.0 - babel-preset-current-node-syntax@1.2.0(@babel/core@7.29.0): - dependencies: - '@babel/core': 7.29.0 - '@babel/plugin-syntax-async-generators': 7.8.4(@babel/core@7.29.0) - '@babel/plugin-syntax-bigint': 7.8.3(@babel/core@7.29.0) - '@babel/plugin-syntax-class-properties': 7.12.13(@babel/core@7.29.0) - '@babel/plugin-syntax-class-static-block': 7.14.5(@babel/core@7.29.0) - '@babel/plugin-syntax-import-attributes': 7.28.6(@babel/core@7.29.0) - '@babel/plugin-syntax-import-meta': 7.10.4(@babel/core@7.29.0) - '@babel/plugin-syntax-json-strings': 7.8.3(@babel/core@7.29.0) - '@babel/plugin-syntax-logical-assignment-operators': 7.10.4(@babel/core@7.29.0) - '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3(@babel/core@7.29.0) - '@babel/plugin-syntax-numeric-separator': 7.10.4(@babel/core@7.29.0) - '@babel/plugin-syntax-object-rest-spread': 7.8.3(@babel/core@7.29.0) - '@babel/plugin-syntax-optional-catch-binding': 7.8.3(@babel/core@7.29.0) - '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.29.0) - '@babel/plugin-syntax-private-property-in-object': 7.14.5(@babel/core@7.29.0) - '@babel/plugin-syntax-top-level-await': 7.14.5(@babel/core@7.29.0) - - babel-preset-jest@29.6.3(@babel/core@7.29.0): - dependencies: - '@babel/core': 7.29.0 - babel-plugin-jest-hoist: 29.6.3 - babel-preset-current-node-syntax: 1.2.0(@babel/core@7.29.0) + ansi-regex@5.0.1: {} - babel-preset-jest@30.3.0(@babel/core@7.29.0): + ansi-regex@6.2.2: {} + + ansi-styles@4.3.0: dependencies: - '@babel/core': 7.29.0 - babel-plugin-jest-hoist: 30.3.0 - babel-preset-current-node-syntax: 1.2.0(@babel/core@7.29.0) + color-convert: 2.0.1 + + ansi-styles@6.2.3: {} + + assertion-error@2.0.1: {} balanced-match@1.0.2: {} + balanced-match@4.0.4: {} + base-x@4.0.1: {} base64-js@1.5.1: {} - baseline-browser-mapping@2.10.16: {} - bl@5.1.0: dependencies: buffer: 6.0.3 inherits: 2.0.4 readable-stream: 3.6.2 - brace-expansion@1.1.13: - dependencies: - balanced-match: 1.0.2 - concat-map: 0.0.1 - brace-expansion@2.0.3: dependencies: balanced-match: 1.0.2 - braces@3.0.3: - dependencies: - fill-range: 7.1.1 - - browserslist@4.28.2: - dependencies: - baseline-browser-mapping: 2.10.16 - caniuse-lite: 1.0.30001787 - electron-to-chromium: 1.5.334 - node-releases: 2.0.37 - update-browserslist-db: 1.2.3(browserslist@4.28.2) - - bs-logger@0.2.6: + brace-expansion@5.0.5: dependencies: - fast-json-stable-stringify: 2.1.0 + balanced-match: 4.0.4 bs58@5.0.0: dependencies: @@ -3627,24 +1675,12 @@ snapshots: '@noble/hashes': 1.8.0 bs58: 5.0.0 - bser@2.1.1: - dependencies: - node-int64: 0.4.0 - - buffer-from@1.1.2: {} - buffer@6.0.3: dependencies: base64-js: 1.5.1 ieee754: 1.2.1 - callsites@3.1.0: {} - - camelcase@5.3.1: {} - - camelcase@6.3.0: {} - - caniuse-lite@1.0.30001787: {} + cac@6.7.14: {} cbor-extract@2.2.2: dependencies: @@ -3662,20 +1698,17 @@ snapshots: optionalDependencies: cbor-extract: 2.2.2 - chalk@4.1.2: + chai@5.3.3: dependencies: - ansi-styles: 4.3.0 - supports-color: 7.2.0 + assertion-error: 2.0.1 + check-error: 2.1.3 + deep-eql: 5.0.2 + loupe: 3.2.1 + pathval: 2.0.1 chalk@5.6.2: {} - char-regex@1.0.2: {} - - ci-info@3.9.0: {} - - ci-info@4.4.0: {} - - cjs-module-lexer@1.4.3: {} + check-error@2.1.3: {} cli-cursor@4.0.0: dependencies: @@ -3683,16 +1716,6 @@ snapshots: cli-spinners@2.9.2: {} - cliui@8.0.1: - dependencies: - string-width: 4.2.3 - strip-ansi: 6.0.1 - wrap-ansi: 7.0.0 - - co@4.6.0: {} - - collect-v8-coverage@1.0.3: {} - color-convert@2.0.1: dependencies: color-name: 1.1.4 @@ -3701,29 +1724,6 @@ snapshots: commander@14.0.3: {} - concat-map@0.0.1: {} - - convert-source-map@2.0.0: {} - - core-js-compat@3.49.0: - dependencies: - browserslist: 4.28.2 - - create-jest@29.7.0(@types/node@20.19.39): - dependencies: - '@jest/types': 29.6.3 - chalk: 4.1.2 - exit: 0.1.2 - graceful-fs: 4.2.11 - jest-config: 29.7.0(@types/node@20.19.39) - jest-util: 29.7.0 - prompts: 2.4.2 - transitivePeerDependencies: - - '@types/node' - - babel-plugin-macros - - supports-color - - ts-node - cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -3734,665 +1734,184 @@ snapshots: dependencies: ms: 2.1.3 - dedent@1.7.2: {} - - deepmerge@4.3.1: {} + deep-eql@5.0.2: {} detect-libc@2.1.2: optional: true - detect-newline@3.1.0: {} - - diff-sequences@29.6.3: {} - diff@8.0.4: {} eastasianwidth@0.2.0: {} - electron-to-chromium@1.5.334: {} - - emittery@0.13.1: {} - emoji-regex@10.6.0: {} emoji-regex@8.0.0: {} emoji-regex@9.2.2: {} - error-ex@1.3.4: - dependencies: - is-arrayish: 0.2.1 - - esbuild@0.27.7: - optionalDependencies: - '@esbuild/aix-ppc64': 0.27.7 - '@esbuild/android-arm': 0.27.7 - '@esbuild/android-arm64': 0.27.7 - '@esbuild/android-x64': 0.27.7 - '@esbuild/darwin-arm64': 0.27.7 - '@esbuild/darwin-x64': 0.27.7 - '@esbuild/freebsd-arm64': 0.27.7 - '@esbuild/freebsd-x64': 0.27.7 - '@esbuild/linux-arm': 0.27.7 - '@esbuild/linux-arm64': 0.27.7 - '@esbuild/linux-ia32': 0.27.7 - '@esbuild/linux-loong64': 0.27.7 - '@esbuild/linux-mips64el': 0.27.7 - '@esbuild/linux-ppc64': 0.27.7 - '@esbuild/linux-riscv64': 0.27.7 - '@esbuild/linux-s390x': 0.27.7 - '@esbuild/linux-x64': 0.27.7 - '@esbuild/netbsd-arm64': 0.27.7 - '@esbuild/netbsd-x64': 0.27.7 - '@esbuild/openbsd-arm64': 0.27.7 - '@esbuild/openbsd-x64': 0.27.7 - '@esbuild/openharmony-arm64': 0.27.7 - '@esbuild/sunos-x64': 0.27.7 - '@esbuild/win32-arm64': 0.27.7 - '@esbuild/win32-ia32': 0.27.7 - '@esbuild/win32-x64': 0.27.7 - - escalade@3.2.0: {} - - escape-string-regexp@2.0.0: {} - - esprima@4.0.1: {} - - esutils@2.0.3: {} - - eventemitter3@5.0.4: {} - - execa@5.1.1: - dependencies: - cross-spawn: 7.0.6 - get-stream: 6.0.1 - human-signals: 2.1.0 - is-stream: 2.0.1 - merge-stream: 2.0.0 - npm-run-path: 4.0.1 - onetime: 5.1.2 - signal-exit: 3.0.7 - strip-final-newline: 2.0.0 - - exit@0.1.2: {} - - expect@29.7.0: - dependencies: - '@jest/expect-utils': 29.7.0 - jest-get-type: 29.6.3 - jest-matcher-utils: 29.7.0 - jest-message-util: 29.7.0 - jest-util: 29.7.0 - - fast-check@4.6.0: - dependencies: - pure-rand: 8.4.0 - - fast-json-stable-stringify@2.1.0: {} + es-module-lexer@1.7.0: {} - fast-sha256@1.3.0: {} - - fb-watchman@2.0.2: - dependencies: - bser: 2.1.1 - - fill-range@7.1.1: - dependencies: - to-regex-range: 5.0.1 - - find-up@4.1.0: - dependencies: - locate-path: 5.0.0 - path-exists: 4.0.0 - - foreground-child@3.3.1: - dependencies: - cross-spawn: 7.0.6 - signal-exit: 4.1.0 - - fs.realpath@1.0.0: {} - - fsevents@2.3.3: - optional: true - - function-bind@1.1.2: {} - - gensync@1.0.0-beta.2: {} - - get-caller-file@2.0.5: {} - - get-package-type@0.1.0: {} - - get-stream@6.0.1: {} - - get-tsconfig@4.13.7: - dependencies: - resolve-pkg-maps: 1.0.0 - - glob@10.5.0: - dependencies: - foreground-child: 3.3.1 - jackspeak: 3.4.3 - minimatch: 9.0.9 - minipass: 7.1.3 - package-json-from-dist: 1.0.1 - path-scurry: 1.11.1 - - glob@7.2.3: - dependencies: - fs.realpath: 1.0.0 - inflight: 1.0.6 - inherits: 2.0.4 - minimatch: 3.1.5 - once: 1.4.0 - path-is-absolute: 1.0.1 - - graceful-fs@4.2.11: {} - - handlebars@4.7.9: - dependencies: - minimist: 1.2.8 - neo-async: 2.6.2 - source-map: 0.6.1 - wordwrap: 1.0.0 - optionalDependencies: - uglify-js: 3.19.3 - - has-flag@4.0.0: {} - - hasown@2.0.2: - dependencies: - function-bind: 1.1.2 - - html-escaper@2.0.2: {} - - human-signals@2.1.0: {} - - ieee754@1.2.1: {} - - ignore@5.3.2: {} - - import-local@3.2.0: - dependencies: - pkg-dir: 4.2.0 - resolve-cwd: 3.0.0 - - imurmurhash@0.1.4: {} - - inflight@1.0.6: - dependencies: - once: 1.4.0 - wrappy: 1.0.2 - - inherits@2.0.4: {} - - is-arrayish@0.2.1: {} - - is-core-module@2.16.1: - dependencies: - hasown: 2.0.2 - - is-fullwidth-code-point@3.0.0: {} - - is-generator-fn@2.1.0: {} - - is-interactive@2.0.0: {} - - is-number@7.0.0: {} - - is-stream@2.0.1: {} - - is-unicode-supported@1.3.0: {} - - isexe@2.0.0: {} - - isomorphic-ws@5.0.0(ws@8.20.0): - dependencies: - ws: 8.20.0 - - istanbul-lib-coverage@3.2.2: {} - - istanbul-lib-instrument@5.2.1: - dependencies: - '@babel/core': 7.29.0 - '@babel/parser': 7.29.2 - '@istanbuljs/schema': 0.1.3 - istanbul-lib-coverage: 3.2.2 - semver: 6.3.1 - transitivePeerDependencies: - - supports-color - - istanbul-lib-instrument@6.0.3: - dependencies: - '@babel/core': 7.29.0 - '@babel/parser': 7.29.2 - '@istanbuljs/schema': 0.1.3 - istanbul-lib-coverage: 3.2.2 - semver: 7.7.4 - transitivePeerDependencies: - - supports-color - - istanbul-lib-report@3.0.1: - dependencies: - istanbul-lib-coverage: 3.2.2 - make-dir: 4.0.0 - supports-color: 7.2.0 - - istanbul-lib-source-maps@4.0.1: - dependencies: - debug: 4.4.3 - istanbul-lib-coverage: 3.2.2 - source-map: 0.6.1 - transitivePeerDependencies: - - supports-color - - istanbul-reports@3.2.0: - dependencies: - html-escaper: 2.0.2 - istanbul-lib-report: 3.0.1 - - jackspeak@3.4.3: - dependencies: - '@isaacs/cliui': 8.0.2 + esbuild@0.21.5: optionalDependencies: - '@pkgjs/parseargs': 0.11.0 - - jest-changed-files@29.7.0: - dependencies: - execa: 5.1.1 - jest-util: 29.7.0 - p-limit: 3.1.0 - - jest-circus@29.7.0: - dependencies: - '@jest/environment': 29.7.0 - '@jest/expect': 29.7.0 - '@jest/test-result': 29.7.0 - '@jest/types': 29.6.3 - '@types/node': 20.19.39 - chalk: 4.1.2 - co: 4.6.0 - dedent: 1.7.2 - is-generator-fn: 2.1.0 - jest-each: 29.7.0 - jest-matcher-utils: 29.7.0 - jest-message-util: 29.7.0 - jest-runtime: 29.7.0 - jest-snapshot: 29.7.0 - jest-util: 29.7.0 - p-limit: 3.1.0 - pretty-format: 29.7.0 - pure-rand: 6.1.0 - slash: 3.0.0 - stack-utils: 2.0.6 - transitivePeerDependencies: - - babel-plugin-macros - - supports-color - - jest-cli@29.7.0(@types/node@20.19.39): - dependencies: - '@jest/core': 29.7.0 - '@jest/test-result': 29.7.0 - '@jest/types': 29.6.3 - chalk: 4.1.2 - create-jest: 29.7.0(@types/node@20.19.39) - exit: 0.1.2 - import-local: 3.2.0 - jest-config: 29.7.0(@types/node@20.19.39) - jest-util: 29.7.0 - jest-validate: 29.7.0 - yargs: 17.7.2 - transitivePeerDependencies: - - '@types/node' - - babel-plugin-macros - - supports-color - - ts-node + '@esbuild/aix-ppc64': 0.21.5 + '@esbuild/android-arm': 0.21.5 + '@esbuild/android-arm64': 0.21.5 + '@esbuild/android-x64': 0.21.5 + '@esbuild/darwin-arm64': 0.21.5 + '@esbuild/darwin-x64': 0.21.5 + '@esbuild/freebsd-arm64': 0.21.5 + '@esbuild/freebsd-x64': 0.21.5 + '@esbuild/linux-arm': 0.21.5 + '@esbuild/linux-arm64': 0.21.5 + '@esbuild/linux-ia32': 0.21.5 + '@esbuild/linux-loong64': 0.21.5 + '@esbuild/linux-mips64el': 0.21.5 + '@esbuild/linux-ppc64': 0.21.5 + '@esbuild/linux-riscv64': 0.21.5 + '@esbuild/linux-s390x': 0.21.5 + '@esbuild/linux-x64': 0.21.5 + '@esbuild/netbsd-x64': 0.21.5 + '@esbuild/openbsd-x64': 0.21.5 + '@esbuild/sunos-x64': 0.21.5 + '@esbuild/win32-arm64': 0.21.5 + '@esbuild/win32-ia32': 0.21.5 + '@esbuild/win32-x64': 0.21.5 - jest-config@29.7.0(@types/node@20.19.39): - dependencies: - '@babel/core': 7.29.0 - '@jest/test-sequencer': 29.7.0 - '@jest/types': 29.6.3 - babel-jest: 29.7.0(@babel/core@7.29.0) - chalk: 4.1.2 - ci-info: 3.9.0 - deepmerge: 4.3.1 - glob: 7.2.3 - graceful-fs: 4.2.11 - jest-circus: 29.7.0 - jest-environment-node: 29.7.0 - jest-get-type: 29.6.3 - jest-regex-util: 29.6.3 - jest-resolve: 29.7.0 - jest-runner: 29.7.0 - jest-util: 29.7.0 - jest-validate: 29.7.0 - micromatch: 4.0.8 - parse-json: 5.2.0 - pretty-format: 29.7.0 - slash: 3.0.0 - strip-json-comments: 3.1.1 + esbuild@0.27.7: optionalDependencies: - '@types/node': 20.19.39 - transitivePeerDependencies: - - babel-plugin-macros - - supports-color + '@esbuild/aix-ppc64': 0.27.7 + '@esbuild/android-arm': 0.27.7 + '@esbuild/android-arm64': 0.27.7 + '@esbuild/android-x64': 0.27.7 + '@esbuild/darwin-arm64': 0.27.7 + '@esbuild/darwin-x64': 0.27.7 + '@esbuild/freebsd-arm64': 0.27.7 + '@esbuild/freebsd-x64': 0.27.7 + '@esbuild/linux-arm': 0.27.7 + '@esbuild/linux-arm64': 0.27.7 + '@esbuild/linux-ia32': 0.27.7 + '@esbuild/linux-loong64': 0.27.7 + '@esbuild/linux-mips64el': 0.27.7 + '@esbuild/linux-ppc64': 0.27.7 + '@esbuild/linux-riscv64': 0.27.7 + '@esbuild/linux-s390x': 0.27.7 + '@esbuild/linux-x64': 0.27.7 + '@esbuild/netbsd-arm64': 0.27.7 + '@esbuild/netbsd-x64': 0.27.7 + '@esbuild/openbsd-arm64': 0.27.7 + '@esbuild/openbsd-x64': 0.27.7 + '@esbuild/openharmony-arm64': 0.27.7 + '@esbuild/sunos-x64': 0.27.7 + '@esbuild/win32-arm64': 0.27.7 + '@esbuild/win32-ia32': 0.27.7 + '@esbuild/win32-x64': 0.27.7 - jest-diff@29.7.0: + estree-walker@3.0.3: dependencies: - chalk: 4.1.2 - diff-sequences: 29.6.3 - jest-get-type: 29.6.3 - pretty-format: 29.7.0 + '@types/estree': 1.0.8 - jest-docblock@29.7.0: - dependencies: - detect-newline: 3.1.0 + eventemitter3@5.0.4: {} - jest-each@29.7.0: - dependencies: - '@jest/types': 29.6.3 - chalk: 4.1.2 - jest-get-type: 29.6.3 - jest-util: 29.7.0 - pretty-format: 29.7.0 + expect-type@1.3.0: {} - jest-environment-node@29.7.0: + fast-check@4.6.0: dependencies: - '@jest/environment': 29.7.0 - '@jest/fake-timers': 29.7.0 - '@jest/types': 29.6.3 - '@types/node': 20.19.39 - jest-mock: 29.7.0 - jest-util: 29.7.0 - - jest-get-type@29.6.3: {} + pure-rand: 8.4.0 - jest-haste-map@29.7.0: - dependencies: - '@jest/types': 29.6.3 - '@types/graceful-fs': 4.1.9 - '@types/node': 20.19.39 - anymatch: 3.1.3 - fb-watchman: 2.0.2 - graceful-fs: 4.2.11 - jest-regex-util: 29.6.3 - jest-util: 29.7.0 - jest-worker: 29.7.0 - micromatch: 4.0.8 - walker: 1.0.8 - optionalDependencies: - fsevents: 2.3.3 + fast-sha256@1.3.0: {} - jest-haste-map@30.3.0: + foreground-child@3.3.1: dependencies: - '@jest/types': 30.3.0 - '@types/node': 20.19.39 - anymatch: 3.1.3 - fb-watchman: 2.0.2 - graceful-fs: 4.2.11 - jest-regex-util: 30.0.1 - jest-util: 30.3.0 - jest-worker: 30.3.0 - picomatch: 4.0.4 - walker: 1.0.8 - optionalDependencies: - fsevents: 2.3.3 + cross-spawn: 7.0.6 + signal-exit: 4.1.0 - jest-leak-detector@29.7.0: - dependencies: - jest-get-type: 29.6.3 - pretty-format: 29.7.0 + fsevents@2.3.3: + optional: true - jest-matcher-utils@29.7.0: + get-tsconfig@4.13.7: dependencies: - chalk: 4.1.2 - jest-diff: 29.7.0 - jest-get-type: 29.6.3 - pretty-format: 29.7.0 + resolve-pkg-maps: 1.0.0 - jest-message-util@29.7.0: - dependencies: - '@babel/code-frame': 7.29.0 - '@jest/types': 29.6.3 - '@types/stack-utils': 2.0.3 - chalk: 4.1.2 - graceful-fs: 4.2.11 - micromatch: 4.0.8 - pretty-format: 29.7.0 - slash: 3.0.0 - stack-utils: 2.0.6 - - jest-mock@29.7.0: + glob@10.5.0: dependencies: - '@jest/types': 29.6.3 - '@types/node': 20.19.39 - jest-util: 29.7.0 + foreground-child: 3.3.1 + jackspeak: 3.4.3 + minimatch: 9.0.9 + minipass: 7.1.3 + package-json-from-dist: 1.0.1 + path-scurry: 1.11.1 - jest-pnp-resolver@1.2.3(jest-resolve@29.7.0): - optionalDependencies: - jest-resolve: 29.7.0 + has-flag@4.0.0: {} - jest-regex-util@29.6.3: {} + html-escaper@2.0.2: {} - jest-regex-util@30.0.1: {} + ieee754@1.2.1: {} - jest-resolve-dependencies@29.7.0: - dependencies: - jest-regex-util: 29.6.3 - jest-snapshot: 29.7.0 - transitivePeerDependencies: - - supports-color + ignore@5.3.2: {} - jest-resolve@29.7.0: - dependencies: - chalk: 4.1.2 - graceful-fs: 4.2.11 - jest-haste-map: 29.7.0 - jest-pnp-resolver: 1.2.3(jest-resolve@29.7.0) - jest-util: 29.7.0 - jest-validate: 29.7.0 - resolve: 1.22.11 - resolve.exports: 2.0.3 - slash: 3.0.0 - - jest-runner@29.7.0: - dependencies: - '@jest/console': 29.7.0 - '@jest/environment': 29.7.0 - '@jest/test-result': 29.7.0 - '@jest/transform': 29.7.0 - '@jest/types': 29.6.3 - '@types/node': 20.19.39 - chalk: 4.1.2 - emittery: 0.13.1 - graceful-fs: 4.2.11 - jest-docblock: 29.7.0 - jest-environment-node: 29.7.0 - jest-haste-map: 29.7.0 - jest-leak-detector: 29.7.0 - jest-message-util: 29.7.0 - jest-resolve: 29.7.0 - jest-runtime: 29.7.0 - jest-util: 29.7.0 - jest-watcher: 29.7.0 - jest-worker: 29.7.0 - p-limit: 3.1.0 - source-map-support: 0.5.13 - transitivePeerDependencies: - - supports-color + inherits@2.0.4: {} - jest-runtime@29.7.0: - dependencies: - '@jest/environment': 29.7.0 - '@jest/fake-timers': 29.7.0 - '@jest/globals': 29.7.0 - '@jest/source-map': 29.6.3 - '@jest/test-result': 29.7.0 - '@jest/transform': 29.7.0 - '@jest/types': 29.6.3 - '@types/node': 20.19.39 - chalk: 4.1.2 - cjs-module-lexer: 1.4.3 - collect-v8-coverage: 1.0.3 - glob: 7.2.3 - graceful-fs: 4.2.11 - jest-haste-map: 29.7.0 - jest-message-util: 29.7.0 - jest-mock: 29.7.0 - jest-regex-util: 29.6.3 - jest-resolve: 29.7.0 - jest-snapshot: 29.7.0 - jest-util: 29.7.0 - slash: 3.0.0 - strip-bom: 4.0.0 - transitivePeerDependencies: - - supports-color + is-fullwidth-code-point@3.0.0: {} - jest-snapshot@29.7.0: - dependencies: - '@babel/core': 7.29.0 - '@babel/generator': 7.29.1 - '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.0) - '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.29.0) - '@babel/types': 7.29.0 - '@jest/expect-utils': 29.7.0 - '@jest/transform': 29.7.0 - '@jest/types': 29.6.3 - babel-preset-current-node-syntax: 1.2.0(@babel/core@7.29.0) - chalk: 4.1.2 - expect: 29.7.0 - graceful-fs: 4.2.11 - jest-diff: 29.7.0 - jest-get-type: 29.6.3 - jest-matcher-utils: 29.7.0 - jest-message-util: 29.7.0 - jest-util: 29.7.0 - natural-compare: 1.4.0 - pretty-format: 29.7.0 - semver: 7.7.4 - transitivePeerDependencies: - - supports-color + is-interactive@2.0.0: {} - jest-util@29.7.0: - dependencies: - '@jest/types': 29.6.3 - '@types/node': 20.19.39 - chalk: 4.1.2 - ci-info: 3.9.0 - graceful-fs: 4.2.11 - picomatch: 2.3.2 + is-unicode-supported@1.3.0: {} - jest-util@30.3.0: - dependencies: - '@jest/types': 30.3.0 - '@types/node': 20.19.39 - chalk: 4.1.2 - ci-info: 4.4.0 - graceful-fs: 4.2.11 - picomatch: 4.0.4 + isexe@2.0.0: {} - jest-validate@29.7.0: - dependencies: - '@jest/types': 29.6.3 - camelcase: 6.3.0 - chalk: 4.1.2 - jest-get-type: 29.6.3 - leven: 3.1.0 - pretty-format: 29.7.0 - - jest-watcher@29.7.0: + isomorphic-ws@5.0.0(ws@8.20.0): dependencies: - '@jest/test-result': 29.7.0 - '@jest/types': 29.6.3 - '@types/node': 20.19.39 - ansi-escapes: 4.3.2 - chalk: 4.1.2 - emittery: 0.13.1 - jest-util: 29.7.0 - string-length: 4.0.2 + ws: 8.20.0 - jest-worker@29.7.0: - dependencies: - '@types/node': 20.19.39 - jest-util: 29.7.0 - merge-stream: 2.0.0 - supports-color: 8.1.1 + istanbul-lib-coverage@3.2.2: {} - jest-worker@30.3.0: + istanbul-lib-report@3.0.1: dependencies: - '@types/node': 20.19.39 - '@ungap/structured-clone': 1.3.0 - jest-util: 30.3.0 - merge-stream: 2.0.0 - supports-color: 8.1.1 + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.2.0 - jest@29.7.0(@types/node@20.19.39): + istanbul-lib-source-maps@5.0.6: dependencies: - '@jest/core': 29.7.0 - '@jest/types': 29.6.3 - import-local: 3.2.0 - jest-cli: 29.7.0(@types/node@20.19.39) + '@jridgewell/trace-mapping': 0.3.31 + debug: 4.4.3 + istanbul-lib-coverage: 3.2.2 transitivePeerDependencies: - - '@types/node' - - babel-plugin-macros - supports-color - - ts-node - js-tokens@4.0.0: {} - - js-yaml@3.14.2: + istanbul-reports@3.2.0: dependencies: - argparse: 1.0.10 - esprima: 4.0.1 - - jsesc@3.1.0: {} - - json-parse-even-better-errors@2.3.1: {} - - json5@2.2.3: {} - - kleur@3.0.3: {} - - leven@3.1.0: {} - - lines-and-columns@1.2.4: {} + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 - locate-path@5.0.0: + jackspeak@3.4.3: dependencies: - p-locate: 4.1.0 - - lodash.debounce@4.0.8: {} - - lodash.memoize@4.1.2: {} + '@isaacs/cliui': 8.0.2 + optionalDependencies: + '@pkgjs/parseargs': 0.11.0 log-symbols@5.1.0: dependencies: chalk: 5.6.2 is-unicode-supported: 1.3.0 - lru-cache@10.4.3: {} + loupe@3.2.1: {} - lru-cache@5.1.1: - dependencies: - yallist: 3.1.1 + lru-cache@10.4.3: {} - make-dir@4.0.0: + magic-string@0.30.21: dependencies: - semver: 7.7.4 - - make-error@1.3.6: {} + '@jridgewell/sourcemap-codec': 1.5.5 - makeerror@1.0.12: + magicast@0.3.5: dependencies: - tmpl: 1.0.5 - - merge-stream@2.0.0: {} + '@babel/parser': 7.29.2 + '@babel/types': 7.29.0 + source-map-js: 1.2.1 - micromatch@4.0.8: + make-dir@4.0.0: dependencies: - braces: 3.0.3 - picomatch: 2.3.2 + semver: 7.7.4 mime-db@1.52.0: {} @@ -4402,43 +1921,25 @@ snapshots: mimic-fn@2.1.0: {} - minimatch@3.1.5: + minimatch@10.2.5: dependencies: - brace-expansion: 1.1.13 + brace-expansion: 5.0.5 minimatch@9.0.9: dependencies: brace-expansion: 2.0.3 - minimist@1.2.8: {} - minipass@7.1.3: {} ms@2.1.3: {} - natural-compare@1.4.0: {} - - neo-async@2.6.2: {} + nanoid@3.3.11: {} node-gyp-build-optional-packages@5.1.1: dependencies: detect-libc: 2.1.2 optional: true - node-int64@0.4.0: {} - - node-releases@2.0.37: {} - - normalize-path@3.0.0: {} - - npm-run-path@4.0.1: - dependencies: - path-key: 3.1.1 - - once@1.4.0: - dependencies: - wrappy: 1.0.2 - onetime@5.1.2: dependencies: mimic-fn: 2.1.0 @@ -4455,116 +1956,37 @@ snapshots: string-width: 6.1.0 strip-ansi: 7.2.0 - p-limit@2.3.0: - dependencies: - p-try: 2.2.0 - - p-limit@3.1.0: - dependencies: - yocto-queue: 0.1.0 - - p-locate@4.1.0: - dependencies: - p-limit: 2.3.0 - - p-try@2.2.0: {} - package-json-from-dist@1.0.1: {} - parse-json@5.2.0: - dependencies: - '@babel/code-frame': 7.29.0 - error-ex: 1.3.4 - json-parse-even-better-errors: 2.3.1 - lines-and-columns: 1.2.4 - - path-exists@4.0.0: {} - - path-is-absolute@1.0.1: {} - path-key@3.1.1: {} - path-parse@1.0.7: {} - path-scurry@1.11.1: dependencies: lru-cache: 10.4.3 minipass: 7.1.3 - picocolors@1.1.1: {} - - picomatch@2.3.2: {} - - picomatch@4.0.4: {} - - pirates@4.0.7: {} + pathe@1.1.2: {} - pkg-dir@4.2.0: - dependencies: - find-up: 4.1.0 + pathval@2.0.1: {} - pretty-format@29.7.0: - dependencies: - '@jest/schemas': 29.6.3 - ansi-styles: 5.2.0 - react-is: 18.3.1 + picocolors@1.1.1: {} - prompts@2.4.2: + postcss@8.5.12: dependencies: - kleur: 3.0.3 - sisteransi: 1.0.5 - - pure-rand@6.1.0: {} + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 pure-rand@8.4.0: {} - react-is@18.3.1: {} - readable-stream@3.6.2: dependencies: inherits: 2.0.4 string_decoder: 1.3.0 util-deprecate: 1.0.2 - regenerate-unicode-properties@10.2.2: - dependencies: - regenerate: 1.4.2 - - regenerate@1.4.2: {} - - regexpu-core@6.4.0: - dependencies: - regenerate: 1.4.2 - regenerate-unicode-properties: 10.2.2 - regjsgen: 0.8.0 - regjsparser: 0.13.1 - unicode-match-property-ecmascript: 2.0.0 - unicode-match-property-value-ecmascript: 2.2.1 - - regjsgen@0.8.0: {} - - regjsparser@0.13.1: - dependencies: - jsesc: 3.1.0 - - require-directory@2.1.1: {} - - resolve-cwd@3.0.0: - dependencies: - resolve-from: 5.0.0 - - resolve-from@5.0.0: {} - resolve-pkg-maps@1.0.0: {} - resolve.exports@2.0.3: {} - - resolve@1.22.11: - dependencies: - is-core-module: 2.16.1 - path-parse: 1.0.7 - supports-preserve-symlinks-flag: 1.0.0 - restore-cursor@4.0.0: dependencies: onetime: 5.1.2 @@ -4574,9 +1996,38 @@ snapshots: dependencies: glob: 10.5.0 - safe-buffer@5.2.1: {} + rollup@4.60.2: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.60.2 + '@rollup/rollup-android-arm64': 4.60.2 + '@rollup/rollup-darwin-arm64': 4.60.2 + '@rollup/rollup-darwin-x64': 4.60.2 + '@rollup/rollup-freebsd-arm64': 4.60.2 + '@rollup/rollup-freebsd-x64': 4.60.2 + '@rollup/rollup-linux-arm-gnueabihf': 4.60.2 + '@rollup/rollup-linux-arm-musleabihf': 4.60.2 + '@rollup/rollup-linux-arm64-gnu': 4.60.2 + '@rollup/rollup-linux-arm64-musl': 4.60.2 + '@rollup/rollup-linux-loong64-gnu': 4.60.2 + '@rollup/rollup-linux-loong64-musl': 4.60.2 + '@rollup/rollup-linux-ppc64-gnu': 4.60.2 + '@rollup/rollup-linux-ppc64-musl': 4.60.2 + '@rollup/rollup-linux-riscv64-gnu': 4.60.2 + '@rollup/rollup-linux-riscv64-musl': 4.60.2 + '@rollup/rollup-linux-s390x-gnu': 4.60.2 + '@rollup/rollup-linux-x64-gnu': 4.60.2 + '@rollup/rollup-linux-x64-musl': 4.60.2 + '@rollup/rollup-openbsd-x64': 4.60.2 + '@rollup/rollup-openharmony-arm64': 4.60.2 + '@rollup/rollup-win32-arm64-msvc': 4.60.2 + '@rollup/rollup-win32-ia32-msvc': 4.60.2 + '@rollup/rollup-win32-x64-gnu': 4.60.2 + '@rollup/rollup-win32-x64-msvc': 4.60.2 + fsevents: 2.3.3 - semver@6.3.1: {} + safe-buffer@5.2.1: {} semver@7.7.4: {} @@ -4586,36 +2037,22 @@ snapshots: shebang-regex@3.0.0: {} + siginfo@2.0.0: {} + signal-exit@3.0.7: {} signal-exit@4.1.0: {} - sisteransi@1.0.5: {} - - slash@3.0.0: {} + source-map-js@1.2.1: {} - source-map-support@0.5.13: - dependencies: - buffer-from: 1.1.2 - source-map: 0.6.1 - - source-map@0.6.1: {} + stackback@0.0.2: {} - sprintf-js@1.0.3: {} - - stack-utils@2.0.6: - dependencies: - escape-string-regexp: 2.0.0 + std-env@3.10.0: {} stdin-discarder@0.1.0: dependencies: bl: 5.1.0 - string-length@4.0.2: - dependencies: - char-regex: 1.0.2 - strip-ansi: 6.0.1 - string-width@4.2.3: dependencies: emoji-regex: 8.0.0 @@ -4646,55 +2083,27 @@ snapshots: dependencies: ansi-regex: 6.2.2 - strip-bom@4.0.0: {} - - strip-final-newline@2.0.0: {} - - strip-json-comments@3.1.1: {} - supports-color@7.2.0: dependencies: has-flag: 4.0.0 - supports-color@8.1.1: + test-exclude@7.0.2: dependencies: - has-flag: 4.0.0 + '@istanbuljs/schema': 0.1.3 + glob: 10.5.0 + minimatch: 10.2.5 - supports-preserve-symlinks-flag@1.0.0: {} + tinybench@2.9.0: {} - test-exclude@6.0.0: - dependencies: - '@istanbuljs/schema': 0.1.3 - glob: 7.2.3 - minimatch: 3.1.5 + tinyexec@0.3.2: {} - tmp@0.2.5: {} + tinypool@1.1.1: {} - tmpl@1.0.5: {} + tinyrainbow@1.2.0: {} - to-regex-range@5.0.1: - dependencies: - is-number: 7.0.0 + tinyspy@3.0.2: {} - ts-jest@29.4.9(@babel/core@7.29.0)(@jest/transform@30.3.0)(@jest/types@30.3.0)(babel-jest@30.3.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@29.7.0(@types/node@20.19.39))(typescript@5.9.3): - dependencies: - bs-logger: 0.2.6 - fast-json-stable-stringify: 2.1.0 - handlebars: 4.7.9 - jest: 29.7.0(@types/node@20.19.39) - json5: 2.2.3 - lodash.memoize: 4.1.2 - make-error: 1.3.6 - semver: 7.7.4 - type-fest: 4.41.0 - typescript: 5.9.3 - yargs-parser: 21.1.1 - optionalDependencies: - '@babel/core': 7.29.0 - '@jest/transform': 30.3.0 - '@jest/types': 30.3.0 - babel-jest: 30.3.0(@babel/core@7.29.0) - jest-util: 30.3.0 + tmp@0.2.5: {} tsx@4.21.0: dependencies: @@ -4703,55 +2112,84 @@ snapshots: optionalDependencies: fsevents: 2.3.3 - type-detect@4.0.8: {} - - type-fest@0.21.3: {} - - type-fest@4.41.0: {} - typescript@5.9.3: {} - uglify-js@3.19.3: - optional: true - undici-types@6.21.0: {} - unicode-canonical-property-names-ecmascript@2.0.1: {} - - unicode-match-property-ecmascript@2.0.0: - dependencies: - unicode-canonical-property-names-ecmascript: 2.0.1 - unicode-property-aliases-ecmascript: 2.2.0 - - unicode-match-property-value-ecmascript@2.2.1: {} - - unicode-property-aliases-ecmascript@2.2.0: {} - - update-browserslist-db@1.2.3(browserslist@4.28.2): - dependencies: - browserslist: 4.28.2 - escalade: 3.2.0 - picocolors: 1.1.1 - util-deprecate@1.0.2: {} uuid@9.0.1: {} - v8-to-istanbul@9.3.0: + vite-node@2.1.9(@types/node@20.19.39): dependencies: - '@jridgewell/trace-mapping': 0.3.31 - '@types/istanbul-lib-coverage': 2.0.6 - convert-source-map: 2.0.0 + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 1.1.2 + vite: 5.4.21(@types/node@20.19.39) + transitivePeerDependencies: + - '@types/node' + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + + vite@5.4.21(@types/node@20.19.39): + dependencies: + esbuild: 0.21.5 + postcss: 8.5.12 + rollup: 4.60.2 + optionalDependencies: + '@types/node': 20.19.39 + fsevents: 2.3.3 - walker@1.0.8: + vitest@2.1.9(@types/node@20.19.39): dependencies: - makeerror: 1.0.12 + '@vitest/expect': 2.1.9 + '@vitest/mocker': 2.1.9(vite@5.4.21(@types/node@20.19.39)) + '@vitest/pretty-format': 2.1.9 + '@vitest/runner': 2.1.9 + '@vitest/snapshot': 2.1.9 + '@vitest/spy': 2.1.9 + '@vitest/utils': 2.1.9 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.3.0 + magic-string: 0.30.21 + pathe: 1.1.2 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinypool: 1.1.1 + tinyrainbow: 1.2.0 + vite: 5.4.21(@types/node@20.19.39) + vite-node: 2.1.9(@types/node@20.19.39) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 20.19.39 + transitivePeerDependencies: + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser which@2.0.2: dependencies: isexe: 2.0.0 - wordwrap@1.0.0: {} + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 wrap-ansi@7.0.0: dependencies: @@ -4765,36 +2203,6 @@ snapshots: string-width: 5.1.2 strip-ansi: 7.2.0 - wrappy@1.0.2: {} - - write-file-atomic@4.0.2: - dependencies: - imurmurhash: 0.1.4 - signal-exit: 3.0.7 - - write-file-atomic@5.0.1: - dependencies: - imurmurhash: 0.1.4 - signal-exit: 4.1.0 - ws@8.20.0: {} xstate@5.30.0: {} - - y18n@5.0.8: {} - - yallist@3.1.1: {} - - yargs-parser@21.1.1: {} - - yargs@17.7.2: - dependencies: - cliui: 8.0.1 - escalade: 3.2.0 - get-caller-file: 2.0.5 - require-directory: 2.1.1 - string-width: 4.2.3 - y18n: 5.0.8 - yargs-parser: 21.1.1 - - yocto-queue@0.1.0: {} diff --git a/src/commands.ts b/src/commands.ts index 805ca0e..101eaf7 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -262,24 +262,12 @@ export async function init( await syncEngine.setRootDirectoryUrl(rootHandle.url); // 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_server_storage_id) { - out.update("Syncing to server"); - const { failed } = await waitForSync([rootHandle], config.sync_server_storage_id); - if (failed.length > 0) { - out.taskLine("Root document failed to sync to server", true); - // 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 - // data that never reached the server. - out.taskLine( - "Warning: sync_server_storage_id is not set; skipping post-init sync verification", - true - ); + if (config.sync_enabled) { + out.update("Syncing to server"); + const { failed } = await waitForSync([rootHandle]); + if (failed.length > 0) { + out.taskLine("Root document failed to sync to server", true); + // Continue anyway - the document is created locally and will sync later } } diff --git a/src/core/sync-engine.ts b/src/core/sync-engine.ts index a1bac34..f300867 100644 --- a/src/core/sync-engine.ts +++ b/src/core/sync-engine.ts @@ -590,10 +590,6 @@ export class SyncEngine { // Wait for network sync (important for clone scenarios) if (this.config.sync_enabled) { const sub = options?.sub ?? false - // 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. - const storageId = sub ? undefined : this.config.sync_server_storage_id try { // Ensure root directory handle is tracked for sync @@ -613,10 +609,7 @@ export class SyncEngine { const handlePaths = Array.from(this.handlesByPath.keys()) debug(`sync: waiting for ${allHandles.length} handles to sync to server: ${handlePaths.slice(0, 10).map(p => p || "(root)").join(", ")}${handlePaths.length > 10 ? ` ...and ${handlePaths.length - 10} more` : ""}`) out.update(`Uploading ${allHandles.length} documents to sync server`) - const {failed} = await waitForSync( - allHandles, - storageId - ) + const {failed} = await waitForSync(allHandles) // Recreate failed documents and retry once. // Skip in Subduction mode — SubductionSource has its @@ -628,10 +621,7 @@ export class SyncEngine { if (retryHandles.length > 0) { debug(`sync: retrying ${retryHandles.length} recreated handles`) out.update(`Retrying ${retryHandles.length} recreated documents`) - const retry = await waitForSync( - retryHandles, - storageId - ) + const retry = await waitForSync(retryHandles) if (retry.failed.length > 0) { const msg = `${retry.failed.length} documents failed to sync to server after recreation` debug(`sync: ${msg}`) @@ -665,6 +655,7 @@ export class SyncEngine { timeoutMs: BIDIRECTIONAL_SYNC_TIMEOUT_MS, pollIntervalMs: 100, stableChecksRequired: 3, + minWaitMs: 0, handles: changedHandles.length > 0 ? changedHandles : undefined, } ) @@ -683,10 +674,7 @@ export class SyncEngine { ) debug("sync: syncing root directory touch to server") out.update("Syncing root directory update") - const rootSync = await waitForSync( - [rootHandle], - storageId - ) + const rootSync = await waitForSync([rootHandle]) if (rootSync.failed.length > 0) { const msg = "Root directory update did not converge to server; consumers may not see recent changes until next sync" debug(`sync: ${msg}`) diff --git a/src/utils/network-sync.ts b/src/utils/network-sync.ts index 51f1cfd..8651193 100644 --- a/src/utils/network-sync.ts +++ b/src/utils/network-sync.ts @@ -1,6 +1,5 @@ import { DocHandle, - StorageId, Repo, AutomergeUrl, } from "@automerge/automerge-repo"; @@ -30,6 +29,7 @@ export async function waitForBidirectionalSync( timeoutMs?: number; pollIntervalMs?: number; stableChecksRequired?: number; + minWaitMs?: number; handles?: DocHandle[]; } = {}, ): Promise { @@ -37,6 +37,10 @@ export async function waitForBidirectionalSync( timeoutMs = 10000, pollIntervalMs = 100, stableChecksRequired = 3, + // Head-stability alone is a weak signal: if the network hasn't pushed + // anything yet, heads stay "stable" trivially. Require a minimum elapsed + // time so the sync server has a chance to relay changes from peers. + minWaitMs = 2000, handles, } = options; @@ -73,9 +77,9 @@ export async function waitForBidirectionalSync( if (isStable) { stableCount++; - debug(`waitForBidirectionalSync: stable check ${stableCount}/${stableChecksRequired} (${currentHeads.size} docs, poll #${pollCount})`); - if (stableCount >= stableChecksRequired) { - const elapsed = Date.now() - startTime; + const elapsed = Date.now() - startTime; + debug(`waitForBidirectionalSync: stable check ${stableCount}/${stableChecksRequired} (${currentHeads.size} docs, poll #${pollCount}, ${elapsed}ms elapsed)`); + if (stableCount >= stableChecksRequired && elapsed >= minWaitMs) { debug(`waitForBidirectionalSync: converged in ${elapsed}ms after ${pollCount} polls (${currentHeads.size} docs)`); out.taskLine(`Bidirectional sync converged (${currentHeads.size} docs, ${elapsed}ms)`); return; // Converged! @@ -211,20 +215,37 @@ export interface SyncWaitResult { failed: DocHandle[]; } -/** Maximum documents to sync concurrently to avoid flooding the server */ -const SYNC_BATCH_SIZE = 10; - /** - * Wait for a single document handle to sync to the server. - * Resolves with the handle on success, rejects with the handle on timeout. + * Wait for a single doc handle until we have positive confirmation that the + * remote sync server holds the handle's current heads. + * + * Two signals can resolve us: + * 1. A `remote-heads` event whose heads match the handle's current local + * heads. This is the strict signal — fires from `SyncStateTracker` in + * WebSocket mode when the server reports its sync state. (We accept any + * storageId; pushwork only configures one upstream peer.) + * 2. Head stability: heads remain unchanged for STABLE_REQUIRED consecutive + * polls. This is the fallback used when the strict signal isn't + * available — notably in Subduction mode, where direct-peer head reports + * feed `handleImmediateRemoteHeadsChanged` (which stores them but does + * not currently emit `remote-heads-changed`). The Subduction source has + * already saved + sync'd, so stability tells us "no further outbound or + * inbound activity for this doc". + * + * If local heads change mid-wait (e.g. an incoming merge), we reset the + * stability counter and wait for confirmation of the new heads. */ +const POLL_INTERVAL_MS = 100; +const STABLE_REQUIRED = 3; + function waitForHandleSync( handle: DocHandle, - syncServerStorageId: StorageId, timeoutMs: number, startTime: number, ): Promise> { return new Promise>((resolve, reject) => { + let lastHeadsKey = JSON.stringify(handle.heads()); + let stableCount = 0; let pollInterval: NodeJS.Timeout; const cleanup = () => { @@ -233,62 +254,56 @@ function waitForHandleSync( handle.off("remote-heads", onRemoteHeads); }; - const onConverged = () => { - debug(`waitForSync: ${handle.url}... converged in ${Date.now() - startTime}ms`); + const onConfirmed = (reason: string) => { + debug(`waitForSync: ${handle.url}... ${reason} in ${Date.now() - startTime}ms`); cleanup(); resolve(handle); }; - const timeout = setTimeout(() => { - debug(`waitForSync: ${handle.url}... timed out after ${timeoutMs}ms`); - cleanup(); - reject(handle); - }, timeoutMs); - - const isConverged = () => { - const localHeads = handle.heads(); - const info = handle.getSyncInfo(syncServerStorageId); - return A.equals(localHeads, info?.lastHeads); - }; - - const onRemoteHeads = ({ - storageId, - }: { - storageId: StorageId; - heads: any; - }) => { - if (storageId === syncServerStorageId && isConverged()) { - onConverged(); + const onRemoteHeads = ({ heads }: { storageId: unknown; heads: unknown }) => { + if (A.equals(handle.heads(), heads as any)) { + onConfirmed("server confirmed"); } }; - // Initial check - if (isConverged()) { - cleanup(); - resolve(handle); - return; - } - - // Start polling and event listening pollInterval = setInterval(() => { - if (isConverged()) { - onConverged(); + const currentKey = JSON.stringify(handle.heads()); + if (currentKey === lastHeadsKey) { + stableCount++; + if (stableCount >= STABLE_REQUIRED) { + onConfirmed("stable"); + } + } else { + stableCount = 0; + lastHeadsKey = currentKey; } - }, 100); + }, POLL_INTERVAL_MS); + + const timeout = setTimeout(() => { + debug(`waitForSync: ${handle.url}... timed out after ${timeoutMs}ms`); + cleanup(); + reject(handle); + }, timeoutMs); handle.on("remote-heads", onRemoteHeads); }); } /** - * Wait for documents to sync to the remote server. - * Processes handles in batches to avoid flooding the server. - * Returns a result with any failed handles instead of throwing, - * so callers can attempt recovery (e.g. recreating documents). + * Wait until the remote sync server confirms it has the current heads of + * every passed-in handle. Returns failed handles instead of throwing so + * callers can attempt recovery (e.g. recreating documents). + * + * Confirmation comes from `remote-heads` events emitted on the handle when + * a peer reports their heads. With `enableRemoteHeadsGossiping: true` (set + * in repo-factory), Subduction's onRemoteHeadsChanged callback feeds these + * events, and the legacy WebSocket sync path emits them directly via + * SyncStateTracker. The peer's storageId is included in the event payload + * but we don't filter on it: pushwork connects only to the configured sync + * server, so any remote-heads event for a handle is the server confirming. */ export async function waitForSync( handlesToWaitOn: DocHandle[], - syncServerStorageId?: StorageId, timeoutMs: number = 60000, ): Promise { const startTime = Date.now(); @@ -298,66 +313,20 @@ export async function waitForSync( return { failed: [] }; } - // When no StorageId is available (Subduction mode), use head-stability - // polling. The SubductionSource handles sync internally — we just wait - // for each handle's heads to stop changing. - if (!syncServerStorageId) { - debug(`waitForSync: no storage ID, using head-stability polling for ${handlesToWaitOn.length} documents`); - out.taskLine(`Waiting for ${handlesToWaitOn.length} documents to sync`); - return waitForSyncViaHeadStability(handlesToWaitOn, timeoutMs, startTime); - } - - debug(`waitForSync: waiting for ${handlesToWaitOn.length} documents (timeout=${timeoutMs}ms, batchSize=${SYNC_BATCH_SIZE})`); - - // Separate already-synced from needs-sync - const needsSync: DocHandle[] = []; - let alreadySynced = 0; + debug(`waitForSync: waiting for ${handlesToWaitOn.length} documents (timeout=${timeoutMs}ms)`); + out.taskLine(`Waiting for ${handlesToWaitOn.length} documents to sync`); - for (const handle of handlesToWaitOn) { - const heads = handle.heads(); - const syncInfo = handle.getSyncInfo(syncServerStorageId); - const remoteHeads = syncInfo?.lastHeads; - if (A.equals(heads, remoteHeads)) { - alreadySynced++; - debug(`waitForSync: ${handle.url}... already synced`); - } else { - debug(`waitForSync: ${handle.url}... needs sync (remoteHeads=${remoteHeads ? 'present' : 'missing'})`); - needsSync.push(handle); - } - } + const results = await Promise.allSettled( + handlesToWaitOn.map(handle => waitForHandleSync(handle, timeoutMs, startTime)) + ); - if (needsSync.length > 0) { - debug(`waitForSync: ${alreadySynced} already synced, ${needsSync.length} need sync`); - out.taskLine(`Uploading: ${alreadySynced}/${handlesToWaitOn.length} already synced, waiting for ${needsSync.length} more`); - } else { - debug(`waitForSync: all ${handlesToWaitOn.length} already synced`); - return { failed: [] }; - } - - // Process in batches to avoid flooding the server const failed: DocHandle[] = []; - let synced = alreadySynced; - - for (let i = 0; i < needsSync.length; i += SYNC_BATCH_SIZE) { - const batch = needsSync.slice(i, i + SYNC_BATCH_SIZE); - const batchNum = Math.floor(i / SYNC_BATCH_SIZE) + 1; - const totalBatches = Math.ceil(needsSync.length / SYNC_BATCH_SIZE); - - if (totalBatches > 1) { - debug(`waitForSync: batch ${batchNum}/${totalBatches} (${batch.length} docs)`); - out.update(`Uploading batch ${batchNum}/${totalBatches} (${synced}/${handlesToWaitOn.length} done)`); - } - - const results = await Promise.allSettled( - batch.map(handle => waitForHandleSync(handle, syncServerStorageId, timeoutMs, startTime)) - ); - - for (const result of results) { - if (result.status === "rejected") { - failed.push(result.reason as DocHandle); - } else { - synced++; - } + let synced = 0; + for (const result of results) { + if (result.status === "rejected") { + failed.push(result.reason as DocHandle); + } else { + synced++; } } @@ -366,91 +335,9 @@ export async function waitForSync( debug(`waitForSync: ${failed.length} documents failed after ${elapsed}ms`); out.taskLine(`Upload: ${synced} synced, ${failed.length} failed after ${(elapsed / 1000).toFixed(1)}s`, true); } else { - debug(`waitForSync: all ${handlesToWaitOn.length} documents synced in ${elapsed}ms (${alreadySynced} were already synced)`); - out.taskLine(`All ${handlesToWaitOn.length} documents uploaded to server (${(elapsed / 1000).toFixed(1)}s)`); + debug(`waitForSync: all ${handlesToWaitOn.length} documents synced in ${elapsed}ms`); + out.taskLine(`All ${handlesToWaitOn.length} documents confirmed by server (${(elapsed / 1000).toFixed(1)}s)`); } return { failed }; } - -/** - * Wait for sync by polling head stability (Subduction mode). - * Each handle's heads are polled until they remain unchanged for - * several consecutive checks, indicating the SubductionSource has - * finished syncing. - */ -async function waitForSyncViaHeadStability( - handles: DocHandle[], - timeoutMs: number, - startTime: number, -): Promise { - const failed: DocHandle[] = []; - let synced = 0; - - // Process in batches - for (let i = 0; i < handles.length; i += SYNC_BATCH_SIZE) { - const batch = handles.slice(i, i + SYNC_BATCH_SIZE); - - const results = await Promise.allSettled( - batch.map(handle => waitForHandleHeadStability(handle, timeoutMs, startTime)) - ); - - for (const result of results) { - if (result.status === "rejected") { - failed.push(result.reason as DocHandle); - } else { - synced++; - } - } - } - - const elapsed = Date.now() - startTime; - if (failed.length > 0) { - debug(`waitForSync(heads): ${failed.length} documents failed after ${elapsed}ms`); - out.taskLine(`Sync: ${synced} synced, ${failed.length} timed out after ${(elapsed / 1000).toFixed(1)}s`, true); - } else { - debug(`waitForSync(heads): all ${handles.length} documents synced in ${elapsed}ms`); - out.taskLine(`All ${handles.length} documents synced (${(elapsed / 1000).toFixed(1)}s)`); - } - - return { failed }; -} - -/** - * Wait for a single handle's heads to stabilize. - * Polls heads at 100ms intervals; resolves after 3 consecutive stable - * checks, rejects on timeout. - */ -function waitForHandleHeadStability( - handle: DocHandle, - timeoutMs: number, - startTime: number, -): Promise> { - return new Promise>((resolve, reject) => { - let lastHeads = JSON.stringify(handle.heads()); - let stableCount = 0; - const stableRequired = 3; - - const pollInterval = setInterval(() => { - const currentHeads = JSON.stringify(handle.heads()); - if (currentHeads === lastHeads) { - stableCount++; - if (stableCount >= stableRequired) { - clearInterval(pollInterval); - clearTimeout(timeout); - debug(`waitForSync(heads): ${handle.url}... converged in ${Date.now() - startTime}ms`); - resolve(handle); - } - } else { - stableCount = 0; - lastHeads = currentHeads; - } - }, 100); - - const timeout = setTimeout(() => { - clearInterval(pollInterval); - debug(`waitForSync(heads): ${handle.url}... timed out after ${timeoutMs}ms`); - reject(handle); - }, timeoutMs); - }); -} diff --git a/src/utils/repo-factory.ts b/src/utils/repo-factory.ts index 4ec1697..17b9f2c 100644 --- a/src/utils/repo-factory.ts +++ b/src/utils/repo-factory.ts @@ -117,11 +117,18 @@ export async function createRepo( return new RepoClass({ storage, subductionWebsocketEndpoints: endpoints, + // Enable so Subduction's onRemoteHeadsChanged is wired up and + // remote-heads events fire on doc handles. waitForSync uses these + // events to confirm the sync server has our heads before returning. + enableRemoteHeadsGossiping: true, }); } // Default: WebSocket sync adapter - const repoConfig: RepoConfig = { storage }; + const repoConfig: RepoConfig = { + storage, + enableRemoteHeadsGossiping: true, + }; if (config.sync_enabled && config.sync_server) { // Load the WebSocket adapter via ESM dynamic import to stay in the diff --git a/test/integration/fuzzer.test.ts b/test/integration/fuzzer.test.ts index 2971683..c369fda 100644 --- a/test/integration/fuzzer.test.ts +++ b/test/integration/fuzzer.test.ts @@ -376,7 +376,7 @@ describe("Pushwork Fuzzer", () => { // Cleanup tmpObj.removeCallback(); }, - 20000 + 120000 ); it.concurrent( @@ -450,7 +450,7 @@ describe("Pushwork Fuzzer", () => { // Cleanup tmpObj.removeCallback(); }, - 20000 + 120000 ); it.concurrent( diff --git a/test/integration/in-memory-sync.test.ts b/test/integration/in-memory-sync.test.ts index 66cd371..3e7df40 100644 --- a/test/integration/in-memory-sync.test.ts +++ b/test/integration/in-memory-sync.test.ts @@ -114,7 +114,7 @@ async function syncUntilConverged( timeoutMs?: number; } = {} ): Promise<{ rounds: number; hashA: string; hashB: string }> { - const { maxRounds = 5, timeoutMs = 30000 } = options; + const { maxRounds = 5, timeoutMs = 60000 } = options; const startTime = Date.now(); for (let round = 1; round <= maxRounds; round++) { @@ -207,7 +207,7 @@ describe("Sync Reliability Tests", () => { const contentB = await fs.readFile(path.join(repoB, "test.txt"), "utf-8"); expect(contentA).toBe("Hello from A"); expect(contentB).toBe("Hello from A"); - }, 30000); + }, 60000); it("should sync a file from A to B (with convergence)", async () => { const repoA = path.join(tmpDir, "repo-a"); @@ -234,7 +234,7 @@ describe("Sync Reliability Tests", () => { const contentB = await fs.readFile(path.join(repoB, "test.txt"), "utf-8"); expect(contentA).toBe(contentB); expect(contentA).toBe("Hello from A"); - }, 30000); + }, 60000); it("should sync a new file added to B back to A", async () => { const repoA = path.join(tmpDir, "repo-a"); @@ -265,7 +265,7 @@ describe("Sync Reliability Tests", () => { expect(await pathExists(path.join(repoA, "from-b.txt"))).toBe(true); const content = await fs.readFile(path.join(repoA, "from-b.txt"), "utf-8"); expect(content).toBe("Created by B"); - }, 30000); + }, 60000); it("should sync subdirectories correctly", async () => { const repoA = path.join(tmpDir, "repo-a"); @@ -291,7 +291,7 @@ describe("Sync Reliability Tests", () => { expect(await pathExists(path.join(repoB, "subdir", "nested.txt"))).toBe(true); const content = await fs.readFile(path.join(repoB, "subdir", "nested.txt"), "utf-8"); expect(content).toBe("Nested content"); - }, 30000); + }, 60000); }); describe("Concurrent Operations", () => { @@ -326,7 +326,7 @@ describe("Sync Reliability Tests", () => { expect(await pathExists(path.join(repoA, "file-b.txt"))).toBe(true); expect(await pathExists(path.join(repoB, "file-a.txt"))).toBe(true); expect(await pathExists(path.join(repoB, "file-b.txt"))).toBe(true); - }, 30000); + }, 60000); it("should handle file modification sync", async () => { const repoA = path.join(tmpDir, "repo-a"); @@ -356,7 +356,7 @@ describe("Sync Reliability Tests", () => { // B should have the modification const contentB = await fs.readFile(path.join(repoB, "shared.txt"), "utf-8"); expect(contentB).toBe("Modified by A"); - }, 30000); + }, 60000); it("should handle file deletion sync", async () => { const repoA = path.join(tmpDir, "repo-a"); @@ -388,7 +388,7 @@ describe("Sync Reliability Tests", () => { // File should be deleted in B expect(await pathExists(path.join(repoB, "to-delete.txt"))).toBe(false); - }, 30000); + }, 60000); }); describe("Subdirectory File Deletion - Resurrection Bug", () => { @@ -825,6 +825,6 @@ describe("Sync Reliability Tests", () => { // Verify content preserved const contentB = await fs.readFile(path.join(repoB, "renamed.txt"), "utf-8"); expect(contentB).toBe(content); - }, 30000); + }, 60000); }); }); diff --git a/test/integration/sub-flag.test.ts b/test/integration/sub-flag.test.ts index f36cccd..f2f80a7 100644 --- a/test/integration/sub-flag.test.ts +++ b/test/integration/sub-flag.test.ts @@ -90,18 +90,18 @@ describe("--sub flag integration", () => { }, 60000); }); - describe("sync --sub", () => { + describe("sync (after init --sub)", () => { + // `--sub` is only accepted on init/clone; subsequent `sync` calls read + // the subduction flag from .pushwork/config.json. 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]); + await pushwork(["sync", tmpDir]); // Verify the new file is now tracked const snapshotManager = new SnapshotManager(tmpDir); @@ -111,7 +111,7 @@ describe("--sub flag integration", () => { expect(snapshot!.files.has("file2.txt")).toBe(true); }, 60000); - it("should detect file modifications on sync --sub", async () => { + it("should detect file modifications on sync", async () => { await fs.writeFile(path.join(tmpDir, "mutable.txt"), "version 1"); await pushwork(["init", "--sub", tmpDir]); @@ -124,8 +124,7 @@ describe("--sub flag integration", () => { // Modify the file await fs.writeFile(path.join(tmpDir, "mutable.txt"), "version 2"); - // Sync - await pushwork(["sync", "--sub", tmpDir]); + await pushwork(["sync", tmpDir]); // Heads should have changed const snapshot2 = await snapshotManager.load(); @@ -133,7 +132,7 @@ describe("--sub flag integration", () => { expect(updatedHead).not.toEqual(initialHead); }, 60000); - it("should handle file deletions on sync --sub", async () => { + it("should handle file deletions on sync", async () => { await fs.writeFile(path.join(tmpDir, "ephemeral.txt"), "delete me"); await fs.writeFile(path.join(tmpDir, "keeper.txt"), "keep me"); @@ -142,8 +141,7 @@ describe("--sub flag integration", () => { // Delete a file await fs.unlink(path.join(tmpDir, "ephemeral.txt")); - // Sync - await pushwork(["sync", "--sub", tmpDir]); + await pushwork(["sync", tmpDir]); // Deleted file should be gone from snapshot const snapshotManager = new SnapshotManager(tmpDir); diff --git a/test/jest.setup.ts b/test/jest.setup.ts deleted file mode 100644 index 3fc4d9f..0000000 --- a/test/jest.setup.ts +++ /dev/null @@ -1,34 +0,0 @@ -/** - * Jest setup file to mock ESM modules that don't work well with Jest - */ - -// Mock chalk (ESM-only module) -jest.mock("chalk", () => ({ - __esModule: true, - default: new Proxy( - {}, - { - get: (target, prop) => { - if (prop === "default") return target; - // Return a function that returns the input string unchanged - return (str: string) => str; - }, - } - ), -})); - -// Mock ora (ESM-only module) -jest.mock("ora", () => ({ - __esModule: true, - default: jest.fn(() => ({ - start: jest.fn().mockReturnThis(), - stop: jest.fn().mockReturnThis(), - succeed: jest.fn().mockReturnThis(), - fail: jest.fn().mockReturnThis(), - warn: jest.fn().mockReturnThis(), - info: jest.fn().mockReturnThis(), - clear: jest.fn().mockReturnThis(), - text: "", - })), -})); - diff --git a/test/setup.ts b/test/setup.ts new file mode 100644 index 0000000..c2a1b35 --- /dev/null +++ b/test/setup.ts @@ -0,0 +1,29 @@ +/** + * Vitest setup: stub ESM-only modules that misbehave under bundled tests. + */ +import { vi } from "vitest"; + +vi.mock("chalk", () => ({ + default: new Proxy( + {}, + { + get: (target, prop) => { + if (prop === "default") return target; + return (str: string) => str; + }, + }, + ), +})); + +vi.mock("ora", () => ({ + default: vi.fn(() => ({ + start: vi.fn().mockReturnThis(), + stop: vi.fn().mockReturnThis(), + succeed: vi.fn().mockReturnThis(), + fail: vi.fn().mockReturnThis(), + warn: vi.fn().mockReturnThis(), + info: vi.fn().mockReturnThis(), + clear: vi.fn().mockReturnThis(), + text: "", + })), +})); diff --git a/test/unit/network-sync-sub.test.ts b/test/unit/network-sync-sub.test.ts index 61b641e..9c75aa4 100644 --- a/test/unit/network-sync-sub.test.ts +++ b/test/unit/network-sync-sub.test.ts @@ -1,144 +1,128 @@ import { waitForSync } from "../../src/utils/network-sync"; -import { DocHandle, StorageId } from "@automerge/automerge-repo"; +import { DocHandle } from "@automerge/automerge-repo"; +import { EventEmitter } from "events"; /** - * Create a mock DocHandle with controllable heads. + * waitForSync resolves when EITHER: + * - a `remote-heads` event reports heads matching the handle's local heads + * (strict signal — works in WS mode), OR + * - the handle's local heads remain unchanged for 3 consecutive polls + * (stability fallback — used when the strict signal isn't available, e.g. + * Subduction direct-peer connections). * - * @param headSequence - An array of head values the handle returns on - * successive calls to heads(). Once exhausted, the last value repeats. - * This lets us simulate heads that change (sync in progress) and then - * stabilize (sync complete). + * Polls happen at 100ms intervals, so stability resolves at ~300ms. */ -function mockHandle(headSequence: string[][]): DocHandle { - let callCount = 0; - return { - url: `automerge:mock-${Math.random().toString(36).slice(2)}`, - heads: () => { - const idx = Math.min(callCount++, headSequence.length - 1); - return headSequence[idx]; - }, - // getSyncInfo is only called in the StorageId path, not the head-stability path - getSyncInfo: jest.fn(), - on: jest.fn(), - off: jest.fn(), - } as unknown as DocHandle; +interface FakeHandle extends DocHandle { + setHeads(h: string[]): void; + emitRemote(h: string[], storageId?: string): void; } -describe("waitForSync (Subduction / head-stability mode)", () => { - // When syncServerStorageId is undefined, waitForSync should use the - // head-stability polling path instead of the getSyncInfo-based path. - - it("should return immediately for empty handle list", async () => { - const result = await waitForSync([], undefined); - expect(result.failed).toHaveLength(0); - }); - - it("should resolve when handle heads are already stable", async () => { - // Heads never change — stable from the start - const handle = mockHandle([["head-a", "head-b"]]); - const result = await waitForSync([handle], undefined, 5000); - - expect(result.failed).toHaveLength(0); - // getSyncInfo should never be called in head-stability mode - expect(handle.getSyncInfo).not.toHaveBeenCalled(); - }); - - it("should resolve after heads stabilize", async () => { - // Heads change for the first few polls, then stabilize - const handle = mockHandle([ - ["head-1"], // poll 1: initial - ["head-2"], // poll 2: changed (reset stable count) - ["head-3"], // poll 3: changed again - ["head-3"], // poll 4: stable check 1 - ["head-3"], // poll 5: stable check 2 - ["head-3"], // poll 6: stable check 3 → converged - ]); - - const result = await waitForSync([handle], undefined, 10000); - expect(result.failed).toHaveLength(0); - }); - - it("should report handle as failed on timeout", async () => { - // Heads keep changing — never stabilize - let counter = 0; - const neverStable = { - url: "automerge:never-stable", - heads: () => [`head-${counter++}`], - getSyncInfo: jest.fn(), - on: jest.fn(), - off: jest.fn(), - } as unknown as DocHandle; - - const result = await waitForSync([neverStable], undefined, 500); - expect(result.failed).toHaveLength(1); - expect(result.failed[0]).toBe(neverStable); - }); - - it("should handle a mix of stable and unstable handles", async () => { - const stable = mockHandle([["stable-head"]]); - - let counter = 0; - const unstable = { - url: "automerge:unstable", - heads: () => [`changing-${counter++}`], - getSyncInfo: jest.fn(), - on: jest.fn(), - off: jest.fn(), - } as unknown as DocHandle; - - const result = await waitForSync([stable, unstable], undefined, 500); - - // The stable handle should succeed, the unstable one should fail - expect(result.failed).toHaveLength(1); - expect(result.failed[0]).toBe(unstable); - }); - - it("should not use getSyncInfo when no StorageId is provided", async () => { - const handle = mockHandle([["head-a"]]); - await waitForSync([handle], undefined, 5000); - - // The head-stability path does not call getSyncInfo at all - expect(handle.getSyncInfo).not.toHaveBeenCalled(); - }); -}); - -describe("waitForSync (WebSocket / StorageId mode)", () => { - // When a StorageId IS provided, waitForSync should use getSyncInfo-based - // verification instead of head-stability polling. - - it("should use getSyncInfo when StorageId is provided", async () => { - const storageId = "test-storage-id" as StorageId; - const heads = ["head-a"]; - - const handle = { - url: "automerge:ws-handle", - heads: () => heads, - getSyncInfo: jest.fn().mockReturnValue({ lastHeads: heads }), - on: jest.fn(), - off: jest.fn(), - } as unknown as DocHandle; - - const result = await waitForSync([handle], storageId, 5000); - - expect(result.failed).toHaveLength(0); - expect(handle.getSyncInfo).toHaveBeenCalledWith(storageId); - }); - - it("should detect already-synced handles via getSyncInfo", async () => { - const storageId = "test-storage-id" as StorageId; - const heads = ["same-head"]; - - const handle = { - url: "automerge:already-synced", - heads: () => heads, - // getSyncInfo returns matching heads → already synced - getSyncInfo: jest.fn().mockReturnValue({ lastHeads: heads }), - on: jest.fn(), - off: jest.fn(), - } as unknown as DocHandle; +function mockHandle(initialHeads: string[]): FakeHandle { + const ee = new EventEmitter(); + let current = initialHeads; + const handle = { + url: `automerge:mock-${Math.random().toString(36).slice(2)}`, + heads: () => current, + on: ee.on.bind(ee), + off: ee.off.bind(ee), + setHeads: (h: string[]) => { + current = h; + }, + emitRemote: (h: string[], storageId = "test-storage-id") => { + ee.emit("remote-heads", { storageId, heads: h, timestamp: Date.now() }); + }, + }; + return handle as unknown as FakeHandle; +} - const result = await waitForSync([handle], storageId, 5000); - expect(result.failed).toHaveLength(0); - }); +describe("waitForSync", () => { + it("returns immediately for empty handle list", async () => { + const result = await waitForSync([]); + expect(result.failed).toHaveLength(0); + }); + + it("resolves quickly when a remote-heads event reports matching heads", async () => { + const handle = mockHandle(["head-a"]); + const promise = waitForSync([handle], 5000); + setImmediate(() => handle.emitRemote(["head-a"])); + const result = await promise; + expect(result.failed).toHaveLength(0); + }); + + it("ignores remote-heads events whose heads don't match", async () => { + const handle = mockHandle(["head-a"]); + const promise = waitForSync([handle], 5000); + setImmediate(() => { + handle.emitRemote(["head-stale"]); + handle.emitRemote(["head-a"]); + }); + const result = await promise; + expect(result.failed).toHaveLength(0); + }); + + it("accepts confirmation regardless of which storageId reports it", async () => { + const handle = mockHandle(["head-a"]); + const promise = waitForSync([handle], 5000); + setImmediate(() => handle.emitRemote(["head-a"], "any-other-storage-id")); + const result = await promise; + expect(result.failed).toHaveLength(0); + }); + + it("falls back to head stability when no remote-heads event arrives", async () => { + // Heads never change → resolves via stability after ~300ms. + const handle = mockHandle(["stable-head"]); + const result = await waitForSync([handle], 5000); + expect(result.failed).toHaveLength(0); + }); + + it("times out if heads keep changing and no event confirms", async () => { + const ee = new EventEmitter(); + let counter = 0; + const neverStable = { + url: "automerge:never-stable", + heads: () => [`head-${counter++}`], + on: ee.on.bind(ee), + off: ee.off.bind(ee), + } as unknown as DocHandle; + + const result = await waitForSync([neverStable], 500); + expect(result.failed).toHaveLength(1); + expect(result.failed[0]).toBe(neverStable); + }); + + it("waits for the latest local heads if a merge advances them mid-wait", async () => { + const handle = mockHandle(["head-a"]); + const promise = waitForSync([handle], 5000); + setImmediate(() => { + handle.setHeads(["head-b"]); + // Stale event for head-a should be ignored. + handle.emitRemote(["head-a"]); + // Confirmation of new heads. + handle.emitRemote(["head-b"]); + }); + const result = await promise; + expect(result.failed).toHaveLength(0); + }); + + it("handles a mix of confirmed and timed-out handles concurrently", async () => { + const fast = mockHandle(["fast-head"]); + + // Build an unstable handle that never converges. + const ee = new EventEmitter(); + let counter = 0; + const slow = { + url: "automerge:unstable", + heads: () => [`changing-${counter++}`], + on: ee.on.bind(ee), + off: ee.off.bind(ee), + } as unknown as DocHandle; + + const promise = waitForSync([fast, slow], 500); + setImmediate(() => fast.emitRemote(["fast-head"])); + + const result = await promise; + expect(result.failed).toHaveLength(1); + expect(result.failed[0]).toBe(slow); + }); }); diff --git a/tsconfig.json b/tsconfig.json index 36ac3d7..3d80853 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -13,7 +13,7 @@ "declarationMap": true, "sourceMap": true, "resolveJsonModule": true, - "types": ["node", "jest"], + "types": ["node"], "noUnusedLocals": true, "noUnusedParameters": true }, diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..96d0517 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + globals: true, + environment: "node", + include: ["src/**/*.{test,spec}.ts", "test/**/*.{test,spec}.ts"], + setupFiles: ["./test/setup.ts"], + // Many integration tests spawn the CLI as a subprocess; allow time + // for build server / sync server roundtrips. + testTimeout: 60000, + hookTimeout: 60000, + }, +}); From 67854c692ff6a26c591edf73b826f889ce4e82d7 Mon Sep 17 00:00:00 2001 From: chee Date: Mon, 4 May 2026 00:57:39 +0100 Subject: [PATCH 03/21] pushwork v2 v1 --- ARCHITECTURE-ACCORDING-TO-CLAUDE.md | 254 --- CLAUDE.md | 185 -- README.md | 235 -- scripts/roundtrip-test.sh | 35 - src/cli.ts | 549 +---- src/commands.ts | 1182 ---------- src/config.ts | 63 + src/core/change-detection.ts | 712 ------ src/core/config.ts | 327 --- src/core/index.ts | 5 - src/core/move-detection.ts | 171 -- src/core/snapshot.ts | 275 --- src/core/sync-engine.ts | 1919 ----------------- src/fs-tree.ts | 99 + src/ignore.ts | 21 + src/index.ts | 6 +- src/pushwork.ts | 153 ++ src/repo.ts | 58 + src/types/config.ts | 115 - src/types/documents.ts | 91 - src/types/index.ts | 3 - src/types/snapshot.ts | 67 - src/utils/content.ts | 34 - src/utils/directory.ts | 73 - src/utils/fs.ts | 295 --- src/utils/index.ts | 4 - src/utils/mime-types.ts | 244 --- src/utils/network-sync.ts | 343 --- src/utils/output.ts | 450 ---- src/utils/repo-factory.ts | 149 -- src/utils/string-similarity.ts | 54 - src/utils/text-diff.ts | 101 - src/utils/trace.ts | 70 - test/integration/README.md | 328 --- test/integration/clone-test.sh | 310 --- test/integration/conflict-resolution-test.sh | 309 --- test/integration/debug-both-nested.sh | 74 - test/integration/debug-concurrent-nested.sh | 87 - test/integration/debug-nested.sh | 73 - test/integration/deletion-behavior-test.sh | 487 ----- test/integration/deletion-sync-test-simple.sh | 193 -- test/integration/deletion-sync-test.sh | 297 --- test/integration/exclude-patterns.test.ts | 144 -- test/integration/full-integration-test.sh | 363 ---- test/integration/fuzzer.test.ts | 818 ------- test/integration/in-memory-sync.test.ts | 830 ------- test/integration/init-sync.test.ts | 89 - test/integration/manual-sync-test.sh | 84 - test/integration/pushwork.test.ts | 547 +++++ test/integration/sub-flag.test.ts | 185 -- test/integration/sync-deletion.test.ts | 280 --- test/integration/sync-flow.test.ts | 291 --- test/run-tests.sh | 225 -- test/unit/artifact-nuke-reinsert.test.ts | 80 - test/unit/deletion-behavior.test.ts | 249 --- test/unit/enhanced-mime-detection.test.ts | 244 --- test/unit/network-sync-sub.test.ts | 128 -- test/unit/repo-factory.test.ts | 111 - test/unit/snapshot.test.ts | 404 ---- test/unit/subduction-config.test.ts | 69 - test/unit/sync-convergence.test.ts | 298 --- test/unit/sync-timing.test.ts | 134 -- test/unit/utils.test.ts | 366 ---- 63 files changed, 998 insertions(+), 15441 deletions(-) delete mode 100644 ARCHITECTURE-ACCORDING-TO-CLAUDE.md delete mode 100644 CLAUDE.md delete mode 100644 README.md delete mode 100755 scripts/roundtrip-test.sh delete mode 100644 src/commands.ts create mode 100644 src/config.ts delete mode 100644 src/core/change-detection.ts delete mode 100644 src/core/config.ts delete mode 100644 src/core/index.ts delete mode 100644 src/core/move-detection.ts delete mode 100644 src/core/snapshot.ts delete mode 100644 src/core/sync-engine.ts create mode 100644 src/fs-tree.ts create mode 100644 src/ignore.ts create mode 100644 src/pushwork.ts create mode 100644 src/repo.ts delete mode 100644 src/types/config.ts delete mode 100644 src/types/documents.ts delete mode 100644 src/types/index.ts delete mode 100644 src/types/snapshot.ts delete mode 100644 src/utils/content.ts delete mode 100644 src/utils/directory.ts delete mode 100644 src/utils/fs.ts delete mode 100644 src/utils/index.ts delete mode 100644 src/utils/mime-types.ts delete mode 100644 src/utils/network-sync.ts delete mode 100644 src/utils/output.ts delete mode 100644 src/utils/repo-factory.ts delete mode 100644 src/utils/string-similarity.ts delete mode 100644 src/utils/text-diff.ts delete mode 100644 src/utils/trace.ts delete mode 100644 test/integration/README.md delete mode 100755 test/integration/clone-test.sh delete mode 100755 test/integration/conflict-resolution-test.sh delete mode 100644 test/integration/debug-both-nested.sh delete mode 100644 test/integration/debug-concurrent-nested.sh delete mode 100644 test/integration/debug-nested.sh delete mode 100755 test/integration/deletion-behavior-test.sh delete mode 100755 test/integration/deletion-sync-test-simple.sh delete mode 100755 test/integration/deletion-sync-test.sh delete mode 100644 test/integration/exclude-patterns.test.ts delete mode 100755 test/integration/full-integration-test.sh delete mode 100644 test/integration/fuzzer.test.ts delete mode 100644 test/integration/in-memory-sync.test.ts delete mode 100644 test/integration/init-sync.test.ts delete mode 100755 test/integration/manual-sync-test.sh create mode 100644 test/integration/pushwork.test.ts delete mode 100644 test/integration/sub-flag.test.ts delete mode 100644 test/integration/sync-deletion.test.ts delete mode 100644 test/integration/sync-flow.test.ts delete mode 100755 test/run-tests.sh delete mode 100644 test/unit/artifact-nuke-reinsert.test.ts delete mode 100644 test/unit/deletion-behavior.test.ts delete mode 100644 test/unit/enhanced-mime-detection.test.ts delete mode 100644 test/unit/network-sync-sub.test.ts delete mode 100644 test/unit/repo-factory.test.ts delete mode 100644 test/unit/snapshot.test.ts delete mode 100644 test/unit/subduction-config.test.ts delete mode 100644 test/unit/sync-convergence.test.ts delete mode 100644 test/unit/sync-timing.test.ts delete mode 100644 test/unit/utils.test.ts diff --git a/ARCHITECTURE-ACCORDING-TO-CLAUDE.md b/ARCHITECTURE-ACCORDING-TO-CLAUDE.md deleted file mode 100644 index bc23cda..0000000 --- a/ARCHITECTURE-ACCORDING-TO-CLAUDE.md +++ /dev/null @@ -1,254 +0,0 @@ -# Pushwork Architecture - -> 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. - -## Module Diagram - -``` -┌─────────────────────────────────────────────────────────────┐ -│ CLI (cli.ts) │ -│ Commander.js argument parsing │ -└────────────────────────┬────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────┐ -│ Commands (commands.ts) │ -│ setupCommandContext() → creates Repo + SyncEngine │ -│ sync(), commit(), status(), diff(), clone(), init(), ... │ -└────────────────────────┬────────────────────────────────────┘ - │ - ┌──────────────┼──────────────┐ - ▼ ▼ ▼ -┌──────────────┐ ┌──────────────┐ ┌──────────────────────┐ -│ ConfigMgr │ │ RepoFactory │ │ SyncEngine │ -│ (config.ts) │ │(repo-factory)│ │ (sync-engine.ts) │ -│ │ │ │ │ │ -│ defaults < │ │ Automerge │ │ Orchestrates the │ -│ global < │ │ Repo with │ │ entire sync cycle │ -│ local │ │ storage + │ │ │ -└──────────────┘ │ WebSocket or │ │ ┌────────────────┐ │ - │ Subduction │ │ │ChangeDetector │ │ - └──────────────┘ │ │ FS vs snapshot │ │ - │ │ vs remote docs │ │ - │ └────────────────┘ │ - │ ┌────────────────┐ │ - │ │ MoveDetector │ │ - │ │ content-sim │ │ - │ │ rename detect │ │ - │ └────────────────┘ │ - │ ┌────────────────┐ │ - │ │SnapshotManager │ │ - │ │ .pushwork/ │ │ - │ │ snapshot.json │ │ - │ └────────────────┘ │ - └──────────┬───────────┘ - │ - ┌────────────────────────┼────────────────────┐ - ▼ ▼ ▼ - ┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐ - │ text-diff.ts │ │ network-sync.ts │ │ directory.ts │ - │ │ │ │ │ │ - │ spliceText() │ │ waitForSync() │ │ getPlainUrl() │ - │ updateText │ │ waitForBidi │ │ getVersioned │ - │ Content() │ │ directionalSync()│ │ Url() │ - │ readDocContent()│ │ batch size: 10 │ │ findFileIn │ - └─────────────────┘ └────────┬─────────┘ │ Directory() │ - │ └─────────────────┘ - ▼ - ┌───────────────────────────────┐ - │ Sync backend (one of) │ - │ │ - │ • WebSocket relay (default) │ - │ sync3.automerge.org │ - │ │ - │ • Subduction (--sub opt-in) │ - │ subduction.sync │ - │ .inkandswitch.com │ - └───────────────────────────────┘ -``` - -## Source Layout - -``` -src/ - cli.ts — Commander.js entry point; registers all commands - commands.ts — Command implementations; shared setupCommandContext() - index.ts — Library export entry point - - core/ - sync-engine.ts — Two-phase sync coordinator (the heart of the system) - change-detection.ts — ChangeDetector: local + remote diff engine - move-detection.ts — MoveDetector: content-similarity-based rename detection - snapshot.ts — SnapshotManager: reads/writes .pushwork/snapshot.json - config.ts — ConfigManager: loads/merges defaults < global < local - - utils/ - repo-factory.ts — Creates the Automerge Repo with storage + network adapters - network-sync.ts — waitForSync(), waitForBidirectionalSync() - text-diff.ts — spliceText(), updateTextContent(), readDocContent() - content.ts — contentHash(), isContentEqual() - directory.ts — getPlainUrl(), findFileInDirectoryHierarchy() - fs.ts — readFileContent(), writeFileContent(), listDirectory() - mime-types.ts — isEnhancedTextFile(), getEnhancedMimeType() - string-similarity.ts — Sørensen-Dice coefficient for move detection - output.ts — Output singleton (ora spinner + chalk colors) - trace.ts — Debug tracing helpers - - types/ - documents.ts — FileDocument, DirectoryDocument, DirectoryEntry - snapshot.ts — SyncSnapshot, SnapshotFileEntry, SyncResult - config.ts — DirectoryConfig, GlobalConfig, CLI option interfaces -``` - -## Data Model - -Every node in the synced tree is an Automerge document: - -- **FileDocument** — A file. Content is either collaborative text (mutable CRDT string supporting character-level `splice`) or raw bytes (for binary files). Also stores name, extension, mimeType, and permissions metadata. -- **DirectoryDocument** — A directory. Contains a `docs` array of `{name, type, url}` entries pointing to child documents. Forms a tree rooted at one directory document. -- **DirectoryEntry** — A single `{name, type, url}` reference inside a directory's `docs` array. For artifact files, the URL includes Automerge heads (a versioned snapshot); for regular files, it's a plain mutable URL. - -``` -DirectoryDocument (root) -├── docs: [ -│ { name: "readme.md", type: "file", url: "automerge:abc123" } -│ { name: "src", type: "folder", url: "automerge:def456" } -│ { name: "config.json", type: "file", url: "automerge:ghi789" } -│ ] -│ -└── DirectoryDocument (src/) - └── docs: [ - { name: "index.ts", type: "file", url: "automerge:jkl012" } - ] -``` - -## How the Layers Connect - -### CLI → Commands - -`cli.ts` registers Commander.js commands and calls exported functions from `commands.ts`. The CLI layer is thin — it only handles argument parsing, option validation, and process exits. It doesn't touch Automerge directly. - -### Commands → Core - -`commands.ts` provides the shared setup function `setupCommandContext()`, which: - -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 -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()`. - -### SyncEngine — The Coordinator - -`SyncEngine` owns the entire sync cycle and holds references to three subsystems: - -- **ChangeDetector** — compares local filesystem state against the snapshot and remote Automerge documents to classify every file as local-only, remote-only, both-changed, or unchanged -- **MoveDetector** — pairs local-only deletions with local-only creations using content similarity (Sørensen-Dice coefficient) to detect renames -- **SnapshotManager** — reads and writes `.pushwork/snapshot.json`, which records the last-known state of every tracked file and directory - -## The Sync Cycle - -When you run `pushwork sync`, the following happens: - -``` -1. NETWORK WAIT - Wait for any incoming remote changes to arrive - (bidirectional sync poll until heads stabilize) - │ - ▼ -2. DETECT CHANGES + MOVES - Compare local filesystem, snapshot heads, and - remote document heads to classify every file. - Pair deletions with creations to detect renames. - │ - ▼ -3. PUSH (local → remote) - Process directories deepest-first: - new files → repo.create() a new Automerge doc - modified → handle.changeAt(heads) + spliceText() - deleted → remove entry from parent directory doc - moved → rename entry in parent directory doc - │ - ▼ -4. NETWORK UPLOAD - waitForSync() — push docs to relay in batches of 10 - waitForBidirectionalSync() — poll until heads stabilize - │ - ▼ -5. RE-DETECT CHANGES - Fresh change detection pass; filter to remote-only - and both-changed files - │ - ▼ -6. PULL (remote → local) - Write remote changes to the local filesystem - Process shallowest paths first (parents before children) - │ - ▼ -7. UPDATE SNAPSHOT - Re-read all document heads, save snapshot.json -``` - -### The Snapshot as 3-Way Baseline - -The snapshot's `head` (Automerge document heads) for each file acts as the common ancestor in a 3-way comparison: - -- **Local filesystem content** vs **content at snapshot head** = local changes -- **Current Automerge document** vs **snapshot head** = remote changes -- Both changed → `BOTH_CHANGED` (the CRDT handles merge automatically via `changeAt`) - -## Key Design Decisions - -### Leaf-First Push Ordering - -During the push phase, directories are sorted deepest-first. This guarantees that when a parent directory document is updated, all its children's Automerge documents are already finalized with their correct URLs. The parent then records the correct versioned URLs. - -### `changeAt(heads)` for Merging - -Edits branch from the snapshot's known heads using `handle.changeAt(heads, callback)`. This gives Automerge a proper common ancestor for conflict-free 3-way merge. Without this, concurrent edits from different peers would overwrite each other instead of merging. - -### Orphaned Deletions - -Deleting a file just removes it from its parent directory's `docs` array. The Automerge document itself is left as an orphan — clearing a large text CRDT would be O(n) in character operations, which is very slow for large files. - -### Artifact Directories - -Files in configured `artifact_directories` (default: `dist/`) are treated as immutable snapshots rather than collaboratively-edited text. They use `RawString` (not the text CRDT), are never diffed/spliced, and are always replaced with a new document on update. A SHA-256 `contentHash` in the snapshot detects local changes without reading the Automerge doc. - -### Batched Network Sync - -Documents sync to the relay server in batches of 10 (`SYNC_BATCH_SIZE`). The Automerge sync server is single-threaded with no backpressure, so sending 100+ documents simultaneously would overwhelm it. - -### Immutable String Handling - -Old Automerge documents may store text content as `RawString` (immutable) instead of the collaborative text CRDT. You can't `splice` into these. The codebase handles this with `updateTextContent()` which detects the type and either splices or assigns directly, and a nuclear path in `updateRemoteFile()` that recreates the entire document if needed. - -## On-Disk Layout - -``` -your-project/ - .pushwork/ - config.json ← local config overrides - snapshot.json ← change-detection baseline (paths, URLs, heads) - automerge/ ← Automerge document binary storage - -~/.pushwork/ - config.json ← global user-level config -``` - -## Key Dependencies - -| Package | Purpose | -|---|---| -| `@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-repo-storage-nodefs` | Local filesystem persistence for Automerge docs | -| `@commander-js/extra-typings` | CLI command framework | -| `diff` | Character-level diffing to feed `A.splice()` | -| `glob` | Recursive filesystem enumeration | -| `ignore` | Gitignore-style pattern matching for excludes | diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 020543d..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,185 +0,0 @@ -# Pushwork - Claude's Notes - -Always update this file as you learn new things about the codebase — patterns, pitfalls, performance considerations, architectural decisions. This is your persistent memory across sessions. - -## What to do after changing code - -Always run `npm run build` (which runs `tsc`) after finishing changes to verify compilation. - -## Code style - -- `src/core/sync-engine.ts` and `src/commands.ts` use tabs for indentation -- `src/utils/network-sync.ts` and `src/cli.ts` use 2-space indentation -- Adding a new CLI command requires changes in 4 places: types (`src/types/config.ts` for options interface), engine (`src/core/sync-engine.ts` for method), commands (`src/commands.ts` for command function), CLI (`src/cli.ts` for registration + import) - -## What pushwork is - -Pushwork is a CLI tool for bidirectional file synchronization using Automerge CRDTs. It maps a local filesystem directory to a tree of Automerge documents, syncing changes in both directions through a relay server. Multiple users can edit the same files and changes merge automatically without conflicts. - -## Architecture overview - -``` -CLI (cli.ts) -> Commands (commands.ts) -> SyncEngine (core/sync-engine.ts) - | - +---------+---------+ - | | | - ChangeDetector | MoveDetector - SnapshotManager -``` - -### Key files - -- `src/cli.ts` - Commander.js CLI entry point, defines all commands -- `src/commands.ts` - Command implementations, `setupCommandContext()` is the shared setup -- `src/core/sync-engine.ts` - The heart of the system. Two-phase sync: push local changes, then pull remote changes -- `src/core/change-detection.ts` - Compares local filesystem state against snapshot to find changes -- `src/core/move-detection.ts` - Detects file renames/moves by content similarity -- `src/core/snapshot.ts` - Manages `.pushwork/snapshot.json`, tracks what's been synced -- `src/core/config.ts` - Config loading/merging (defaults < global < local) -- `src/utils/text-diff.ts` - `spliceText()` for character-level CRDT edits, `updateTextContent()` for handling legacy immutable strings, `readDocContent()` for normalizing content reads - -### Type definitions - -- `src/types/documents.ts` - FileDocument, DirectoryDocument, DirectoryEntry -- `src/types/config.ts` - DirectoryConfig, GlobalConfig, all CLI option interfaces -- `src/types/snapshot.ts` - SyncSnapshot, SnapshotFileEntry, SyncResult - -## How sync works - -### Data model - -Every file becomes an Automerge document (`FileDocument`) with content stored as either collaborative text (for text files, supporting character-level merge) or raw bytes (for binary files). Directories become `DirectoryDocument`s containing a `docs` array of `{name, type, url}` entries pointing to children. The whole thing forms a tree rooted at one directory document. - -### Two-phase sync - -1. **Push** (local -> remote): Detect local filesystem changes vs snapshot. New files get new Automerge docs. Modified files get spliced. Deleted files are removed from their parent directory document (the orphaned doc is left as-is). -2. **Network sync**: Wait for documents to reach the relay server, level-by-level deepest-first (children before parents). -3. **Pull** (remote -> local): Re-detect changes after network sync. Write remote-only changes to the local filesystem. - -### Snapshot - -The snapshot (`.pushwork/snapshot.json`) records: - -- `rootDirectoryUrl` - the root Automerge document URL -- `files` - map of relative path -> `{url, head}` for every tracked file -- `directories` - map of relative path -> `{url, head}` for every tracked directory - -The `head` (Automerge document heads) is how change detection works: if a document's current heads differ from the snapshot heads, it has changed. - -### Versioned URLs - -Automerge URLs can include heads (e.g. `automerge:docid#head1,head2`). Pushwork stores versioned URLs in directory entries so clients can fetch the exact version. `getPlainUrl()` strips heads when you need a mutable handle; `getVersionedUrl()` adds current heads. - -## Immutable string handling - -Old Automerge documents may store text content as `RawString` (aka `ImmutableString`) instead of the collaborative text CRDT. You can't `splice` into these. Two strategies: - -1. **`updateTextContent()`** - Inside a change callback, detects if the field is a regular string (splice-able) or legacy immutable (assign directly to convert it). -2. **`updateRemoteFile()` nuclear path** - If `A.isImmutableString(content)` is true, throws away the old document entirely, creates a brand new one with proper mutable text, and replaces the entry in the parent directory via `replaceFileInDirectory()`. - -`readDocContent()` normalizes `RawString` to plain strings when reading. - -## CLI commands - -- `pushwork init [path]` - Initialize, creates root directory document -- `pushwork clone ` - Clone from an Automerge URL -- `pushwork sync [path]` - Full bidirectional sync (default: force mode — uses default config, preserves snapshot for incremental change detection) - - `--dry-run` - Preview only - - `--gentle` - Use merged config instead of defaults - - `--nuclear` - Recreate all Automerge documents from scratch (except root) - - `--force` - Silently accepted for backwards compatibility (does nothing, force is now the default) -- `pushwork track [path]` - Set root directory URL without full init (creates minimal `.pushwork/snapshot.json`). `root` is a hidden alias. -- `pushwork commit [path]` - Save to Automerge docs without network sync -- `pushwork status [path]` - Show sync status -- `pushwork diff [path]` - Show changes -- `pushwork url [path]` - Print root Automerge URL -- `pushwork ls [path]` - List tracked files -- `pushwork config [path]` - View config -- `pushwork watch [path]` - Watch + build + sync loop -- `pushwork rm [path]` - Remove local `.pushwork` data - -## Config - -Stored in `.pushwork/config.json` (local) and `~/.pushwork/config.json` (global). Merged: defaults < global < local. - -Key fields: - -- `sync_enabled: boolean` - Whether to do network sync -- `sync_server: string` - WebSocket relay URL (default: `wss://sync3.automerge.org`) -- `sync_server_storage_id: StorageId` - Server identity for sync verification -- `exclude_patterns: string[]` - Gitignore-style patterns (default: `.git`, `node_modules`, `*.tmp`, `.pushwork`, `.DS_Store`) -- `sync.move_detection_threshold: number` - Similarity threshold for move detection (0-1, default 0.7) - -## Network sync details - -- Uses `waitForSync()` to wait until each handle has positive sync confirmation. Two signals can resolve a handle: (1) a `remote-heads` event whose heads match local heads — emitted by `SyncStateTracker` in WebSocket mode when the server reports its sync state; (2) head stability — heads unchanged for 3 consecutive 100ms polls. Subduction direct-peer connections take path (2) because `handleImmediateRemoteHeadsChanged` in `RemoteHeadsSubscriptions` stores received heads but does not currently emit `remote-heads-changed`, so no `remote-heads` event reaches the handle for the directly-connected sync server (only indirect/gossip updates do). `enableRemoteHeadsGossiping: true` is set in `repo-factory.ts` so the path is wired up; if upstream emits the strict signal for direct peers we'll start getting it for free. We don't filter on `storageId` — pushwork configures one upstream peer. No batching: all handles are awaited concurrently. The `sync_server_storage_id` config field is no longer used for verification. -- Uses `waitForBidirectionalSync()` to poll until document heads stabilize (no more incoming changes) - - Accepts optional `handles` param to check only specific handles instead of full tree traversal (used post-push in `sync()`) - - Timeout scales dynamically: `max(timeoutMs, 5000 + docCount * 50)` so large trees don't prematurely time out - - Tree traversal (`collectHeadsRecursive`) fetches siblings concurrently via `Promise.all` -- Documents sync level-by-level, deepest first, so children are on the server before their parents -- `handlesByPath` map tracks which documents changed and need syncing - -## Leaf-first ordering - -`pushLocalChanges()` processes directories deepest-first via `batchUpdateDirectory()`, propagating subdirectory URL updates as it walks up toward the root. This ensures directory entries always point to the latest version of their children. - -**Invariant: any change to dist's heads must update parents recursively, leaf-first.** Local file changes are caught by the loop above. Heads can also drift from remote merges that land during `waitForBidirectionalSync` — the artifact directory advances locally but no file-level change is detected, so leaf-first propagation never kicks in and the parent's versioned URL goes stale. `findStaleArtifactDirs()` scans every artifact dir in the snapshot, compares its live `handle.heads()` against the heads encoded in its parent's stored URL entry, and returns paths that have drifted. `pushLocalChanges()` then folds these into `allDirsToProcess` and pre-populates `modifiedDirs` so the existing leaf-first machinery emits a `subdirUpdates` entry for each stale dir's parent. This is self-healing — even if drift happens after a sync exits, the next sync catches it. - -## The `changeWithOptionalHeads` helper - -Used throughout sync-engine: if heads are available, calls `handle.changeAt(heads, cb)` to branch from a known version; otherwise falls back to `handle.change(cb)`. This is important for conflict-free merging when multiple peers are editing. - -## Performance pitfalls - -- **Avoid splicing large text deletions.** Automerge text CRDTs track every character as an individual op. `A.splice(doc, path, 0, largeString.length)` to clear a large file is O(n) in CRDT ops and very slow. This is why `deleteRemoteFile()` no longer clears content — it just lets the document become orphaned when removed from its parent directory. -- **Avoid diffing artifact files.** `diffChars()` is O(n\*m) and pointless for artifact directories since they use RawString (immutable snapshots). Artifact files should always be replaced with a fresh document rather than diffed+spliced. This applies to `updateRemoteFile()`, `applyMoveToRemote()`, and change detection. `ChangeDetector` skips `getContentAtHead()` and `getCurrentRemoteContent()` for artifact paths — it uses a SHA-256 `contentHash` stored in the snapshot to detect local changes, and checks heads to detect remote changes. If neither changed, the artifact is skipped entirely. The `contentHash` field on `SnapshotFileEntry` is optional and only populated for artifact files. -- **Artifact directories are always nuked.** `batchUpdateDirectory` uses a plain `dirHandle.change()` (not `changeWithOptionalHeads`) for artifact directory paths and rebuilds the entire `docs` array from scratch. This avoids `changeAt` forking from stale heads, which previously caused bugs like deleted entries resurrecting. The rebuild reads the current entries, applies all changes (deletes, updates, additions, subdir URL updates), then splices out the old array and pushes the computed entries. -- **Sync timeout recovery.** `waitForSync()` returns `{ failed: DocHandle[] }` instead of throwing. When documents fail to sync (timeout or unavailable), `recreateFailedDocuments()` creates new Automerge docs with the same content, updates snapshot entries and parent directory references, then retries once. If documents still fail after recreation, it's reported as an error (not a warning) so the sync shows as "PARTIAL" rather than "SYNCED". -- **Document availability during clone.** `repo.find()` rejects with "Document X is unavailable" if the sync server doesn't have the document yet. `DocHandle.doc()` is synchronous and throws if the handle isn't ready. For clone scenarios, `sync()` retries `repo.find()` for the root document with exponential backoff (up to 6 attempts). `ChangeDetector.findDocument()` wraps `repo.find()` + `doc()` with retry logic for all document fetches during change detection. -- **Server load.** `enableRemoteHeadsGossiping` is now enabled — `waitForSync` requires the `remote-heads` events that gossip wires up. Previously batched concurrent waits via `SYNC_BATCH_SIZE`; that was removed because head-stability polling falsely reported "synced" before the server actually had the data, and the batching wasn't doing useful work once we switched to event-based confirmation. -- **`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`) - -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. - -### 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) -- `network-sync.ts`: `waitForSync` is the same in both modes — listen for `remote-heads` events on each handle and resolve when reported heads match local heads. Subduction's gossip callback fires these events because `enableRemoteHeadsGossiping: true` is now set in `repo-factory.ts` -- `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 - -### 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. - -`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. - -`repo-factory.ts` uses a `new Function("specifier", "return import(specifier)")` wrapper to perform _real_ ESM `import()` calls that Node.js evaluates as ESM. This is necessary because TypeScript with `"module": "commonjs"` compiles `await import("x")` to `require("x")`, which resolves CJS entries. The CJS and ESM module graphs have separate Wasm instances, so initializing via CJS `require()` doesn't help the ESM `/slim` imports inside `automerge-repo`. The `new Function` trick bypasses tsc's transformation and shares the same ESM module graph as the Repo's internal imports. - -The Repo class itself is also loaded via this ESM dynamic import (cached after first call) so that `new Repo()` sees the initialized Wasm module. - -### Packaging notes - -- `automerge-repo@2.6.0-subduction.14` correctly pins `@automerge/automerge-subduction@0.7.0` — no pnpm override needed (unlike subduction.7 which required an override to fix a version mismatch). -- `RepoConfig` properly types the Subduction options pushwork uses (`subductionWebsocketEndpoints`, `signer`, `subductionPolicy`, `subductionAdapters`) — no `as any` cast needed. -- 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 - -`--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`. - -When Subduction mode is active, commands print a banner: "Using Subduction sync backend (from config)". - -Every `sync` run prints the root Automerge URL at the end. - -### 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. - -This is a safety net for the Subduction `HydrationError: LooseCommit too short` crash. The upstream fix (`Repo.shutdown()` now calls `flush()` and `SubductionSource.shutdown()` awaits pending writes) prevents the corruption from happening in the first place, but edge cases (SIGKILL, OOM, power loss) can still produce 0-byte files. diff --git a/README.md b/README.md deleted file mode 100644 index 3c48191..0000000 --- a/README.md +++ /dev/null @@ -1,235 +0,0 @@ -# Pushwork - -Bidirectional file synchronization using Automerge CRDTs for conflict-free collaborative editing. - -## Features - -- **Conflict-Free Sync**: Automatic conflict resolution using Automerge CRDTs -- **Real-time Collaboration**: Multiple users can edit the same files simultaneously -- **Intelligent Move Detection**: Detects file renames and moves based on content similarity -- **Offline Support**: Works offline and gracefully handles network interruptions -- **Cross-Platform**: Runs on Windows, macOS, and Linux - -## Installation - -```bash -pnpm install -pnpm run build -pnpm link --global -``` - -Requires: Node.js 18+, pnpm 8.15.0+ - -## Quick Start - -```bash -# Initialize a directory -pushwork init ./my-project - -# Clone an existing repository -pushwork clone ./project - -# Sync changes -pushwork sync - -# Check status -pushwork status - -# Get shareable URL -pushwork url -``` - -## Commands - -### Core Commands - -**`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) -- `--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 [path]`** - Run bidirectional synchronization - -- `--dry-run` - Preview changes without applying -- `--verbose` - Show detailed progress -- `--debug` - Export performance flame graphs - -**`status [path]`** - Show sync status and repository info - -- `--verbose` - Show detailed status including all tracked files - -**`commit [path]`** - Commit local changes without network sync - -- `--dry-run` - Preview what would be committed -- `--debug` - Export performance flame graphs - -### Utility Commands - -**`diff [path]`** - Show differences between local and remote - -- `--name-only` - Show only changed file names - -**`url [path]`** - Show the Automerge root URL for sharing - -**`ls [path]`** - List tracked files - -- `--long` - Show Automerge URLs - -**`config [path]`** - View or edit configuration - -- `--list` - Show full configuration -- `--get ` - Get specific config value (dot notation) - -**`rm [path]`** - Remove local pushwork data - -**`watch [path]`** - Watch directory, build, and sync automatically - -- `--script ` - Build script (default: "pnpm build") -- `--dir ` - Directory to watch (default: "src") -- `--verbose` - Show build output - -**`log [path]`** - Show sync history _(experimental, limited functionality)_ - -**`checkout [path]`** - Restore to previous sync _(not yet implemented)_ - -## Configuration - -Configuration is stored in `.pushwork/config.json`: - -```json -{ - "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 - }, - "sync": { - "move_detection_threshold": 0.8, - "prompt_threshold": 0.5, - "auto_sync": false, - "parallel_operations": 4 - } -} -``` - -### 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. - -## How It Works - -Pushwork uses Automerge CRDTs for automatic conflict resolution: - -- **Text files**: Character-level merging preserves all changes -- **Binary files**: Last-writer-wins with automatic convergence -- **Directories**: Additive merging supports simultaneous file creation - -Sync process: - -1. **Push**: Apply local changes to Automerge documents -2. **Pull**: Apply remote changes to local filesystem -3. **Convergence**: All repositories reach identical state - -State tracking: - -- `.pushwork/snapshot.json` - Tracks sync state and file mappings -- `.pushwork/config.json` - Configuration settings -- Content-based change detection using Automerge document heads - -### Document Schema - -**File Document:** - -```typescript -{ - "@patchwork": { type: "file" }; - name: string; - extension: string; - mimeType: string; - content: string | Uint8Array; - metadata: { - permissions: number; - }; -} -``` - -**Directory Document:** - -```typescript -{ - "@patchwork": { type: "folder" }; - docs: Array<{ - name: string; - type: "file" | "folder"; - url: AutomergeUrl; - }>; - lastSyncAt?: number; -} -``` - -## Development - -### Setup - -```bash -git clone -cd pushwork -pnpm install -pnpm run build -pnpm run dev # Watch mode -pnpm test # Run tests -pnpm run test:watch # Watch mode for tests -``` - -### Project Structure - -``` -src/ -├── cli/ # Command-line interface -├── core/ # Core sync engine -├── config/ # Configuration management -├── tracing/ # Performance tracing -├── types/ # TypeScript type definitions -└── utils/ # Shared utilities -``` - -### Testing - -```bash -pnpm test # Unit tests -./test/run-tests.sh # All integration tests -./test/integration/conflict-resolution-test.sh # Specific test -``` - -### Profiling - -```bash -pushwork sync --debug # Export flame graphs -clinic flame --collect-only -- node --enable-source-maps --prof $(pnpm root -g)/pushwork/dist/cli.js sync -``` - -## License - -MIT License diff --git a/scripts/roundtrip-test.sh b/scripts/roundtrip-test.sh deleted file mode 100755 index a7d6ed0..0000000 --- a/scripts/roundtrip-test.sh +++ /dev/null @@ -1,35 +0,0 @@ -#!/usr/bin/env bash -# Roundtrip-test pushwork by init-ing a directory, cloning it elsewhere, -# and diffing the two trees (ignoring files that aren't synced). -# -# Usage: roundtrip-test.sh - -set -euo pipefail - -if [ $# -lt 1 ]; then - echo "usage: $0 " >&2 - exit 1 -fi - -SRC=$(cd "$1" && pwd) -CLONE_DIR=$(mktemp -d -t pushwork-roundtrip-XXXXXX)/clone - -echo ">> init $SRC" -pushwork init --sub "$SRC" - -URL=$(pushwork url "$SRC") -echo ">> url $URL" - -echo ">> clone $CLONE_DIR" -pushwork clone "$URL" "$CLONE_DIR" --sub - -echo ">> diff $SRC <-> $CLONE_DIR" -if diff -r \ - --exclude=.pushwork \ - --exclude=node_modules \ - "$SRC" "$CLONE_DIR"; then - echo ">> OK: directories match" -else - echo ">> FAIL: directories differ" >&2 - exit 1 -fi diff --git a/src/cli.ts b/src/cli.ts index 2b03195..dfad067 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,498 +1,59 @@ #!/usr/bin/env node - -import { StorageId } from "@automerge/automerge-repo"; -import { Command, Option } from "@commander-js/extra-typings"; -import chalk from "chalk"; -import { - init, - clone, - sync, - root, - diff, - status, - log, - checkout, - commit, - url, - rm, - ls, - config, - watch, -} from "./commands"; - -const pkg = require("../package.json"); -const version = pkg.version; - -// Resolve dependency versions from installed package.json files. These -// are the actual runtime versions, which is what users care about when -// reporting bugs (they may differ from package.json ranges after a -// `pnpm install` that satisfied a range with a newer patch). -// -// Some packages (like @automerge/automerge-repo) have an `exports` field -// that blocks `require("pkg/package.json")`, so we resolve the package -// entry point with `require.resolve` and walk up to find package.json. -function depVersion(pkgName: string): string { - try { - const fs = require("fs"); - const path = require("path"); - let dir = path.dirname(require.resolve(pkgName)); - while (dir !== path.dirname(dir)) { - const candidate = path.join(dir, "package.json"); - if (fs.existsSync(candidate)) { - const data = JSON.parse(fs.readFileSync(candidate, "utf8")); - if (data.name === pkgName) { - return data.version; - } - } - dir = path.dirname(dir); - } - return "unknown"; - } catch { - return "unknown"; - } -} - -const versionString = [ - `pushwork ${version}`, - ` @automerge/automerge ${depVersion("@automerge/automerge")}`, - ` @automerge/automerge-repo ${depVersion("@automerge/automerge-repo")}`, - ` @automerge/automerge-subduction ${depVersion("@automerge/automerge-subduction")}`, -].join("\n"); +import { Command } from "@commander-js/extra-typings"; +import * as path from "path"; +import { clone, init, sync, url } from "./pushwork.js"; const program = new Command() - .name("pushwork") - .description("Bidirectional directory synchronization using Automerge CRDTs") - .version(versionString, "-V, --version", "output the version number"); - -// Init command -program - .command("init") - .summary("Initialize sync in a directory") - .argument( - "[path]", - "Directory path to initialize (default: current directory)", - "." - ) - .option( - "--sync-server ", - "Custom sync server URL and storage ID" - ) - .option("--sub", "Use Subduction sync backend", false) - .action(async (path, opts) => { - const [syncServer, syncServerStorageId] = validateSyncServer( - opts.syncServer - ); - await init(path, { syncServer, syncServerStorageId, sub: opts.sub }); - }); - -// Track command (set root directory URL without full initialization) -const trackAction = async ( - url: string, - path: string, - opts: { force: boolean; sub: boolean } -) => { - await root(url, path, { force: opts.force, sub: opts.sub }); -}; - -program - .command("track") - .summary("Set root directory URL without full initialization") - .argument( - "", - "AutomergeUrl of root directory (format: automerge:XXXXX)" - ) - .argument( - "[path]", - "Directory path (default: current directory)", - "." - ) - .option("-f, --force", "Overwrite existing pushwork setup", false) - .option("--sub", "Use Subduction sync backend", false) - .action(async (url, path, opts) => { - await trackAction(url, path, opts); - }); - -// Hidden alias for backwards compatibility -program - .command("root", { hidden: true }) - .argument("") - .argument("[path]", "", ".") - .option("-f, --force", "", false) - .option("--sub", "", false) - .action(async (url: string, path: string, opts: { force: boolean; sub: boolean }) => { - await trackAction(url, path, opts); - }); - -// Clone command -program - .command("clone") - .summary("Clone an existing synced directory") - .argument( - "", - "AutomergeUrl of root directory to clone (format: automerge:XXXXX)" - ) - .argument("", "Target directory path") - .option("-f, --force", "Overwrite existing directory", false) - .option( - "--sync-server ", - "Custom sync server URL and storage ID" - ) - .option("--sub", "Use Subduction sync backend", false) - .option("-v, --verbose", "Verbose output", false) - .action(async (url, path, opts) => { - const [syncServer, syncServerStorageId] = validateSyncServer( - opts.syncServer - ); - await clone(url, path, { - force: opts.force, - verbose: opts.verbose, - syncServer, - syncServerStorageId, - sub: opts.sub, - }); - }); - -// Commit command -program - .command("commit") - .summary("Save local changes to Automerge documents") - .argument( - "[path]", - "Directory path to commit (default: current directory)", - "." - ) - .action(async (path, _opts) => { - await commit(path); - }); - -// Sync command -program - .command("sync") - .summary("Run full bidirectional synchronization") - .argument( - "[path]", - "Directory path to sync (default: current directory)", - "." - ) - .option( - "--dry-run", - "Show what would be done without applying changes", - false - ) - .option( - "--gentle", - "Use config files and only sync changed files (instead of default full resync)", - false - ) - .option( - "--nuclear", - "Recreate all Automerge documents from scratch", - false - ) - .addOption(new Option("-f, --force", "Accepted for backwards compatibility").default(false).hideHelp()) - .option("-v, --verbose", "Verbose output", false) - .action(async (path, opts) => { - await sync(path, { - dryRun: opts.dryRun, - force: opts.force, - gentle: opts.gentle, - nuclear: opts.nuclear, - verbose: opts.verbose, - }); - }); - -// Diff command -program - .command("diff") - .summary("Show changes in working directory") - .argument( - "[path]", - "Limit diff to specific path (default: current directory)", - "." - ) - .option("--name-only", "Show only changed file names", false) - .action(async (path, opts) => { - await diff(path, { - nameOnly: opts.nameOnly, - }); - }); - -// Status command -program - .command("status") - .summary("Show sync status summary") - .argument("[path]", "Directory path (default: current directory)", ".") - .option( - "-v, --verbose", - "Show detailed status including document info and all tracked files", - false - ) - .action(async (path, opts) => { - await status(path, { - verbose: opts.verbose, - }); - }); - -// Log command -program - .command("log") - .summary("Show sync history (experimental)") - .argument( - "[path]", - "Show history for specific file or directory (default: current directory)", - "." - ) - .option("--oneline", "Compact one-line per sync format", false) - .option("--since ", "Show syncs since date") - .option("--limit ", "Limit number of syncs shown", "10") - .action(async (path, opts) => { - await log(path, { - oneline: opts.oneline, - since: opts.since, - limit: parseInt(opts.limit), - }); - }); - -// Checkout command -program - .command("checkout") - .summary("Restore to previous sync (experimental)") - .argument("", "Sync ID to restore to") - .argument( - "[path]", - "Specific path to restore (default: current directory)", - "." - ) - .option( - "-f, --force", - "Force checkout even if there are uncommitted changes", - false - ) - .action(async (syncId, path, opts) => { - await checkout(syncId, path, { - force: opts.force, - }); - }); - -// URL command -program - .command("url") - .summary("Show the Automerge root URL") - .argument("[path]", "Directory path (default: current directory)", ".") - .action(async (path) => { - await url(path); - }); - -// Remove command -program - .command("rm") - .summary("Remove local pushwork data") - .argument("[path]", "Directory path (default: current directory)", ".") - .action(async (path) => { - await rm(path); - }); - -// List command -program - .command("ls") - .summary("List tracked files") - .argument("[path]", "Directory path (default: current directory)", ".") - .option("-v, --verbose", "Show with Automerge URLs", false) - .action(async (path, opts) => { - await ls(path, { - verbose: opts.verbose, - }); - }); - -// Config command -program - .command("config") - .summary("View or edit configuration") - .argument("[path]", "Directory path (default: current directory)", ".") - .option("--list", "Show full configuration", false) - .option( - "--get ", - "Get specific config value (dot notation, e.g., sync.move_detection_threshold)" - ) - .action(async (path, opts) => { - await config(path, { - list: opts.list, - get: opts.get, - }); - }); - -// Watch command -program - .command("watch") - .summary("Watch directory for changes, build, and sync") - .argument( - "[path]", - "Directory path to sync (default: current directory)", - "." - ) - .option( - "--script ", - "Build script to run before syncing", - "pnpm build" - ) - .option( - "--dir ", - "Directory to watch for changes (relative to working directory)", - "src" - ) - .option("-v, --verbose", "Show build script output", false) - .action(async (path, opts) => { - await watch(path, { - script: opts.script, - watchDir: opts.dir, - verbose: opts.verbose, - }); - }); - -// Completion command (hidden from help) -program.command("completion", { hidden: true }).action(() => { - // Generate completion dynamically from registered commands - const commands = program.commands - .filter((cmd) => cmd.name() !== "completion") // Exclude self - .map((cmd) => { - const name = cmd.name(); - const desc = (cmd.summary() || cmd.description() || "").replace( - /'/g, - "\\'" - ); - return `'${name}:${desc}'`; - }) - .join(" "); - - // Generate option completions for each command - const commandCases = program.commands - .filter((cmd) => cmd.name() !== "completion") - .map((cmd) => { - const options = cmd.options - .filter((opt) => opt.flags !== "-h, --help") // Exclude help - .map((opt) => { - // Parse flags like "-v, --verbose" or "--dry-run" - const flags = opt.flags.split(",").map((f) => f.trim()); - const desc = (opt.description || "") - .replace(/'/g, "\\'") - .replace(/\n/g, " "); - - // For options with arguments like "--sync-server " - // Extract just the flag part - const cleanFlags = flags.map((f) => f.split(/\s+/)[0]); - - if (cleanFlags.length > 1) { - // Multiple flags (short and long): '(-v --verbose)'{-v,--verbose}'[description]' - const short = cleanFlags[0]; - const long = cleanFlags[1]; - return `'(${short} ${long})'{${short},${long}}'[${desc}]'`; - } else { - // Single flag: '--flag[description]' - return `'${cleanFlags[0]}[${desc}]'`; - } - }) - .join(" \\\n "); - - return options - ? ` ${cmd.name()}) - _arguments \\ - ${options} - ;;` - : ""; - }) - .filter(Boolean) - .join("\n"); - - const completionScript = ` -# pushwork completion for zsh -_pushwork() { - local -a commands - commands=(${commands}) - - _arguments -C \\ - '1: :->command' \\ - '*::arg:->args' - - case $state in - command) - _describe 'command' commands - ;; - args) - case $words[1] in -${commandCases} - esac - ;; - esac -} - -compdef _pushwork pushwork - `.trim(); - - console.log(completionScript); + .name("pushwork") + .description("Bidirectional directory synchronization using Automerge CRDTs"); + +program + .command("init") + .description("Initialize pushwork in a directory") + .argument("[dir]", "Directory to initialize", ".") + .option("--sub", "Use subduction backend") + .action(async (dir, opts) => { + const u = await init({ + dir: path.resolve(dir), + backend: opts.sub ? "subduction" : "legacy", + }); + process.stderr.write(`initialized ${u}\n`); + }); + +program + .command("clone") + .description("Clone an automerge URL into a directory") + .argument("", "automerge: URL") + .argument("[dir]", "Target directory", ".") + .option("--sub", "Use subduction backend") + .action(async (u, dir, opts) => { + await clone({ + url: u, + dir: path.resolve(dir), + backend: opts.sub ? "subduction" : "legacy", + }); + process.stderr.write(`cloned into ${path.resolve(dir)}\n`); + }); + +program + .command("url") + .description("Print the automerge URL of this pushwork repo") + .action(async () => { + const u = await url(process.cwd()); + process.stdout.write(u + "\n"); + }); + +program + .command("sync") + .description("Sync local changes with peers") + .action(async () => { + await sync(process.cwd()); + process.stderr.write("synced\n"); + }); + +program.parseAsync(process.argv).catch((err) => { + process.stderr.write( + `pushwork: ${err instanceof Error ? err.message : String(err)}\n`, + ); + process.exit(1); }); - -// Helper to validate and extract sync server options -function validateSyncServer( - syncServerOpt: string[] | undefined -): [string | undefined, StorageId | undefined] { - if (!syncServerOpt) { - return [undefined, undefined]; - } - - if (syncServerOpt.length < 2) { - console.error( - chalk.red("Error: --sync-server requires both URL and storage ID") - ); - process.exit(1); - } - - const [syncServer, syncServerStorageId] = syncServerOpt; - return [syncServer, syncServerStorageId as StorageId]; -} - -process.on("unhandledRejection", (error) => { - console.log(chalk.bgRed.white(" ERROR ")); - if (error instanceof Error && error.stack) { - console.log(chalk.red(error.stack)); - } else { - console.error(chalk.red(error)); - } - process.exit(1); -}); - -// Configure help colors using Commander v13's built-in color support -program - .configureHelp({ - styleTitle: (str) => chalk.bold(str), - styleCommandText: (str) => chalk.white(str), - styleCommandDescription: (str) => chalk.dim(str), - styleOptionText: (str) => chalk.green(str), - styleArgumentText: (str) => chalk.cyan(str), - subcommandTerm: (cmd) => { - const opts = cmd.options - .filter((opt) => opt.flags !== "-h, --help") - .map((opt) => opt.short || opt.long) - .join(", "); - - const name = chalk.white(cmd.name()); - const args = cmd.registeredArguments - .map((arg) => - arg.required - ? chalk.cyan(`<${arg.name()}>`) - : chalk.dim(`[${arg.name()}]`) - ) - .join(" "); - - return [name, args, opts && chalk.dim(`[${opts}]`)] - .filter(Boolean) - .join(" "); - }, - }) - .addHelpText( - "after", - chalk.dim( - '\nEnable tab completion by adding this to your ~/.zshrc:\neval "$(pushwork completion)"' - ) - ); - -program.parseAsync(); diff --git a/src/commands.ts b/src/commands.ts deleted file mode 100644 index 101eaf7..0000000 --- a/src/commands.ts +++ /dev/null @@ -1,1182 +0,0 @@ -import * as path from "path"; -import * as fs from "fs/promises"; -import * as fsSync from "fs"; -import { Repo, AutomergeUrl } from "@automerge/automerge-repo"; -import * as diffLib from "diff"; -import { spawn } from "child_process"; -import { - CloneOptions, - SyncOptions, - DiffOptions, - LogOptions, - CheckoutOptions, - InitOptions, - ConfigOptions, - StatusOptions, - WatchOptions, - DirectoryConfig, - DirectoryDocument, - CommandOptions, -} from "./types"; -import { DEFAULT_SUBDUCTION_SERVER } from "./types/config"; -import { SyncEngine } from "./core"; -import { pathExists, ensureDirectoryExists, formatRelativePath } from "./utils"; -import { ConfigManager } from "./core/config"; -import { createRepo } from "./utils/repo-factory"; -import { out } from "./utils/output"; -import { waitForSync } from "./utils/network-sync"; -import chalk from "chalk"; - -/** - * Shared context that commands can use - */ -interface CommandContext { - repo: Repo; - syncEngine: SyncEngine; - config: DirectoryConfig; - workingDir: string; -} - -/** - * Initialize repository directory structure and configuration - * Shared logic for init and clone commands - */ -async function initializeRepository( - resolvedPath: string, - overrides: Partial, - sub: boolean = false -): 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, - }; - } - - // 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); - } - - // Create repository and sync engine - const repo = await createRepo(resolvedPath, config, sub); - const syncEngine = new SyncEngine(repo, resolvedPath, config); - - return { config, repo, syncEngine }; -} - -/** - * Shared pre-action that ensures repository and sync engine are properly initialized - * This function always works, with or without network connectivity - */ -async function setupCommandContext( - workingDir: string = process.cwd(), - options?: { syncEnabled?: boolean; forceDefaults?: boolean } -): Promise { - const resolvedPath = path.resolve(workingDir); - - // Check if initialized - const syncToolDir = path.join(resolvedPath, ConfigManager.CONFIG_DIR); - if (!(await pathExists(syncToolDir))) { - throw new Error( - 'Directory not initialized for sync. Run "pushwork init" first.' - ); - } - - // Load configuration - const configManager = new ConfigManager(resolvedPath); - let config: DirectoryConfig; - - 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(); - 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; - } - } - } else { - config = await configManager.getMerged(); - } - - // Override sync_enabled if explicitly specified (e.g., for local-only operations) - if (options?.syncEnabled !== undefined) { - 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 ...`). - 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; - } - - // Create repo with config - const repo = await createRepo(resolvedPath, config, sub); - - // Create sync engine - const syncEngine = new SyncEngine(repo, resolvedPath, config); - - return { - repo, - syncEngine, - config, - workingDir: resolvedPath, - }; -} -/** - * Safely shutdown a repository with proper error handling - */ -async function safeRepoShutdown(repo: Repo): Promise { - // TEMPORARY WORKAROUND: pushwork's Subduction sync-verification only - // watches local head stability, which doesn't actually confirm the - // server received anything. Give any in-flight `syncWithAllPeers` - // calls a chance to finish (and the scheduler time to heal transient - // failures) before we tear the repo down. Remove once awaitSynced() - // (or equivalent) lands in @automerge/automerge-repo@subduction. - const graceMsEnv = process.env.PUSHWORK_SYNC_GRACE_MS; - const graceMs = graceMsEnv !== undefined ? Number(graceMsEnv) : 3000; - if (Number.isFinite(graceMs) && graceMs > 0) { - await new Promise((resolve) => setTimeout(resolve, graceMs)); - } - - // Handle uncaught WebSocket errors that occur during shutdown - const uncaughtErrorHandler = (err: Error) => { - if (err.message.includes("WebSocket")) { - // Silently suppress WebSocket errors during shutdown - return; - } - // Re-throw non-WebSocket errors - throw err; - }; - - // Add the error handler before shutdown - process.on("uncaughtException", uncaughtErrorHandler); - - try { - await repo.shutdown(); - } catch (shutdownError) { - // WebSocket errors during shutdown are common and non-critical - // Silently ignore them - they don't affect data integrity - const errorMessage = - shutdownError instanceof Error - ? shutdownError.message - : String(shutdownError); - - // Ignore WebSocket-related errors entirely - if (errorMessage.includes("WebSocket")) { - // Silently ignore WebSocket shutdown errors - return; - } - } finally { - process.off("uncaughtException", uncaughtErrorHandler); - } -} - -/** - * Initialize sync in a directory - */ -export async function init( - targetPath: string, - options: InitOptions = {} -): Promise { - const resolvedPath = path.resolve(targetPath); - - const sub = options.sub ?? false; - - out.task(`Initializing`); - if (sub) { - out.taskLine("Using Subduction sync backend", true); - } - - await ensureDirectoryExists(resolvedPath); - - // Check if already initialized - const syncToolDir = path.join(resolvedPath, ConfigManager.CONFIG_DIR); - if (await pathExists(syncToolDir)) { - out.error("Directory already initialized for sync"); - out.exit(1); - } - - // 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); - - // Create new root directory document - out.update("Creating root directory"); - const dirName = path.basename(resolvedPath); - const rootDoc: DirectoryDocument = { - "@patchwork": { type: "folder" }, - name: dirName, - title: dirName, - docs: [], - }; - const rootHandle = repo.create(rootDoc); - - // Set root directory URL in snapshot - await syncEngine.setRootDirectoryUrl(rootHandle.url); - - // Wait for root document to sync to server if sync is enabled. - if (config.sync_enabled) { - out.update("Syncing to server"); - const { failed } = await waitForSync([rootHandle]); - if (failed.length > 0) { - out.taskLine("Root document failed to sync to server", true); - // Continue anyway - the document is created locally and will sync later - } - } - - // Run initial sync to capture existing files - out.update("Running initial sync"); - const result = await syncEngine.sync({ sub }); - - out.update("Writing to disk"); - await safeRepoShutdown(repo); - - out.done("Initialized"); - out.successBlock("INITIALIZED", rootHandle.url); - if (result.filesChanged > 0) { - out.info(`Synced ${result.filesChanged} ${plural("file", result.filesChanged)}`); - } - - process.exit(); -} - -/** - * Run bidirectional sync - */ -export async function sync( - targetPath = ".", - options: SyncOptions -): Promise { - out.task( - options.nuclear - ? "Nuclear syncing" - : options.gentle - ? "Gentle syncing" - : "Syncing" - ); - - 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); - } - - if (options.nuclear) { - await syncEngine.nuclearReset(); - } - - if (options.dryRun) { - out.update("Analyzing changes"); - const preview = await syncEngine.previewChanges(); - - if (preview.changes.length === 0 && preview.moves.length === 0) { - out.done("Already synced"); - return; - } - - out.done(); - out.infoBlock("CHANGES"); - out.obj({ - Changes: preview.changes.length.toString(), - Moves: - preview.moves.length > 0 ? preview.moves.length.toString() : undefined, - }); - - out.log(""); - out.log("Files:"); - for (const change of preview.changes.slice(0, 10)) { - const prefix = - change.changeType === "local_only" - ? "[local] " - : change.changeType === "remote_only" - ? "[remote] " - : "[conflict]"; - out.log(` ${prefix} ${change.path}`); - } - if (preview.changes.length > 10) { - out.log(` ... and ${preview.changes.length - 10} more`); - } - - if (preview.moves.length > 0) { - out.log(""); - out.log("Moves:"); - for (const move of preview.moves.slice(0, 5)) { - out.log(` ${move.fromPath} → ${move.toPath}`); - } - if (preview.moves.length > 5) { - out.log(` ... and ${preview.moves.length - 5} more`); - } - } - - out.log(""); - out.log("Run without --dry-run to apply these changes"); - } else { - const result = await syncEngine.sync({ sub }); - - out.taskLine("Writing to disk"); - await safeRepoShutdown(repo); - - if (result.success) { - out.done("Synced"); - if (result.filesChanged === 0 && result.directoriesChanged === 0) { - } else { - out.successBlock( - "SYNCED", - `${result.filesChanged} ${plural("file", result.filesChanged)}` - ); - } - - if (result.warnings.length > 0) { - out.log(""); - out.warnBlock("WARNINGS", `${result.warnings.length} warnings`); - for (const warning of result.warnings.slice(0, 5)) { - out.log(` ${warning}`); - } - if (result.warnings.length > 5) { - out.log(` ... and ${result.warnings.length - 5} more`); - } - } - } else { - out.done("partial", false); - out.warnBlock( - "PARTIAL", - `${result.filesChanged} updated, ${result.errors.length} errors` - ); - out.obj({ - Files: result.filesChanged, - Errors: result.errors.length, - }); - - result.errors - .slice(0, 5) - .forEach((error) => out.error(`${error.path}: ${error.error.message}`)); - if (result.errors.length > 5) { - out.warn(`... and ${result.errors.length - 5} more errors`); - } - } - - // Always print the root URL - const rootUrl = await syncEngine.getRootDirectoryUrl(); - if (rootUrl) { - out.info(`Root: ${rootUrl}`); - } - } - - process.exit(); -} - -/** - * Show differences between local and remote - */ -export async function diff( - targetPath = ".", - options: DiffOptions -): Promise { - out.task("Analyzing changes"); - - const { repo, syncEngine } = await setupCommandContext(targetPath, { syncEnabled: false }); - const preview = await syncEngine.previewChanges(); - - out.done(); - - if (options.nameOnly) { - for (const change of preview.changes) { - out.log(change.path); - } - return; - } - - if (preview.changes.length === 0) { - out.success("No changes detected"); - await safeRepoShutdown(repo); - out.exit(); - return; - } - - out.warn(`${preview.changes.length} changes detected`); - - for (const change of preview.changes) { - const prefix = - change.changeType === "local_only" - ? "[local] " - : change.changeType === "remote_only" - ? "[remote] " - : "[conflict]"; - - try { - // Get old content (from snapshot/remote) - const oldContent = change.remoteContent || ""; - // Get new content (current local) - const newContent = change.localContent || ""; - - // Convert binary content to string representation if needed - const oldText = - typeof oldContent === "string" - ? oldContent - : ``; - const newText = - typeof newContent === "string" - ? newContent - : ``; - - // Generate unified diff - const diffResult = diffLib.createPatch( - change.path, - oldText, - newText, - "previous", - "current" - ); - - // Skip the header lines and process the diff - const lines = diffResult.split("\n").slice(4); // Skip index, ===, ---, +++ lines - - if (lines.length === 0 || (lines.length === 1 && lines[0] === "")) { - out.log(`${prefix}${change.path} (content identical)`, "cyan"); - continue; - } - - // Extract first hunk header and show inline with path - let firstHunk = ""; - let diffLines = lines; - if (lines[0]?.startsWith("@@")) { - firstHunk = ` ${lines[0]}`; - diffLines = lines.slice(1); - } - - out.log(`${prefix}${change.path}${firstHunk}`, "cyan"); - - for (const line of diffLines) { - if (line.startsWith("@@")) { - // Additional hunk headers - out.log(line, "dim"); - } else if (line.startsWith("+")) { - // Added line - out.log(line, "green"); - } else if (line.startsWith("-")) { - // Removed line - out.log(line, "red"); - } else if (line.startsWith(" ") || line === "") { - // Context line or empty - out.log(line, "dim"); - } - } - } catch (error) { - out.log(`${prefix}${change.path} (diff error: ${error})`, "cyan"); - } - } - - await safeRepoShutdown(repo); -} - -/** - * Show sync status - */ -export async function status( - targetPath: string = ".", - options: StatusOptions = {} -): Promise { - const { repo, syncEngine, config } = await setupCommandContext( - targetPath, - { syncEnabled: false } - ); - const syncStatus = await syncEngine.getStatus(); - - out.infoBlock("STATUS"); - - const statusInfo: Record = {}; - const fileCount = syncStatus.snapshot?.files.size || 0; - - statusInfo["URL"] = syncStatus.snapshot?.rootDirectoryUrl; - statusInfo["Files"] = syncStatus.snapshot - ? `${fileCount} tracked` - : undefined; - statusInfo["Backend"] = config?.subduction ? "subduction" : "websocket"; - statusInfo["Sync"] = config?.sync_server; - - // Add more detailed info in verbose mode - if (options.verbose && syncStatus.snapshot?.rootDirectoryUrl) { - try { - const rootHandle = await repo.find( - syncStatus.snapshot.rootDirectoryUrl - ); - const rootDoc = await rootHandle.doc(); - - if (rootDoc) { - statusInfo["Entries"] = rootDoc.docs.length; - statusInfo["Directories"] = syncStatus.snapshot.directories.size; - if (rootDoc.lastSyncAt) { - const lastSyncDate = new Date(rootDoc.lastSyncAt); - statusInfo["Last sync"] = lastSyncDate.toISOString(); - } - } - } catch (error) { - out.warn(`Warning: Could not load detailed info: ${error}`); - } - } - - statusInfo["Changes"] = syncStatus.hasChanges - ? `${syncStatus.changeCount} pending` - : undefined; - statusInfo["Status"] = !syncStatus.hasChanges ? "up to date" : undefined; - - out.obj(statusInfo); - - // Show verbose details if requested - if (options.verbose && syncStatus.snapshot?.rootDirectoryUrl) { - const rootHandle = await repo.find( - syncStatus.snapshot.rootDirectoryUrl - ); - const rootDoc = await rootHandle.doc(); - - if (rootDoc) { - out.infoBlock("HEADS"); - out.arr(rootHandle.heads()); - - if (syncStatus.snapshot && syncStatus.snapshot.files.size > 0) { - out.infoBlock("TRACKED FILES"); - const filesObj: Record = {}; - syncStatus.snapshot.files.forEach((entry, filePath) => { - filesObj[filePath] = entry.url; - }); - out.obj(filesObj); - } - } - } - - if (syncStatus.hasChanges && !options.verbose) { - out.info("Run 'pushwork diff' to see changes"); - } - - await safeRepoShutdown(repo); -} - -/** - * Show sync history - */ -export async function log( - targetPath = ".", - _options: LogOptions -): Promise { - const { repo: logRepo, workingDir } = await setupCommandContext( - targetPath, - { syncEnabled: false } - ); - - // TODO: Implement history tracking - const snapshotPath = path.join( - workingDir, - ConfigManager.CONFIG_DIR, - "snapshot.json" - ); - if (await pathExists(snapshotPath)) { - const stats = await fs.stat(snapshotPath); - out.infoBlock("HISTORY", "Sync history (stub)"); - out.obj({ "Last sync": stats.mtime.toISOString() }); - } else { - out.info("No sync history found"); - } - - await safeRepoShutdown(logRepo); -} - -/** - * Checkout/restore from previous sync - */ -export async function checkout( - syncId: string, - targetPath = ".", - _options: CheckoutOptions -): Promise { - const { workingDir } = await setupCommandContext(targetPath); - - // TODO: Implement checkout functionality - out.warnBlock("NOT IMPLEMENTED", "Checkout not yet implemented"); - out.obj({ - "Sync ID": syncId, - Path: workingDir, - }); -} - -/** - * Clone an existing synced directory from an AutomergeUrl - */ -export async function clone( - rootUrl: string, - targetPath: string, - options: CloneOptions -): Promise { - // Validate that rootUrl is actually an Automerge URL - if (!rootUrl.startsWith("automerge:")) { - out.error( - `Invalid Automerge URL: ${rootUrl}\n` + - `Expected format: automerge:XXXXX\n` + - `Usage: pushwork clone ` - ); - out.exit(1); - } - - - const resolvedPath = path.resolve(targetPath); - - const sub = options.sub ?? false; - - out.task(`Cloning ${rootUrl}`); - if (sub) { - out.taskLine("Using Subduction sync backend", true); - } - - // Check if directory exists and handle --force - if (await pathExists(resolvedPath)) { - const files = await fs.readdir(resolvedPath); - if (files.length > 0 && !options.force) { - out.error("Target directory is not empty. Use --force to overwrite"); - out.exit(1); - } - } else { - await ensureDirectoryExists(resolvedPath); - } - - // Check if already initialized - const syncToolDir = path.join(resolvedPath, ConfigManager.CONFIG_DIR); - if (await pathExists(syncToolDir)) { - if (!options.force) { - out.error("Directory already initialized. Use --force to overwrite"); - out.exit(1); - } - await fs.rm(syncToolDir, { recursive: true, force: true }); - } - - // Initialize repository with optional CLI overrides - out.update("Setting up repository"); - const { config, repo, syncEngine } = await initializeRepository( - resolvedPath, - { - sync_server: options.syncServer, - sync_server_storage_id: options.syncServerStorageId, - }, - sub - ); - - // Connect to existing root directory and download files - out.update("Downloading files"); - await syncEngine.setRootDirectoryUrl(rootUrl as AutomergeUrl); - const result = await syncEngine.sync({ sub }); - - out.update("Writing to disk"); - await safeRepoShutdown(repo); - - out.done(); - - out.obj({ - Path: resolvedPath, - Files: `${result.filesChanged} downloaded`, - Backend: config.subduction ? "subduction" : "websocket", - Sync: config.sync_server, - }); - out.successBlock("CLONED", rootUrl); - process.exit(); -} - -/** - * Get the root URL for the current pushwork repository - */ -export async function url(targetPath: string = "."): Promise { - const resolvedPath = path.resolve(targetPath); - const syncToolDir = path.join(resolvedPath, ConfigManager.CONFIG_DIR); - - if (!(await pathExists(syncToolDir))) { - out.error("Directory not initialized for sync"); - out.exit(1); - } - - const snapshotPath = path.join(syncToolDir, "snapshot.json"); - if (!(await pathExists(snapshotPath))) { - out.error("No snapshot found"); - out.exit(1); - } - - const snapshotData = await fs.readFile(snapshotPath, "utf-8"); - const snapshot = JSON.parse(snapshotData); - - if (snapshot.rootDirectoryUrl) { - // Output just the URL for easy use in scripts - out.log(snapshot.rootDirectoryUrl); - } else { - out.error("No root URL found in snapshot"); - out.exit(1); - } -} - -/** - * Remove local pushwork data and log URL for recovery - */ -export async function rm(targetPath: string = "."): Promise { - const resolvedPath = path.resolve(targetPath); - const syncToolDir = path.join(resolvedPath, ConfigManager.CONFIG_DIR); - - if (!(await pathExists(syncToolDir))) { - out.error("Directory not initialized for sync"); - out.exit(1); - } - - // Read the URL before deletion for recovery - let recoveryUrl = ""; - const snapshotPath = path.join(syncToolDir, "snapshot.json"); - if (await pathExists(snapshotPath)) { - try { - const snapshotData = await fs.readFile(snapshotPath, "utf-8"); - const snapshot = JSON.parse(snapshotData); - recoveryUrl = snapshot.rootDirectoryUrl || null; - } catch (error) { - out.error(`Remove failed: ${error}`); - out.exit(1); - return; - } - } - - out.task("Removing local pushwork data"); - await fs.rm(syncToolDir, { recursive: true, force: true }); - out.done(); - - out.warnBlock("REMOVED", recoveryUrl); - process.exit(); -} - -export async function commit( - targetPath: string, - _options: CommandOptions = {} -): Promise { - out.task("Committing local changes"); - - const { repo, syncEngine } = await setupCommandContext(targetPath, { syncEnabled: false }); - - const result = await syncEngine.commitLocal(); - await safeRepoShutdown(repo); - - out.done(); - - if (result.errors.length > 0) { - out.errorBlock("ERROR", `${result.errors.length} errors`); - result.errors.forEach((error) => out.error(error)); - out.exit(1); - } - - out.successBlock("COMMITTED", `${result.filesChanged} files`); - out.obj({ - Files: result.filesChanged, - Directories: result.directoriesChanged, - }); - - if (result.warnings.length > 0) { - result.warnings.forEach((warning) => out.warn(warning)); - } - process.exit(); -} - -/** - * List tracked files - */ -export async function ls( - targetPath: string = ".", - options: CommandOptions = {} -): Promise { - const { repo, syncEngine } = await setupCommandContext(targetPath, { syncEnabled: false }); - const syncStatus = await syncEngine.getStatus(); - - if (!syncStatus.snapshot) { - out.error("No snapshot found"); - await safeRepoShutdown(repo); - out.exit(1); - return; - } - - const files = Array.from(syncStatus.snapshot.files.entries()).sort( - ([pathA], [pathB]) => pathA.localeCompare(pathB) - ); - - if (files.length === 0) { - out.info("No tracked files"); - await safeRepoShutdown(repo); - return; - } - - if (options.verbose) { - // Long format with URLs - for (const [filePath, entry] of files) { - const url = entry?.url || "unknown"; - out.log(`${filePath} -> ${url}`); - } - } else { - // Simple list - for (const [filePath] of files) { - out.log(filePath); - } - } - - await safeRepoShutdown(repo); -} - -/** - * View or edit configuration - */ -export async function config( - targetPath: string = ".", - options: ConfigOptions = {} -): Promise { - const resolvedPath = path.resolve(targetPath); - const syncToolDir = path.join(resolvedPath, ConfigManager.CONFIG_DIR); - - if (!(await pathExists(syncToolDir))) { - out.error("Directory not initialized for sync"); - out.exit(1); - } - - const configManager = new ConfigManager(resolvedPath); - const config = await configManager.getMerged(); - - if (options.list) { - // List all configuration - out.infoBlock("CONFIGURATION", "Full configuration"); - out.log(JSON.stringify(config, null, 2)); - } else if (options.get) { - // Get specific config value - const keys = options.get.split("."); - let value: any = config; - for (const key of keys) { - value = value?.[key]; - } - if (value !== undefined) { - out.log( - typeof value === "object" ? JSON.stringify(value, null, 2) : value - ); - } else { - out.error(`Config key not found: ${options.get}`); - out.exit(1); - } - } else { - // Show basic config info - out.infoBlock("CONFIGURATION"); - out.obj({ - Backend: config.subduction ? "subduction" : "websocket", - "Sync server": config.sync_server || "default", - "Sync enabled": config.sync_enabled ? "yes" : "no", - Exclusions: config.exclude_patterns?.length, - }); - out.log(""); - out.log("Use --list to see full configuration"); - } -} - -/** - * Watch a directory and sync after build script completes - */ -export async function watch( - targetPath: string = ".", - options: WatchOptions = {} -): 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, config, workingDir } = await setupCommandContext( - targetPath, - ); - - const sub = config.subduction ?? false; - - const absoluteWatchDir = path.resolve(workingDir, watchDir); - - // Check if watch directory exists - if (!(await pathExists(absoluteWatchDir))) { - out.error(`Watch directory does not exist: ${watchDir}`); - await safeRepoShutdown(repo); - out.exit(1); - return; - } - - out.spicyBlock( - "WATCHING", - `${chalk.underline(formatRelativePath(watchDir))} for changes...` - ); - if (sub) { - out.info("Using Subduction sync backend (from config)"); - } - out.info(`Build script: ${script}`); - out.info(`Working directory: ${workingDir}`); - - let isProcessing = false; - let pendingChange = false; - - // Function to run build and sync - const runBuildAndSync = async () => { - if (isProcessing) { - pendingChange = true; - return; - } - - isProcessing = true; - pendingChange = false; - - try { - out.spicy(`[${new Date().toLocaleTimeString()}] Changes detected...`); - // Run build script - const buildResult = await runScript(script, workingDir, verbose); - - if (!buildResult.success) { - out.warn("Build script failed"); - if (buildResult.output) { - out.log(""); - out.log(buildResult.output); - } - isProcessing = false; - if (pendingChange) { - setImmediate(() => runBuildAndSync()); - } - return; - } - - out.info("Build completed..."); - - // Run sync - out.task("Syncing"); - const result = await syncEngine.sync({ sub }); - - if (result.success) { - if (result.filesChanged === 0 && result.directoriesChanged === 0) { - out.done("Already synced"); - } else { - out.done( - `Synced ${result.filesChanged} ${plural( - "file", - result.filesChanged - )}` - ); - } - } else { - out.warn( - `⚠ Partial sync: ${result.filesChanged} updated, ${result.errors.length} errors` - ); - result.errors - .slice(0, 3) - .forEach((error) => - out.error(` ${error.path}: ${error.error.message}`) - ); - if (result.errors.length > 3) { - out.warn(` ... and ${result.errors.length - 3} more errors`); - } - } - - if (result.warnings.length > 0) { - result.warnings - .slice(0, 3) - .forEach((warning) => out.warn(` ${warning}`)); - if (result.warnings.length > 3) { - out.warn(` ... and ${result.warnings.length - 3} more warnings`); - } - } - } catch (error) { - out.error(`Error during build/sync: ${error}`); - } finally { - isProcessing = false; - - // If changes occurred while we were processing, run again - if (pendingChange) { - setImmediate(() => runBuildAndSync()); - } - } - }; - - // Set up file watcher - watches everything in the specified directory - const watcher = fsSync.watch( - absoluteWatchDir, - { recursive: true }, - (_eventType, filename) => { - if (filename) { - runBuildAndSync(); - } - } - ); - - // Handle graceful shutdown - const shutdown = async () => { - out.log(""); - out.info("Shutting down..."); - watcher.close(); - await safeRepoShutdown(repo); - out.rainbow("Goodbye!"); - process.exit(0); - }; - - process.on("SIGINT", shutdown); - process.on("SIGTERM", shutdown); - - // Run initial build and sync - await runBuildAndSync(); - - // Keep process alive - await new Promise(() => {}); // Never resolves, keeps watching -} - -/** - * Run a shell script and wait for completion - */ -async function runScript( - script: string, - cwd: string, - verbose: boolean -): Promise<{ success: boolean; output?: string }> { - return new Promise((resolve) => { - const [command, ...args] = script.split(" "); - const child = spawn(command, args, { - cwd, - stdio: verbose ? "inherit" : "pipe", // Show output directly if verbose, otherwise capture - shell: true, - }); - - let output = ""; - - // Capture output if not verbose (so we can show it on error) - if (!verbose) { - child.stdout?.on("data", (data) => { - output += data.toString(); - }); - child.stderr?.on("data", (data) => { - output += data.toString(); - }); - } - - child.on("close", (code) => { - resolve({ - success: code === 0, - output: !verbose ? output : undefined, - }); - }); - - child.on("error", (error) => { - out.error(`Failed to run script: ${error.message}`); - resolve({ - success: false, - output: !verbose ? output : undefined, - }); - }); - }); -} - -/** - * Set root directory URL for an existing or new pushwork directory - */ -export async function root( - rootUrl: string, - targetPath: string = ".", - options: { force?: boolean; sub?: boolean } = {} -): Promise { - if (!rootUrl.startsWith("automerge:")) { - out.error( - `Invalid Automerge URL: ${rootUrl}\n` + - `Expected format: automerge:XXXXX` - ); - out.exit(1); - } - - const resolvedPath = path.resolve(targetPath); - const syncToolDir = path.join(resolvedPath, ConfigManager.CONFIG_DIR); - const sub = options.sub ?? false; - - if (await pathExists(syncToolDir)) { - if (!options.force) { - out.error("Directory already initialized for pushwork. Use --force to overwrite"); - out.exit(1); - } - } - - await ensureDirectoryExists(syncToolDir); - await ensureDirectoryExists(path.join(syncToolDir, "automerge")); - - // Create minimal snapshot with just the root URL - const snapshotPath = path.join(syncToolDir, "snapshot.json"); - const snapshot = { - timestamp: Date.now(), - rootPath: resolvedPath, - rootDirectoryUrl: rootUrl, - files: [], - directories: [], - }; - 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. - 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({}); - } - - out.successBlock("ROOT SET", rootUrl); - if (sub) { - out.info("Using Subduction sync backend"); - } - process.exit(); -} - -function plural(word: string, count: number): string { - return count === 1 ? word : `${word}s`; -} diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..5486a7b --- /dev/null +++ b/src/config.ts @@ -0,0 +1,63 @@ +import * as fs from "fs/promises"; +import * as path from "path"; +import type { AutomergeUrl, UrlHeads } from "@automerge/automerge-repo"; + +export type Backend = "legacy" | "subduction"; + +export interface PushworkConfig { + rootUrl: AutomergeUrl; + backend: Backend; +} + +const DIR = ".pushwork"; +const CONFIG = "config.json"; +const HEADS = "heads.json"; +const STORAGE = "storage"; + +export const pushworkDir = (root: string) => path.join(root, DIR); +export const storageDir = (root: string) => path.join(root, DIR, STORAGE); + +export async function readConfig(root: string): Promise { + const text = await fs.readFile(path.join(root, DIR, CONFIG), "utf8"); + return JSON.parse(text) as PushworkConfig; +} + +export async function writeConfig( + root: string, + config: PushworkConfig, +): Promise { + await fs.mkdir(path.join(root, DIR), { recursive: true }); + await fs.writeFile( + path.join(root, DIR, CONFIG), + JSON.stringify(config, null, 2) + "\n", + ); +} + +export async function configExists(root: string): Promise { + try { + await fs.access(path.join(root, DIR, CONFIG)); + return true; + } catch { + return false; + } +} + +export async function readHeads(root: string): Promise { + try { + const text = await fs.readFile(path.join(root, DIR, HEADS), "utf8"); + return JSON.parse(text) as UrlHeads; + } catch { + return undefined; + } +} + +export async function writeHeads( + root: string, + heads: UrlHeads, +): Promise { + await fs.mkdir(path.join(root, DIR), { recursive: true }); + await fs.writeFile( + path.join(root, DIR, HEADS), + JSON.stringify(heads) + "\n", + ); +} diff --git a/src/core/change-detection.ts b/src/core/change-detection.ts deleted file mode 100644 index 933a356..0000000 --- a/src/core/change-detection.ts +++ /dev/null @@ -1,712 +0,0 @@ -import { - AutomergeUrl, - DocHandle, - Repo, - UrlHeads, -} from "@automerge/automerge-repo" -import * as A from "@automerge/automerge" -import { - ChangeType, - FileType, - SyncSnapshot, - FileDocument, - DirectoryDocument, - DetectedChange, -} from "../types" -import { - readFileContent, - listDirectory, - getRelativePath, - findFileInDirectoryHierarchy, - joinAndNormalizePath, - getPlainUrl, - readDocContent, -} from "../utils" -import {isContentEqual, contentHash} from "../utils/content" -import {out} from "../utils/output" - -const isDebug = !!process.env.DEBUG -function debug(...args: any[]) { - if (isDebug) console.error("[pushwork:change-detection]", ...args) -} - -/** - * Change detection engine - */ -export class ChangeDetector { - constructor( - private repo: Repo, - private rootPath: string, - private excludePatterns: string[] = [], - private artifactDirectories: string[] = [] - ) {} - - /** - * Check if a file path is inside an artifact directory. - * Artifact files use RawString and are always replaced wholesale, - * so we can skip expensive remote content reads for them. - */ - private isArtifactPath(filePath: string): boolean { - return this.artifactDirectories.some( - dir => filePath === dir || filePath.startsWith(dir + "/") - ) - } - - /** - * Detect all changes between local filesystem and snapshot - */ - async detectChanges(snapshot: SyncSnapshot, excludePaths?: Set): Promise { - const changes: DetectedChange[] = [] - - // Get current filesystem state - const currentFiles = await this.getCurrentFilesystemState() - - // Check for local changes (new, modified, deleted files) - const localChanges = await this.detectLocalChanges(snapshot, currentFiles) - changes.push(...localChanges) - - // Check for remote changes (changes in Automerge documents) - const remoteChanges = await this.detectRemoteChanges(snapshot) - changes.push(...remoteChanges) - - // Check for new remote documents not in snapshot (critical for clone scenarios) - const newRemoteDocuments = await this.detectNewRemoteDocuments(snapshot, excludePaths) - changes.push(...newRemoteDocuments) - - return changes - } - - /** - * Detect changes in local filesystem compared to snapshot - */ - private async detectLocalChanges( - snapshot: SyncSnapshot, - currentFiles: Map - ): Promise { - const changes: DetectedChange[] = [] - - // Check for new and modified files in parallel for better performance - await Promise.all( - Array.from(currentFiles.entries()).map( - async ([relativePath, fileInfo]) => { - const snapshotEntry = snapshot.files.get(relativePath) - - if (!snapshotEntry) { - // New file - changes.push({ - path: relativePath, - changeType: ChangeType.LOCAL_ONLY, - fileType: fileInfo.type, - localContent: fileInfo.content, - remoteContent: null, - }) - } else if (this.isArtifactPath(relativePath)) { - // Artifact files are always replaced wholesale (RawString). - // Skip remote doc content reads — compare local hash against - // stored hash to detect local changes, and check heads for remote. - const localHash = contentHash(fileInfo.content) - const localChanged = snapshotEntry.contentHash - ? localHash !== snapshotEntry.contentHash - : true // No stored hash = first sync with hash support, assume changed - - const remoteHead = await this.getCurrentRemoteHead( - snapshotEntry.url - ) - const remoteChanged = !A.equals(remoteHead, snapshotEntry.head) - - if (localChanged || remoteChanged) { - changes.push({ - path: relativePath, - changeType: - localChanged && remoteChanged - ? ChangeType.BOTH_CHANGED - : localChanged - ? ChangeType.LOCAL_ONLY - : ChangeType.REMOTE_ONLY, - fileType: fileInfo.type, - localContent: fileInfo.content, - remoteContent: null, - localHead: snapshotEntry.head, - remoteHead, - }) - } - } else { - // Check if content changed - const lastKnownContent = await this.getContentAtHead( - snapshotEntry.url, - snapshotEntry.head - ) - - const contentChanged = !isContentEqual( - fileInfo.content, - lastKnownContent - ) - - if (contentChanged) { - // Check remote state too - const currentRemoteContent = await this.getCurrentRemoteContent( - snapshotEntry.url - ) - - const remoteChanged = !isContentEqual( - lastKnownContent, - currentRemoteContent - ) - - const changeType = remoteChanged - ? ChangeType.BOTH_CHANGED - : ChangeType.LOCAL_ONLY - - const remoteHead = await this.getCurrentRemoteHead( - snapshotEntry.url - ) - - changes.push({ - path: relativePath, - changeType, - fileType: fileInfo.type, - localContent: fileInfo.content, - remoteContent: currentRemoteContent, - localHead: snapshotEntry.head, - remoteHead, - }) - } - } - } - ) - ) - - // Check for deleted files in parallel - await Promise.all( - Array.from(snapshot.files.entries()) - .filter(([relativePath]) => !currentFiles.has(relativePath)) - .map(async ([relativePath, snapshotEntry]) => { - if (this.isArtifactPath(relativePath)) { - // Artifact deletion: skip remote content read - const remoteHead = await this.getCurrentRemoteHead( - snapshotEntry.url - ) - const remoteChanged = !A.equals(remoteHead, snapshotEntry.head) - - changes.push({ - path: relativePath, - changeType: remoteChanged - ? ChangeType.BOTH_CHANGED - : ChangeType.LOCAL_ONLY, - fileType: FileType.TEXT, - localContent: null, - remoteContent: null, - localHead: snapshotEntry.head, - remoteHead, - }) - return - } - - // File was deleted locally - const currentRemoteContent = await this.getCurrentRemoteContent( - snapshotEntry.url - ) - const lastKnownContent = await this.getContentAtHead( - snapshotEntry.url, - snapshotEntry.head - ) - - const remoteChanged = !isContentEqual( - lastKnownContent, - currentRemoteContent - ) - - const changeType = remoteChanged - ? ChangeType.BOTH_CHANGED - : ChangeType.LOCAL_ONLY - - changes.push({ - path: relativePath, - changeType, - fileType: FileType.TEXT, // Will be determined from document - localContent: null, - remoteContent: currentRemoteContent, - localHead: snapshotEntry.head, - remoteHead: await this.getCurrentRemoteHead(snapshotEntry.url), - }) - }) - ) - - return changes - } - - /** - * Detect changes in remote Automerge documents compared to snapshot - */ - private async detectRemoteChanges( - snapshot: SyncSnapshot - ): Promise { - const changes: DetectedChange[] = [] - - await Promise.all( - Array.from(snapshot.files.entries()).map( - async ([relativePath, snapshotEntry]) => { - // Find the file's current entry in the remote directory hierarchy - const remoteEntry = await this.findInRemoteDirectory( - snapshot.rootDirectoryUrl, - relativePath - ) - - if (!remoteEntry) { - // File was removed from remote directory listing - const localContent = await this.getLocalContent(relativePath) - - // Only report as deleted if local file still exists - // (if local file is also deleted, detectLocalChanges handles it) - if (localContent !== null) { - changes.push({ - path: relativePath, - changeType: ChangeType.REMOTE_ONLY, - fileType: FileType.TEXT, - localContent, - remoteContent: null, // File deleted remotely - localHead: snapshotEntry.head, - remoteHead: snapshotEntry.head, - }) - } - return - } - - // Check if the document was replaced entirely (new URL). - // This happens when a peer replaces an artifact file, fixes a - // legacy immutable string, or recreates a failed document. - // The old snapshot URL is now orphaned — read from the new one. - const urlReplaced = getPlainUrl(remoteEntry.url) !== getPlainUrl(snapshotEntry.url) - const remoteUrl = urlReplaced ? remoteEntry.url : snapshotEntry.url - - const currentRemoteHead = await this.getCurrentRemoteHead(remoteUrl) - - if (urlReplaced || !A.equals(currentRemoteHead, snapshotEntry.head)) { - if (this.isArtifactPath(relativePath)) { - // Artifact: skip content reads, just report head change - const localContent = await this.getLocalContent(relativePath) - changes.push({ - path: relativePath, - changeType: - localContent !== null - ? ChangeType.BOTH_CHANGED - : ChangeType.REMOTE_ONLY, - fileType: FileType.TEXT, - localContent, - remoteContent: null, - localHead: snapshotEntry.head, - remoteHead: currentRemoteHead, - ...(urlReplaced ? {remoteUrl: remoteEntry.url} : {}), - }) - return - } - - // Remote document has changed - const currentRemoteContent = await this.getCurrentRemoteContent(remoteUrl) - const localContent = await this.getLocalContent(relativePath) - const lastKnownContent = urlReplaced - ? null // Can't diff against old doc when URL changed - : await this.getContentAtHead( - snapshotEntry.url, - snapshotEntry.head - ) - - const localChanged = localContent && lastKnownContent - ? !isContentEqual(localContent, lastKnownContent) - : localContent !== null - - const changeType = localChanged - ? ChangeType.BOTH_CHANGED - : ChangeType.REMOTE_ONLY - - changes.push({ - path: relativePath, - changeType, - fileType: await this.getFileTypeFromContent(currentRemoteContent), - localContent, - remoteContent: currentRemoteContent, - localHead: snapshotEntry.head, - remoteHead: currentRemoteHead, - ...(urlReplaced ? {remoteUrl: remoteEntry.url} : {}), - }) - } - } - ) - ) - - return changes - } - - /** - * Detect new remote documents from directory hierarchy that aren't in snapshot - * This is critical for clone scenarios where local snapshot is empty - */ - private async detectNewRemoteDocuments( - snapshot: SyncSnapshot, - excludePaths?: Set - ): Promise { - const changes: DetectedChange[] = [] - - // If no root directory URL, nothing to discover - if (!snapshot.rootDirectoryUrl) { - return changes - } - - try { - // Recursively traverse the directory hierarchy - await this.discoverRemoteDocumentsRecursive( - snapshot.rootDirectoryUrl, - "", - snapshot, - changes, - excludePaths - ) - } catch (error) { - out.taskLine(`Failed to discover remote documents: ${error}`, true) - } - - return changes - } - - /** - * Recursively discover remote documents in directory hierarchy - */ - private async discoverRemoteDocumentsRecursive( - directoryUrl: AutomergeUrl, - currentPath: string, - snapshot: SyncSnapshot, - changes: DetectedChange[], - excludePaths?: Set - ): Promise { - try { - // Find and wait for document to be available (retries on "unavailable") - const plainUrl = getPlainUrl(directoryUrl) - const result = await this.findDocument(plainUrl) - - if (!result) { - return - } - const dirDoc = result.doc - - // Process each entry in the directory - for (const entry of dirDoc.docs) { - const entryPath = currentPath - ? `${currentPath}/${entry.name}` - : entry.name - - if (entry.type === "file") { - // Skip files that were deliberately deleted during this sync cycle - if (excludePaths?.has(entryPath)) { - debug(`skipping deleted path during re-detection: ${entryPath}`) - continue - } - - // Check if this file is already tracked in the snapshot - const existingEntry = snapshot.files.get(entryPath) - - if (!existingEntry) { - // This is a remote file not in our snapshot - const localContent = await this.getLocalContent(entryPath) - const remoteContent = await this.getCurrentRemoteContent(entry.url) - const remoteHead = await this.getCurrentRemoteHead(entry.url) - - if (localContent != null && remoteContent == null) { - // File exists both locally and remotely but not in snapshot - changes.push({ - path: entryPath, - changeType: ChangeType.BOTH_CHANGED, - fileType: await this.getFileTypeFromContent(remoteContent), - localContent, - remoteContent, - remoteHead, - }) - } else if (localContent !== null && remoteContent === null) { - // File exists locally but not remotely (shouldn't happen in this flow) - changes.push({ - path: entryPath, - changeType: ChangeType.LOCAL_ONLY, - fileType: await this.getFileTypeFromContent(localContent), - localContent, - remoteContent: null, - }) - } else if (localContent === null && remoteContent !== null) { - // File exists remotely but not locally - this is what we need for clone! - changes.push({ - path: entryPath, - changeType: ChangeType.REMOTE_ONLY, - fileType: await this.getFileTypeFromContent(remoteContent), - localContent: null, - remoteContent, - remoteHead, - }) - } - // Only ignore if neither local nor remote content exists (ghost entry) - } else if ( - getPlainUrl(entry.url) !== getPlainUrl(existingEntry.url) - ) { - // HACK: URL replacement detection bolted onto the "discover new docs" walk. - // - // A peer can replace a document entirely (creating a new URL) rather than mutating - // the existing one. This happens in several cases in updateRemoteFile(): artifact - // paths are always replaced; non-artifact docs with legacy immutable string content - // are also replaced; and recreateFailedDocuments() replaces docs that timed out - // during network sync. The two normal remote-change scans both miss this: - // - detectRemoteChanges() is snapshot-centric: it checks the old (now orphaned) - // doc's heads, which haven't changed, so it reports no change. - // - The "new doc" branch above is directory-centric: it skips paths already in - // the snapshot, assuming they're handled by detectRemoteChanges(). - // - // A cleaner fix would be to have detectRemoteChanges() also verify that the - // directory still points to the same URL for each snapshot entry, treating a - // mismatch as a first-class URL-replacement change rather than a special case here. - const localContent = await this.getLocalContent(entryPath) - const remoteContent = await this.getCurrentRemoteContent(entry.url) - const remoteHead = await this.getCurrentRemoteHead(entry.url) - - if (remoteContent !== null) { - changes.push({ - path: entryPath, - changeType: - localContent !== null - ? ChangeType.BOTH_CHANGED - : ChangeType.REMOTE_ONLY, - fileType: await this.getFileTypeFromContent(remoteContent), - localContent: localContent ?? null, - remoteContent, - remoteHead, - remoteUrl: entry.url, - }) - } - } - } else if (entry.type === "folder") { - // Recursively process subdirectory - await this.discoverRemoteDocumentsRecursive( - entry.url, - entryPath, - snapshot, - changes, - excludePaths - ) - } - } - } catch (error) { - out.taskLine(`Failed to process directory: ${error}`, true) - } - } - - /** - * Get current filesystem state as a map - */ - private async getCurrentFilesystemState(): Promise< - Map - > { - const fileMap = new Map< - string, - {content: string | Uint8Array; type: FileType} - >() - - try { - const entries = await listDirectory( - this.rootPath, - true, - this.excludePatterns - ) - - const fileEntries = entries.filter( - entry => entry.type !== FileType.DIRECTORY - ) - - await Promise.all( - fileEntries.map(async entry => { - const relativePath = getRelativePath(this.rootPath, entry.path) - const content = await readFileContent(entry.path) - - fileMap.set(relativePath, {content, type: entry.type}) - }) - ) - } catch (error) { - out.taskLine(`Failed to scan filesystem: ${error}`, true) - // Log more details about the error - if (error instanceof Error) { - out.taskLine(`Error details: ${error.message}`, true) - if (error.stack) { - out.taskLine(`Stack: ${error.stack}`, true) - } - } - } - - return fileMap - } - - /** - * Get local file content if it exists - */ - private async getLocalContent( - relativePath: string - ): Promise { - try { - const fullPath = joinAndNormalizePath(this.rootPath, relativePath) - return await readFileContent(fullPath) - } catch { - return null - } - } - - /** - * Get content from Automerge document at specific head - */ - private async getContentAtHead( - url: AutomergeUrl, - heads: UrlHeads - ): Promise { - try { - // Strip heads for current document state - const plainUrl = getPlainUrl(url) - const handle = await this.repo.find(plainUrl) - const doc = await handle.view(heads).doc() - - const content = (doc as FileDocument | undefined)?.content - return readDocContent(content) - } catch { - return null - } - } - - /** - * Get current content from Automerge document - */ - private async getCurrentRemoteContent( - url: AutomergeUrl - ): Promise { - try { - const plainUrl = getPlainUrl(url) - const result = await this.findDocument(plainUrl) - - if (!result) return null - - const content = result.doc.content - return readDocContent(content) - } catch (error) { - out.taskLine(`Failed to get remote content: ${error}`, true) - return null - } - } - - /** - * Find and wait for a document to be available, with retry logic. - * repo.find() rejects with "unavailable" if the server doesn't have the - * document yet, and doc() throws if the handle isn't ready. We retry - * both with backoff since the document may just not have propagated yet. - */ - private async findDocument( - url: AutomergeUrl, - options: {maxRetries?: number; retryDelayMs?: number} = {} - ): Promise<{handle: DocHandle; doc: T} | undefined> { - const {maxRetries = 5, retryDelayMs = 500} = options - - for (let attempt = 0; attempt < maxRetries; attempt++) { - try { - const handle = await this.repo.find(url) - const doc = handle.doc() - return {handle, doc} - } catch { - // Document may be unavailable — retry after a delay - if (attempt < maxRetries - 1) { - await new Promise(r => setTimeout(r, retryDelayMs * (attempt + 1))) - } - } - } - - return undefined - } - - /** - * Get current head of Automerge document - */ - private async getCurrentRemoteHead(url: AutomergeUrl): Promise { - try { - const plainUrl = getPlainUrl(url) - const result = await this.findDocument(plainUrl, { - maxRetries: 3, - retryDelayMs: 200, - }) - if (!result) return [] as unknown as UrlHeads - return result.handle.heads() - } catch { - return [] as unknown as UrlHeads - } - } - - /** - * Determine file type from content - */ - private async getFileTypeFromContent( - content: string | Uint8Array | null - ): Promise { - if (content == null) return FileType.TEXT - - if (content instanceof Uint8Array) { - return FileType.BINARY - } else { - return FileType.TEXT - } - } - - /** - * Classify change type for a path - */ - async classifyChange( - relativePath: string, - snapshot: SyncSnapshot - ): Promise { - const snapshotEntry = snapshot.files.get(relativePath) - const localContent = await this.getLocalContent(relativePath) - - if (!snapshotEntry) { - // New file - return ChangeType.LOCAL_ONLY - } - - const lastKnownContent = await this.getContentAtHead( - snapshotEntry.url, - snapshotEntry.head - ) - const currentRemoteContent = await this.getCurrentRemoteContent( - snapshotEntry.url - ) - - const localChanged = localContent - ? !isContentEqual(localContent, lastKnownContent) - : true - const remoteChanged = !isContentEqual( - lastKnownContent, - currentRemoteContent - ) - - if (!localChanged && !remoteChanged) { - return ChangeType.NO_CHANGE - } else if (localChanged && !remoteChanged) { - return ChangeType.LOCAL_ONLY - } else if (!localChanged && remoteChanged) { - return ChangeType.REMOTE_ONLY - } else { - return ChangeType.BOTH_CHANGED - } - } - - /** - * Find a file's entry in the remote directory hierarchy. - * Returns the entry (with name, type, url) or null if not found. - */ - private async findInRemoteDirectory( - rootDirectoryUrl: AutomergeUrl | undefined, - filePath: string - ): Promise<{ name: string; type: string; url: AutomergeUrl } | null> { - if (!rootDirectoryUrl) return null - return findFileInDirectoryHierarchy( - this.repo, - rootDirectoryUrl, - filePath - ) - } -} diff --git a/src/core/config.ts b/src/core/config.ts deleted file mode 100644 index c0a6311..0000000 --- a/src/core/config.ts +++ /dev/null @@ -1,327 +0,0 @@ -import * as fs from "fs/promises"; -import * as path from "path"; -import * as os from "os"; -import { - GlobalConfig, - DirectoryConfig, - DEFAULT_SYNC_SERVER, - DEFAULT_SYNC_SERVER_STORAGE_ID, -} from "../types"; -import { pathExists, ensureDirectoryExists } from "../utils"; - -/** - * Configuration manager for pushwork - */ -export class ConfigManager { - private static readonly GLOBAL_CONFIG_DIR = ".pushwork"; - private static readonly CONFIG_FILENAME = "config.json"; - - static readonly CONFIG_DIR = ".pushwork"; - - constructor(private workingDir?: string) {} - - /** - * Get global configuration path - */ - private getGlobalConfigPath(): string { - return path.join( - os.homedir(), - ConfigManager.GLOBAL_CONFIG_DIR, - ConfigManager.CONFIG_FILENAME - ); - } - - /** - * Get local configuration path - */ - private getLocalConfigPath(): string { - if (!this.workingDir) { - throw new Error("Working directory not set for local config"); - } - return path.join( - this.workingDir, - ConfigManager.CONFIG_DIR, - ConfigManager.CONFIG_FILENAME - ); - } - - /** - * Load global configuration - */ - async loadGlobal(): Promise { - try { - const configPath = this.getGlobalConfigPath(); - if (!(await pathExists(configPath))) { - return null; - } - - const content = await fs.readFile(configPath, "utf8"); - return JSON.parse(content) as GlobalConfig; - } catch (error) { - // Failed to load global config - return null; - } - } - - /** - * Save global configuration - */ - async saveGlobal(config: GlobalConfig): Promise { - try { - const configPath = this.getGlobalConfigPath(); - await ensureDirectoryExists(path.dirname(configPath)); - - const content = JSON.stringify(config, null, 2); - await fs.writeFile(configPath, content, "utf8"); - } catch (error) { - throw new Error(`Failed to save global config: ${error}`); - } - } - - /** - * Load local/directory configuration - */ - async load(): Promise { - if (!this.workingDir) { - return null; - } - - try { - const configPath = this.getLocalConfigPath(); - if (!(await pathExists(configPath))) { - return null; - } - - const content = await fs.readFile(configPath, "utf8"); - return JSON.parse(content) as DirectoryConfig; - } catch (error) { - // Failed to load local config - return null; - } - } - - /** - * Save local/directory configuration - */ - async save(config: DirectoryConfig): Promise { - if (!this.workingDir) { - throw new Error("Working directory not set for local config"); - } - - try { - const configPath = this.getLocalConfigPath(); - await ensureDirectoryExists(path.dirname(configPath)); - - const content = JSON.stringify(config, null, 2); - await fs.writeFile(configPath, content, "utf8"); - } catch (error) { - throw new Error(`Failed to save local config: ${error}`); - } - } - - private getDefaultGlobalConfig(): GlobalConfig { - return { - exclude_patterns: [ - ".git", - "node_modules", - "*.tmp", - ".DS_Store", - ".pushwork", - ], - artifact_directories: ["dist"], - sync_server: DEFAULT_SYNC_SERVER, - sync_server_storage_id: DEFAULT_SYNC_SERVER_STORAGE_ID, - sync: { - move_detection_threshold: 0.7, - }, - }; - } - - /** - * Get default configuration - */ - getDefaultDirectoryConfig(): DirectoryConfig { - return { - sync_enabled: true, - sync_server: DEFAULT_SYNC_SERVER, - sync_server_storage_id: DEFAULT_SYNC_SERVER_STORAGE_ID, - exclude_patterns: [ - ".git", - "node_modules", - "*.tmp", - ".pushwork", - ".DS_Store", - ], - artifact_directories: ["dist"], - sync: { - move_detection_threshold: 0.7, - }, - }; - } - - /** - * Get merged configuration (global + local) - */ - async getMerged(): Promise { - const globalConfig = await this.loadGlobal(); - const localConfig = await this.load(); - - // Merge configurations: default < global < local - let merged = this.getDefaultDirectoryConfig(); - - if (globalConfig) { - merged = this.mergeConfigs(merged, globalConfig); - } - - if (localConfig) { - merged = this.mergeConfigs(merged, localConfig); - } - - return merged; - } - - /** - * Initialize with CLI option overrides - * Creates a new config with defaults + CLI overrides and saves it - */ - async initializeWithOverrides( - overrides: Partial = {} - ): Promise { - const config = this.mergeConfigs(this.getDefaultDirectoryConfig(), overrides); - await this.save(config); - return config; - } - - /** - * Merge two configuration objects - */ - private mergeConfigs( - base: DirectoryConfig, - override: Partial | GlobalConfig - ): DirectoryConfig { - const merged = { ...base }; - - if ("sync_server" in override && override.sync_server !== undefined) { - merged.sync_server = override.sync_server; - } - - if ( - "sync_server_storage_id" in override && - override.sync_server_storage_id !== undefined - ) { - merged.sync_server_storage_id = override.sync_server_storage_id; - } - - if ("subduction" in override && override.subduction !== undefined) { - merged.subduction = override.subduction; - } - - if ("sync_enabled" in override && override.sync_enabled !== undefined) { - merged.sync_enabled = override.sync_enabled; - } - - // Handle GlobalConfig structure - if ("exclude_patterns" in override && override.exclude_patterns) { - merged.exclude_patterns = override.exclude_patterns; - } - - if ("artifact_directories" in override && override.artifact_directories) { - merged.artifact_directories = override.artifact_directories; - } - - if ("sync" in override && override.sync) { - merged.sync = { ...merged.sync, ...override.sync }; - } - - return merged; - } - - /** - * Create default global configuration - */ - async createDefaultGlobal(): Promise { - const defaultGlobal = this.getDefaultGlobalConfig(); - await this.saveGlobal(defaultGlobal); - } - - /** - * Check if global configuration exists - */ - async globalConfigExists(): Promise { - return await pathExists(this.getGlobalConfigPath()); - } - - /** - * Check if local configuration exists - */ - async localConfigExists(): Promise { - if (!this.workingDir) return false; - return await pathExists(this.getLocalConfigPath()); - } - - /** - * Get configuration value by path (e.g., 'sync.move_detection_threshold') - */ - async getValue(keyPath: string): Promise { - const config = await this.getMerged(); - - const keys = keyPath.split("."); - let value: any = config; - - for (const key of keys) { - if (value && typeof value === "object" && key in value) { - value = value[key]; - } else { - return undefined; - } - } - - return value; - } - - /** - * Set configuration value by path - */ - async setValue(keyPath: string, value: any): Promise { - const config = (await this.load()) || ({} as DirectoryConfig); - - const keys = keyPath.split("."); - let current: any = config; - - // Navigate to the parent of the target key - for (let i = 0; i < keys.length - 1; i++) { - const key = keys[i]; - if (!(key in current) || typeof current[key] !== "object") { - current[key] = {}; - } - current = current[key]; - } - - // Set the value - const finalKey = keys[keys.length - 1]; - current[finalKey] = value; - - await this.save(config); - } - - /** - * Validate configuration - */ - validate(config: DirectoryConfig): { valid: boolean; errors: string[] } { - const errors: string[] = []; - - if (config.sync?.move_detection_threshold !== undefined) { - if ( - config.sync.move_detection_threshold < 0 || - config.sync.move_detection_threshold > 1 - ) { - errors.push("move_detection_threshold must be between 0 and 1"); - } - } - - return { - valid: errors.length === 0, - errors, - }; - } -} diff --git a/src/core/index.ts b/src/core/index.ts deleted file mode 100644 index 5e2c276..0000000 --- a/src/core/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export * from "./snapshot"; -export * from "./change-detection"; -export * from "./move-detection"; -export * from "./sync-engine"; -export * from "./config"; diff --git a/src/core/move-detection.ts b/src/core/move-detection.ts deleted file mode 100644 index b30e475..0000000 --- a/src/core/move-detection.ts +++ /dev/null @@ -1,171 +0,0 @@ -import { SyncSnapshot, MoveCandidate } from "../types"; -import { isTextFile } from "../utils"; -import { stringSimilarity } from "../utils/string-similarity"; -import { ChangeType, DetectedChange } from "../types"; - -/** - * Simplified move detection engine - */ -export class MoveDetector { - private readonly moveThreshold: number; - - constructor(moveThreshold: number = 0.7) { - this.moveThreshold = moveThreshold; - } - - /** - * Detect file moves by analyzing deleted and created files - */ - async detectMoves( - changes: DetectedChange[], - snapshot: SyncSnapshot - ): Promise<{ moves: MoveCandidate[]; remainingChanges: DetectedChange[] }> { - const deletedFiles = changes.filter( - (c) => !c.localContent && c.changeType === ChangeType.LOCAL_ONLY - ); - const createdFiles = changes.filter( - (c) => - c.localContent && - c.changeType === ChangeType.LOCAL_ONLY && - !snapshot.files.has(c.path) - ); - - if (deletedFiles.length === 0 || createdFiles.length === 0) { - return { moves: [], remainingChanges: changes }; - } - - const moves: MoveCandidate[] = []; - const usedCreations = new Set(); - const usedDeletions = new Set(); - - // Find potential moves by comparing content - for (const deletedFile of deletedFiles) { - const deletedContent = deletedFile.remoteContent; - if (deletedContent === null) continue; - - let bestMatch: { file: DetectedChange; similarity: number } | null = null; - - for (const createdFile of createdFiles) { - if (usedCreations.has(createdFile.path)) continue; - if (createdFile.localContent === null) continue; - - const similarity = await this.calculateSimilarity( - deletedContent, - createdFile.localContent, - deletedFile.path - ); - - if (similarity >= this.moveThreshold) { - if (!bestMatch || similarity > bestMatch.similarity) { - bestMatch = { file: createdFile, similarity }; - } - } - } - - if (bestMatch) { - // If we detected a move above threshold, we apply it - moves.push({ - fromPath: deletedFile.path, - toPath: bestMatch.file.path, - similarity: bestMatch.similarity, - newContent: bestMatch.file.localContent || undefined, - }); - - // Consume the deletion and creation (move replaces both) - usedCreations.add(bestMatch.file.path); - usedDeletions.add(deletedFile.path); - } - } - - const remainingChanges = changes.filter( - (change) => - !usedCreations.has(change.path) && !usedDeletions.has(change.path) - ); - - return { moves, remainingChanges }; - } - - /** - * Calculate similarity between two content pieces - * Optimized for speed while maintaining accuracy - */ - private async calculateSimilarity( - content1: string | Uint8Array, - content2: string | Uint8Array, - path: string - ): Promise { - if (content1 === content2) return 1.0; - - // Early exit: size difference too large - const size1 = - typeof content1 === "string" ? content1.length : content1.length; - const size2 = - typeof content2 === "string" ? content2.length : content2.length; - const maxSize = Math.max(size1, size2); - if (maxSize === 0) return 1.0; - const sizeDiff = Math.abs(size1 - size2) / maxSize; - if (sizeDiff > 0.5) return 0.0; - - // Binary files: hash mismatch = not a move - const isText = await isTextFile(path); - if (!isText) return 0.0; - - // Text files: use string similarity - const str1 = - typeof content1 === "string" ? content1 : this.bufferToString(content1); - const str2 = - typeof content2 === "string" ? content2 : this.bufferToString(content2); - - // For small files (<4KB), compare full content - if (size1 < 4096 && size2 < 4096) { - return stringSimilarity(str1, str2); - } - - // For large files, sample 3 locations - const samples1 = this.getSamples(str1); - const samples2 = this.getSamples(str2); - - let totalSimilarity = 0; - for (let i = 0; i < Math.min(samples1.length, samples2.length); i++) { - totalSimilarity += stringSimilarity(samples1[i], samples2[i]); - } - - return totalSimilarity / Math.min(samples1.length, samples2.length); - } - - /** - * Get representative samples from content (beginning, middle, end) - */ - private getSamples(str: string): string[] { - const CHUNK_SIZE = 1024; - const length = str.length; - - if (length <= CHUNK_SIZE) { - return [str]; - } - - return [ - str.slice(0, CHUNK_SIZE), // Beginning - str.slice( - Math.floor(length / 2) - Math.floor(CHUNK_SIZE / 2), - Math.floor(length / 2) + Math.floor(CHUNK_SIZE / 2) - ), // Middle - str.slice(-CHUNK_SIZE), // End - ]; - } - - /** - * Convert buffer to string (for text comparison) - */ - private bufferToString(buffer: Uint8Array): string { - return new TextDecoder().decode(buffer); - } - - /** - * Format move for display - */ - formatMove(move: MoveCandidate): string { - const percentage = Math.round(move.similarity * 100); - return `${move.fromPath} → ${move.toPath} (${percentage}% similar)`; - } -} diff --git a/src/core/snapshot.ts b/src/core/snapshot.ts deleted file mode 100644 index 21f2ead..0000000 --- a/src/core/snapshot.ts +++ /dev/null @@ -1,275 +0,0 @@ -/** - * pvh TODO: the files & directories could be unified into a single map of entries with a type field - */ - -import * as fs from "fs/promises"; -import * as path from "path"; -import { - SyncSnapshot, - SerializableSyncSnapshot, - SnapshotFileEntry, - SnapshotDirectoryEntry, -} from "../types"; -import { pathExists, ensureDirectoryExists } from "../utils"; -import { out } from "../utils/output"; - -/** - * Manages sync snapshots for local state tracking - */ -export class SnapshotManager { - private static readonly SNAPSHOT_FILENAME = "snapshot.json"; - private static readonly SYNC_TOOL_DIR = ".pushwork"; - - constructor(private rootPath: string) {} - - /** - * Get path to sync tool directory - */ - private getSyncToolDir(): string { - return path.join(this.rootPath, SnapshotManager.SYNC_TOOL_DIR); - } - - /** - * Get path to snapshot file - */ - private getSnapshotPath(): string { - return path.join(this.getSyncToolDir(), SnapshotManager.SNAPSHOT_FILENAME); - } - - /** - * Check if snapshot exists - */ - async exists(): Promise { - return await pathExists(this.getSnapshotPath()); - } - - /** - * Load snapshot from disk - */ - async load(): Promise { - try { - const snapshotPath = this.getSnapshotPath(); - if (!(await pathExists(snapshotPath))) { - return null; - } - - const content = await fs.readFile(snapshotPath, "utf8"); - const serializable: SerializableSyncSnapshot = JSON.parse(content); - - return this.deserializeSnapshot(serializable); - } catch (error) { - out.taskLine(`Failed to load snapshot: ${error}`); - return null; - } - } - - /** - * Save snapshot to disk - */ - async save(snapshot: SyncSnapshot): Promise { - try { - await ensureDirectoryExists(this.getSyncToolDir()); - - const serializable = this.serializeSnapshot(snapshot); - const content = JSON.stringify(serializable, null, 2); - - await fs.writeFile(this.getSnapshotPath(), content, "utf8"); - } catch (error) { - throw new Error(`Failed to save snapshot: ${error}`); - } - } - - /** - * Create empty snapshot - */ - createEmpty(): SyncSnapshot { - return { - timestamp: Date.now(), - rootPath: this.rootPath, - rootDirectoryUrl: undefined, - files: new Map(), - directories: new Map(), - }; - } - - /** - * Update file entry in snapshot - */ - updateFileEntry( - snapshot: SyncSnapshot, - relativePath: string, - entry: SnapshotFileEntry - ): void { - snapshot.files.set(relativePath, entry); - snapshot.timestamp = Date.now(); - } - - /** - * Update directory entry in snapshot - */ - updateDirectoryEntry( - snapshot: SyncSnapshot, - relativePath: string, - entry: SnapshotDirectoryEntry - ): void { - snapshot.directories.set(relativePath, entry); - snapshot.timestamp = Date.now(); - } - - /** - * Remove file entry from snapshot - */ - removeFileEntry(snapshot: SyncSnapshot, relativePath: string): void { - snapshot.files.delete(relativePath); - snapshot.timestamp = Date.now(); - } - - /** - * Remove directory entry from snapshot - */ - removeDirectoryEntry(snapshot: SyncSnapshot, relativePath: string): void { - snapshot.directories.delete(relativePath); - snapshot.timestamp = Date.now(); - } - - /** - * Get all file paths in snapshot - */ - getFilePaths(snapshot: SyncSnapshot): string[] { - return Array.from(snapshot.files.keys()); - } - - /** - * Get all directory paths in snapshot - */ - getDirectoryPaths(snapshot: SyncSnapshot): string[] { - return Array.from(snapshot.directories.keys()); - } - - /** - * Get file entry by path - */ - getFileEntry( - snapshot: SyncSnapshot, - relativePath: string - ): SnapshotFileEntry | undefined { - return snapshot.files.get(relativePath); - } - - /** - * Get directory entry by path - */ - getDirectoryEntry( - snapshot: SyncSnapshot, - relativePath: string - ): SnapshotDirectoryEntry | undefined { - return snapshot.directories.get(relativePath); - } - - /** - * Check if path is tracked in snapshot - */ - isTracked(snapshot: SyncSnapshot, relativePath: string): boolean { - return ( - snapshot.files.has(relativePath) || snapshot.directories.has(relativePath) - ); - } - - /** - * Get snapshot statistics - */ - getStats(snapshot: SyncSnapshot): { - files: number; - directories: number; - timestamp: Date; - } { - return { - files: snapshot.files.size, - directories: snapshot.directories.size, - timestamp: new Date(snapshot.timestamp), - }; - } - - /** - * Validate snapshot integrity - */ - validate(snapshot: SyncSnapshot): { valid: boolean; errors: string[] } { - const errors: string[] = []; - - if (!snapshot.timestamp || snapshot.timestamp <= 0) { - errors.push("Invalid timestamp"); - } - - if (!snapshot.rootPath) { - errors.push("Missing root path"); - } - - if (!snapshot.files || !snapshot.directories) { - errors.push("Missing files or directories map"); - } - - // Check for path conflicts (file and directory with same path) - for (const filePath of snapshot.files.keys()) { - if (snapshot.directories.has(filePath)) { - errors.push( - `Path conflict: ${filePath} exists as both file and directory` - ); - } - } - - return { - valid: errors.length === 0, - errors, - }; - } - - /** - * Convert snapshot to serializable format - */ - private serializeSnapshot(snapshot: SyncSnapshot): SerializableSyncSnapshot { - return { - timestamp: snapshot.timestamp, - rootPath: snapshot.rootPath, - rootDirectoryUrl: snapshot.rootDirectoryUrl, - files: Array.from(snapshot.files.entries()), - directories: Array.from(snapshot.directories.entries()), - }; - } - - /** - * Convert serializable format back to snapshot - */ - private deserializeSnapshot( - serializable: SerializableSyncSnapshot - ): SyncSnapshot { - return { - timestamp: serializable.timestamp, - rootPath: serializable.rootPath, - rootDirectoryUrl: serializable.rootDirectoryUrl, - files: new Map(serializable.files), - directories: new Map(serializable.directories), - }; - } - - /** - * Clear all snapshot data - */ - clear(snapshot: SyncSnapshot): void { - snapshot.files.clear(); - snapshot.directories.clear(); - snapshot.timestamp = Date.now(); - } - - /** - * Clone snapshot for safe manipulation - */ - clone(snapshot: SyncSnapshot): SyncSnapshot { - return { - timestamp: snapshot.timestamp, - rootPath: snapshot.rootPath, - rootDirectoryUrl: snapshot.rootDirectoryUrl, - files: new Map(snapshot.files), - directories: new Map(snapshot.directories), - }; - } -} diff --git a/src/core/sync-engine.ts b/src/core/sync-engine.ts deleted file mode 100644 index f300867..0000000 --- a/src/core/sync-engine.ts +++ /dev/null @@ -1,1919 +0,0 @@ -import { - AutomergeUrl, - Repo, - DocHandle, - UrlHeads, - parseAutomergeUrl, - stringifyAutomergeUrl, -} from "@automerge/automerge-repo" -import * as A from "@automerge/automerge" -import { - SyncSnapshot, - SyncResult, - FileDocument, - DirectoryDocument, - DirectoryEntry, - ChangeType, - FileType, - MoveCandidate, - DirectoryConfig, - DetectedChange, -} from "../types" -import { - writeFileContent, - removePath, - getFileExtension, - getEnhancedMimeType, - formatRelativePath, - findFileInDirectoryHierarchy, - joinAndNormalizePath, - getPlainUrl, - updateTextContent, - readDocContent, -} from "../utils" -import {isContentEqual, contentHash} from "../utils/content" -import {waitForSync, waitForBidirectionalSync} from "../utils/network-sync" -import {SnapshotManager} from "./snapshot" -import {ChangeDetector} from "./change-detection" -import {MoveDetector} from "./move-detection" -import {out} from "../utils/output" -import * as path from "path" - -const isDebug = !!process.env.DEBUG -function debug(...args: any[]) { - if (isDebug) console.error("[pushwork:engine]", ...args) -} - -/** - * Apply a change to a document handle, using changeAt when heads are available - * to branch from a known version, otherwise falling back to change. - */ -function changeWithOptionalHeads( - handle: DocHandle, - heads: UrlHeads | undefined, - callback: A.ChangeFn -): void { - if (heads && heads.length > 0) { - handle.changeAt(heads, callback) - } else { - handle.change(callback) - } -} - -/** - * Nuke an artifact directory's docs array and rebuild it from scratch. - * Entries must be spread into plain objects — pushing Automerge proxy objects - * back after splicing them out throws "Cannot create a reference to an - * existing document object". - */ -export function nukeAndRebuildDocs( - doc: DirectoryDocument, - dirPath: string, - newEntries: {name: string; url: AutomergeUrl}[], - updatedEntries: {name: string; url: AutomergeUrl}[], - deletedNames: string[], - subdirUpdates: {name: string; url: AutomergeUrl}[], -): void { - const deletedSet = new Set(deletedNames) - const updatedMap = new Map(updatedEntries.map(e => [e.name, e.url])) - const newMap = new Map(newEntries.map(e => [e.name, e.url])) - const subdirMap = new Map(subdirUpdates.map(e => [e.name, e.url])) - - const kept: DirectoryEntry[] = [] - for (const entry of doc.docs) { - if (entry.type === "file" && deletedSet.has(entry.name)) { - out.taskLine( - `Removed ${entry.name} from ${ - formatRelativePath(dirPath) || "root" - }` - ) - continue - } - if (entry.type === "file" && updatedMap.has(entry.name)) { - kept.push({...entry, url: updatedMap.get(entry.name)!}) - continue - } - if (entry.type === "file" && newMap.has(entry.name)) { - // Existing entry being re-added (e.g. from immutable string replacement) - kept.push({...entry, url: newMap.get(entry.name)!}) - newMap.delete(entry.name) - continue - } - if (entry.type === "folder" && subdirMap.has(entry.name)) { - kept.push({...entry, url: subdirMap.get(entry.name)!}) - continue - } - kept.push({...entry}) - } - - // Add genuinely new file entries - for (const [name, url] of newMap) { - kept.push({name, type: "file", url}) - } - - // Nuke and rebuild - doc.docs.splice(0, doc.docs.length) - for (const entry of kept) { - doc.docs.push(entry) - } -} - -/** - * Sync configuration constants - */ -const BIDIRECTIONAL_SYNC_TIMEOUT_MS = 5000 // Timeout for bidirectional sync stability check - -/** - * Bidirectional sync engine implementing two-phase sync - */ -export class SyncEngine { - private snapshotManager: SnapshotManager - private changeDetector: ChangeDetector - private moveDetector: MoveDetector - // Map from path to handle for leaf-first sync ordering - // Path depth determines sync order (deepest first) - private handlesByPath: Map> = new Map() - private config: DirectoryConfig - - constructor( - private repo: Repo, - private rootPath: string, - config: DirectoryConfig - ) { - this.config = config - this.snapshotManager = new SnapshotManager(rootPath) - this.changeDetector = new ChangeDetector( - repo, - rootPath, - config.exclude_patterns, - config.artifact_directories || [] - ) - this.moveDetector = new MoveDetector(config.sync.move_detection_threshold) - } - - /** - * Determine if content should be treated as text for Automerge text operations - * Note: This method checks the runtime type. File type detection happens - * during reading with isEnhancedTextFile() which now has better dev file support. - */ - private isTextContent(content: string | Uint8Array): boolean { - // Simply check the actual type of the content - return typeof content === "string" - } - - /** - * Get a versioned URL from a handle (includes current heads). - * This ensures clients can fetch the exact version of the document. - */ - private getVersionedUrl(handle: DocHandle): AutomergeUrl { - const {documentId} = parseAutomergeUrl(handle.url) - const heads = handle.heads() - return stringifyAutomergeUrl({documentId, heads}) - } - - /** - * Determine if a file path is inside an artifact directory. - * Artifact files are stored as immutable strings (RawString) and - * referenced with versioned URLs in directory entries. - */ - private isArtifactPath(filePath: string): boolean { - const artifactDirs = this.config.artifact_directories || [] - return artifactDirs.some( - dir => filePath === dir || filePath.startsWith(dir + "/") - ) - } - - /** - * Get the appropriate URL for a file's directory entry. - * Artifact paths get versioned URLs (with heads) for exact version fetching. - * Non-artifact paths get plain URLs for collaborative editing. - */ - private getEntryUrl(handle: DocHandle, filePath: string): AutomergeUrl { - if (this.isArtifactPath(filePath)) { - return this.getVersionedUrl(handle) - } - return getPlainUrl(handle.url) - } - - /** - * Get the appropriate URL for a subdirectory's directory entry. - * Artifact directories get versioned URLs (with heads) so consumers can - * fetch the exact snapshotted version, matching how artifact files work. - * Non-artifact directories get plain URLs for collaborative editing. - */ - private getDirEntryUrl(handle: DocHandle, dirPath: string): AutomergeUrl { - if (this.isArtifactPath(dirPath)) { - return this.getVersionedUrl(handle) - } - return getPlainUrl(handle.url) - } - - /** - * Find artifact directories whose live heads don't match the heads - * encoded in their parent's stored URL entry. This drift happens when - * remote changes land via bidirectional sync — the directory advances - * locally but no file-level change is detected, so leaf-first - * propagation never kicks in. Returning these here lets pushLocalChanges - * treat them as modified and refresh parent URLs all the way to the root. - */ - private async findStaleArtifactDirs(snapshot: SyncSnapshot): Promise { - if (!snapshot.rootDirectoryUrl) return [] - - const stale: string[] = [] - for (const [dirPath, entry] of snapshot.directories.entries()) { - if (!dirPath) continue - if (!this.isArtifactPath(dirPath)) continue - - const parts = dirPath.split("/") - const dirName = parts.pop()! - const parentPath = parts.join("/") - const parentUrl = !parentPath - ? snapshot.rootDirectoryUrl - : snapshot.directories.get(parentPath)?.url - if (!parentUrl) continue - - try { - const parentHandle = await this.repo.find( - getPlainUrl(parentUrl) - ) - const parentDoc = parentHandle.doc() - if (!parentDoc) continue - - const entryInParent = parentDoc.docs.find( - (e: DirectoryEntry) => e.name === dirName && e.type === "folder" - ) - if (!entryInParent) continue - - const dirHandle = await this.repo.find( - getPlainUrl(entry.url) - ) - const liveHeads = dirHandle.heads() - const urlHeadsInParent = parseAutomergeUrl(entryInParent.url).heads - - if ( - !urlHeadsInParent || - !A.equals(urlHeadsInParent as unknown as UrlHeads, liveHeads) - ) { - stale.push(dirPath) - } - } catch (err) { - debug(`findStaleArtifactDirs: ${dirPath}: ${err}`) - } - } - return stale - } - - /** - * Set the root directory URL in the snapshot - */ - async getRootDirectoryUrl(): Promise { - const snapshot = await this.snapshotManager.load() - return snapshot?.rootDirectoryUrl - } - - async setRootDirectoryUrl(url: AutomergeUrl): Promise { - let snapshot = await this.snapshotManager.load() - if (!snapshot) { - snapshot = this.snapshotManager.createEmpty() - } - snapshot.rootDirectoryUrl = url - await this.snapshotManager.save(snapshot) - } - - /** - * Reset the snapshot, clearing all tracked files and directories. - * Preserves the rootDirectoryUrl so sync can still operate. - * Used by --force to re-sync every file. - */ - async resetSnapshot(): Promise { - let snapshot = await this.snapshotManager.load() - if (!snapshot) return - this.snapshotManager.clear(snapshot) - await this.snapshotManager.save(snapshot) - } - - /** - * Nuclear reset: clear the snapshot AND wipe the root directory document's - * entries so that every file and subdirectory gets brand-new Automerge - * documents. The root directory document itself is preserved. - */ - async nuclearReset(): Promise { - let snapshot = await this.snapshotManager.load() - if (!snapshot) return - - // Clear the root directory document's entries - if (snapshot.rootDirectoryUrl) { - const rootHandle = await this.repo.find( - getPlainUrl(snapshot.rootDirectoryUrl) - ) - rootHandle.change((doc: DirectoryDocument) => { - doc.docs.splice(0, doc.docs.length) - }) - } - - // Clear all tracked files and directories from snapshot - this.snapshotManager.clear(snapshot) - await this.snapshotManager.save(snapshot) - } - - /** - * Commit local changes only (no network sync) - */ - async commitLocal(): Promise { - const result: SyncResult = { - success: false, - filesChanged: 0, - directoriesChanged: 0, - errors: [], - warnings: [], - } - - try { - // Load current snapshot - let snapshot = await this.snapshotManager.load() - if (!snapshot) { - snapshot = this.snapshotManager.createEmpty() - } - - // Detect all changes - const changes = await this.changeDetector.detectChanges(snapshot) - - // Detect moves - const {moves, remainingChanges} = await this.moveDetector.detectMoves( - changes, - snapshot - ) - - // Apply local changes only (no network sync) - const commitResult = await this.pushLocalChanges( - remainingChanges, - moves, - snapshot - ) - - result.filesChanged += commitResult.filesChanged - result.directoriesChanged += commitResult.directoriesChanged - result.errors.push(...commitResult.errors) - result.warnings.push(...commitResult.warnings) - - // Always touch root directory after commit - await this.touchRootDirectory(snapshot) - - // Save updated snapshot - await this.snapshotManager.save(snapshot) - - result.success = result.errors.length === 0 - - return result - } catch (error) { - result.errors.push({ - path: this.rootPath, - operation: "commitLocal", - error: error instanceof Error ? error : new Error(String(error)), - recoverable: true, - }) - result.success = false - return result - } - } - - /** - * Recreate documents that failed to sync. Creates new Automerge documents - * with the same content and updates all references (snapshot, parent directory). - * Returns new handles that should be retried for sync. - */ - private async recreateFailedDocuments( - failedHandles: DocHandle[], - snapshot: SyncSnapshot - ): Promise[]> { - const failedUrls = new Set(failedHandles.map(h => getPlainUrl(h.url))) - const newHandles: DocHandle[] = [] - - // Find which paths correspond to the failed handles - for (const [filePath, entry] of snapshot.files.entries()) { - const plainUrl = getPlainUrl(entry.url) - if (!failedUrls.has(plainUrl)) continue - - debug(`recreate: recreating document for ${filePath} (${plainUrl})`) - out.taskLine(`Recreating document for ${filePath}`) - - try { - // Read the current content from the old handle - const oldHandle = await this.repo.find(plainUrl) - const doc = await oldHandle.doc() - if (!doc) { - debug(`recreate: could not read doc for ${filePath}, skipping`) - continue - } - - const content = readDocContent(doc.content) - if (content === null) { - debug(`recreate: null content for ${filePath}, skipping`) - continue - } - - // Create a fresh document - const fakeChange: DetectedChange = { - path: filePath, - changeType: ChangeType.LOCAL_ONLY, - fileType: this.isTextContent(content) ? FileType.TEXT : FileType.BINARY, - localContent: content, - remoteContent: null, - } - const newHandle = await this.createRemoteFile(fakeChange) - if (!newHandle) continue - - const entryUrl = this.getEntryUrl(newHandle, filePath) - - // Update snapshot entry - this.snapshotManager.updateFileEntry(snapshot, filePath, { - ...entry, - url: entryUrl, - head: newHandle.heads(), - ...(this.isArtifactPath(filePath) ? {contentHash: contentHash(content)} : {}), - }) - - // Update parent directory entry to point to new document - const pathParts = filePath.split("/") - const fileName = pathParts.pop() || "" - const dirPath = pathParts.join("/") - - let dirUrl: AutomergeUrl - if (!dirPath || dirPath === "") { - dirUrl = snapshot.rootDirectoryUrl! - } else { - const dirEntry = snapshot.directories.get(dirPath) - if (!dirEntry) continue - dirUrl = dirEntry.url - } - - const dirHandle = await this.repo.find(getPlainUrl(dirUrl)) - dirHandle.change((d: DirectoryDocument) => { - const idx = d.docs.findIndex( - e => e.name === fileName && e.type === "file" - ) - if (idx !== -1) { - d.docs[idx].url = entryUrl - } - }) - - // Track new handles - this.handlesByPath.set(filePath, newHandle) - this.handlesByPath.set(dirPath, dirHandle) - newHandles.push(newHandle) - newHandles.push(dirHandle) - - debug(`recreate: created new doc for ${filePath} -> ${newHandle.url}`) - } catch (error) { - debug(`recreate: failed for ${filePath}: ${error}`) - out.taskLine(`Failed to recreate ${filePath}: ${error}`, true) - } - } - - // Also check directory documents - for (const [dirPath, entry] of snapshot.directories.entries()) { - const plainUrl = getPlainUrl(entry.url) - if (!failedUrls.has(plainUrl)) continue - - // Directory docs can't be easily recreated (they reference children). - // Just log a warning — the child recreation above should handle most cases. - debug(`recreate: directory ${dirPath || "(root)"} failed to sync, cannot recreate`) - out.taskLine(`Warning: directory ${dirPath || "(root)"} failed to sync`, true) - } - - return newHandles - } - - /** - * Run full bidirectional sync - */ - async sync(options?: {sub?: boolean}): Promise { - const result: SyncResult = { - success: false, - filesChanged: 0, - directoriesChanged: 0, - errors: [], - warnings: [], - timings: {}, - } - - // Reset tracked handles for sync - this.handlesByPath = new Map() - - try { - // Load current snapshot - const snapshot = - (await this.snapshotManager.load()) || - this.snapshotManager.createEmpty() - - debug(`sync: rootDirectoryUrl=${snapshot.rootDirectoryUrl}, files=${snapshot.files.size}, dirs=${snapshot.directories.size}`) - - // Wait for initial sync to receive any pending remote changes - if (this.config.sync_enabled && snapshot.rootDirectoryUrl) { - debug("sync: waiting for root document to be ready") - out.update("Waiting for root document from server") - - // Wait for the root document to be fetched from the network. - // repo.find() rejects with "unavailable" if the server doesn't - // have the document yet, so we retry with backoff. - // This is critical for clone scenarios. - const plainRootUrl = getPlainUrl(snapshot.rootDirectoryUrl) - const maxAttempts = 6 - for (let attempt = 1; attempt <= maxAttempts; attempt++) { - try { - const rootHandle = await this.repo.find(plainRootUrl) - rootHandle.doc() // throws if not ready - debug(`sync: root document ready (attempt ${attempt})`) - break - } catch (error) { - const isUnavailable = String(error).includes("unavailable") || String(error).includes("not ready") - if (isUnavailable && attempt < maxAttempts) { - const delay = Math.min(1000 * Math.pow(2, attempt - 1), 10000) - debug(`sync: root document not available (attempt ${attempt}/${maxAttempts}), retrying in ${delay}ms`) - out.update(`Waiting for root document (attempt ${attempt}/${maxAttempts})`) - await new Promise(r => setTimeout(r, delay)) - } else { - debug(`sync: root document unavailable after ${maxAttempts} attempts: ${error}`) - out.taskLine(`Root document unavailable: ${error}`, true) - break - } - } - } - - debug("sync: waiting for initial bidirectional sync") - out.update("Waiting for initial sync from server") - try { - await waitForBidirectionalSync( - this.repo, - snapshot.rootDirectoryUrl, - { - timeoutMs: 5000, // Increased timeout for initial sync - pollIntervalMs: 100, - stableChecksRequired: 3, - } - ) - } catch (error) { - out.taskLine(`Initial sync: ${error}`, true) - } - } - - // Detect all changes - debug("sync: detecting changes") - out.update("Detecting local and remote changes") - // Capture pre-push snapshot file paths to detect deletions after push - const prePushFilePaths = new Set(snapshot.files.keys()) - const changes = await this.changeDetector.detectChanges(snapshot) - - // Detect moves - const {moves, remainingChanges} = await this.moveDetector.detectMoves( - changes, - snapshot - ) - - debug(`sync: detected ${changes.length} changes, ${moves.length} moves, ${remainingChanges.length} remaining`) - - // Phase 1: Push local changes to remote - debug("sync: phase 1 - pushing local changes") - const phase1Result = await this.pushLocalChanges( - remainingChanges, - moves, - snapshot - ) - - result.filesChanged += phase1Result.filesChanged - result.directoriesChanged += phase1Result.directoriesChanged - result.errors.push(...phase1Result.errors) - result.warnings.push(...phase1Result.warnings) - - debug(`sync: phase 1 complete - ${phase1Result.filesChanged} files, ${phase1Result.directoriesChanged} dirs changed`) - - // Wait for network sync (important for clone scenarios) - if (this.config.sync_enabled) { - const sub = options?.sub ?? false - - try { - // Ensure root directory handle is tracked for sync - if (snapshot.rootDirectoryUrl) { - const rootHandle = - await this.repo.find( - snapshot.rootDirectoryUrl - ) - this.handlesByPath.set("", rootHandle) - } - - // Single waitForSync with ALL tracked handles at once - if (this.handlesByPath.size > 0) { - const allHandles = Array.from( - this.handlesByPath.values() - ) - const handlePaths = Array.from(this.handlesByPath.keys()) - debug(`sync: waiting for ${allHandles.length} handles to sync to server: ${handlePaths.slice(0, 10).map(p => p || "(root)").join(", ")}${handlePaths.length > 10 ? ` ...and ${handlePaths.length - 10} more` : ""}`) - out.update(`Uploading ${allHandles.length} documents to sync server`) - const {failed} = await waitForSync(allHandles) - - // Recreate failed documents and retry once. - // Skip in Subduction mode — SubductionSource has its - // own heal-sync retry logic. - if (failed.length > 0 && !sub) { - debug(`sync: ${failed.length} documents failed, recreating`) - out.update(`Recreating ${failed.length} failed documents`) - const retryHandles = await this.recreateFailedDocuments(failed, snapshot) - if (retryHandles.length > 0) { - debug(`sync: retrying ${retryHandles.length} recreated handles`) - out.update(`Retrying ${retryHandles.length} recreated documents`) - const retry = await waitForSync(retryHandles) - if (retry.failed.length > 0) { - const msg = `${retry.failed.length} documents failed to sync to server after recreation` - debug(`sync: ${msg}`) - result.errors.push({ - path: "sync", - operation: "upload", - error: new Error(msg), - recoverable: true, - }) - } - } - } else if (failed.length > 0 && sub) { - const msg = `${failed.length} document${failed.length === 1 ? '' : 's'} did not converge during sync (Subduction will retry in the background; re-run sync to confirm)` - debug(`sync: ${msg}`) - out.taskLine(msg, true) - result.warnings.push(msg) - } - - debug("sync: all handles synced to server") - } - - // Wait for bidirectional sync to stabilize - // Use tracked handles for post-push check (cheaper than full tree scan) - const changedHandles = Array.from(this.handlesByPath.values()) - debug(`sync: waiting for bidirectional sync to stabilize (${changedHandles.length} tracked handles)`) - out.update("Waiting for bidirectional sync to stabilize") - await waitForBidirectionalSync( - this.repo, - snapshot.rootDirectoryUrl, - { - timeoutMs: BIDIRECTIONAL_SYNC_TIMEOUT_MS, - pollIntervalMs: 100, - stableChecksRequired: 3, - minWaitMs: 0, - handles: changedHandles.length > 0 ? changedHandles : undefined, - } - ) - - // Touch root directory AFTER all docs are synced and stable. - // This signals consumers (e.g. Patchwork) that new content is - // available. Because file docs are already on the server, - // consumers can immediately fetch them when they see the root change. - const hasPhase1Changes = - phase1Result.filesChanged > 0 || phase1Result.directoriesChanged > 0 - if (hasPhase1Changes && snapshot.rootDirectoryUrl) { - await this.touchRootDirectory(snapshot) - const rootHandle = - await this.repo.find( - snapshot.rootDirectoryUrl - ) - debug("sync: syncing root directory touch to server") - out.update("Syncing root directory update") - const rootSync = await waitForSync([rootHandle]) - if (rootSync.failed.length > 0) { - const msg = "Root directory update did not converge to server; consumers may not see recent changes until next sync" - debug(`sync: ${msg}`) - result.warnings.push(msg) - } - } - } catch (error) { - debug(`sync: network sync error: ${error}`) - out.taskLine(`Network sync failed: ${error}`, true) - result.errors.push({ - path: "sync", - operation: "network-sync", - error: error instanceof Error ? error : new Error(String(error)), - recoverable: true, - }) - } - } - - // Re-detect changes after network sync for fresh state - // Compute paths deleted during push so they aren't resurrected during pull - const deletedPaths = new Set() - for (const p of prePushFilePaths) { - if (!snapshot.files.has(p)) { - deletedPaths.add(p) - } - } - if (deletedPaths.size > 0) { - debug(`sync: excluding ${deletedPaths.size} deleted paths from re-detection`) - } - debug("sync: re-detecting changes after network sync") - const freshChanges = await this.changeDetector.detectChanges(snapshot, deletedPaths) - const freshRemoteChanges = freshChanges.filter( - c => - c.changeType === ChangeType.REMOTE_ONLY || - c.changeType === ChangeType.BOTH_CHANGED - ) - - debug(`sync: phase 2 - pulling ${freshRemoteChanges.length} remote changes`) - if (freshRemoteChanges.length > 0) { - out.update(`Pulling ${freshRemoteChanges.length} remote changes`) - } - // Phase 2: Pull remote changes to local using fresh detection - const phase2Result = await this.pullRemoteChanges( - freshRemoteChanges, - snapshot - ) - result.filesChanged += phase2Result.filesChanged - result.directoriesChanged += phase2Result.directoriesChanged - result.errors.push(...phase2Result.errors) - result.warnings.push(...phase2Result.warnings) - - // Update snapshot heads after pulling remote changes - // IMPORTANT: Use getPlainUrl() to strip version/heads from URLs. - // Artifact entries store versioned URLs (with heads baked in). - // repo.find(versionedUrl) returns a view handle whose .heads() - // returns the VERSION heads, not the current document heads. - // Using the versioned URL here would overwrite correct heads with - // stale ones, causing changeAt() to fork from the wrong point - // on the next sync (e.g. an empty directory state where deletions - // can't find the entries to splice out). - for (const [filePath, snapshotEntry] of snapshot.files.entries()) { - try { - const handle = await this.repo.find(getPlainUrl(snapshotEntry.url)) - const currentHeads = handle.heads() - if (!A.equals(currentHeads, snapshotEntry.head)) { - // Update snapshot with current heads after pulling changes - snapshot.files.set(filePath, { - ...snapshotEntry, - head: currentHeads, - }) - } - } catch (error) { - // Handle might not exist if file was deleted - } - } - - // Update directory document heads - for (const [dirPath, snapshotEntry] of snapshot.directories.entries()) { - try { - const handle = await this.repo.find(getPlainUrl(snapshotEntry.url)) - const currentHeads = handle.heads() - if (!A.equals(currentHeads, snapshotEntry.head)) { - // Update snapshot with current heads after pulling changes - snapshot.directories.set(dirPath, { - ...snapshotEntry, - head: currentHeads, - }) - } - } catch (error) { - // Handle might not exist if directory was deleted - } - } - - // Save updated snapshot if not dry run - await this.snapshotManager.save(snapshot) - - result.success = result.errors.length === 0 - return result - } catch (error) { - result.errors.push({ - path: "sync", - operation: "full-sync", - error: error as Error, - recoverable: false, - }) - return result - } - } - - /** - * Phase 1: Push local changes to Automerge documents. - * - * Works depth-first: processes the deepest files first, creates/updates all - * file docs at each level, then batch-updates the parent directory document - * in a single change. Propagates subdirectory URL updates as we walk up - * toward the root. This eliminates the need for a separate URL update pass. - */ - private async pushLocalChanges( - changes: DetectedChange[], - moves: MoveCandidate[], - snapshot: SyncSnapshot - ): Promise { - const result: SyncResult = { - success: true, - filesChanged: 0, - directoriesChanged: 0, - errors: [], - warnings: [], - } - - // Process moves first - all detected moves are applied - if (moves.length > 0) { - debug(`push: processing ${moves.length} moves`) - out.update(`Processing ${moves.length} move${moves.length > 1 ? "s" : ""}`) - } - for (let i = 0; i < moves.length; i++) { - const move = moves[i] - try { - debug(`push: move ${i + 1}/${moves.length}: ${move.fromPath} -> ${move.toPath}`) - out.taskLine(`Moving ${move.fromPath} -> ${move.toPath}`) - await this.applyMoveToRemote(move, snapshot) - result.filesChanged++ - } catch (error) { - debug(`push: move failed for ${move.fromPath}: ${error}`) - result.errors.push({ - path: move.fromPath, - operation: "move", - error: error as Error, - recoverable: true, - }) - } - } - - // Filter to local changes only - const localChanges = changes.filter( - c => - c.changeType === ChangeType.LOCAL_ONLY || - c.changeType === ChangeType.BOTH_CHANGED - ) - - // Detect artifact directories whose heads have drifted from what's - // encoded in their parent's URL (typically from remote merges during - // bidirectional sync). Treat them as modified so the existing - // leaf-first propagation refreshes parent URLs all the way up. - const staleArtifactDirs = await this.findStaleArtifactDirs(snapshot) - - if (localChanges.length === 0 && staleArtifactDirs.length === 0) { - debug("push: no local changes to push") - return result - } - - if (staleArtifactDirs.length > 0) { - debug(`push: ${staleArtifactDirs.length} stale artifact dirs need parent URL refresh: ${staleArtifactDirs.join(", ")}`) - } - - const newFiles = localChanges.filter(c => !snapshot.files.has(c.path) && c.localContent !== null) - const modifiedFiles = localChanges.filter(c => snapshot.files.has(c.path) && c.localContent !== null) - const deletedFiles = localChanges.filter(c => c.localContent === null && snapshot.files.has(c.path)) - debug(`push: ${localChanges.length} local changes (${newFiles.length} new, ${modifiedFiles.length} modified, ${deletedFiles.length} deleted)`) - out.update(`Pushing ${localChanges.length} local changes (${newFiles.length} new, ${modifiedFiles.length} modified, ${deletedFiles.length} deleted)`) - - // Group changes by parent directory path - const changesByDir = new Map() - for (const change of localChanges) { - const pathParts = change.path.split("/") - pathParts.pop() // remove filename - const dirPath = pathParts.join("/") - if (!changesByDir.has(dirPath)) { - changesByDir.set(dirPath, []) - } - changesByDir.get(dirPath)!.push(change) - } - - // Collect all directory paths that need processing: - // directories with file changes + stale artifact dirs + all ancestors - const allDirsToProcess = new Set() - const addWithAncestors = (dirPath: string) => { - allDirsToProcess.add(dirPath) - // Add ancestors so subdirectory URL updates propagate to root - let current = dirPath - while (current) { - const parts = current.split("/") - parts.pop() - current = parts.join("/") - allDirsToProcess.add(current) - } - } - for (const dirPath of changesByDir.keys()) addWithAncestors(dirPath) - for (const dirPath of staleArtifactDirs) addWithAncestors(dirPath) - - // Sort deepest-first - const sortedDirPaths = Array.from(allDirsToProcess).sort((a, b) => { - const depthA = a ? a.split("/").length : 0 - const depthB = b ? b.split("/").length : 0 - return depthB - depthA - }) - - debug(`push: processing ${sortedDirPaths.length} directories (deepest first)`) - - // Track which directories were modified (for subdirectory URL propagation). - // Pre-populate with stale artifact dirs so their parents emit a - // subdirUpdate even if no local file change touches them. - const modifiedDirs = new Set(staleArtifactDirs) - let filesProcessed = 0 - const totalFiles = localChanges.length - - for (const dirPath of sortedDirPaths) { - const dirChanges = changesByDir.get(dirPath) || [] - const dirLabel = dirPath || "(root)" - - if (dirChanges.length > 0) { - debug(`push: directory "${dirLabel}": ${dirChanges.length} file changes`) - } - - // Ensure directory document exists - if (snapshot.rootDirectoryUrl) { - await this.ensureDirectoryDocument(snapshot, dirPath) - } - - // Process all file changes in this directory - const newEntries: {name: string; url: AutomergeUrl}[] = [] - const updatedEntries: {name: string; url: AutomergeUrl}[] = [] - const deletedNames: string[] = [] - - for (const change of dirChanges) { - const fileName = change.path.split("/").pop() || "" - const snapshotEntry = snapshot.files.get(change.path) - filesProcessed++ - - try { - if (change.localContent === null && snapshotEntry) { - // Delete file - debug(`push: [${filesProcessed}/${totalFiles}] delete ${change.path}`) - out.update(`Pushing local changes [${filesProcessed}/${totalFiles}] deleting ${change.path}`) - await this.deleteRemoteFile( - snapshotEntry.url, - snapshot, - change.path - ) - deletedNames.push(fileName) - this.snapshotManager.removeFileEntry(snapshot, change.path) - result.filesChanged++ - } else if (!snapshotEntry) { - // New file - debug(`push: [${filesProcessed}/${totalFiles}] create ${change.path} (${change.fileType})`) - out.update(`Pushing local changes [${filesProcessed}/${totalFiles}] creating ${change.path}`) - const handle = await this.createRemoteFile(change) - if (handle) { - const entryUrl = this.getEntryUrl(handle, change.path) - newEntries.push({name: fileName, url: entryUrl}) - this.snapshotManager.updateFileEntry( - snapshot, - change.path, - { - path: joinAndNormalizePath( - this.rootPath, - change.path - ), - url: entryUrl, - head: handle.heads(), - extension: getFileExtension(change.path), - mimeType: getEnhancedMimeType(change.path), - ...(this.isArtifactPath(change.path) && change.localContent - ? {contentHash: contentHash(change.localContent)} - : {}), - } - ) - result.filesChanged++ - debug(`push: created ${change.path} -> ${handle.url}`) - } - } else { - // Update existing file - const contentSize = typeof change.localContent === "string" - ? `${change.localContent!.length} chars` - : `${(change.localContent as Uint8Array).length} bytes` - debug(`push: [${filesProcessed}/${totalFiles}] update ${change.path} (${contentSize})`) - out.update(`Pushing local changes [${filesProcessed}/${totalFiles}] updating ${change.path}`) - await this.updateRemoteFile( - snapshotEntry.url, - change.localContent!, - snapshot, - change.path - ) - // Get current entry URL (updateRemoteFile updates snapshot) - const updatedFileEntry = snapshot.files.get(change.path) - if (updatedFileEntry) { - const fileHandle = - await this.repo.find( - getPlainUrl(updatedFileEntry.url) - ) - updatedEntries.push({ - name: fileName, - url: this.getEntryUrl(fileHandle, change.path), - }) - } - result.filesChanged++ - } - } catch (error) { - debug(`push: error processing ${change.path}: ${error}`) - out.taskLine(`Error pushing ${change.path}: ${error}`, true) - result.errors.push({ - path: change.path, - operation: "local-to-remote", - error: error as Error, - recoverable: true, - }) - } - } - - // Collect subdirectory URL updates for child dirs already processed - const subdirUpdates: {name: string; url: AutomergeUrl}[] = [] - for (const modifiedDir of modifiedDirs) { - // Check if modifiedDir is a direct child of dirPath - const parts = modifiedDir.split("/") - const childName = parts.pop() || "" - const parentOfModified = parts.join("/") - if (parentOfModified === dirPath) { - const dirEntry = snapshot.directories.get(modifiedDir) - if (dirEntry) { - const childHandle = - await this.repo.find( - getPlainUrl(dirEntry.url) - ) - subdirUpdates.push({ - name: childName, - url: this.getDirEntryUrl(childHandle, modifiedDir), - }) - } - } - } - - // Batch-update the directory document in a single change - const hasChanges = - newEntries.length > 0 || - updatedEntries.length > 0 || - deletedNames.length > 0 || - subdirUpdates.length > 0 - if (hasChanges && snapshot.rootDirectoryUrl) { - debug(`push: batch-updating directory "${dirLabel}" (+${newEntries.length} new, ~${updatedEntries.length} updated, -${deletedNames.length} deleted, ${subdirUpdates.length} subdir URL updates)`) - await this.batchUpdateDirectory( - snapshot, - dirPath, - newEntries, - updatedEntries, - deletedNames, - subdirUpdates - ) - modifiedDirs.add(dirPath) - result.directoriesChanged++ - } - } - - debug(`push: complete - ${result.filesChanged} files, ${result.directoriesChanged} dirs changed, ${result.errors.length} errors`) - return result - } - - /** - * Phase 2: Pull remote changes to local filesystem - */ - private async pullRemoteChanges( - changes: DetectedChange[], - snapshot: SyncSnapshot - ): Promise { - const result: SyncResult = { - success: true, - filesChanged: 0, - directoriesChanged: 0, - errors: [], - warnings: [], - } - - // Process remote changes - const remoteChanges = changes.filter( - c => - c.changeType === ChangeType.REMOTE_ONLY || - c.changeType === ChangeType.BOTH_CHANGED - ) - - // Sort changes by dependency order (parents before children) - const sortedChanges = this.sortChangesByDependency(remoteChanges) - - for (const change of sortedChanges) { - try { - await this.applyRemoteChangeToLocal(change, snapshot) - result.filesChanged++ - } catch (error) { - result.errors.push({ - path: change.path, - operation: "remote-to-local", - error: error as Error, - recoverable: true, - }) - } - } - - return result - } - - /** - * Apply remote change to local filesystem - */ - private async applyRemoteChangeToLocal( - change: DetectedChange, - snapshot: SyncSnapshot - ): Promise { - const localPath = joinAndNormalizePath(this.rootPath, change.path) - - if (!change.remoteHead) { - throw new Error( - `No remote head found for remote change to ${change.path}` - ) - } - - // Check for null (empty string/Uint8Array are valid content) - if (change.remoteContent === null) { - // File was deleted remotely - await removePath(localPath) - this.snapshotManager.removeFileEntry(snapshot, change.path) - return - } - - // Create or update local file - await writeFileContent(localPath, change.remoteContent) - - // Update or create snapshot entry for this file - const snapshotEntry = snapshot.files.get(change.path) - if (snapshotEntry) { - // Update existing entry - snapshotEntry.head = change.remoteHead - // If the remote document was replaced (new URL), update the snapshot URL - if (change.remoteUrl) { - const fileHandle = await this.repo.find(change.remoteUrl) - snapshotEntry.url = this.getEntryUrl(fileHandle, change.path) - } - } else { - // Create new snapshot entry for newly discovered remote file - // We need to find the remote file's URL from the directory hierarchy - if (snapshot.rootDirectoryUrl) { - try { - const fileEntry = await findFileInDirectoryHierarchy( - this.repo, - snapshot.rootDirectoryUrl, - change.path - ) - - if (fileEntry) { - const fileHandle = await this.repo.find(fileEntry.url) - const entryUrl = this.getEntryUrl(fileHandle, change.path) - this.snapshotManager.updateFileEntry(snapshot, change.path, { - path: localPath, - url: entryUrl, - head: change.remoteHead, - extension: getFileExtension(change.path), - mimeType: getEnhancedMimeType(change.path), - }) - } - } catch (error) { - // Failed to update snapshot - file may have been deleted - out.taskLine( - `Warning: Failed to update snapshot for remote file ${change.path}`, - true - ) - } - } - } - } - - /** - * Apply move to remote documents - */ - private async applyMoveToRemote( - move: MoveCandidate, - snapshot: SyncSnapshot - ): Promise { - const fromEntry = snapshot.files.get(move.fromPath) - if (!fromEntry) return - - // Parse paths - const toParts = move.toPath.split("/") - const toFileName = toParts.pop() || "" - const toDirPath = toParts.join("/") - - // 1) Remove file entry from old directory document - if (move.fromPath !== move.toPath) { - await this.removeFileFromDirectory(snapshot, move.fromPath) - } - - // 2) Ensure destination directory document exists - await this.ensureDirectoryDocument(snapshot, toDirPath) - - // 3) Update the FileDocument name and content to match new location/state - try { - let entryUrl: AutomergeUrl - let finalHeads: UrlHeads - - if (this.isArtifactPath(move.toPath)) { - // Artifact files use RawString — no diffing needed, just create a fresh doc - const content = move.newContent !== undefined - ? move.newContent - : readDocContent((await (await this.repo.find(getPlainUrl(fromEntry.url))).doc())?.content) - const fakeChange: DetectedChange = { - path: move.toPath, - changeType: ChangeType.LOCAL_ONLY, - fileType: content != null && typeof content === "string" ? FileType.TEXT : FileType.BINARY, - localContent: content, - remoteContent: null, - } - const newHandle = await this.createRemoteFile(fakeChange) - if (!newHandle) return - entryUrl = this.getEntryUrl(newHandle, move.toPath) - finalHeads = newHandle.heads() - } else { - // Use plain URL for mutable handle - const handle = await this.repo.find( - getPlainUrl(fromEntry.url) - ) - const heads = fromEntry.head - - // Update both name and content (if content changed during move) - changeWithOptionalHeads(handle, heads, (doc: FileDocument) => { - doc.name = toFileName - - // If new content is provided, update it (handles move + modification case) - if (move.newContent !== undefined) { - if (typeof move.newContent === "string") { - updateTextContent(doc, ["content"], move.newContent) - } else { - doc.content = move.newContent - } - } - }) - - entryUrl = this.getEntryUrl(handle, move.toPath) - finalHeads = handle.heads() - - // Track file handle for network sync - this.handlesByPath.set(move.toPath, handle) - } - - // 4) Add file entry to destination directory - await this.addFileToDirectory(snapshot, move.toPath, entryUrl) - - // 5) Update snapshot entries - this.snapshotManager.removeFileEntry(snapshot, move.fromPath) - this.snapshotManager.updateFileEntry(snapshot, move.toPath, { - ...fromEntry, - path: joinAndNormalizePath(this.rootPath, move.toPath), - url: entryUrl, - head: finalHeads, - ...(this.isArtifactPath(move.toPath) && move.newContent != null - ? {contentHash: contentHash(move.newContent)} - : {}), - }) - } catch (e) { - // Failed to update file name - file may have been deleted - out.taskLine( - `Warning: Failed to rename ${move.fromPath} to ${move.toPath}`, - true - ) - } - } - - /** - * Create new remote file document - */ - private async createRemoteFile( - change: DetectedChange - ): Promise | null> { - if (change.localContent === null) return null - - const isText = this.isTextContent(change.localContent) - const isArtifact = this.isArtifactPath(change.path) - - // For artifact files, store text as RawString (immutable snapshot). - // For regular files, store as collaborative text (empty string + splice). - const fileDoc: FileDocument = { - "@patchwork": {type: "file"}, - name: change.path.split("/").pop() || "", - extension: getFileExtension(change.path), - mimeType: getEnhancedMimeType(change.path), - content: - isText && isArtifact - ? new A.RawString(change.localContent as string) as unknown as string - : isText - ? "" - : change.localContent, - metadata: { - permissions: 0o644, - }, - } - - const handle = this.repo.create(fileDoc) - - // For non-artifact text files, splice in the content so it's stored as collaborative text - if (isText && !isArtifact && typeof change.localContent === "string") { - handle.change((doc: FileDocument) => { - updateTextContent(doc, ["content"], change.localContent as string) - }) - } - - // Always track newly created files for network sync - // (they always represent a change that needs to sync) - this.handlesByPath.set(change.path, handle) - - return handle - } - - /** - * Update existing remote file document - */ - private async updateRemoteFile( - url: AutomergeUrl, - content: string | Uint8Array, - snapshot: SyncSnapshot, - filePath: string - ): Promise { - // Use plain URL for mutable handle - const handle = await this.repo.find(getPlainUrl(url)) - - // Check if content actually changed before tracking for sync - const doc = await handle.doc() - const rawContent = doc?.content - - // For artifact paths, always replace with a new document containing RawString. - // For non-artifact paths with immutable strings, replace with mutable text. - // In both cases we create a new document and update the snapshot URL. - const isArtifact = this.isArtifactPath(filePath) - if ( - isArtifact || - !doc || - (rawContent != null && A.isImmutableString(rawContent)) - ) { - if (!isArtifact) { - out.taskLine( - `Replacing ${!doc ? 'unavailable' : 'immutable string'} document for ${filePath}`, - true - ) - } - const fakeChange: DetectedChange = { - path: filePath, - changeType: ChangeType.LOCAL_ONLY, - fileType: this.isTextContent(content) - ? FileType.TEXT - : FileType.BINARY, - localContent: content, - remoteContent: null, - } - const newHandle = await this.createRemoteFile(fakeChange) - if (newHandle) { - const entryUrl = this.getEntryUrl(newHandle, filePath) - this.snapshotManager.updateFileEntry(snapshot, filePath, { - path: joinAndNormalizePath(this.rootPath, filePath), - url: entryUrl, - head: newHandle.heads(), - extension: getFileExtension(filePath), - mimeType: getEnhancedMimeType(filePath), - ...(this.isArtifactPath(filePath) - ? {contentHash: contentHash(content)} - : {}), - }) - } - return - } - - const currentContent = readDocContent(rawContent) - const contentChanged = !isContentEqual(content, currentContent) - - // Update snapshot heads even when content is identical - const snapshotEntry = snapshot.files.get(filePath) - if (snapshotEntry) { - // Update snapshot with current document heads - snapshot.files.set(filePath, { - ...snapshotEntry, - head: handle.heads(), - }) - } - - if (!contentChanged) { - // Content is identical, but we've updated the snapshot heads above - // This prevents fresh change detection from seeing stale heads - return - } - - const heads = snapshotEntry?.head - - if (!heads) { - throw new Error(`No heads found for ${url}`) - } - - handle.changeAt(heads, (doc: FileDocument) => { - if (typeof content === "string") { - updateTextContent(doc, ["content"], content) - } else { - doc.content = content - } - }) - - // Update snapshot with new heads after content change - if (snapshotEntry) { - snapshot.files.set(filePath, { - ...snapshotEntry, - head: handle.heads(), - }) - } - - // Only track files that actually changed content - this.handlesByPath.set(filePath, handle) - } - - /** - * Delete remote file document - */ - private async deleteRemoteFile( - _url: AutomergeUrl, - _snapshot?: SyncSnapshot, - _filePath?: string - ): Promise { - // In Automerge, we don't actually delete documents. - // The file entry is removed from its parent directory, making the - // document orphaned. Clearing content via splice is expensive for - // large text files (every character is a CRDT op), so we skip it. - } - - /** - * Add file entry to appropriate directory document (maintains hierarchy) - */ - private async addFileToDirectory( - snapshot: SyncSnapshot, - filePath: string, - fileUrl: AutomergeUrl - ): Promise { - if (!snapshot.rootDirectoryUrl) return - - const pathParts = filePath.split("/") - const fileName = pathParts.pop() || "" - const directoryPath = pathParts.join("/") - - // Get or create the parent directory document - const parentDirUrl = await this.ensureDirectoryDocument( - snapshot, - directoryPath - ) - - // Use plain URL for mutable handle - const dirHandle = await this.repo.find( - getPlainUrl(parentDirUrl) - ) - - let didChange = false - const snapshotEntry = snapshot.directories.get(directoryPath) - const heads = snapshotEntry?.head - changeWithOptionalHeads(dirHandle, heads, (doc: DirectoryDocument) => { - const existingIndex = doc.docs.findIndex( - entry => entry.name === fileName && entry.type === "file" - ) - if (existingIndex === -1) { - doc.docs.push({ - name: fileName, - type: "file", - url: fileUrl, - }) - didChange = true - } - }) - // Always track the directory (even if unchanged) for proper leaf-first sync ordering - this.handlesByPath.set(directoryPath, dirHandle) - - if (didChange && snapshotEntry) { - snapshotEntry.head = dirHandle.heads() - } - } - - /** - * Ensure directory document exists for the given path, creating hierarchy as needed - * First checks for existing shared directories before creating new ones - */ - private async ensureDirectoryDocument( - snapshot: SyncSnapshot, - directoryPath: string - ): Promise { - // Root directory case - if (!directoryPath || directoryPath === "") { - return snapshot.rootDirectoryUrl! - } - - // Check if we already have this directory in snapshot - const existingDir = snapshot.directories.get(directoryPath) - if (existingDir) { - return existingDir.url - } - - // Split path into parent and current directory name - const pathParts = directoryPath.split("/") - const currentDirName = pathParts.pop() || "" - const parentPath = pathParts.join("/") - - // Ensure parent directory exists first (recursive) - const parentDirUrl = await this.ensureDirectoryDocument( - snapshot, - parentPath - ) - - // DISCOVERY: Check if directory already exists in parent on server - try { - const parentHandle = await this.repo.find(parentDirUrl) - const parentDoc = await parentHandle.doc() - - if (parentDoc) { - const existingDirEntry = parentDoc.docs.find( - (entry: {name: string; type: string; url: AutomergeUrl}) => - entry.name === currentDirName && entry.type === "folder" - ) - - if (existingDirEntry) { - // Resolve the actual directory handle and use its current heads - // Directory entries in parent docs may not carry valid heads - try { - const childDirHandle = await this.repo.find( - existingDirEntry.url - ) - - // Track discovered directory for sync - this.handlesByPath.set(directoryPath, childDirHandle) - - // Get appropriate URL for directory entry - const entryUrl = this.getDirEntryUrl(childDirHandle, directoryPath) - - // Update snapshot with discovered directory - this.snapshotManager.updateDirectoryEntry(snapshot, directoryPath, { - path: joinAndNormalizePath(this.rootPath, directoryPath), - url: entryUrl, - head: childDirHandle.heads(), - entries: [], - }) - - return entryUrl - } catch (resolveErr) { - // Failed to resolve directory - fall through to create a fresh directory document - } - } - } - } catch (error) { - // Failed to check for existing directory - will create new one - } - - // CREATE: Directory doesn't exist, create new one - const dirDoc: DirectoryDocument = { - "@patchwork": {type: "folder"}, - name: currentDirName, - title: currentDirName, - docs: [], - } - - const dirHandle = this.repo.create(dirDoc) - - // Get appropriate URL for directory entry - const dirEntryUrl = this.getDirEntryUrl(dirHandle, directoryPath) - - // Add this directory to its parent - // Use plain URL for mutable handle - const parentHandle = await this.repo.find( - getPlainUrl(parentDirUrl) - ) - - let didChange = false - parentHandle.change((doc: DirectoryDocument) => { - // Double-check that entry doesn't exist (race condition protection) - const existingIndex = doc.docs.findIndex( - (entry: {name: string; type: string; url: AutomergeUrl}) => - entry.name === currentDirName && entry.type === "folder" - ) - if (existingIndex === -1) { - doc.docs.push({ - name: currentDirName, - type: "folder", - url: dirEntryUrl, - }) - didChange = true - } - }) - - // Track directory handles for sync - this.handlesByPath.set(directoryPath, dirHandle) - if (didChange) { - this.handlesByPath.set(parentPath, parentHandle) - - const parentSnapshotEntry = snapshot.directories.get(parentPath) - if (parentSnapshotEntry) { - parentSnapshotEntry.head = parentHandle.heads() - } - } - - // Update snapshot with new directory - this.snapshotManager.updateDirectoryEntry(snapshot, directoryPath, { - path: joinAndNormalizePath(this.rootPath, directoryPath), - url: dirEntryUrl, - head: dirHandle.heads(), - entries: [], - }) - - return dirEntryUrl - } - - /** - * Remove file entry from directory document - */ - private async removeFileFromDirectory( - snapshot: SyncSnapshot, - filePath: string - ): Promise { - if (!snapshot.rootDirectoryUrl) return - - const pathParts = filePath.split("/") - const fileName = pathParts.pop() || "" - const directoryPath = pathParts.join("/") - - // Get the parent directory URL - let parentDirUrl: AutomergeUrl - if (!directoryPath || directoryPath === "") { - parentDirUrl = snapshot.rootDirectoryUrl - } else { - const existingDir = snapshot.directories.get(directoryPath) - if (!existingDir) { - // Directory not found - file may already be removed - return - } - parentDirUrl = existingDir.url - } - - try { - // Use plain URL for mutable handle - const dirHandle = await this.repo.find( - getPlainUrl(parentDirUrl) - ) - - // Track this handle for network sync waiting - this.handlesByPath.set(directoryPath, dirHandle) - const snapshotEntry = snapshot.directories.get(directoryPath) - const heads = snapshotEntry?.head - let didChange = false - - changeWithOptionalHeads(dirHandle, heads, (doc: DirectoryDocument) => { - const indexToRemove = doc.docs.findIndex( - entry => entry.name === fileName && entry.type === "file" - ) - if (indexToRemove !== -1) { - doc.docs.splice(indexToRemove, 1) - didChange = true - out.taskLine( - `Removed ${fileName} from ${ - formatRelativePath(directoryPath) || "root" - }` - ) - } - }) - - if (didChange && snapshotEntry) { - snapshotEntry.head = dirHandle.heads() - } - } catch (error) { - throw error - } - } - - /** - * Batch-update a directory document in a single change: add new file entries, - * update URLs for modified files, remove deleted entries, and update - * subdirectory URLs. This replaces the separate per-file directory mutations - * and the post-hoc URL update pass. - */ - private async batchUpdateDirectory( - snapshot: SyncSnapshot, - dirPath: string, - newEntries: {name: string; url: AutomergeUrl}[], - updatedEntries: {name: string; url: AutomergeUrl}[], - deletedNames: string[], - subdirUpdates: {name: string; url: AutomergeUrl}[] - ): Promise { - let dirUrl: AutomergeUrl - if (!dirPath || dirPath === "") { - dirUrl = snapshot.rootDirectoryUrl! - } else { - const dirEntry = snapshot.directories.get(dirPath) - if (!dirEntry) return - dirUrl = dirEntry.url - } - - const dirHandle = await this.repo.find( - getPlainUrl(dirUrl) - ) - - const snapshotEntry = snapshot.directories.get(dirPath) - const heads = snapshotEntry?.head - - // Determine directory name - const dirName = dirPath ? dirPath.split("/").pop() || "" : path.basename(this.rootPath) - - if (this.isArtifactPath(dirPath)) { - // Artifact directories are always nuked: rebuild docs array from scratch - // using a plain change() to avoid changeAt forking from stale heads. - dirHandle.change((doc: DirectoryDocument) => { - if (!doc.name) doc.name = dirName - if (!doc.title) doc.title = dirName - nukeAndRebuildDocs(doc, dirPath, newEntries, updatedEntries, deletedNames, subdirUpdates) - }) - } else { - changeWithOptionalHeads(dirHandle, heads, (doc: DirectoryDocument) => { - // Ensure name and title fields are set - if (!doc.name) doc.name = dirName - if (!doc.title) doc.title = dirName - - // Remove deleted file entries - for (const name of deletedNames) { - const idx = doc.docs.findIndex( - entry => entry.name === name && entry.type === "file" - ) - if (idx !== -1) { - doc.docs.splice(idx, 1) - out.taskLine( - `Removed ${name} from ${ - formatRelativePath(dirPath) || "root" - }` - ) - } - } - - // Update URLs for modified files - for (const {name, url} of updatedEntries) { - const idx = doc.docs.findIndex( - entry => entry.name === name && entry.type === "file" - ) - if (idx !== -1) { - doc.docs[idx].url = url - } - } - - // Add new file entries - for (const {name, url} of newEntries) { - const existing = doc.docs.findIndex( - entry => entry.name === name && entry.type === "file" - ) - if (existing === -1) { - doc.docs.push({name, type: "file", url}) - } else { - // Entry already exists (e.g. from immutable string replacement) - doc.docs[existing].url = url - } - } - - // Update subdirectory URLs with current heads - for (const {name, url} of subdirUpdates) { - const idx = doc.docs.findIndex( - entry => entry.name === name && entry.type === "folder" - ) - if (idx !== -1) { - doc.docs[idx].url = url - } - } - }) - } - - // Track directory handle and update snapshot heads - this.handlesByPath.set(dirPath, dirHandle) - if (snapshotEntry) { - snapshotEntry.head = dirHandle.heads() - } - } - - /** - * Sort changes by dependency order - */ - private sortChangesByDependency(changes: DetectedChange[]): DetectedChange[] { - // Sort by path depth (shallower paths first) - return changes.sort((a, b) => { - const depthA = a.path.split("/").length - const depthB = b.path.split("/").length - return depthA - depthB - }) - } - - /** - * Get sync status - */ - async getStatus(): Promise<{ - snapshot: SyncSnapshot | null - hasChanges: boolean - changeCount: number - lastSync: Date | null - }> { - const snapshot = await this.snapshotManager.load() - - if (!snapshot) { - return { - snapshot: null, - hasChanges: false, - changeCount: 0, - lastSync: null, - } - } - - const changes = await this.changeDetector.detectChanges(snapshot) - - return { - snapshot, - hasChanges: changes.length > 0, - changeCount: changes.length, - lastSync: new Date(snapshot.timestamp), - } - } - - /** - * Preview changes without applying them - */ - async previewChanges(): Promise<{ - changes: DetectedChange[] - moves: MoveCandidate[] - summary: string - }> { - const snapshot = await this.snapshotManager.load() - if (!snapshot) { - return { - changes: [], - moves: [], - summary: "No snapshot found - run init first", - } - } - - const changes = await this.changeDetector.detectChanges(snapshot) - const {moves} = await this.moveDetector.detectMoves(changes, snapshot) - - const summary = this.generateChangeSummary(changes, moves) - - return {changes, moves, summary} - } - - /** - * Generate human-readable summary of changes - */ - private generateChangeSummary( - changes: DetectedChange[], - moves: MoveCandidate[] - ): string { - const localChanges = changes.filter( - c => - c.changeType === ChangeType.LOCAL_ONLY || - c.changeType === ChangeType.BOTH_CHANGED - ).length - - const remoteChanges = changes.filter( - c => - c.changeType === ChangeType.REMOTE_ONLY || - c.changeType === ChangeType.BOTH_CHANGED - ).length - - const conflicts = changes.filter( - c => c.changeType === ChangeType.BOTH_CHANGED - ).length - - const parts: string[] = [] - - if (localChanges > 0) { - parts.push(`${localChanges} local change${localChanges > 1 ? "s" : ""}`) - } - - if (remoteChanges > 0) { - parts.push( - `${remoteChanges} remote change${remoteChanges > 1 ? "s" : ""}` - ) - } - - if (moves.length > 0) { - parts.push(`${moves.length} potential move${moves.length > 1 ? "s" : ""}`) - } - - if (conflicts > 0) { - parts.push(`${conflicts} conflict${conflicts > 1 ? "s" : ""}`) - } - - if (parts.length === 0) { - return "No changes detected" - } - - return parts.join(", ") - } - - /** - * Update the lastSyncAt timestamp on the root directory document - */ - private async touchRootDirectory(snapshot: SyncSnapshot): Promise { - if (!snapshot.rootDirectoryUrl) { - return - } - - try { - const rootHandle = await this.repo.find( - snapshot.rootDirectoryUrl - ) - - const snapshotEntry = snapshot.directories.get("") - const heads = snapshotEntry?.head - - const timestamp = Date.now() - - const version = require("../../package.json").version - - changeWithOptionalHeads(rootHandle, heads, (doc: DirectoryDocument) => { - doc.lastSyncAt = timestamp - doc.with = `pushwork@${version}` - }) - - // Track root directory for network sync - this.handlesByPath.set("", rootHandle) - - if (snapshotEntry) { - snapshotEntry.head = rootHandle.heads() - } - } catch (error) { - // Failed to update root directory timestamp - } - } - -} diff --git a/src/fs-tree.ts b/src/fs-tree.ts new file mode 100644 index 0000000..b2e9fb8 --- /dev/null +++ b/src/fs-tree.ts @@ -0,0 +1,99 @@ +import * as fs from "fs/promises"; +import * as path from "path"; +import type { Ignore } from "ignore"; +import { isIgnored } from "./ignore.js"; + +export type FileTree = Map; + +const toPosix = (p: string) => p.split(path.sep).join("/"); +const fromPosix = (p: string) => p.split("/").join(path.sep); + +export async function walkDir(root: string, ig: Ignore): Promise { + const tree: FileTree = new Map(); + await walk(root, root, ig, tree); + return tree; +} + +async function walk( + root: string, + current: string, + ig: Ignore, + tree: FileTree, +): Promise { + let names: string[]; + try { + names = await fs.readdir(current); + } catch { + return; + } + for (const name of names) { + const full = path.join(current, name); + const rel = toPosix(path.relative(root, full)); + if (isIgnored(ig, rel)) continue; + let stat; + try { + stat = await fs.lstat(full); + } catch { + continue; + } + if (stat.isSymbolicLink()) continue; + if (stat.isDirectory()) { + await walk(root, full, ig, tree); + } else if (stat.isFile()) { + const bytes = await fs.readFile(full); + tree.set(rel, new Uint8Array(bytes)); + } + } +} + +export function byteEq(a: Uint8Array | undefined, b: Uint8Array): boolean { + if (!a) return false; + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false; + return true; +} + +export async function writeFileAtomic( + target: string, + bytes: Uint8Array, +): Promise { + await fs.mkdir(path.dirname(target), { recursive: true }); + await fs.writeFile(target, bytes); +} + +export async function materialize( + root: string, + docFiles: Record, + currentFiles: FileTree, +): Promise { + for (const [rel, bytes] of Object.entries(docFiles)) { + const view = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes); + if (byteEq(currentFiles.get(rel), view)) continue; + await writeFileAtomic(path.join(root, fromPosix(rel)), view); + } + for (const rel of currentFiles.keys()) { + if (!(rel in docFiles)) { + try { + await fs.unlink(path.join(root, fromPosix(rel))); + } catch { + // already gone + } + await pruneEmptyDirs(root, path.dirname(fromPosix(rel))); + } + } +} + +async function pruneEmptyDirs(root: string, relDir: string): Promise { + let dir = relDir; + while (dir && dir !== "." && dir !== path.sep) { + const full = path.join(root, dir); + try { + const entries = await fs.readdir(full); + if (entries.length > 0) return; + await fs.rmdir(full); + } catch { + return; + } + dir = path.dirname(dir); + } +} diff --git a/src/ignore.ts b/src/ignore.ts new file mode 100644 index 0000000..7a692f1 --- /dev/null +++ b/src/ignore.ts @@ -0,0 +1,21 @@ +import * as fs from "fs/promises"; +import * as path from "path"; +import ignore, { type Ignore } from "ignore"; + +const ALWAYS_IGNORE = [".pushwork", ".git", "node_modules"]; + +export async function loadIgnore(root: string): Promise { + const ig = ignore().add(ALWAYS_IGNORE); + try { + const text = await fs.readFile(path.join(root, ".gitignore"), "utf8"); + ig.add(text); + } catch { + // no .gitignore — fine + } + return ig; +} + +export function isIgnored(ig: Ignore, relativePath: string): boolean { + if (relativePath === "" || relativePath === ".") return false; + return ig.ignores(relativePath); +} diff --git a/src/index.ts b/src/index.ts index a3030ba..530689e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,2 @@ -export * from "./core"; -export * from "./utils"; -export * from "./types"; -export * from "./cli"; +export { init, clone, sync, url, type RootDoc } from "./pushwork.js"; +export type { Backend, PushworkConfig } from "./config.js"; diff --git a/src/pushwork.ts b/src/pushwork.ts new file mode 100644 index 0000000..8e04570 --- /dev/null +++ b/src/pushwork.ts @@ -0,0 +1,153 @@ +import * as fs from "fs/promises"; +import * as path from "path"; +import { isValidAutomergeUrl, type AutomergeUrl } from "@automerge/automerge-repo"; +import { + configExists, + pushworkDir, + readConfig, + readHeads, + storageDir, + writeConfig, + writeHeads, + type Backend, +} from "./config.js"; +import { loadIgnore } from "./ignore.js"; +import { byteEq, materialize, walkDir, type FileTree } from "./fs-tree.js"; +import { openRepo, waitForSync } from "./repo.js"; + +export type RootDoc = { + files: { [path: string]: Uint8Array }; +}; + +const empty = (): RootDoc => ({ files: {} }); + +async function dirIsEmpty(dir: string): Promise { + try { + const entries = await fs.readdir(dir); + return entries.length === 0; + } catch (err) { + if ((err as NodeJS.ErrnoException).code === "ENOENT") return true; + throw err; + } +} + +export async function init(opts: { + dir: string; + backend: Backend; +}): Promise { + const root = path.resolve(opts.dir); + if (await configExists(root)) { + throw new Error(`pushwork already initialized at ${root}`); + } + await fs.mkdir(pushworkDir(root), { recursive: true }); + + const repo = await openRepo(opts.backend, storageDir(root)); + try { + const ig = await loadIgnore(root); + const tree = await walkDir(root, ig); + + const initial = empty(); + for (const [p, bytes] of tree) initial.files[p] = bytes; + + const handle = repo.create(initial); + await handle.whenReady(); + await waitForSync(handle, { idleMs: 1500, maxMs: 8000 }); + + await writeConfig(root, { rootUrl: handle.url, backend: opts.backend }); + await writeHeads(root, handle.heads()); + return handle.url; + } finally { + await repo.shutdown(); + } +} + +export async function clone(opts: { + url: string; + dir: string; + backend: Backend; +}): Promise { + if (!isValidAutomergeUrl(opts.url)) { + throw new Error(`invalid automerge URL: ${opts.url}`); + } + const root = path.resolve(opts.dir); + await fs.mkdir(root, { recursive: true }); + if (await configExists(root)) { + throw new Error(`pushwork already initialized at ${root}`); + } + if (!(await dirIsEmpty(root))) { + // allow non-empty if no .pushwork — we just refuse to clobber existing files later + } + await fs.mkdir(pushworkDir(root), { recursive: true }); + await writeConfig(root, { + rootUrl: opts.url as AutomergeUrl, + backend: opts.backend, + }); + + const repo = await openRepo(opts.backend, storageDir(root)); + try { + const handle = await repo.find(opts.url as AutomergeUrl); + await waitForSync(handle, { idleMs: 1500, maxMs: 15000 }); + + const ig = await loadIgnore(root); + const fsFiles = await walkDir(root, ig); + await materialize(root, handle.doc().files, fsFiles); + await writeHeads(root, handle.heads()); + } finally { + await repo.shutdown(); + } +} + +export async function url(cwd: string): Promise { + const config = await readConfig(path.resolve(cwd)); + return config.rootUrl; +} + +export async function sync(cwd: string): Promise { + const root = path.resolve(cwd); + const config = await readConfig(root); + const savedHeads = await readHeads(root); + + const repo = await openRepo(config.backend, storageDir(root)); + try { + const handle = await repo.find(config.rootUrl); + + const ig = await loadIgnore(root); + const fsFiles = await walkDir(root, ig); + + const oldFiles: Record = savedHeads + ? { ...handle.view(savedHeads).doc().files } + : {}; + + applyLocalChanges(handle, oldFiles, fsFiles); + + await waitForSync(handle, { idleMs: 1500, maxMs: 15000 }); + + const finalDoc = handle.doc(); + await materialize(root, finalDoc.files, fsFiles); + await writeHeads(root, handle.heads()); + } finally { + await repo.shutdown(); + } +} + +function applyLocalChanges( + handle: { change: (fn: (d: RootDoc) => void) => void }, + oldFiles: Record, + fsFiles: FileTree, +): void { + const adds: Array<[string, Uint8Array]> = []; + const dels: string[] = []; + for (const [p, bytes] of fsFiles) { + const old = oldFiles[p]; + const oldView = old ? new Uint8Array(old) : undefined; + if (!byteEq(oldView, bytes)) adds.push([p, bytes]); + } + for (const p of Object.keys(oldFiles)) { + if (!fsFiles.has(p)) dels.push(p); + } + if (adds.length === 0 && dels.length === 0) return; + handle.change((d) => { + for (const [p, bytes] of adds) d.files[p] = bytes; + for (const p of dels) delete d.files[p]; + }); +} diff --git a/src/repo.ts b/src/repo.ts new file mode 100644 index 0000000..640a772 --- /dev/null +++ b/src/repo.ts @@ -0,0 +1,58 @@ +import { + Repo, + initSubduction, + type DocHandle, + type NetworkAdapterInterface, +} from "@automerge/automerge-repo"; +import { WebSocketClientAdapter } from "@automerge/automerge-repo-network-websocket"; +import { NodeFSStorageAdapter } from "@automerge/automerge-repo-storage-nodefs"; +import type { Backend } from "./config.js"; + +const DEFAULT_LEGACY = "wss://sync3.automerge.org"; +const DEFAULT_SUBDUCTION = "wss://subduction.sync.inkandswitch.com"; + +export const legacyUrl = () => + process.env.PUSHWORK_LEGACY_SERVER || DEFAULT_LEGACY; +export const subductionUrl = () => + process.env.PUSHWORK_SUBDUCTION_SERVER || DEFAULT_SUBDUCTION; + +export async function openRepo( + backend: Backend, + storageDir: string, +): Promise { + await initSubduction(); + const storage = new NodeFSStorageAdapter(storageDir); + if (backend === "legacy") { + const adapter = new WebSocketClientAdapter( + legacyUrl(), + ) as unknown as NetworkAdapterInterface; + return new Repo({ storage, network: [adapter] }); + } + return new Repo({ + storage, + network: [], + subductionWebsocketEndpoints: [subductionUrl()], + }); +} + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +export async function waitForSync( + handle: DocHandle, + { idleMs = 1500, maxMs = 15000, pollMs = 200 } = {}, +): Promise { + const headsKey = () => JSON.stringify(handle.heads()); + let last = headsKey(); + let lastChange = Date.now(); + const start = Date.now(); + while (Date.now() - start < maxMs) { + await sleep(pollMs); + const next = headsKey(); + if (next !== last) { + last = next; + lastChange = Date.now(); + } else if (Date.now() - lastChange >= idleMs) { + return; + } + } +} diff --git a/src/types/config.ts b/src/types/config.ts deleted file mode 100644 index 5ac1e32..0000000 --- a/src/types/config.ts +++ /dev/null @@ -1,115 +0,0 @@ -import { StorageId } from "@automerge/automerge-repo"; - -/** - * Default sync server configuration - */ -export const DEFAULT_SYNC_SERVER = "wss://sync3.automerge.org"; -export const DEFAULT_SYNC_SERVER_STORAGE_ID = - "3760df37-a4c6-4f66-9ecd-732039a9385d" as StorageId; -export const DEFAULT_SUBDUCTION_SERVER = "wss://subduction.sync.inkandswitch.com"; - -/** - * Global configuration options - */ -export interface GlobalConfig { - sync_server?: string; - sync_server_storage_id?: StorageId; - exclude_patterns: string[]; - artifact_directories: string[]; - sync: { - move_detection_threshold: number; - }; -} - -/** - * Per-directory configuration - */ -export interface DirectoryConfig extends GlobalConfig { - root_directory_url?: string; - subduction?: boolean; - sync_enabled: boolean; -} - -/** - * CLI command options - */ -export interface CommandOptions { - verbose?: boolean; -} - -/** - * Clone command specific options - */ -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; -} - -/** - * Sync command specific options - */ -export interface SyncOptions extends CommandOptions { - force?: boolean; - nuclear?: boolean; - gentle?: boolean; - dryRun?: boolean; -} - -/** - * Diff command specific options - */ -export interface DiffOptions extends CommandOptions { - nameOnly: boolean; -} - -/** - * Log command specific options - */ -export interface LogOptions extends CommandOptions { - oneline: boolean; - since?: string; - limit?: number; -} - -/** - * Checkout command specific options - */ -export interface CheckoutOptions extends CommandOptions { - force?: boolean; -} - -/** - * Init command specific options - */ -export interface InitOptions extends CommandOptions { - syncServer?: string; - syncServerStorageId?: StorageId; - sub?: boolean; -} - -/** - * Config command specific options - */ -export interface ConfigOptions extends CommandOptions { - list?: boolean; - get?: string; - set?: string; - value?: string; -} - -/** - * Status command specific options - */ -export interface StatusOptions extends CommandOptions { - verbose?: boolean; -} - -/** - * Watch command specific options - */ -export interface WatchOptions extends CommandOptions { - script?: string; // Script to run before syncing - watchDir?: string; // Directory to watch (relative to working dir) -} diff --git a/src/types/documents.ts b/src/types/documents.ts deleted file mode 100644 index b675d4b..0000000 --- a/src/types/documents.ts +++ /dev/null @@ -1,91 +0,0 @@ -import {AutomergeUrl, UrlHeads} from "@automerge/automerge-repo" - -/** - * Entry in a directory document - */ -export interface DirectoryEntry { - name: string - type: "file" | "folder" - url: AutomergeUrl -} - -/** - * Directory document structure - */ -export interface DirectoryDocument { - "@patchwork": {type: "folder"} - name: string - title: string - docs: DirectoryEntry[] - lastSyncAt?: number // Timestamp of last sync operation that made changes - with?: string // Tool identifier that last synced, e.g. "pushwork@1.0.19" -} - -/** - * File document structure - */ -export interface FileDocument { - "@patchwork": {type: "file"} - name: string - extension: string - mimeType: string - content: string | Uint8Array - metadata: { - permissions: number - } -} - -/** - * File type classification - */ -export enum FileType { - TEXT = "text", - BINARY = "binary", - DIRECTORY = "directory" -} - -/** - * Change type classification for sync operations - */ -export enum ChangeType { - NO_CHANGE = "no_change", - LOCAL_ONLY = "local_only", - REMOTE_ONLY = "remote_only", - BOTH_CHANGED = "both_changed" -} - -/** - * File system entry metadata - */ -export interface FileSystemEntry { - path: string - type: FileType - size: number - mtime: Date - permissions: number -} - -/** - * Move detection result - */ -export interface MoveCandidate { - fromPath: string - toPath: string - similarity: number - newContent?: string | Uint8Array // Content at destination (may differ from source if modified during move) -} - -/** - * Represents a detected change - */ -export interface DetectedChange { - path: string - changeType: ChangeType - fileType: FileType - localContent: string | Uint8Array | null - remoteContent: string | Uint8Array | null - localHead?: UrlHeads - remoteHead?: UrlHeads - /** New remote URL when the remote document was replaced (artifact URL change) */ - remoteUrl?: AutomergeUrl -} diff --git a/src/types/index.ts b/src/types/index.ts deleted file mode 100644 index 26b81c1..0000000 --- a/src/types/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from "./documents"; -export * from "./snapshot"; -export * from "./config"; diff --git a/src/types/snapshot.ts b/src/types/snapshot.ts deleted file mode 100644 index 71f751c..0000000 --- a/src/types/snapshot.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { AutomergeUrl, UrlHeads } from "@automerge/automerge-repo"; - -/** - * Tracked file entry in the sync snapshot - */ -export interface SnapshotFileEntry { - path: string; // Full filesystem path for mapping - url: AutomergeUrl; // Automerge document URL - head: UrlHeads; // Document head at last sync - extension: string; // File extension - mimeType: string; // MIME type - contentHash?: string; // SHA-256 of content at last sync (used by artifact files to skip remote reads) -} - -/** - * Tracked directory entry in the sync snapshot - */ -export interface SnapshotDirectoryEntry { - path: string; // Full filesystem path for mapping - url: AutomergeUrl; // Automerge document URL - head: UrlHeads; // Document head at last sync - entries: string[]; // List of child entry names -} - -/** - * Sync snapshot for local state management - */ -export interface SyncSnapshot { - timestamp: number; - rootPath: string; - rootDirectoryUrl?: AutomergeUrl; // URL of the root directory document - files: Map; - directories: Map; -} - -/** - * Serializable version of sync snapshot for storage - */ -export interface SerializableSyncSnapshot { - timestamp: number; - rootPath: string; - rootDirectoryUrl?: AutomergeUrl; // URL of the root directory document - files: Array<[string, SnapshotFileEntry]>; - directories: Array<[string, SnapshotDirectoryEntry]>; -} - -/** - * Sync operation result - */ -export interface SyncResult { - success: boolean; - filesChanged: number; - directoriesChanged: number; - errors: SyncError[]; - warnings: string[]; - timings?: { [key: string]: number }; -} - -/** - * Sync error details - */ -export interface SyncError { - path: string; - operation: string; - error: Error; - recoverable: boolean; -} diff --git a/src/utils/content.ts b/src/utils/content.ts deleted file mode 100644 index 4cc400b..0000000 --- a/src/utils/content.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { createHash } from "crypto"; - -/** - * Compute a SHA-256 hash of file content. - * Used to detect local changes for artifact files without reading remote docs. - */ -export function contentHash(content: string | Uint8Array): string { - return createHash("sha256").update(content).digest("hex"); -} - -/** - * Compare two content pieces for equality - */ -export function isContentEqual( - content1: string | Uint8Array | null, - content2: string | Uint8Array | null -): boolean { - if (content1 === content2) return true; - if (content1 == null || content2 == null) return false; - - if (typeof content1 !== typeof content2) return false; - - if (typeof content1 === "string") { - return content1 === content2; - } else { - // Compare Uint8Array using native Buffer.equals() for better performance - const buf1 = content1 as Uint8Array; - const buf2 = content2 as Uint8Array; - - if (buf1.length !== buf2.length) return false; - - return Buffer.from(buf1).equals(Buffer.from(buf2)); - } -} diff --git a/src/utils/directory.ts b/src/utils/directory.ts deleted file mode 100644 index 52e7a21..0000000 --- a/src/utils/directory.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { - AutomergeUrl, - Repo, - parseAutomergeUrl, - stringifyAutomergeUrl, -} from "@automerge/automerge-repo"; -import { DirectoryDocument } from "../types"; - -/** - * Get a plain URL (without heads) from any URL. - * Versioned URLs with heads return view handles, which show a frozen point in time. - * For internal navigation, we always want to see the CURRENT state of documents. - */ -export function getPlainUrl(url: AutomergeUrl): AutomergeUrl { - const { documentId } = parseAutomergeUrl(url); - return stringifyAutomergeUrl({ documentId }); -} - -/** - * Find a file in the directory hierarchy by path. - * - * IMPORTANT: This function strips heads from all URLs before navigation. - * This ensures we always see the CURRENT state of directories, not a frozen - * point-in-time view. This is critical because: - * 1. Directory documents store versioned URLs for subdirectories - * 2. These URLs may have been captured when the subdirectory was empty - * 3. Using versioned URLs would make files appear to not exist - * 4. This would trigger false "remote deletion" detection - */ -export async function findFileInDirectoryHierarchy( - repo: Repo, - directoryUrl: AutomergeUrl, - filePath: string -): Promise<{ name: string; type: string; url: AutomergeUrl } | null> { - try { - const pathParts = filePath.split("/"); - let currentDirUrl = getPlainUrl(directoryUrl); - - // Navigate through directories to find the parent directory - for (let i = 0; i < pathParts.length - 1; i++) { - const dirName = pathParts[i]; - const dirHandle = await repo.find(currentDirUrl); - const dirDoc = await dirHandle.doc(); - - if (!dirDoc) return null; - - const subDirEntry = dirDoc.docs.find( - (entry: { name: string; type: string; url: AutomergeUrl }) => - entry.name === dirName && entry.type === "folder" - ); - - if (!subDirEntry) return null; - currentDirUrl = getPlainUrl(subDirEntry.url); - } - - // Now look for the file in the final directory - const fileName = pathParts[pathParts.length - 1]; - const finalDirHandle = await repo.find(currentDirUrl); - const finalDirDoc = await finalDirHandle.doc(); - - if (!finalDirDoc) return null; - - const fileEntry = finalDirDoc.docs.find( - (entry: { name: string; type: string; url: AutomergeUrl }) => - entry.name === fileName && entry.type === "file" - ); - - return fileEntry || null; - } catch (error) { - // Failed to find file in hierarchy - return null; - } -} diff --git a/src/utils/fs.ts b/src/utils/fs.ts deleted file mode 100644 index c88d616..0000000 --- a/src/utils/fs.ts +++ /dev/null @@ -1,295 +0,0 @@ -import * as fs from "fs/promises" -import * as path from "path" -import * as crypto from "crypto" -import {glob} from "glob" -import * as mimeTypes from "mime-types" -import * as ignore from "ignore" -import {FileSystemEntry, FileType} from "../types" -import {isEnhancedTextFile} from "./mime-types" - -/** - * Check if a path exists - */ -export async function pathExists(filePath: string): Promise { - try { - await fs.access(filePath) - return true - } catch { - return false - } -} - -/** - * Get file system entry metadata - */ -export async function getFileSystemEntry( - filePath: string -): Promise { - try { - const stats = await fs.stat(filePath) - const type = stats.isDirectory() - ? FileType.DIRECTORY - : (await isEnhancedTextFile(filePath)) - ? FileType.TEXT - : FileType.BINARY - - return { - path: filePath, - type, - size: stats.size, - mtime: stats.mtime, - permissions: stats.mode & parseInt("777", 8), - } - } catch { - return null - } -} - -/** - * Determine if a file is text or binary - */ -export async function isTextFile(filePath: string): Promise { - try { - const mimeType = mimeTypes.lookup(filePath) - if (mimeType) { - return ( - mimeType.startsWith("text/") || - mimeType === "application/json" || - mimeType === "application/xml" || - mimeType.includes("javascript") || - mimeType.includes("typescript") - ) - } - - // Sample first 8KB to detect binary content - const handle = await fs.open(filePath, "r") - const buffer = Buffer.alloc(Math.min(8192, (await handle.stat()).size)) - await handle.read(buffer, 0, buffer.length, 0) - await handle.close() - - // Check for null bytes which indicate binary content - return !buffer.includes(0) - } catch { - return false - } -} - -/** - * Read file content as string or buffer - */ -export async function readFileContent( - filePath: string -): Promise { - const isText = await isEnhancedTextFile(filePath) - - if (isText) { - return await fs.readFile(filePath, "utf8") - } else { - const buffer = await fs.readFile(filePath) - return new Uint8Array(buffer) - } -} - -/** - * Write file content from string or buffer - */ -export async function writeFileContent( - filePath: string, - content: string | Uint8Array -): Promise { - await ensureDirectoryExists(path.dirname(filePath)) - - if (typeof content === "string") { - await fs.writeFile(filePath, content, "utf8") - } else { - await fs.writeFile(filePath, content) - } -} - -/** - * Ensure directory exists, creating it if necessary - */ -export async function ensureDirectoryExists(dirPath: string): Promise { - try { - await fs.mkdir(dirPath, {recursive: true}) - } catch (error: any) { - if (error.code !== "EEXIST") { - throw error - } - } -} - -/** - * Remove file or directory - */ -export async function removePath(filePath: string): Promise { - try { - const stats = await fs.stat(filePath) - if (stats.isDirectory()) { - await fs.rm(filePath, {recursive: true}) - } else { - await fs.unlink(filePath) - } - } catch (error: any) { - if (error.code !== "ENOENT") { - throw error - } - } -} - -/** - * Check if a path matches any of the exclude patterns using the ignore library - * Supports proper gitignore-style patterns (e.g., "node_modules", "*.tmp", ".git") - */ -function isExcluded( - filePath: string, - basePath: string, - excludePatterns: string[] -): boolean { - if (excludePatterns.length === 0) return false - - const relativePath = path.relative(basePath, filePath) - - // Use the ignore library which implements proper .gitignore semantics - // This is the same library used by ESLint and other major tools - const ig = ignore.default().add(excludePatterns) - - return ig.ignores(relativePath) -} - -/** - * List directory contents with metadata - */ -export async function listDirectory( - dirPath: string, - recursive = false, - excludePatterns: string[] = [] -): Promise { - const entries: FileSystemEntry[] = [] - - try { - // Construct pattern using path.join for proper cross-platform handling - const pattern = recursive - ? path.join(dirPath, "**/*") - : path.join(dirPath, "*") - - // glob expects forward slashes, even on Windows - const normalizedPattern = pattern.replace(/\\/g, "/") - - // Use glob to get all paths (with dot files) - // Note: We don't use glob's ignore option because it doesn't support gitignore semantics - const paths = await glob(normalizedPattern, { - dot: true, - }) - - // Parallelize all stat calls for better performance - const allEntries = await Promise.all( - paths.map(async filePath => { - // Filter using proper gitignore semantics from the ignore library - if (isExcluded(filePath, dirPath, excludePatterns)) { - return null - } - return await getFileSystemEntry(filePath) - }) - ) - - // Filter out null entries (excluded files or files that couldn't be read) - entries.push(...allEntries.filter((e): e is FileSystemEntry => e !== null)) - } catch { - // Return empty array if directory doesn't exist or can't be read - } - - return entries -} - -/** - * Copy file with metadata preservation - */ -export async function copyFile( - sourcePath: string, - destPath: string -): Promise { - await ensureDirectoryExists(path.dirname(destPath)) - await fs.copyFile(sourcePath, destPath) - - // Preserve file permissions - const stats = await fs.stat(sourcePath) - await fs.chmod(destPath, stats.mode) -} - -/** - * Move/rename file or directory - */ -export async function movePath( - sourcePath: string, - destPath: string -): Promise { - await ensureDirectoryExists(path.dirname(destPath)) - await fs.rename(sourcePath, destPath) -} - -/** - * Calculate content hash for change detection - */ -export async function calculateContentHash( - content: string | Uint8Array -): Promise { - const hash = crypto.createHash("sha256") - hash.update(content) - return hash.digest("hex") -} - -/** - * Get MIME type for file - */ -export function getMimeType(filePath: string): string { - return mimeTypes.lookup(filePath) || "application/octet-stream" -} - -/** - * Get file extension - */ -export function getFileExtension(filePath: string): string { - const ext = path.extname(filePath) - return ext.startsWith(".") ? ext.slice(1) : ext -} - -/** - * Normalize path separators for cross-platform compatibility - * Converts all path separators to forward slashes for consistent storage - */ -export function normalizePath(filePath: string): string { - return path.posix.normalize(filePath.replace(/\\/g, "/")) -} - -/** - * Join paths and normalize separators for cross-platform compatibility - * Use this instead of string concatenation to ensure proper path handling on Windows - */ -export function joinAndNormalizePath(...paths: string[]): string { - // Use path.join to properly handle path construction (handles Windows drive letters, etc.) - const joined = path.join(...paths) - // Then normalize to forward slashes for consistent storage/comparison - return normalizePath(joined) -} - -/** - * Get relative path from base directory - */ -export function getRelativePath(basePath: string, filePath: string): string { - return normalizePath(path.relative(basePath, filePath)) -} - -/** - * Format a path as a relative path with proper prefix - * Ensures paths like "src" become "./src" for clarity - * Leaves absolute paths and paths already starting with . or .. unchanged - */ -export function formatRelativePath(filePath: string): string { - // Already starts with . or / - leave as-is - if (filePath.startsWith(".") || filePath.startsWith("/")) { - return filePath - } - // Add ./ prefix for clarity - return `./${filePath}` -} diff --git a/src/utils/index.ts b/src/utils/index.ts deleted file mode 100644 index 454bb58..0000000 --- a/src/utils/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export * from "./fs" -export * from "./mime-types" -export * from "./directory" -export * from "./text-diff" diff --git a/src/utils/mime-types.ts b/src/utils/mime-types.ts deleted file mode 100644 index 2776c2b..0000000 --- a/src/utils/mime-types.ts +++ /dev/null @@ -1,244 +0,0 @@ -import * as mimeTypes from "mime-types"; - -/** - * Custom MIME type definitions for developer files - * Based on patchwork-cli's approach - */ -const CUSTOM_MIME_TYPES: Record = { - // TypeScript files - override the incorrect video/mp2t detection - ".ts": "text/typescript", - ".tsx": "text/tsx", - - // Config file formats - ".json": "application/json", - ".yaml": "text/yaml", - ".yml": "text/yaml", - ".toml": "application/toml", - ".ini": "text/plain", - ".conf": "text/plain", - ".config": "text/plain", - - // Vue.js single file components - ".vue": "text/vue", - - // Modern CSS preprocessors - ".scss": "text/scss", - ".sass": "text/sass", - ".less": "text/less", - ".styl": "text/stylus", - - // Modern JavaScript variants - ".mjs": "application/javascript", - ".cjs": "application/javascript", - - // React JSX - ".jsx": "text/jsx", - - // Svelte components - ".svelte": "text/svelte", - - // Web assembly - ".wasm": "application/wasm", - - // Other common dev files - ".d.ts": "text/typescript", - ".map": "application/json", // Source maps - ".env": "text/plain", - ".gitignore": "text/plain", - ".gitattributes": "text/plain", - ".editorconfig": "text/plain", - ".prettierrc": "application/json", - ".eslintrc": "application/json", - ".babelrc": "application/json", - - // Documentation formats - ".mdx": "text/markdown", - ".rst": "text/x-rst", - - // Docker files - Dockerfile: "text/plain", - ".dockerignore": "text/plain", - - // Package manager files - "package.json": "application/json", - "package-lock.json": "application/json", - "yarn.lock": "text/plain", - "pnpm-lock.yaml": "text/yaml", - "composer.json": "application/json", - Pipfile: "text/plain", - "requirements.txt": "text/plain", - - // Build tool configs - "webpack.config.js": "application/javascript", - "vite.config.js": "application/javascript", - "rollup.config.js": "application/javascript", - "tsconfig.json": "application/json", - "jsconfig.json": "application/json", -}; - -/** - * File extensions that should always be treated as text - * regardless of MIME type detection - */ -const FORCE_TEXT_EXTENSIONS = new Set([ - ".ts", - ".tsx", - ".jsx", - ".vue", - ".svelte", - ".scss", - ".sass", - ".less", - ".styl", - ".env", - ".gitignore", - ".gitattributes", - ".editorconfig", - ".d.ts", - ".map", - ".mdx", - ".rst", - ".toml", - ".ini", - ".conf", - ".config", - ".lock", -]); - -/** - * Get enhanced MIME type for file with custom dev file support - */ -export function getEnhancedMimeType(filePath: string): string { - const normalized = normalizePathSeparators(filePath); - const filename = normalized.split("/").pop() || ""; - const extension = getFileExtension(normalized); - - // Check custom definitions first (by extension) - if (extension && CUSTOM_MIME_TYPES[extension]) { - return CUSTOM_MIME_TYPES[extension]; - } - - // Check custom definitions by full filename - if (CUSTOM_MIME_TYPES[filename]) { - return CUSTOM_MIME_TYPES[filename]; - } - - // Fall back to standard mime-types library - const standardMime = mimeTypes.lookup(normalized); - if (standardMime) { - return standardMime; - } - - // Final fallback - return "application/octet-stream"; -} - -/** - * Check if file extension should be forced to text type - */ -export function shouldForceAsText(filePath: string): boolean { - const extension = getFileExtension(filePath); - return extension ? FORCE_TEXT_EXTENSIONS.has(extension) : false; -} - -/** - * Get file extension including the dot (internal helper) - */ -function getFileExtension(filePath: string): string { - const match = filePath.match(/\.[^.]*$/); - return match ? match[0].toLowerCase() : ""; -} - -/** - * Normalize path separators to forward slashes for cross-platform consistency - */ -function normalizePathSeparators(p: string): string { - return p.replace(/\\/g, "/"); -} - -/** - * Enhanced text file detection with developer file support - */ -export async function isEnhancedTextFile(filePath: string): Promise { - // Force certain extensions to be treated as text - if (shouldForceAsText(filePath)) { - return true; - } - - // Check MIME type - const mimeType = getEnhancedMimeType(filePath); - if (isTextMimeType(mimeType)) { - return true; - } - - // If it's a known binary type (but not the generic fallback), don't fall back to content detection - if (isBinaryMimeType(mimeType) && mimeType !== "application/octet-stream") { - return false; - } - - // For generic octet-stream or unknown types, use content-based detection - return isTextByContent(filePath); -} - -/** - * Check if MIME type indicates text content - */ -function isTextMimeType(mimeType: string): boolean { - return ( - mimeType.startsWith("text/") || - mimeType === "application/json" || - mimeType === "application/xml" || - mimeType === "application/javascript" || - mimeType === "application/typescript" || - mimeType === "application/toml" || - mimeType.includes("javascript") || - mimeType.includes("typescript") || - mimeType.includes("json") || - mimeType.includes("xml") - ); -} - -/** - * Check if MIME type indicates binary content - */ -function isBinaryMimeType(mimeType: string): boolean { - return ( - mimeType.startsWith("image/") || - mimeType.startsWith("video/") || - mimeType.startsWith("audio/") || - mimeType.startsWith("font/") || - mimeType === "application/zip" || - mimeType === "application/pdf" || - mimeType === "application/octet-stream" || - mimeType === "application/wasm" || - mimeType.includes("binary") - ); -} - -/** - * Content-based text detection (fallback method) - */ -async function isTextByContent(filePath: string): Promise { - try { - const fs = await import("fs/promises"); - - // Sample first 8KB to detect binary content - const handle = await fs.open(filePath, "r"); - const stats = await handle.stat(); - const sampleSize = Math.min(8192, stats.size); - - if (sampleSize === 0) { - await handle.close(); - return true; // Empty file is text - } - - const buffer = Buffer.alloc(sampleSize); - await handle.read(buffer, 0, sampleSize, 0); - await handle.close(); - - // Check for null bytes which indicate binary content - return !buffer.includes(0); - } catch { - return false; - } -} diff --git a/src/utils/network-sync.ts b/src/utils/network-sync.ts deleted file mode 100644 index 8651193..0000000 --- a/src/utils/network-sync.ts +++ /dev/null @@ -1,343 +0,0 @@ -import { - DocHandle, - Repo, - AutomergeUrl, -} from "@automerge/automerge-repo"; -import * as A from "@automerge/automerge"; -import { out } from "./output"; -import { DirectoryDocument } from "../types"; -import { getPlainUrl } from "./directory"; - -const isDebug = !!process.env.DEBUG; -function debug(...args: any[]) { - if (isDebug) console.error("[pushwork:sync]", ...args); -} - -/** - * Wait for bidirectional sync to stabilize. - * This function waits until document heads stop changing, indicating that - * both outgoing and incoming sync has completed. - * - * @param repo - The Automerge repository - * @param rootDirectoryUrl - The root directory URL to start traversal from - * @param options - Configuration options - */ -export async function waitForBidirectionalSync( - repo: Repo, - rootDirectoryUrl: AutomergeUrl | undefined, - options: { - timeoutMs?: number; - pollIntervalMs?: number; - stableChecksRequired?: number; - minWaitMs?: number; - handles?: DocHandle[]; - } = {}, -): Promise { - const { - timeoutMs = 10000, - pollIntervalMs = 100, - stableChecksRequired = 3, - // Head-stability alone is a weak signal: if the network hasn't pushed - // anything yet, heads stay "stable" trivially. Require a minimum elapsed - // time so the sync server has a chance to relay changes from peers. - minWaitMs = 2000, - handles, - } = options; - - if (!rootDirectoryUrl) { - return; - } - - const startTime = Date.now(); - let lastSeenHeads = new Map(); - let stableCount = 0; - let pollCount = 0; - let dynamicTimeoutMs = timeoutMs; - - debug(`waitForBidirectionalSync: starting (timeout=${timeoutMs}ms, stableChecks=${stableChecksRequired}${handles ? `, tracking ${handles.length} handles` : ', full tree scan'})`); - - while (Date.now() - startTime < dynamicTimeoutMs) { - pollCount++; - // Get current heads: use provided handles if available, otherwise full tree scan - const currentHeads = handles - ? getHandleHeads(handles) - : await getAllDocumentHeads(repo, rootDirectoryUrl); - - // After first scan: scale timeout to tree size and reset the clock. - // The first scan is just establishing a baseline — its duration - // shouldn't count against the stability-wait timeout. - if (pollCount === 1) { - const scanDuration = Date.now() - startTime; - dynamicTimeoutMs = Math.max(timeoutMs, 5000 + currentHeads.size * 50) + scanDuration; - debug(`waitForBidirectionalSync: first scan took ${scanDuration}ms, timeout now ${dynamicTimeoutMs}ms for ${currentHeads.size} docs`); - } - - // Check if heads are stable (no changes since last check) - const isStable = headsMapEqual(lastSeenHeads, currentHeads); - - if (isStable) { - stableCount++; - const elapsed = Date.now() - startTime; - debug(`waitForBidirectionalSync: stable check ${stableCount}/${stableChecksRequired} (${currentHeads.size} docs, poll #${pollCount}, ${elapsed}ms elapsed)`); - if (stableCount >= stableChecksRequired && elapsed >= minWaitMs) { - debug(`waitForBidirectionalSync: converged in ${elapsed}ms after ${pollCount} polls (${currentHeads.size} docs)`); - out.taskLine(`Bidirectional sync converged (${currentHeads.size} docs, ${elapsed}ms)`); - return; // Converged! - } - } else { - // Find which docs changed - if (lastSeenHeads.size > 0) { - const changedDocs: string[] = []; - for (const [url, heads] of currentHeads) { - if (lastSeenHeads.get(url) !== heads) { - changedDocs.push(url); - } - } - const newDocs = currentHeads.size - lastSeenHeads.size; - if (newDocs > 0) { - debug(`waitForBidirectionalSync: ${newDocs} new docs discovered, ${changedDocs.length} docs changed heads (poll #${pollCount})`); - } else if (changedDocs.length > 0) { - debug(`waitForBidirectionalSync: ${changedDocs.length} docs changed heads: ${changedDocs.slice(0, 5).join(", ")}${changedDocs.length > 5 ? ` ...and ${changedDocs.length - 5} more` : ""} (poll #${pollCount})`); - } - } else { - debug(`waitForBidirectionalSync: initial scan found ${currentHeads.size} docs (poll #${pollCount})`); - } - if (stableCount > 0) { - debug(`waitForBidirectionalSync: heads changed after ${stableCount} stable checks, resetting`); - } - stableCount = 0; - lastSeenHeads = currentHeads; - } - - await new Promise((r) => setTimeout(r, pollIntervalMs)); - } - - // Timeout - but don't throw, just log a warning - // The sync may still work, we just couldn't confirm stability - const elapsed = Date.now() - startTime; - debug(`waitForBidirectionalSync: timed out after ${elapsed}ms (${pollCount} polls, ${lastSeenHeads.size} docs tracked, reached ${stableCount}/${stableChecksRequired} stable checks)`); - out.taskLine(`Bidirectional sync timed out after ${(elapsed / 1000).toFixed(1)}s - document heads were still changing after ${pollCount} checks across ${lastSeenHeads.size} docs (reached ${stableCount}/${stableChecksRequired} stability checks). This may mean another peer is actively editing, or the sync server is slow to relay changes. The sync will continue but some remote changes may not be reflected yet.`, true); -} - -/** - * Get heads from a pre-collected set of handles (cheap, synchronous reads). - * Used for post-push stabilization where we already know which documents changed. - */ -function getHandleHeads( - handles: DocHandle[], -): Map { - const heads = new Map(); - for (const handle of handles) { - heads.set(getPlainUrl(handle.url), JSON.stringify(handle.heads())); - } - return heads; -} - -/** - * Get all document heads in the directory hierarchy. - * Returns a map of document URL -> serialized heads. - * Uses plain URLs (without heads) to ensure we see current document state. - */ -async function getAllDocumentHeads( - repo: Repo, - rootDirectoryUrl: AutomergeUrl, -): Promise> { - const heads = new Map(); - // Pass URL as-is; collectHeadsRecursive will strip heads - await collectHeadsRecursive(repo, rootDirectoryUrl, heads); - return heads; -} - -/** - * Recursively collect document heads from the directory hierarchy. - * Uses getPlainUrl to strip heads and always see the CURRENT state of documents. - */ -async function collectHeadsRecursive( - repo: Repo, - directoryUrl: AutomergeUrl, - heads: Map, -): Promise { - try { - const plainUrl = getPlainUrl(directoryUrl); - const handle = await repo.find(plainUrl); - const doc = await handle.doc(); - - // Record this directory's heads (use plain URL as key for consistency) - heads.set(plainUrl, JSON.stringify(handle.heads())); - - if (!doc || !doc.docs) { - return; - } - - // Process all entries in the directory concurrently - await Promise.all(doc.docs.map(async (entry: { type: string; url: AutomergeUrl; name: string }) => { - if (entry.type === "folder") { - // Recurse into subdirectory (entry.url may have stale heads) - await collectHeadsRecursive(repo, entry.url, heads); - } else if (entry.type === "file") { - // Get file document heads (strip heads from entry.url) - try { - const fileUrl = getPlainUrl(entry.url); - const fileHandle = await repo.find(fileUrl); - heads.set(fileUrl, JSON.stringify(fileHandle.heads())); - } catch { - // File document may not exist yet - } - } - })); - } catch { - // Directory may not exist yet - } -} - -/** - * Compare two heads maps for equality. - */ -function headsMapEqual( - a: Map, - b: Map, -): boolean { - if (a.size !== b.size) { - return false; - } - for (const [key, value] of a) { - if (b.get(key) !== value) { - return false; - } - } - return true; -} - -/** - * Result of waitForSync — lists which handles failed to sync. - */ -export interface SyncWaitResult { - failed: DocHandle[]; -} - -/** - * Wait for a single doc handle until we have positive confirmation that the - * remote sync server holds the handle's current heads. - * - * Two signals can resolve us: - * 1. A `remote-heads` event whose heads match the handle's current local - * heads. This is the strict signal — fires from `SyncStateTracker` in - * WebSocket mode when the server reports its sync state. (We accept any - * storageId; pushwork only configures one upstream peer.) - * 2. Head stability: heads remain unchanged for STABLE_REQUIRED consecutive - * polls. This is the fallback used when the strict signal isn't - * available — notably in Subduction mode, where direct-peer head reports - * feed `handleImmediateRemoteHeadsChanged` (which stores them but does - * not currently emit `remote-heads-changed`). The Subduction source has - * already saved + sync'd, so stability tells us "no further outbound or - * inbound activity for this doc". - * - * If local heads change mid-wait (e.g. an incoming merge), we reset the - * stability counter and wait for confirmation of the new heads. - */ -const POLL_INTERVAL_MS = 100; -const STABLE_REQUIRED = 3; - -function waitForHandleSync( - handle: DocHandle, - timeoutMs: number, - startTime: number, -): Promise> { - return new Promise>((resolve, reject) => { - let lastHeadsKey = JSON.stringify(handle.heads()); - let stableCount = 0; - let pollInterval: NodeJS.Timeout; - - const cleanup = () => { - clearTimeout(timeout); - clearInterval(pollInterval); - handle.off("remote-heads", onRemoteHeads); - }; - - const onConfirmed = (reason: string) => { - debug(`waitForSync: ${handle.url}... ${reason} in ${Date.now() - startTime}ms`); - cleanup(); - resolve(handle); - }; - - const onRemoteHeads = ({ heads }: { storageId: unknown; heads: unknown }) => { - if (A.equals(handle.heads(), heads as any)) { - onConfirmed("server confirmed"); - } - }; - - pollInterval = setInterval(() => { - const currentKey = JSON.stringify(handle.heads()); - if (currentKey === lastHeadsKey) { - stableCount++; - if (stableCount >= STABLE_REQUIRED) { - onConfirmed("stable"); - } - } else { - stableCount = 0; - lastHeadsKey = currentKey; - } - }, POLL_INTERVAL_MS); - - const timeout = setTimeout(() => { - debug(`waitForSync: ${handle.url}... timed out after ${timeoutMs}ms`); - cleanup(); - reject(handle); - }, timeoutMs); - - handle.on("remote-heads", onRemoteHeads); - }); -} - -/** - * Wait until the remote sync server confirms it has the current heads of - * every passed-in handle. Returns failed handles instead of throwing so - * callers can attempt recovery (e.g. recreating documents). - * - * Confirmation comes from `remote-heads` events emitted on the handle when - * a peer reports their heads. With `enableRemoteHeadsGossiping: true` (set - * in repo-factory), Subduction's onRemoteHeadsChanged callback feeds these - * events, and the legacy WebSocket sync path emits them directly via - * SyncStateTracker. The peer's storageId is included in the event payload - * but we don't filter on it: pushwork connects only to the configured sync - * server, so any remote-heads event for a handle is the server confirming. - */ -export async function waitForSync( - handlesToWaitOn: DocHandle[], - timeoutMs: number = 60000, -): Promise { - const startTime = Date.now(); - - if (handlesToWaitOn.length === 0) { - debug("waitForSync: no documents to sync"); - return { failed: [] }; - } - - debug(`waitForSync: waiting for ${handlesToWaitOn.length} documents (timeout=${timeoutMs}ms)`); - out.taskLine(`Waiting for ${handlesToWaitOn.length} documents to sync`); - - const results = await Promise.allSettled( - handlesToWaitOn.map(handle => waitForHandleSync(handle, timeoutMs, startTime)) - ); - - const failed: DocHandle[] = []; - let synced = 0; - for (const result of results) { - if (result.status === "rejected") { - failed.push(result.reason as DocHandle); - } else { - synced++; - } - } - - const elapsed = Date.now() - startTime; - if (failed.length > 0) { - debug(`waitForSync: ${failed.length} documents failed after ${elapsed}ms`); - out.taskLine(`Upload: ${synced} synced, ${failed.length} failed after ${(elapsed / 1000).toFixed(1)}s`, true); - } else { - debug(`waitForSync: all ${handlesToWaitOn.length} documents synced in ${elapsed}ms`); - out.taskLine(`All ${handlesToWaitOn.length} documents confirmed by server (${(elapsed / 1000).toFixed(1)}s)`); - } - - return { failed }; -} diff --git a/src/utils/output.ts b/src/utils/output.ts deleted file mode 100644 index ad6812b..0000000 --- a/src/utils/output.ts +++ /dev/null @@ -1,450 +0,0 @@ -import chalk from "chalk"; -import ora, { Ora } from "ora"; - -/** - * Clean terminal output manager (Singleton) - * - Progress stays on one line (spinner updates in place) - * - No emojis - * - Background colors for section headers - * - Minimal output - * - Supports scrolling task lines (max-lines) - */ -export class Output { - private static instance: Output | null = null; - private spinner: Ora | null = null; - private taskStartTime: number | null = null; - private taskOriginalMessage: string | null = null; // Original task message for done() - private taskCurrentMessage: string | null = null; // Current display message (can be updated) - private taskLines: string[] = []; // Lines written during active task - private taskMaxLines: number = 0; // 0 = unlimited - - private constructor() {} - - /** - * Get the singleton instance - */ - static getInstance(): Output { - if (!Output.instance) { - Output.instance = new Output(); - } - return Output.instance; - } - - /** - * Reset the singleton (useful for testing) - */ - static reset(): void { - if (Output.instance?.spinner) { - Output.instance.spinner.stop(); - Output.instance.spinner.clear(); - } - Output.instance = null; - } - - /** - * Start a task with spinner - updates in place - * Completes any previous task before starting the new one - * @param message - The task message - * @param maxLines - Maximum number of task lines to show (0 = unlimited, lines scroll) - */ - task(message: string, maxLines: number = 0): void { - // Complete any existing task first - if (this.spinner) { - this.done(); - } - - this.taskStartTime = Date.now(); - this.taskOriginalMessage = message; - this.taskCurrentMessage = message; - this.taskMaxLines = maxLines; - this.taskLines = []; - this.spinner = ora(message).start(); - } - - /** - * Update spinner text (stays on same line) - */ - update(message: string): void { - if (this.spinner) { - this.taskCurrentMessage = message; - this.#updateTaskDisplay(); - } - } - - /** - * Add a line to the active task (appears below spinner, scrolls if max-lines set) - * Lines are dimmed and temporary - they disappear when task completes unless kept - * If no task is active, displays as a regular log message - */ - taskLine(message: string, keepOnComplete: boolean = false): void { - if (!this.spinner) { - // No active task, just log normally as regular output - this.info(message); - return; - } - - // Add to task lines buffer with keep flag - this.taskLines.push(keepOnComplete ? `[keep]${message}` : message); - - // If max lines set, trim from the start (scroll) - if (this.taskMaxLines > 0 && this.taskLines.length > this.taskMaxLines) { - this.taskLines = this.taskLines.slice(-this.taskMaxLines); - } - - this.#updateTaskDisplay(); - } - - /** - * Clear all task lines (useful when you want to reset the scrolling window) - */ - clearTaskLines(): void { - this.taskLines = []; - this.#updateTaskDisplay(); - } - - /** - * Update the task display (spinner + task lines) - * Uses ora's multiline text support to keep spinner at top with lines below - */ - #updateTaskDisplay(): void { - if (!this.spinner) return; - - const currentText = - this.taskCurrentMessage || this.spinner.text.split("\n")[0] || ""; - - // If no task lines, show just the spinner message - if (this.taskLines.length === 0) { - this.spinner.text = currentText; - return; - } - - // Build multiline text: spinner message + task lines below - const taskLinesText = this.taskLines - .map((line) => { - const cleanLine = line.startsWith("[keep]") ? line.slice(6) : line; - return chalk.dim(` ${cleanLine}`); - }) - .join("\n"); - - // Set spinner text to include task lines (ora handles multiline rendering) - this.spinner.text = `${currentText}\n${taskLinesText}`; - } - - /** - * Complete task with optional duration display - * Defaults to showing the original task message with duration - * Task lines marked with keepOnComplete will be preserved, others are cleared - */ - done(message?: string, showTime: boolean = true): void { - if (!this.spinner) return; - - let text = message || this.taskOriginalMessage || "done"; - if (showTime && this.taskStartTime) { - const durationMs = Date.now() - this.taskStartTime; - const durationText = (() => { - switch (true) { - case durationMs < 1000: - return `${durationMs}ms`; - case durationMs < 2000: - return `${(durationMs / 1000).toFixed(2)}s`; - default: - return `${(durationMs / 1000).toFixed(1)}s`; - } - })(); - text += chalk.dim(` (${durationText})`); - } - - // Clear multiline text and set to just completion message - this.spinner.text = text; - this.spinner.succeed(); - this.spinner = null; - - // Print kept task lines after completion - const keptLines = this.taskLines.filter((line) => - line.startsWith("[keep]") - ); - for (const line of keptLines) { - console.log(chalk.dim(` ${line.slice(6)}`)); - } - - this.taskStartTime = null; - this.taskOriginalMessage = null; - this.taskCurrentMessage = null; - this.taskLines = []; - this.taskMaxLines = 0; - } - - /** - * Show an object as a table of key-value pairs - * Filters out undefined values and applies optional transforms - * Automatically calculates key padding from max key length - */ - obj( - obj: Record, - keyTransform?: (key: string) => string, - valueTransform?: (value: any, key: string) => string - ): void { - this.#stopTask(); - - // Filter out undefined values and apply key transform - const entries: Array<[string, string, any]> = []; - for (const [key, value] of Object.entries(obj)) { - if (value === undefined) continue; - const displayKey = keyTransform ? keyTransform(key) : key; - entries.push([key, displayKey, value]); - } - - // Calculate max key length for padding - const maxKeyLength = Math.max( - ...entries.map(([, displayKey]) => displayKey.length) - ); - - // Print each entry - for (const [key, displayKey, value] of entries) { - const displayValue = valueTransform - ? valueTransform(value, key) - : String(value); - const keyFormatted = chalk.dim(displayKey.padEnd(maxKeyLength + 2)); - console.log(`${keyFormatted}${displayValue}`); - } - } - - /** - * Display array as bulleted list - * Each item shown with dim bullet and white text - */ - arr(items: any[]): void { - this.#stopTask(); - - for (const item of items) { - const bullet = chalk.dim("• "); - console.log(`${bullet}${String(item)}`); - } - } - - /** - * Show plain message with optional color - */ - log( - message: string, - color?: - | "red" - | "green" - | "yellow" - | "blue" - | "cyan" - | "magenta" - | "gray" - | "dim" - ): void { - this.#stopTask(); - - if (color) { - const colorFn = color === "dim" ? chalk.dim : chalk[color]; - console.log(colorFn(message)); - } else { - console.log(message); - } - } - - /** - * Show success message (green text) - */ - success(message: string): void { - this.#stopTask(); - console.log(chalk.green(message)); - } - - /** - * Show success block (green background label + optional message) - */ - successBlock(label: string, message: string = ""): void { - this.#stopTask(); - console.log( - `\n${chalk.bgGreen.black(` ${label} `)}${message && ` ${message}`}` - ); - } - - /** - * Show success message (green text) - */ - spicy(message: string): void { - this.#stopTask(); - console.log(chalk.cyan(message)); - } - - /** - * Show success block (green background label + optional message) - */ - spicyBlock(label: string, message: string = ""): void { - this.#stopTask(); - console.log( - `\n${chalk.bgCyan.black(` ${label} `)}${message && ` ${message}`}` - ); - } - - /** - * Show message with rainbow gradient - */ - rainbow(message: string): void { - this.#stopTask(); - - // Rainbow colors in order - const colors = [ - chalk.red, - chalk.rgb(255, 165, 0), // orange - chalk.yellow, - chalk.green, - chalk.cyan, - chalk.blue, - chalk.magenta, - ]; - - const chars = message.split(""); - const colorCount = colors.length; - - // Spread colors across the string - const rainbow = chars - .map((char, i) => { - // Calculate which color to use based on position - const colorIndex = Math.floor((i / chars.length) * colorCount); - const color = colors[Math.min(colorIndex, colorCount - 1)]; - return color(char); - }) - .join(""); - - console.log(rainbow); - } - - /** - * Show info message (dim text) - */ - info(message: string): void { - this.#stopTask(); - console.log(chalk.dim(message)); - } - - /** - * Show info block (grey background label + optional message) - */ - infoBlock(label: string, message: string = ""): void { - this.#stopTask(); - console.log( - `\n${chalk.bgGrey.white(` ${label} `)}${message && ` ${message}`}` - ); - } - - /** - * Show error message (red text) - fails spinner if running - */ - error(message: string | Error | unknown): void { - if (this.spinner) { - this.spinner.fail("failed"); - this.spinner = null; - this.taskStartTime = null; - this.taskOriginalMessage = null; - this.taskCurrentMessage = null; - } - console.error( - chalk.red( - message instanceof Error - ? message.message - : message instanceof Object - ? JSON.stringify(message) - : String(message) - ) - ); - } - - /** - * Show error block (red background label + optional message) - fails spinner if running - */ - errorBlock(label: string, message: string = ""): void { - if (this.spinner) { - this.spinner.fail("failed"); - this.spinner = null; - this.taskStartTime = null; - this.taskOriginalMessage = null; - this.taskCurrentMessage = null; - } - console.error( - `\n${chalk.bgRed.white(` ${label} `)}${message && ` ${message}`}` - ); - } - - /** - * Show warning message (yellow text) - */ - warn(message: string): void { - this.#stopTask(); - console.log(chalk.yellow(message)); - } - - /** - * Show warning block (yellow background label + optional message) - */ - warnBlock(label: string, message: string = ""): void { - this.#stopTask(); - console.log( - `\n${chalk.bgYellow.black(` ${label} `)}${message && ` ${message}`}` - ); - } - - /** - * Show detailed error information and exit the program - * Use this when an unexpected/unrecoverable error occurs - * Shows error message and stack trace, then exits - */ - crash(error: unknown, exitCode: number = 1): never { - this.#stopTask(); - - if (error instanceof Error) { - // Error type and message - console.error(chalk.red(`${error.name}: ${error.message}`)); - - // Stack trace - if (error.stack) { - console.error(""); - console.error(chalk.dim("Stack trace:")); - const stackLines = error.stack.split("\n").slice(1); // Skip first line (error message) - stackLines.forEach((line) => - console.error(chalk.dim(` ${line.trim()}`)) - ); - } - } else { - console.error(chalk.red(String(error))); - } - - process.exit(exitCode); - } - - /** - * Exit with code - */ - exit(code?: number): never { - this.#stopTask(); - process.exit(code || 0); - } - - /** - * Stop spinner without showing result - */ - #stopTask(): void { - if (this.spinner) { - this.spinner.stop(); - this.spinner.clear(); - this.spinner = null; - } - this.taskStartTime = null; - this.taskOriginalMessage = null; - this.taskCurrentMessage = null; - this.taskLines = []; - this.taskMaxLines = 0; - } -} - -/** - * Global singleton output instance - * Import and use this anywhere in your code - */ -export const out = Output.getInstance(); diff --git a/src/utils/repo-factory.ts b/src/utils/repo-factory.ts deleted file mode 100644 index 17b9f2c..0000000 --- a/src/utils/repo-factory.ts +++ /dev/null @@ -1,149 +0,0 @@ -import { type Repo, type RepoConfig, type NetworkAdapterInterface } from "@automerge/automerge-repo"; -import { NodeFSStorageAdapter } from "@automerge/automerge-repo-storage-nodefs"; -import * as fs from "fs/promises"; -import * as path from "path"; -import { DirectoryConfig } from "../types"; - -/** - * Perform a real ESM dynamic import that tsc won't rewrite to require(). - * - * TypeScript with `"module": "commonjs"` compiles `await import("x")` to - * `require("x")`, which resolves CJS entries instead of ESM entries. The - * Wasm module instance is different between the CJS and ESM module graphs, - * so initializing via CJS require() doesn't help the ESM /slim imports - * inside automerge-repo. - * - * This helper uses `new Function` to create a real `import()` expression - * that Node.js evaluates as ESM, sharing the same module graph as the - * Repo's internal imports. - */ -const dynamicImport = new Function("specifier", "return import(specifier)") as ( - specifier: string, -) => Promise; - -/** - * Initialize the Subduction Wasm module and return the Repo constructor. - * - * The Repo constructor calls set_subduction_logger() and new MemorySigner() - * from @automerge/automerge-subduction/slim, which require the Wasm module - * to be initialized first. automerge-repo exports initSubduction() to - * handle this — it dynamically imports the non-/slim entry (which - * auto-initializes the Wasm as a side effect). - * - * Both the Repo and initSubduction must be loaded via ESM dynamic import() - * so they share the same module graph as the Repo's internal /slim imports. - */ -let cachedRepoClass: typeof Repo | undefined; - -async function getRepoClass(): Promise { - if (cachedRepoClass) return cachedRepoClass; - - // Import Repo and initialize Subduction Wasm via automerge-repo's - // initSubduction() helper. This must happen before new Repo() because - // the constructor calls set_subduction_logger() and new MemorySigner() - // which require the Wasm module to be ready. - // - // Both imports use the ESM dynamic import wrapper so they share the - // same module graph as the Repo's internal /slim imports. - const repoMod = await dynamicImport("@automerge/automerge-repo"); - await repoMod.initSubduction(); - cachedRepoClass = repoMod.Repo as typeof Repo; - return cachedRepoClass; -} - -/** - * Scan a directory tree for 0-byte files, which indicate incomplete writes - * from a previous run (process exited before storage flushed). Returns true - * if any are found. - */ -async function hasCorruptStorage(dir: string): Promise { - try { - await fs.access(dir); - } catch { - return false; - } - - const entries = await fs.readdir(dir, { withFileTypes: true }); - for (const entry of entries) { - const fullPath = path.join(dir, entry.name); - if (entry.isDirectory()) { - if (await hasCorruptStorage(fullPath)) return true; - } else if (entry.isFile()) { - const stat = await fs.stat(fullPath); - if (stat.size === 0) return true; - } - } - return false; -} - -/** - * 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. - */ -export async function createRepo( - workingDir: string, - config: DirectoryConfig, - sub: boolean = false -): Promise { - const RepoClass = await getRepoClass(); - - const syncToolDir = path.join(workingDir, ".pushwork"); - const automergeDir = path.join(syncToolDir, "automerge"); - - // Detect and recover from corrupt local storage (0-byte files left by - // incomplete writes from a previous run). Wipe the cache so the Repo - // hydrates cleanly from the sync server. - if (await hasCorruptStorage(automergeDir)) { - console.warn("[pushwork] Corrupt local storage detected, clearing cache..."); - await fs.rm(automergeDir, { recursive: true, force: true }); - await fs.mkdir(automergeDir, { recursive: true }); - } - - const storage = new NodeFSStorageAdapter(automergeDir); - - if (sub) { - const endpoints: string[] = []; - if (config.sync_enabled && config.sync_server) { - endpoints.push(config.sync_server); - } - - return new RepoClass({ - storage, - subductionWebsocketEndpoints: endpoints, - // Enable so Subduction's onRemoteHeadsChanged is wired up and - // remote-heads events fire on doc handles. waitForSync uses these - // events to confirm the sync server has our heads before returning. - enableRemoteHeadsGossiping: true, - }); - } - - // Default: WebSocket sync adapter - const repoConfig: RepoConfig = { - storage, - enableRemoteHeadsGossiping: true, - }; - - if (config.sync_enabled && config.sync_server) { - // Load the WebSocket adapter via ESM dynamic import to stay in the - // same module graph as the Repo. - const wsMod = await dynamicImport("@automerge/automerge-repo-network-websocket"); - // The websocket adapter package (subduction.8) hasn't updated its - // NetworkAdapter base-class types to match the repo's new - // NetworkAdapterInterface (which added state() and stricter - // EventEmitter generics). At runtime the adapter has all required - // methods; this is purely a declaration mismatch. - const networkAdapter = new wsMod.BrowserWebSocketClientAdapter( - config.sync_server - ) as unknown as NetworkAdapterInterface; - repoConfig.network = [networkAdapter]; - } - - return new RepoClass(repoConfig); -} diff --git a/src/utils/string-similarity.ts b/src/utils/string-similarity.ts deleted file mode 100644 index 2acf2a9..0000000 --- a/src/utils/string-similarity.ts +++ /dev/null @@ -1,54 +0,0 @@ -/* Based on the Sørensen–Dice coefficient, code from https://github.com/stephenjjbrown/string-similarity-js */ - -/** - - * Calculate similarity between two strings - - * @param {string} str1 First string to match - * @param {string} str2 Second string to match - * @param {number} [substringLength=2] Optional. Length of substring to be used in calculating similarity. Default 2. - * @param {boolean} [caseSensitive=false] Optional. Whether you want to consider case in string matching. Default false; - - * @returns Number between 0 and 1, with 0 being a low match score. - - */ - -export const stringSimilarity = ( - str1: string, - str2: string, - substringLength: number = 2, - caseSensitive: boolean = false -) => { - if (str1 === str2) return 1; - if (!caseSensitive) { - str1 = str1.toLowerCase(); - - str2 = str2.toLowerCase(); - } - - if (str1.length < substringLength || str2.length < substringLength) return 0; - - const map = new Map(); - - for (let i = 0; i < str1.length - (substringLength - 1); i++) { - const substr1 = str1.substring(i, i + substringLength); - - map.set(substr1, map.has(substr1) ? map.get(substr1) + 1 : 1); - } - - let match = 0; - - for (let j = 0; j < str2.length - (substringLength - 1); j++) { - const substr2 = str2.substring(j, j + substringLength); - - const count = map.has(substr2) ? map.get(substr2) : 0; - - if (count > 0) { - map.set(substr2, count - 1); - - match++; - } - } - - return (match * 2) / (str1.length + str2.length - (substringLength - 1) * 2); -}; diff --git a/src/utils/text-diff.ts b/src/utils/text-diff.ts deleted file mode 100644 index 8d84cad..0000000 --- a/src/utils/text-diff.ts +++ /dev/null @@ -1,101 +0,0 @@ -import * as A from "@automerge/automerge" -import * as diffLib from "diff" - -/** - * Read content from an Automerge document, normalizing legacy ImmutableString - * values to plain strings for backwards compatibility. - * - * Old documents may store text as ImmutableString. This helper ensures callers - * always get back `string | Uint8Array | null`. - */ -export function readDocContent(content: unknown): string | Uint8Array | null { - if (content == null) return null - if (typeof content === "string") return content - if (content instanceof Uint8Array) return content - // Legacy ImmutableString — convert to plain string - if (A.isImmutableString(content)) return content.toString() - return null -} - -/** - * Update text content on an Automerge document property inside a change - * callback. - * - * If the existing value is already a collaborative text string, we diff and - * splice for minimal CRDT operations. If the existing value is a legacy - * ImmutableString we can't splice into it, so we assign the whole string - * which converts the field to a collaborative text CRDT going forward. - * - * @param doc - The mutable Automerge document (inside a change callback) - * @param path - Property path to the text field, e.g. ["content"] - * @param newContent - The desired new text value - */ -export function updateTextContent( - doc: any, - path: A.Prop[], - newContent: string -): void { - const target = path.reduce((obj: any, key) => obj?.[key], doc) - - if (typeof target === "string") { - // Already a collaborative text string — diff and splice - spliceText(doc, path, target, newContent) - } else { - // Legacy ImmutableString, undefined, or other — assign directly. - // This converts the field to a collaborative text CRDT. - let obj: any = doc - for (let i = 0; i < path.length - 1; i++) { - obj = obj[path[i]] - } - obj[path[path.length - 1]] = newContent - } -} - -/** - * Apply a text diff between oldContent and newContent as Automerge splice - * operations on the given document property path. - * - * This preserves the collaborative text CRDT structure by making minimal - * character-level edits rather than replacing the entire string. - * - * @param doc - The Automerge document (inside a change callback) - * @param path - The property path to the text field, e.g. ["content"] - * @param oldContent - The previous text content - * @param newContent - The desired new text content - */ -export function spliceText( - doc: any, - path: A.Prop[], - oldContent: string, - newContent: string -): void { - if (oldContent === newContent) return - - // Fast path: if old is empty, just insert everything - if (oldContent === "") { - A.splice(doc, path, 0, 0, newContent) - return - } - - // Fast path: if new is empty, just delete everything - if (newContent === "") { - A.splice(doc, path, 0, oldContent.length) - return - } - - const changes = diffLib.diffChars(oldContent, newContent) - - let pos = 0 - for (const part of changes) { - if (part.removed) { - A.splice(doc, path, pos, part.value.length) - // Don't advance pos — text shifted left after deletion - } else if (part.added) { - A.splice(doc, path, pos, 0, part.value) - pos += part.value.length - } else { - // Unchanged text — just advance the cursor - pos += part.value.length - } - } -} diff --git a/src/utils/trace.ts b/src/utils/trace.ts deleted file mode 100644 index b1ad832..0000000 --- a/src/utils/trace.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { out } from "./output"; - -/** - * Global tracing state - */ -let tracingEnabled = false; - -/** - * Enable or disable tracing - */ -export function setTracingEnabled(enabled: boolean): void { - tracingEnabled = enabled; -} - -/** - * Check if tracing is enabled - */ -export function isTracingEnabled(): boolean { - return tracingEnabled; -} - -/** - * Trace a span of work by outputting to console - * Works for both sync and async functions - * Only outputs if tracing is enabled - * - * Usage: - * await span("operation", async () => { ... }) - * span("operation", () => { ... }) - */ -export function span( - name: string, - fn: () => T | Promise -): T | Promise { - if (!tracingEnabled) { - return fn(); - } - - const start = performance.now(); - const result = fn(); - - // Check if it's a promise (async) - if (result instanceof Promise) { - return result.then((value) => { - const duration = performance.now() - start; - out.taskLine(`${name} (${formatDuration(duration)})`, true); - return value; - }) as T; - } - - // Sync case - const duration = performance.now() - start; - out.taskLine(`${name} (${formatDuration(duration)})`, true); - return result; -} - -/** - * Format duration for display - */ -function formatDuration(ms: number): string { - if (ms < 1) { - return `${ms.toFixed(2)}ms`; - } else if (ms < 1000) { - return `${Math.round(ms)}ms`; - } else if (ms < 2000) { - return `${(ms / 1000).toFixed(2)}s`; - } else { - return `${(ms / 1000).toFixed(1)}s`; - } -} diff --git a/test/integration/README.md b/test/integration/README.md deleted file mode 100644 index 0b08f4b..0000000 --- a/test/integration/README.md +++ /dev/null @@ -1,328 +0,0 @@ -# Integration Tests - -This directory contains comprehensive integration tests for the pushwork sync tool. - -## Quick Start - -From the project root directory: - -```bash -# Run all tests with the test runner -./test/run-tests.sh - -# Run specific test suites -./test/run-tests.sh clone # Clone functionality tests -./test/run-tests.sh conflict # CRDT conflict resolution tests -./test/run-tests.sh full # Full integration tests -./test/run-tests.sh unit # Unit tests -``` - -## Test Scripts - -### 1. Test Runner (`../run-tests.sh`) - -Main entry point for running all tests. Provides: - -- Dependency checking -- Multiple test suite options -- Consistent output formatting -- Error handling - -### 2. Full Integration Test (`full-integration-test.sh`) - -Comprehensive test suite covering all major functionality: - -**Features Tested:** - -- ✅ Help commands for all CLI commands -- ✅ Init with default and custom sync servers -- ✅ Clone with default and custom sync servers -- ✅ Status, diff, commit, and sync operations -- ✅ Error handling and parameter validation -- ✅ File operations (create, modify, delete) -- ✅ Bidirectional sync scenarios - -**Test Sections:** - -1. Help Commands - Verify all --help options work -2. Init Functionality - Test directory initialization -3. Status Functionality - Test status reporting -4. Commit Functionality - Test local commits -5. Sync Functionality - Test sync operations -6. Diff Functionality - Test change detection -7. Clone Functionality - Test cloning repositories -8. Bidirectional Sync - Test multi-directory sync -9. File Operations - Test various file types and operations - -### 3. Clone Test (`clone-test.sh`) - -Focused test suite specifically for clone functionality: - -**Features Tested:** - -- ✅ Clone with default sync server settings -- ✅ Clone with custom sync server and storage ID -- ✅ Parameter validation (sync server options must be used together) -- ✅ Force overwrite functionality -- ✅ Configuration verification in cloned repositories -- ✅ Error handling for invalid scenarios -- ✅ Status and diff operations in cloned directories - -**Test Sections:** - -1. Clone Functionality - All clone scenarios -2. Cloned Directory Status - Operations in cloned repos -3. Configuration Comparison - Verify settings propagation - -### 4. CRDT Conflict Resolution Test (`conflict-resolution-test.sh`) - -Specialized test demonstrating pushwork's excellent CRDT-based conflict resolution capabilities: - -**Features Tested:** - -- ✅ Create repository with initial document -- ✅ Clone repository to second location -- ✅ Make simultaneous conflicting edits on both sides -- ✅ Verify CRDT text merging preserves ALL changes -- ✅ Validate that no data is lost during conflicts -- ✅ Confirm true collaborative editing capabilities - -**Test Scenario:** - -1. Alice creates a document with baseline content -2. Bob clones Alice's repository -3. Both users make different additions to the same file simultaneously -4. Alice syncs her changes first -5. Bob syncs his changes (CRDT merging occurs) -6. Final sync rounds ensure eventual consistency -7. **Result**: Bob's repository contains BOTH Alice's AND Bob's changes -8. **Demonstrates**: True CRDT collaborative editing without data loss - -**Key Findings:** - -- ✅ Pushwork uses character-level CRDT text merging -- ✅ Both users' contributions are preserved automatically -- ✅ No manual conflict resolution required -- ✅ Immediate convergence to consistent state -- ✅ Sync timing issue has been resolved -- Repositories eventually converge to consistent state - -## Test Configuration - -### Required Dependencies - -- **Node.js** - For running pushwork CLI -- **npm** - For building the project - -### Optional Dependencies - -- **jq** - For advanced JSON parsing in configuration tests (tests will be skipped if not available) - -Install jq (optional): - -```bash -# macOS -brew install jq - -# Ubuntu/Debian -sudo apt-get install jq - -# Other platforms -# See: https://stedolan.github.io/jq/download/ -``` - -### Test Environment - -- Tests run in isolated temporary directories -- Automatic cleanup on completion -- No modification of project files -- Safe to run multiple times - -### Test Parameters - -```bash -# Default test configuration -TEST_DIR="/tmp/pushwork-*-test" -CUSTOM_SYNC_SERVER="ws://localhost:3030" -CUSTOM_STORAGE_ID="1d89eba7-f7a4-4e8e-80f2-5f4e2406f507" -``` - -## Understanding Test Output - -### Log Levels - -- 🔵 **[INFO]** - General information -- 🟡 **[TEST]** - Test being executed -- 🟢 **[PASS]** - Test passed -- 🔴 **[FAIL]** - Test failed -- 🟡 **[WARN]** - Warning (non-critical) - -### Test Results - -Each test script provides a summary: - -``` -====================================== -Test Results Summary -====================================== -Tests Run: 45 -Tests Passed: 43 -Tests Failed: 2 -``` - -## Running Individual Tests - -### Full Integration Test - -```bash -./test/integration/full-integration-test.sh -``` - -### Clone Test - -```bash -./test/integration/clone-test.sh -``` - -### With Verbose Output - -```bash -# Remove `> /dev/null 2>&1` redirections in scripts for verbose output -# Or modify the log functions to always show output -``` - -## Test Coverage - -### ✅ Covered Functionality - -- All CLI commands and help output -- Directory initialization (init) -- Repository cloning (clone) - **NEW** -- Sync operations (sync, status, diff, commit) -- Parameter validation -- Error handling for common scenarios -- File operations and change detection -- Configuration management -- Custom sync server support - **NEW** - -### ⚠️ Known Limitations - -- Network sync requires actual connectivity -- Some tests skip when dependencies missing (jq) -- Limited testing of concurrent operations -- Performance testing not included - -## Troubleshooting - -### Common Issues - -**"jq: command not found"** - -```bash -# Install jq (see dependencies section above) -brew install jq # macOS -``` - -**"Permission denied"** - -```bash -# Make scripts executable -chmod +x test/integration/*.sh -chmod +x test/run-tests.sh -``` - -**"Not in project directory"** - -```bash -# Run from project root where package.json exists -cd /path/to/pushwork -./test/run-tests.sh -``` - -**Tests failing unexpectedly** - -```bash -# Check if project builds -npm run build - -# Check if CLI works -node dist/cli.js --help -``` - -### Debug Mode - -To see more detailed output, modify the test scripts to remove output redirection: - -```bash -# Change this: -if $PUSHWORK_CMD init . > /dev/null 2>&1; then - -# To this: -if $PUSHWORK_CMD init .; then -``` - -## Adding New Tests - -### Test Script Template - -```bash -#!/bin/bash -set -e - -# Test configuration -TEST_DIR="/tmp/my-test" -PUSHWORK_CMD="node $(pwd)/dist/cli.js" - -# Colors and logging functions -RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m' -BLUE='\033[0;34m'; NC='\033[0m' - -log_info() { echo -e "${BLUE}[INFO]${NC} $1"; } -log_success() { echo -e "${GREEN}[PASS]${NC} $1"; } -log_error() { echo -e "${RED}[FAIL]${NC} $1"; } -log_test() { echo -e "${YELLOW}[TEST]${NC} $1"; } - -# Cleanup -cleanup() { rm -rf "$TEST_DIR"; } -trap cleanup EXIT - -# Test logic here -setup_test_environment -run_tests -``` - -### Guidelines - -1. Use consistent naming patterns -2. Include both positive and negative test cases -3. Test error conditions thoroughly -4. Provide clear test descriptions -5. Clean up resources properly -6. Use the established logging format - -## Integration with CI/CD - -These tests are designed to be run in automated environments: - -```bash -# In your CI pipeline -./test/run-tests.sh full -``` - -Exit codes: - -- `0` - All tests passed -- `1` - Some tests failed -- Non-zero - Setup or dependency errors - -## Contributing - -When adding new features to pushwork: - -1. **Add integration tests** for new CLI commands -2. **Update existing tests** if command behavior changes -3. **Test error scenarios** - not just happy paths -4. **Document test coverage** in this README -5. **Verify tests pass** before submitting PRs - -The integration tests serve as both testing and documentation for how the CLI should behave. diff --git a/test/integration/clone-test.sh b/test/integration/clone-test.sh deleted file mode 100755 index 242cb60..0000000 --- a/test/integration/clone-test.sh +++ /dev/null @@ -1,310 +0,0 @@ -#!/bin/bash - -# Focused Clone Functionality Test for Pushwork -# Tests the clone command with various sync server configurations - -set -e - -# Colors for output -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -BLUE='\033[0;34m' -NC='\033[0m' - -# Test configuration -TEST_DIR="/tmp/pushwork-clone-test" -PUSHWORK_CMD="node $(pwd)/dist/cli.js" -CUSTOM_SYNC_SERVER="ws://localhost:3030" -CUSTOM_STORAGE_ID="1d89eba7-f7a4-4e8e-80f2-5f4e2406f507" - -# Helper functions -log_info() { - echo -e "${BLUE}[INFO]${NC} $1" -} - -log_success() { - echo -e "${GREEN}[PASS]${NC} $1" -} - -log_error() { - echo -e "${RED}[FAIL]${NC} $1" -} - -log_test() { - echo -e "${YELLOW}[TEST]${NC} $1" -} - -# Cleanup function -cleanup() { - log_info "Cleaning up test directory..." - rm -rf "$TEST_DIR" -} - -# Setup function -setup() { - log_info "Setting up clone test environment..." - - # Build the project - log_info "Building pushwork..." - npm run build - - # Clean up and create test directory - rm -rf "$TEST_DIR" - mkdir -p "$TEST_DIR" - cd "$TEST_DIR" - - log_info "Test directory: $TEST_DIR" -} - -# Create a test repository to clone from -create_test_repo() { - log_info "Creating test repository..." - - mkdir source-repo - cd source-repo - - # Add some test files - echo "Hello from source repo" > hello.txt - echo "# Test Repository" > README.md - mkdir -p docs - echo "Documentation content" > docs/guide.md - - # Initialize with default settings - $PUSHWORK_CMD init . - - cd .. - - log_success "Test repository created" -} - -# Test clone functionality -test_clone_functionality() { - log_info "=== Testing Clone Functionality ===" - - cd source-repo - - # Get the root URL from the repository - if [ -f .pushwork/snapshot.json ]; then - ROOT_URL=$($PUSHWORK_CMD url .) - - if [ -n "$ROOT_URL" ]; then - cd .. - - log_test "Clone with default settings" - if $PUSHWORK_CMD clone "$ROOT_URL" clone-default; then - log_success "Clone with default settings" - - # Verify cloned content - if [ -f clone-default/hello.txt ] && [ -f clone-default/README.md ]; then - log_success "Cloned files are present" - else - log_error "Cloned files are missing" - fi - - # Check configuration - if [ -f clone-default/.pushwork/config.json ]; then - if grep -q "wss://sync3.automerge.org" clone-default/.pushwork/config.json; then - log_success "Default sync server in config" - else - log_error "Default sync server not found in config" - fi - fi - else - log_error "Clone with default settings failed" - fi - - log_test "Clone with custom sync server" - if $PUSHWORK_CMD clone "$ROOT_URL" clone-custom --sync-server "$CUSTOM_SYNC_SERVER" --sync-server-storage-id "$CUSTOM_STORAGE_ID"; then - log_success "Clone with custom sync server" - - # Verify custom configuration - if [ -f clone-custom/.pushwork/config.json ]; then - if grep -q "$CUSTOM_SYNC_SERVER" clone-custom/.pushwork/config.json; then - log_success "Custom sync server in config" - else - log_error "Custom sync server not found in config" - fi - - if grep -q "$CUSTOM_STORAGE_ID" clone-custom/.pushwork/config.json; then - log_success "Custom storage ID in config" - else - log_error "Custom storage ID not found in config" - fi - fi - else - log_error "Clone with custom sync server failed" - fi - - # Test error cases - log_test "Clone with incomplete sync server options" - - # Only sync server (should fail) - if $PUSHWORK_CMD clone "$ROOT_URL" clone-fail1 --sync-server "$CUSTOM_SYNC_SERVER" 2>/dev/null; then - log_error "Clone with only sync-server should have failed" - else - log_success "Clone correctly failed with only sync-server" - fi - - # Only storage ID (should fail) - if $PUSHWORK_CMD clone "$ROOT_URL" clone-fail2 --sync-server-storage-id "$CUSTOM_STORAGE_ID" 2>/dev/null; then - log_error "Clone with only storage-id should have failed" - else - log_success "Clone correctly failed with only storage-id" - fi - - # Test force overwrite - mkdir -p existing-dir - echo "existing content" > existing-dir/existing.txt - - log_test "Clone to non-empty directory without force" - if $PUSHWORK_CMD clone "$ROOT_URL" existing-dir 2>/dev/null; then - log_error "Clone to non-empty directory should have failed" - else - log_success "Clone correctly failed for non-empty directory" - fi - - log_test "Clone to non-empty directory with force" - if $PUSHWORK_CMD clone "$ROOT_URL" existing-dir --force; then - log_success "Clone with force succeeded" - - # Check that original files were replaced - if [ -f existing-dir/hello.txt ]; then - log_success "Force clone replaced existing content" - else - log_error "Force clone did not replace content properly" - fi - else - log_error "Clone with force failed" - fi - - else - log_error "No valid root URL found" - fi - else - log_error "Snapshot missing - repository not properly initialized" - fi - - cd .. -} - -# Test status commands in cloned directories -test_cloned_directory_status() { - log_info "=== Testing Status in Cloned Directories ===" - - if [ -d clone-default ]; then - cd clone-default - - log_test "Status in cloned directory" - if $PUSHWORK_CMD status; then - log_success "Status command works in cloned directory" - else - log_error "Status command failed in cloned directory" - fi - - # Make some changes and test - echo "Modified in clone" >> hello.txt - echo "New file in clone" > new-file.txt - - log_test "Status after changes in clone" - if $PUSHWORK_CMD status; then - log_success "Status shows changes in clone" - else - log_error "Status failed to show changes" - fi - - log_test "Diff in cloned directory" - if $PUSHWORK_CMD diff --name-only; then - log_success "Diff command works in cloned directory" - else - log_error "Diff command failed in cloned directory" - fi - - cd .. - else - log_error "Clone directory not available for status testing" - fi -} - -# Compare configurations between source and cloned repos -compare_configurations() { - log_info "=== Comparing Configurations ===" - - if [ -f source-repo/.pushwork/config.json ] && [ -f clone-default/.pushwork/config.json ]; then - log_test "Comparing default clone configuration" - - if command -v jq &> /dev/null; then - SOURCE_SYNC_SERVER=$(jq -r '.sync_server' source-repo/.pushwork/config.json 2>/dev/null || echo "") - CLONE_SYNC_SERVER=$(jq -r '.sync_server' clone-default/.pushwork/config.json 2>/dev/null || echo "") - - if [ "$SOURCE_SYNC_SERVER" = "$CLONE_SYNC_SERVER" ]; then - log_success "Sync server matches between source and clone" - else - log_error "Sync server differs: source=[$SOURCE_SYNC_SERVER] clone=[$CLONE_SYNC_SERVER]" - fi - else - log_success "Sync server comparison (jq not available)" - fi - fi - - if [ -f clone-custom/.pushwork/config.json ]; then - log_test "Verifying custom clone configuration" - - if command -v jq &> /dev/null; then - CUSTOM_CLONE_SERVER=$(jq -r '.sync_server' clone-custom/.pushwork/config.json 2>/dev/null || echo "") - CUSTOM_CLONE_STORAGE=$(jq -r '.sync_server_storage_id' clone-custom/.pushwork/config.json 2>/dev/null || echo "") - - if [ "$CUSTOM_CLONE_SERVER" = "$CUSTOM_SYNC_SERVER" ]; then - log_success "Custom sync server correctly set in clone" - else - log_error "Custom sync server incorrect: expected=[$CUSTOM_SYNC_SERVER] actual=[$CUSTOM_CLONE_SERVER]" - fi - - if [ "$CUSTOM_CLONE_STORAGE" = "$CUSTOM_STORAGE_ID" ]; then - log_success "Custom storage ID correctly set in clone" - else - log_error "Custom storage ID incorrect: expected=[$CUSTOM_STORAGE_ID] actual=[$CUSTOM_CLONE_STORAGE]" - fi - else - log_success "Custom configuration verification (jq not available)" - fi - fi -} - -# Main test execution -main() { - echo "======================================" - echo "Pushwork Clone Functionality Test" - echo "======================================" - - # Trap cleanup on exit - trap cleanup EXIT - - # Setup - setup - - # Create test repository - create_test_repo - - # Run clone tests - test_clone_functionality - test_cloned_directory_status - compare_configurations - - echo "" - echo "======================================" - echo "Clone Test Complete" - echo "======================================" - - log_success "All clone functionality tests completed!" -} - -# Check dependencies -if ! command -v jq &> /dev/null; then - log_warning "jq is not installed - some configuration tests will be skipped" - echo "To install jq: brew install jq (macOS) or apt-get install jq (Ubuntu)" - echo "" -fi - -# Run the tests -main "$@" \ No newline at end of file diff --git a/test/integration/conflict-resolution-test.sh b/test/integration/conflict-resolution-test.sh deleted file mode 100755 index 4eb4a48..0000000 --- a/test/integration/conflict-resolution-test.sh +++ /dev/null @@ -1,309 +0,0 @@ -#!/bin/bash - -# Conflict Resolution Test for Pushwork -# Tests CRDT text merging where both changes are preserved - -set -e - -# Colors for output -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -BLUE='\033[0;34m' -NC='\033[0m' - -# Test configuration -TEST_DIR="/tmp/pushwork-conflict-test" -PUSHWORK_CMD="node $(pwd)/dist/cli.js" - -# Helper functions -log_info() { - echo -e "${BLUE}[INFO]${NC} $1" -} - -log_success() { - echo -e "${GREEN}[SUCCESS]${NC} $1" -} - -log_error() { - echo -e "${RED}[ERROR]${NC} $1" -} - -log_test() { - echo -e "${YELLOW}[TEST]${NC} $1" -} - -# Cleanup function -cleanup() { - log_info "Cleaning up test directory..." - rm -rf "$TEST_DIR" -} - -# Setup function -setup() { - log_info "Setting up conflict resolution test..." - - # Build the project - log_info "Building pushwork..." - npm run build - - # Clean up and create test directory - rm -rf "$TEST_DIR" - mkdir -p "$TEST_DIR" - cd "$TEST_DIR" - - log_info "Test directory: $TEST_DIR" -} - -# Create initial repository with a test file -create_initial_repo() { - log_info "=== Creating Initial Repository ===" - - mkdir alice-repo - cd alice-repo - - # Create a simple test file - cat > document.txt << EOF -Original content -This is the baseline version. -EOF - - log_test "Initializing Alice's repository" - $PUSHWORK_CMD init . - - cd .. - log_success "Alice's repository created" -} - -# Clone the repository -clone_repository() { - log_info "=== Cloning Repository ===" - - cd alice-repo - ROOT_URL=$($PUSHWORK_CMD url .) - cd .. - - log_test "Cloning repository for Bob" - $PUSHWORK_CMD clone "$ROOT_URL" bob-repo - - log_success "Repository cloned successfully" - - # Verify initial content is identical - if cmp -s alice-repo/document.txt bob-repo/document.txt; then - log_success "Initial content is identical" - else - log_error "Initial content differs between repositories" - exit 1 - fi -} - -# Make conflicting edits -make_conflicting_edits() { - log_info "=== Making Conflicting Edits ===" - - # Alice's changes - log_test "Alice adds her content" - cd alice-repo - cat >> document.txt << EOF -Alice's addition: New feature implementation -Alice's note: This adds user authentication -EOF - - log_info "Alice's document:" - cat document.txt - echo "" - cd .. - - # Bob's changes - log_test "Bob adds different content" - cd bob-repo - cat >> document.txt << EOF -Bob's addition: Performance optimization -Bob's note: This improves response time -EOF - - log_info "Bob's document:" - cat document.txt - echo "" - cd .. -} - -# Test conflict resolution -test_conflict_resolution() { - log_info "=== Testing CRDT Conflict Resolution ===" - - # Alice syncs first - log_test "Alice syncs first" - cd alice-repo - $PUSHWORK_CMD sync - log_success "Alice's changes synced" - cd .. - - # Bob syncs (this will merge with Alice's changes) - log_test "Bob syncs (CRDT merging will occur)" - cd bob-repo - $PUSHWORK_CMD sync - log_success "Bob's sync completed" - cd .. - - # Multiple sync rounds needed for full CRDT convergence - log_test "Alice syncs again to get Bob's changes" - cd alice-repo - $PUSHWORK_CMD sync - cd .. - - log_test "Bob syncs to pull merged result" - cd bob-repo - $PUSHWORK_CMD sync - cd .. - - log_test "Alice syncs final time for convergence" - cd alice-repo - $PUSHWORK_CMD sync - cd .. - - log_test "Bob syncs final time to ensure consistency" - cd bob-repo - $PUSHWORK_CMD sync - cd .. - - log_success "All sync operations completed - CRDT convergence achieved" -} - -# Verify conflict resolution results -verify_resolution_results() { - log_info "=== Verifying CRDT Merge Results ===" - - # Check what Alice has - log_test "Alice's final content:" - ALICE_CONTENT=$(cat alice-repo/document.txt) - cat alice-repo/document.txt - echo "" - - # Check what Bob has - log_test "Bob's final content:" - BOB_CONTENT=$(cat bob-repo/document.txt) - cat bob-repo/document.txt - echo "" - - # Verify both users' changes are preserved somewhere - BOTH_ALICE_AND_BOB_PRESERVED=false - - # Check if at least one repository has both Alice's and Bob's changes - if (echo "$ALICE_CONTENT" | grep -q "Alice's addition" && echo "$ALICE_CONTENT" | grep -q "Bob's addition") || \ - (echo "$BOB_CONTENT" | grep -q "Alice's addition" && echo "$BOB_CONTENT" | grep -q "Bob's addition"); then - BOTH_ALICE_AND_BOB_PRESERVED=true - log_success "✅ Both Alice's and Bob's changes are preserved via CRDT merging" - fi - - # Check if repositories eventually converge to the same state - if cmp -s alice-repo/document.txt bob-repo/document.txt; then - log_success "✅ Both repositories have converged to identical content" - if [ "$BOTH_ALICE_AND_BOB_PRESERVED" = true ]; then - log_success "✅ Perfect CRDT behavior: Both changes preserved and repositories consistent" - fi - else - log_error "❌ Repositories still have different content after multiple sync rounds" - echo "This indicates a sync propagation bug in pushwork" - echo "" - echo "Alice's content:" - cat alice-repo/document.txt - echo "" - echo "Bob's content:" - cat bob-repo/document.txt - echo "" - - # Check if at least one has both changes - if [ "$BOTH_ALICE_AND_BOB_PRESERVED" = true ]; then - log_info "✅ CRDT merging is working (both changes preserved somewhere)" - log_info "❌ But sync propagation is incomplete - this is a bug to fix" - else - log_error "❌ Critical: Changes may have been lost completely" - exit 1 - fi - fi - - # Detailed verification - if echo "$ALICE_CONTENT" | grep -q "Alice's addition" || echo "$BOB_CONTENT" | grep -q "Alice's addition"; then - log_success "✅ Alice's changes preserved" - else - log_error "❌ Alice's changes lost" - exit 1 - fi - - if echo "$ALICE_CONTENT" | grep -q "Bob's addition" || echo "$BOB_CONTENT" | grep -q "Bob's addition"; then - log_success "✅ Bob's changes preserved" - else - log_error "❌ Bob's changes lost" - exit 1 - fi -} - -# Show final results -show_results() { - log_info "=== Final Results ===" - - echo "" - echo "Alice's final document:" - echo "==============================" - cat alice-repo/document.txt - echo "==============================" - echo "" - - echo "Bob's final document:" - echo "==============================" - cat bob-repo/document.txt - echo "==============================" - - log_success "✅ CRDT conflict resolution test completed successfully!" - echo "" - echo "Key findings:" - echo "• Pushwork uses CRDT-based conflict resolution ✅" - echo "• Both users' changes are preserved through merging ✅" - echo "• No data loss occurs during conflicts ✅" - echo "• Text content is merged at the character level ✅" - echo "• Sync timing issue has been FIXED ✅" - echo "" - echo "Technical details:" - echo "• Fresh remote state detection after network sync ✅" - echo "• Proper CRDT merge propagation ✅" - echo "• Immediate convergence to consistent state ✅" - echo "• Both repositories end up identical ✅" - echo "" - echo "This demonstrates excellent collaborative editing capabilities!" -} - -# Main test execution -main() { - echo "==========================================" - echo "Pushwork CRDT Conflict Resolution Test" - echo "==========================================" - echo "" - echo "This test validates that:" - echo "1. Multiple users can edit the same file simultaneously" - echo "2. Conflicts are resolved through CRDT merging" - echo "3. Both users' changes are preserved" - echo "4. No data loss occurs during conflict resolution" - echo "5. Repositories eventually reach consistent state" - echo "" - - # Trap cleanup on exit - trap cleanup EXIT - - # Run the test - setup - create_initial_repo - clone_repository - make_conflicting_edits - test_conflict_resolution - verify_resolution_results - show_results - - echo "" - echo "==========================================" - echo "🎉 CRDT Conflict Resolution Test PASSED! 🎉" - echo "==========================================" -} - -# Run the test -main "$@" \ No newline at end of file diff --git a/test/integration/debug-both-nested.sh b/test/integration/debug-both-nested.sh deleted file mode 100644 index ffb6b3e..0000000 --- a/test/integration/debug-both-nested.sh +++ /dev/null @@ -1,74 +0,0 @@ -#!/bin/bash -set -e - -# Get absolute path to pushwork CLI -PUSHWORK_ROOT="$(cd "$(dirname "$0")/../.." && pwd)" -PUSHWORK_CLI="$PUSHWORK_ROOT/dist/cli.js" - -echo "=== Creating test repos ===" -TESTDIR=$(mktemp -d) -REPO_A="$TESTDIR/repo-a" -REPO_B="$TESTDIR/repo-b" -mkdir -p "$REPO_A" "$REPO_B" - -echo "=== Initializing repo A with a file ===" -echo "initial" > "$REPO_A/initial.txt" -cd "$REPO_A" -node "$PUSHWORK_CLI" init . - -echo "" -echo "=== Cloning to repo B ===" -ROOT_URL=$(node "$PUSHWORK_CLI" url) -echo "Root URL: $ROOT_URL" -cd "$TESTDIR" -node "$PUSHWORK_CLI" clone "$ROOT_URL" "$REPO_B" - -echo "" -echo "=== On A: Try to editAndRename non-existent file ===" -# This should be a no-op since tmjaz/namelye.txt doesn't exist -echo "(This is a no-op since source doesn't exist)" - -echo "" -echo "=== On B: Create file in 2-level nested subdirectory ===" -mkdir -p "$REPO_B/rlpjug/ewsv" -echo "" > "$REPO_B/rlpjug/ewsv/sneked.txt" - -echo "" -echo "=== Sync round 1: A ===" -cd "$REPO_A" -node "$PUSHWORK_CLI" sync - -echo "" -echo "=== Sync round 1: B ===" -cd "$REPO_B" -node "$PUSHWORK_CLI" sync - -echo "" -echo "=== Sync round 2: A ===" -cd "$REPO_A" -node "$PUSHWORK_CLI" sync - -echo "" -echo "=== Sync round 2: B ===" -cd "$REPO_B" -node "$PUSHWORK_CLI" sync - -echo "" -echo "=== Verification ===" -echo "Files in A:" -find "$REPO_A" -type f \( -name "*.txt" -o -name "*.md" \) | grep -v "\.pushwork" | sed "s|$REPO_A/||" | sort - -echo "" -echo "Files in B:" -find "$REPO_B" -type f \( -name "*.txt" -o -name "*.md" \) | grep -v "\.pushwork" | sed "s|$REPO_B/||" | sort - -echo "" -if [ -f "$REPO_A/rlpjug/ewsv/sneked.txt" ]; then - echo "✅ SUCCESS: B's nested file synced to A" -else - echo "❌ FAILURE: B's nested file did NOT sync to A" -fi - -echo "" -echo "Test directory: $TESTDIR" - diff --git a/test/integration/debug-concurrent-nested.sh b/test/integration/debug-concurrent-nested.sh deleted file mode 100644 index ed55753..0000000 --- a/test/integration/debug-concurrent-nested.sh +++ /dev/null @@ -1,87 +0,0 @@ -#!/bin/bash -set -e - -# Get absolute path to pushwork CLI -PUSHWORK_ROOT="$(cd "$(dirname "$0")/../.." && pwd)" -PUSHWORK_CLI="$PUSHWORK_ROOT/dist/cli.js" - -echo "=== Creating test repos ===" -TESTDIR=$(mktemp -d) -REPO_A="$TESTDIR/repo-a" -REPO_B="$TESTDIR/repo-b" -mkdir -p "$REPO_A" "$REPO_B" - -echo "=== Initializing repo A with a file ===" -echo "initial" > "$REPO_A/initial.txt" -cd "$REPO_A" -node "$PUSHWORK_CLI" init . - -echo "" -echo "=== Cloning to repo B ===" -ROOT_URL=$(node "$PUSHWORK_CLI" url) -echo "Root URL: $ROOT_URL" -cd "$TESTDIR" -node "$PUSHWORK_CLI" clone "$ROOT_URL" "$REPO_B" - -echo "" -echo "=== On A: Create file in nested directory dirA/subA ===" -mkdir -p "$REPO_A/dirA/subA" -echo "from A" > "$REPO_A/dirA/subA/fileA.txt" - -echo "" -echo "=== On B: Create file in different nested directory dirB/subB ===" -mkdir -p "$REPO_B/dirB/subB" -echo "from B" > "$REPO_B/dirB/subB/fileB.txt" - -echo "" -echo "=== Sync round 1: A (push A's nested file) ===" -cd "$REPO_A" -node "$PUSHWORK_CLI" sync - -echo "" -echo "=== Sync round 1: B (push B's nested file, pull A's) ===" -cd "$REPO_B" -node "$PUSHWORK_CLI" sync - -echo "" -echo "=== Sync round 2: A (pull B's nested file) ===" -cd "$REPO_A" -node "$PUSHWORK_CLI" sync - -echo "" -echo "=== Sync round 2: B (confirm) ===" -cd "$REPO_B" -node "$PUSHWORK_CLI" sync - -echo "" -echo "=== Verification ===" -echo "Files in A:" -find "$REPO_A" -type f \( -name "*.txt" -o -name "*.md" \) | grep -v "\.pushwork" | sed "s|$REPO_A/||" | sort - -echo "" -echo "Files in B:" -find "$REPO_B" -type f \( -name "*.txt" -o -name "*.md" \) | grep -v "\.pushwork" | sed "s|$REPO_B/||" | sort - -echo "" -echo "Checking convergence:" -A_HAS_A=$([ -f "$REPO_A/dirA/subA/fileA.txt" ] && echo YES || echo NO) -A_HAS_B=$([ -f "$REPO_A/dirB/subB/fileB.txt" ] && echo YES || echo NO) -B_HAS_A=$([ -f "$REPO_B/dirA/subA/fileA.txt" ] && echo YES || echo NO) -B_HAS_B=$([ -f "$REPO_B/dirB/subB/fileB.txt" ] && echo YES || echo NO) - -echo " A has its own file (dirA/subA/fileA.txt): $A_HAS_A" -echo " A has B's file (dirB/subB/fileB.txt): $A_HAS_B" -echo " B has A's file (dirA/subA/fileA.txt): $B_HAS_A" -echo " B has its own file (dirB/subB/fileB.txt): $B_HAS_B" - -if [ "$A_HAS_A" = "YES" ] && [ "$A_HAS_B" = "YES" ] && [ "$B_HAS_A" = "YES" ] && [ "$B_HAS_B" = "YES" ]; then - echo "" - echo "✅ SUCCESS: Both nested files synced correctly!" -else - echo "" - echo "❌ FAILURE: Not all files synced" -fi - -echo "" -echo "Test directory: $TESTDIR" - diff --git a/test/integration/debug-nested.sh b/test/integration/debug-nested.sh deleted file mode 100644 index 9563d08..0000000 --- a/test/integration/debug-nested.sh +++ /dev/null @@ -1,73 +0,0 @@ -#!/bin/bash -set -e - -# Get absolute path to pushwork CLI -PUSHWORK_ROOT="$(cd "$(dirname "$0")/../.." && pwd)" -PUSHWORK_CLI="$PUSHWORK_ROOT/dist/cli.js" - -echo "=== Creating test repos ===" -TESTDIR=$(mktemp -d) -REPO_A="$TESTDIR/repo-a" -REPO_B="$TESTDIR/repo-b" -mkdir -p "$REPO_A" "$REPO_B" - -echo "=== Initializing repo A with a file ===" -echo "initial" > "$REPO_A/initial.txt" -cd "$REPO_A" -node "$PUSHWORK_CLI" init . - -echo "" -echo "=== Cloning to repo B ===" -ROOT_URL=$(node "$PUSHWORK_CLI" url) -echo "Root URL: $ROOT_URL" -cd "$TESTDIR" -node "$PUSHWORK_CLI" clone "$ROOT_URL" "$REPO_B" - -echo "" -echo "=== On B: Create file in 2-level nested subdirectory ===" -mkdir -p "$REPO_B/rlpjug/ewsv" -echo "" > "$REPO_B/rlpjug/ewsv/sneked.txt" - -echo "" -echo "=== Sync round 1: A (no changes) ===" -cd "$REPO_A" -node "$PUSHWORK_CLI" sync - -echo "" -echo "=== Sync round 1: B (push new nested file) ===" -cd "$REPO_B" -node "$PUSHWORK_CLI" sync - -echo "" -echo "=== Sync round 2: A (pull B's changes) ===" -cd "$REPO_A" -node "$PUSHWORK_CLI" sync - -echo "" -echo "=== Sync round 2: B (confirm) ===" -cd "$REPO_B" -node "$PUSHWORK_CLI" sync - -echo "" -echo "=== Verification ===" -echo "Files in A:" -find "$REPO_A" -type f \( -name "*.txt" -o -name "*.md" \) | grep -v "\.pushwork" | sed "s|$REPO_A/||" | sort - -echo "" -echo "Files in B:" -find "$REPO_B" -type f \( -name "*.txt" -o -name "*.md" \) | grep -v "\.pushwork" | sed "s|$REPO_B/||" | sort - -echo "" -if [ -f "$REPO_A/rlpjug/ewsv/sneked.txt" ]; then - echo "✅ SUCCESS: Nested file synced to A" -else - echo "❌ FAILURE: Nested file did NOT sync to A" - echo "" - echo "Let's check if directories exist:" - echo "A has rlpjug dir: $([ -d "$REPO_A/rlpjug" ] && echo YES || echo NO)" - echo "A has rlpjug/ewsv dir: $([ -d "$REPO_A/rlpjug/ewsv" ] && echo YES || echo NO)" -fi - -echo "" -echo "Test directory: $TESTDIR" - diff --git a/test/integration/deletion-behavior-test.sh b/test/integration/deletion-behavior-test.sh deleted file mode 100755 index 6f526de..0000000 --- a/test/integration/deletion-behavior-test.sh +++ /dev/null @@ -1,487 +0,0 @@ -#!/bin/bash - -# Deletion Behavior Test for Pushwork -# Tests deletion propagation, delete vs modify conflicts, and directory deletions - -set -e - -# Colors for output -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -BLUE='\033[0;34m' -NC='\033[0m' - -# Test configuration -TEST_DIR="/tmp/pushwork-deletion-test" -PUSHWORK_CMD="node $(pwd)/dist/cli.js" - -# Helper functions -log_info() { - echo -e "${BLUE}[INFO]${NC} $1" -} - -log_success() { - echo -e "${GREEN}[SUCCESS]${NC} $1" -} - -log_error() { - echo -e "${RED}[ERROR]${NC} $1" -} - -log_test() { - echo -e "${YELLOW}[TEST]${NC} $1" -} - -# Cleanup function -cleanup() { - log_info "Cleaning up test directory..." - rm -rf "$TEST_DIR" -} - -# Setup function -setup() { - log_info "Setting up deletion behavior test..." - - # Build the project - log_info "Building pushwork..." - npm run build - - # Clean up and create test directory - rm -rf "$TEST_DIR" - mkdir -p "$TEST_DIR" - cd "$TEST_DIR" - - log_info "Test directory: $TEST_DIR" -} - -# Create initial repository with test files -create_initial_repo() { - log_info "=== Creating Initial Repository ===" - - mkdir alice-repo - cd alice-repo - - # Create test files - cat > simple-file.txt << EOF -This file will be deleted in simple deletion test. -Content to be removed. -EOF - - cat > conflict-file.txt << EOF -This file will be involved in delete vs modify conflict. -Original content that Bob will modify. -And Alice will delete. -EOF - - cat > multi-delete-1.txt << EOF -First file in multi-deletion test. -EOF - - cat > multi-delete-2.txt << EOF -Second file in multi-deletion test. -EOF - - cat > multi-delete-3.txt << EOF -Third file in multi-deletion test. -EOF - - # Create directory structure for directory deletion test - mkdir -p project/src - mkdir -p project/docs - - cat > project/README.md << EOF -Project README file. -This directory will be deleted. -EOF - - cat > project/src/main.ts << EOF -// Main TypeScript file -console.log("Hello, world!"); -EOF - - cat > project/docs/guide.md << EOF -# User Guide -This is the documentation. -EOF - - log_test "Initializing Alice's repository" - $PUSHWORK_CMD init . - - cd .. - log_success "Alice's repository created with test files" -} - -# Clone the repository -clone_repository() { - log_info "=== Cloning Repository ===" - - cd alice-repo - ROOT_URL=$($PUSHWORK_CMD url .) - cd .. - - log_test "Cloning repository for Bob" - $PUSHWORK_CMD clone "$ROOT_URL" bob-repo - - log_success "Repository cloned successfully" - - # Verify initial content is identical - if cmp -s alice-repo/simple-file.txt bob-repo/simple-file.txt; then - log_success "Initial content is identical" - else - log_error "Initial content differs between repositories" - exit 1 - fi -} - -# Test 1: Simple deletion propagation -test_simple_deletion() { - log_info "=== Test 1: Simple Deletion Propagation ===" - - log_test "Alice deletes simple-file.txt" - cd alice-repo - rm simple-file.txt - $PUSHWORK_CMD sync - log_success "Alice synced deletion" - cd .. - - log_test "Bob syncs to receive deletion" - cd bob-repo - $PUSHWORK_CMD sync - cd .. - - # Verify file is deleted on both sides - if [ ! -f alice-repo/simple-file.txt ] && [ ! -f bob-repo/simple-file.txt ]; then - log_success "✅ Simple deletion propagated correctly" - else - log_error "❌ Simple deletion failed to propagate" - if [ -f alice-repo/simple-file.txt ]; then - log_error " File still exists in Alice's repo" - fi - if [ -f bob-repo/simple-file.txt ]; then - log_error " File still exists in Bob's repo" - fi - exit 1 - fi -} - -# Test 2: Delete vs Modify conflict -test_delete_vs_modify_conflict() { - log_info "=== Test 2: Delete vs Modify Conflict ===" - - # Alice deletes the file - log_test "Alice deletes conflict-file.txt" - cd alice-repo - rm conflict-file.txt - $PUSHWORK_CMD sync - log_success "Alice synced deletion" - cd .. - - # Bob modifies the same file - log_test "Bob modifies conflict-file.txt" - cd bob-repo - cat >> conflict-file.txt << EOF -Bob's modification: Added important feature. -Bob's note: This change should be preserved despite deletion conflict. -EOF - $PUSHWORK_CMD sync - log_success "Bob synced modification" - cd .. - - # Cross-sync to resolve conflict - log_test "Alice syncs to get Bob's changes" - cd alice-repo - $PUSHWORK_CMD sync - cd .. - - log_test "Bob syncs to see conflict resolution" - cd bob-repo - $PUSHWORK_CMD sync - cd .. - - # Verify conflict resolution - ALICE_HAS_FILE=false - BOB_HAS_FILE=false - - if [ -f alice-repo/conflict-file.txt ]; then - ALICE_HAS_FILE=true - log_info "Alice's repo: File exists after conflict resolution" - cat alice-repo/conflict-file.txt - echo "" - else - log_info "Alice's repo: File deleted after conflict resolution" - fi - - if [ -f bob-repo/conflict-file.txt ]; then - BOB_HAS_FILE=true - log_info "Bob's repo: File exists after conflict resolution" - cat bob-repo/conflict-file.txt - echo "" - else - log_info "Bob's repo: File deleted after conflict resolution" - fi - - # In CRDT systems, modifications typically win over deletions - # to prevent data loss - if [ "$ALICE_HAS_FILE" = true ] || [ "$BOB_HAS_FILE" = true ]; then - log_success "✅ Delete vs Modify conflict resolved (modification preserved)" - - # Check if Bob's changes are preserved - if ([ "$ALICE_HAS_FILE" = true ] && grep -q "Bob's modification" alice-repo/conflict-file.txt) || \ - ([ "$BOB_HAS_FILE" = true ] && grep -q "Bob's modification" bob-repo/conflict-file.txt); then - log_success "✅ Bob's modifications preserved despite deletion" - else - log_error "❌ Bob's modifications lost in conflict resolution" - exit 1 - fi - else - log_success "✅ Delete vs Modify conflict resolved (deletion won)" - log_info "Note: Deletion won over modification - this is valid behavior" - fi -} - -# Test 3: Multiple file deletions -test_multiple_deletions() { - log_info "=== Test 3: Multiple File Deletions ===" - - log_test "Alice deletes multiple files at once" - cd alice-repo - rm multi-delete-1.txt multi-delete-2.txt multi-delete-3.txt - $PUSHWORK_CMD sync - log_success "Alice synced multiple deletions" - cd .. - - log_test "Bob syncs to receive multiple deletions" - cd bob-repo - $PUSHWORK_CMD sync - cd .. - - # Verify all files are deleted - DELETED_COUNT=0 - FILES=("multi-delete-1.txt" "multi-delete-2.txt" "multi-delete-3.txt") - - for file in "${FILES[@]}"; do - if [ ! -f "alice-repo/$file" ] && [ ! -f "bob-repo/$file" ]; then - ((DELETED_COUNT++)) - log_success "✅ $file deleted successfully" - else - log_error "❌ $file deletion failed" - fi - done - - if [ $DELETED_COUNT -eq 3 ]; then - log_success "✅ Multiple file deletions propagated correctly" - else - log_error "❌ Multiple file deletions failed ($DELETED_COUNT/3 successful)" - exit 1 - fi -} - -# Test 4: Directory deletion -test_directory_deletion() { - log_info "=== Test 4: Directory Deletion ===" - - log_test "Alice deletes entire project directory" - cd alice-repo - rm -rf project/ - $PUSHWORK_CMD sync - log_success "Alice synced directory deletion" - cd .. - - log_test "Bob syncs to receive directory deletion" - cd bob-repo - $PUSHWORK_CMD sync - cd .. - - # Verify directory and all contents are deleted - if [ ! -d alice-repo/project ] && [ ! -d bob-repo/project ]; then - log_success "✅ Directory deletion propagated correctly" - - # Double-check that individual files are also gone - FILES_TO_CHECK=("project/README.md" "project/src/main.ts" "project/docs/guide.md") - ALL_FILES_DELETED=true - - for file in "${FILES_TO_CHECK[@]}"; do - if [ -f "alice-repo/$file" ] || [ -f "bob-repo/$file" ]; then - log_error "❌ File $file still exists after directory deletion" - ALL_FILES_DELETED=false - fi - done - - if [ "$ALL_FILES_DELETED" = true ]; then - log_success "✅ All directory contents properly deleted" - else - log_error "❌ Some directory contents not properly deleted" - exit 1 - fi - else - log_error "❌ Directory deletion failed to propagate" - if [ -d alice-repo/project ]; then - log_error " Directory still exists in Alice's repo" - fi - if [ -d bob-repo/project ]; then - log_error " Directory still exists in Bob's repo" - fi - exit 1 - fi -} - -# Test 5: Simultaneous deletions (race condition) -test_simultaneous_deletions() { - log_info "=== Test 5: Simultaneous Deletions ===" - - # First, create a file that both will delete - log_test "Creating file for simultaneous deletion test" - cd alice-repo - echo "File to be deleted by both users" > race-delete.txt - $PUSHWORK_CMD sync - cd .. - - cd bob-repo - $PUSHWORK_CMD sync - cd .. - - # Both users delete the same file without syncing first - log_test "Alice deletes race-delete.txt" - cd alice-repo - rm race-delete.txt - cd .. - - log_test "Bob also deletes race-delete.txt (before syncing)" - cd bob-repo - rm race-delete.txt - cd .. - - # Now both sync their deletions - log_test "Alice syncs her deletion" - cd alice-repo - $PUSHWORK_CMD sync - cd .. - - log_test "Bob syncs his deletion" - cd bob-repo - $PUSHWORK_CMD sync - cd .. - - # Cross-sync to ensure consistency - log_test "Cross-syncing for consistency" - cd alice-repo - $PUSHWORK_CMD sync - cd .. - - cd bob-repo - $PUSHWORK_CMD sync - cd .. - - # Verify both repos are consistent and file is deleted - if [ ! -f alice-repo/race-delete.txt ] && [ ! -f bob-repo/race-delete.txt ]; then - log_success "✅ Simultaneous deletions handled correctly" - else - log_error "❌ Simultaneous deletions not handled properly" - exit 1 - fi -} - -# Verify final repository states -verify_final_states() { - log_info "=== Verifying Final Repository States ===" - - log_test "Alice's final repository contents:" - cd alice-repo - find . -type f -not -path './.pushwork/*' | sort - cd .. - - log_test "Bob's final repository contents:" - cd bob-repo - find . -type f -not -path './.pushwork/*' | sort - cd .. - - # Check if repositories are consistent - ALICE_FILES=$(cd alice-repo && find . -type f -not -path './.pushwork/*' | sort) - BOB_FILES=$(cd bob-repo && find . -type f -not -path './.pushwork/*' | sort) - - if [ "$ALICE_FILES" = "$BOB_FILES" ]; then - log_success "✅ Both repositories have identical file structure" - else - log_error "❌ Repository file structures differ" - echo "Alice has:" - echo "$ALICE_FILES" - echo "" - echo "Bob has:" - echo "$BOB_FILES" - exit 1 - fi -} - -# Show final results -show_results() { - log_info "=== Final Results ===" - - echo "" - echo "Deletion Test Summary:" - echo "==============================" - echo "✅ Simple deletion propagation" - echo "✅ Delete vs modify conflict resolution" - echo "✅ Multiple file deletions" - echo "✅ Directory deletion propagation" - echo "✅ Simultaneous deletion handling" - echo "✅ Repository consistency maintained" - echo "==============================" - - log_success "✅ ALL DELETION TESTS PASSED!" - echo "" - echo "Key findings:" - echo "• File deletions propagate correctly across users ✅" - echo "• Delete vs modify conflicts are resolved safely ✅" - echo "• Multiple file deletions work atomically ✅" - echo "• Directory deletions cascade properly ✅" - echo "• Race conditions in deletions are handled ✅" - echo "• Repository states remain consistent ✅" - echo "" - echo "Technical validation:" - echo "• Snapshot state updates correctly on deletion ✅" - echo "• Directory documents clean up file references ✅" - echo "• CRDT tombstones prevent resurrection ✅" - echo "• Network sync propagates deletions reliably ✅" - echo "" - echo "This demonstrates robust deletion handling!" -} - -# Main test execution -main() { - echo "==========================================" - echo "Pushwork Deletion Behavior Test" - echo "==========================================" - echo "" - echo "This test validates that:" - echo "1. File deletions propagate correctly between users" - echo "2. Delete vs modify conflicts are resolved safely" - echo "3. Multiple file deletions work atomically" - echo "4. Directory deletions cascade to all contents" - echo "5. Race conditions in deletions are handled properly" - echo "6. Repository states remain consistent after deletions" - echo "" - - # Trap cleanup on exit - trap cleanup EXIT - - # Run the test - setup - create_initial_repo - clone_repository - test_simple_deletion - test_delete_vs_modify_conflict - test_multiple_deletions - test_directory_deletion - test_simultaneous_deletions - verify_final_states - show_results - - echo "" - echo "==========================================" - echo "🎉 DELETION BEHAVIOR TEST PASSED! 🎉" - echo "==========================================" -} - -# Run the test -main "$@" \ No newline at end of file diff --git a/test/integration/deletion-sync-test-simple.sh b/test/integration/deletion-sync-test-simple.sh deleted file mode 100755 index 1b3996a..0000000 --- a/test/integration/deletion-sync-test-simple.sh +++ /dev/null @@ -1,193 +0,0 @@ -#!/bin/bash - -# Simplified Deletion Test: Bob deletes, Alice syncs (local-only mode) -# Tests basic deletion behavior without requiring a sync server - -set -e # Exit on any error - -# Colors for output -RED='\033[0;31m' -GREEN='\033[0;32m' -BLUE='\033[0;34m' -YELLOW='\033[1;33m' -NC='\033[0m' # No Color - -# Test configuration -TEST_DIR="/tmp/pushwork-deletion-simple-$$" -BOB_DIR="$TEST_DIR/bob" -ALICE_DIR="$TEST_DIR/alice" -TEST_FILE="shared-document.ts" -TEST_CONTENT="interface SharedInterface { id: number; name: string; }" - -# Logging functions -log_info() { echo -e "${BLUE}[INFO]${NC} $1"; } -log_success() { echo -e "${GREEN}[PASS]${NC} $1"; } -log_error() { echo -e "${RED}[FAIL]${NC} $1"; } -log_test() { echo -e "${YELLOW}[TEST]${NC} $1"; } - -# Cleanup function -cleanup() { - if [ -d "$TEST_DIR" ]; then - log_info "Cleaning up test directories..." - rm -rf "$TEST_DIR" - fi -} - -# Error handler -handle_error() { - log_error "Test failed at line $1" - cleanup - exit 1 -} - -trap 'handle_error $LINENO' ERR -trap cleanup EXIT - -# Helper function to run pushwork commands -run_pushwork() { - local dir="$1" - local cmd="$2" - local user="$3" - - cd "$dir" - if output=$(eval "$PUSHWORK_CMD $cmd" 2>&1); then - log_test "${user}: pushwork $cmd ✓" - if [ -n "$output" ]; then - echo " → $output" - fi - return 0 - else - log_error "${user}: pushwork $cmd failed: $output" - return 1 - fi -} - -# Helper function to check if file exists -check_file() { - local dir="$1" - local file="$2" - local user="$3" - local should_exist="$4" - - if [ -f "$dir/$file" ]; then - if [ "$should_exist" = "true" ]; then - log_success "${user}: File '$file' exists ✓" - else - log_error "${user}: File '$file' should be deleted but still exists" - return 1 - fi - else - if [ "$should_exist" = "false" ]; then - log_success "${user}: File '$file' correctly deleted ✓" - else - log_error "${user}: File '$file' should exist but is missing" - return 1 - fi - fi -} - -main() { - echo "========================================" - echo "📁 Pushwork Deletion Test (Simplified)" - echo "========================================" - echo "Testing basic deletion behavior with local-only sync" - echo "" - - # Setup - log_info "Setting up test environment..." - mkdir -p "$BOB_DIR" "$ALICE_DIR" - - echo " Bob's directory: $BOB_DIR" - echo " Alice's directory: $ALICE_DIR" - echo " Test file: $TEST_FILE" - echo "" - - # Phase 1: Initialize Bob's repository - log_info "=== Phase 1: Initialize Bob's Repository ===" - run_pushwork "$BOB_DIR" "init ." "Bob" - - # Phase 2: Create test file - log_info "=== Phase 2: Create Test File ===" - echo "$TEST_CONTENT" > "$BOB_DIR/$TEST_FILE" - check_file "$BOB_DIR" "$TEST_FILE" "Bob" "true" - - run_pushwork "$BOB_DIR" "commit ." "Bob" - check_file "$BOB_DIR" "$TEST_FILE" "Bob" "true" - - log_success "Phase 2: File created and committed ✓" - echo "" - - # Phase 3: Bob deletes the file - log_info "=== Phase 3: Bob Deletes File ===" - - log_test "Bob: Deleting $TEST_FILE..." - rm "$BOB_DIR/$TEST_FILE" - check_file "$BOB_DIR" "$TEST_FILE" "Bob" "false" - - log_test "Bob: Committing deletion..." - run_pushwork "$BOB_DIR" "commit ." "Bob" - check_file "$BOB_DIR" "$TEST_FILE" "Bob" "false" - - log_success "Phase 3: Deletion committed successfully ✓" - echo "" - - # Phase 4: Verify deletion persists after sync - log_info "=== Phase 4: Verify Deletion Persistence ===" - - log_test "Bob: Running status to verify deletion persisted..." - run_pushwork "$BOB_DIR" "status" "Bob" - check_file "$BOB_DIR" "$TEST_FILE" "Bob" "false" - - log_test "Bob: Checking status after sync..." - run_pushwork "$BOB_DIR" "status" "Bob" - - log_success "Phase 4: Deletion persisted through sync ✓" - echo "" - - # Phase 5: Test deletion detection - log_info "=== Phase 5: Test Deletion Detection ===" - - # Create the file again to test deletion detection - echo "$TEST_CONTENT" > "$BOB_DIR/$TEST_FILE" - run_pushwork "$BOB_DIR" "commit ." "Bob" - check_file "$BOB_DIR" "$TEST_FILE" "Bob" "true" - - # Delete it again - rm "$BOB_DIR/$TEST_FILE" - - # Check that status detects the deletion - log_test "Bob: Checking that status detects deletion..." - run_pushwork "$BOB_DIR" "status" "Bob" - - # Commit the deletion - run_pushwork "$BOB_DIR" "commit ." "Bob" - check_file "$BOB_DIR" "$TEST_FILE" "Bob" "false" - - log_success "Phase 5: Deletion detection working ✓" - echo "" - - # Success! - echo "========================================" - echo "🎉 DELETION TEST PASSED! 🎉" - echo "========================================" - echo "✅ File deletion works correctly" - echo "✅ Deletions are detected by status" - echo "✅ Deletions can be committed" - echo "✅ Deletions persist through sync" - echo "" - echo "Basic deletion behavior is working!" -} - -# Validation -if [ ! -f "package.json" ] || ! grep -q "pushwork" package.json; then - log_error "This script must be run from the pushwork project root directory" - exit 1 -fi - -# Store the project root for CLI access -PROJECT_ROOT="$(pwd)" -PUSHWORK_CMD="node $PROJECT_ROOT/dist/cli.js" - -# Run the test -main -echo "Test completed successfully! 🚀" \ No newline at end of file diff --git a/test/integration/deletion-sync-test.sh b/test/integration/deletion-sync-test.sh deleted file mode 100755 index a801b66..0000000 --- a/test/integration/deletion-sync-test.sh +++ /dev/null @@ -1,297 +0,0 @@ -#!/bin/bash - -# Deletion Sync Test: Bob deletes, Alice receives deletion -# Tests end-to-end deletion propagation through sync - -set -e # Exit on any error - -# Colors for output -RED='\033[0;31m' -GREEN='\033[0;32m' -BLUE='\033[0;34m' -YELLOW='\033[1;33m' -NC='\033[0m' # No Color - -# Test configuration -TEST_DIR="/tmp/pushwork-deletion-test-$$" -BOB_DIR="$TEST_DIR/bob" -ALICE_DIR="$TEST_DIR/alice" -PUSHWORK_CMD="npm run start --silent --" -SYNC_SERVER="ws://localhost:3030" -STORAGE_ID="deletion-test-$(date +%s)" -TEST_FILE="shared-document.ts" -TEST_CONTENT="interface SharedInterface { id: number; name: string; }" - -# Logging functions -log_info() { - echo -e "${BLUE}[INFO]${NC} $1" -} - -log_success() { - echo -e "${GREEN}[PASS]${NC} $1" -} - -log_error() { - echo -e "${RED}[FAIL]${NC} $1" -} - -log_test() { - echo -e "${YELLOW}[TEST]${NC} $1" -} - -log_warning() { - echo -e "${YELLOW}[WARN]${NC} $1" -} - -# Cleanup function -cleanup() { - if [ -d "$TEST_DIR" ]; then - log_info "Cleaning up test directories..." - rm -rf "$TEST_DIR" - fi -} - -# Error handler -handle_error() { - log_error "Test failed at line $1" - log_info "Bob's directory contents:" - if [ -d "$BOB_DIR" ]; then - ls -la "$BOB_DIR" || true - fi - log_info "Alice's directory contents:" - if [ -d "$ALICE_DIR" ]; then - ls -la "$ALICE_DIR" || true - fi - cleanup - exit 1 -} - -# Set up error handling -trap 'handle_error $LINENO' ERR -trap cleanup EXIT - -# Helper function to run pushwork commands -run_pushwork() { - local dir="$1" - local cmd="$2" - local user="$3" - - log_test "${user}: Running 'pushwork $cmd'" - cd "$dir" - - # Capture both stdout and stderr - if output=$(eval "$PUSHWORK_CMD $cmd" 2>&1); then - if [ -n "$output" ]; then - echo " Output: $output" - fi - return 0 - else - log_error "${user}: Command failed: $output" - return 1 - fi -} - -# Helper function to check if file exists -check_file_exists() { - local dir="$1" - local file="$2" - local user="$3" - local should_exist="$4" - - if [ -f "$dir/$file" ]; then - if [ "$should_exist" = "true" ]; then - log_success "${user}: File '$file' exists (expected)" - return 0 - else - log_error "${user}: File '$file' exists (should be deleted)" - return 1 - fi - else - if [ "$should_exist" = "false" ]; then - log_success "${user}: File '$file' is deleted (expected)" - return 0 - else - log_error "${user}: File '$file' is missing (should exist)" - return 1 - fi - fi -} - -# Helper function to show directory contents -show_directory_contents() { - local dir="$1" - local user="$2" - - log_info "${user}'s directory contents:" - cd "$dir" - if [ "$(ls -A .)" ]; then - ls -la . | grep -v "^total" | tail -n +2 | while read line; do - echo " $line" - done - else - echo " (empty directory)" - fi -} - -# Main test function -main() { - echo "======================================" - echo "Pushwork Deletion Sync Test" - echo "======================================" - echo "Testing: Bob deletes file → sync → Alice loses file" - echo "" - - # Setup - log_info "Setting up test environment..." - mkdir -p "$BOB_DIR" "$ALICE_DIR" - - log_info "Test configuration:" - echo " Bob's directory: $BOB_DIR" - echo " Alice's directory: $ALICE_DIR" - echo " Sync server: $SYNC_SERVER" - echo " Storage ID: $STORAGE_ID" - echo " Test file: $TEST_FILE" - echo "" - - # Phase 1: Initialize both repositories - log_info "=== Phase 1: Initialize Repositories ===" - - log_test "Initializing Bob's repository..." - run_pushwork "$BOB_DIR" "init . --sync-server '$SYNC_SERVER' --storage-id '$STORAGE_ID'" "Bob" - - log_test "Initializing Alice's repository..." - run_pushwork "$ALICE_DIR" "clone --sync-server '$SYNC_SERVER' --storage-id '$STORAGE_ID' ." "Alice" - - # Phase 2: Create initial shared file - log_info "=== Phase 2: Create Shared File ===" - - log_test "Bob creates the shared file..." - echo "$TEST_CONTENT" > "$BOB_DIR/$TEST_FILE" - check_file_exists "$BOB_DIR" "$TEST_FILE" "Bob" "true" - - log_test "Bob commits the file..." - run_pushwork "$BOB_DIR" "commit ." "Bob" - - log_test "Bob syncs to share the file..." - run_pushwork "$BOB_DIR" "sync" "Bob" - - log_test "Alice syncs to receive the file..." - run_pushwork "$ALICE_DIR" "sync" "Alice" - - # Verify both have the file - check_file_exists "$BOB_DIR" "$TEST_FILE" "Bob" "true" - check_file_exists "$ALICE_DIR" "$TEST_FILE" "Alice" "true" - - log_success "Phase 2: Both users have the shared file" - echo "" - - # Phase 3: Bob deletes the file - log_info "=== Phase 3: Bob Deletes File ===" - - show_directory_contents "$BOB_DIR" "Bob (before deletion)" - - log_test "Bob deletes the shared file..." - rm "$BOB_DIR/$TEST_FILE" - check_file_exists "$BOB_DIR" "$TEST_FILE" "Bob" "false" - - show_directory_contents "$BOB_DIR" "Bob (after deletion)" - - log_test "Bob commits the deletion..." - run_pushwork "$BOB_DIR" "commit ." "Bob" - - # Verify file is still gone on Bob's side - check_file_exists "$BOB_DIR" "$TEST_FILE" "Bob" "false" - - log_success "Phase 3: Bob successfully deleted and committed" - echo "" - - # Phase 4: Bob syncs the deletion - log_info "=== Phase 4: Bob Syncs Deletion ===" - - log_test "Bob syncs to propagate the deletion..." - run_pushwork "$BOB_DIR" "sync" "Bob" - - # Verify file is still gone on Bob's side after sync - check_file_exists "$BOB_DIR" "$TEST_FILE" "Bob" "false" - - log_test "Bob checks status after sync..." - run_pushwork "$BOB_DIR" "status" "Bob" - - log_success "Phase 4: Bob's deletion synced successfully" - echo "" - - # Phase 5: Alice syncs to receive the deletion - log_info "=== Phase 5: Alice Syncs to Receive Deletion ===" - - show_directory_contents "$ALICE_DIR" "Alice (before sync)" - - # Alice should still have the file before syncing - check_file_exists "$ALICE_DIR" "$TEST_FILE" "Alice" "true" - - log_test "Alice syncs to receive Bob's deletion..." - run_pushwork "$ALICE_DIR" "sync" "Alice" - - show_directory_contents "$ALICE_DIR" "Alice (after sync)" - - # Critical test: Alice should now have the file deleted - check_file_exists "$ALICE_DIR" "$TEST_FILE" "Alice" "false" - - log_test "Alice checks status after sync..." - run_pushwork "$ALICE_DIR" "status" "Alice" - - log_success "Phase 5: Alice received the deletion successfully" - echo "" - - # Phase 6: Verification - log_info "=== Phase 6: Final Verification ===" - - log_test "Final verification of both repositories..." - - # Both should have no trace of the deleted file - check_file_exists "$BOB_DIR" "$TEST_FILE" "Bob" "false" - check_file_exists "$ALICE_DIR" "$TEST_FILE" "Alice" "false" - - # Check for any unexpected files - show_directory_contents "$BOB_DIR" "Bob (final)" - show_directory_contents "$ALICE_DIR" "Alice (final)" - - # Run final status checks - log_test "Bob's final status:" - run_pushwork "$BOB_DIR" "status" "Bob" - - log_test "Alice's final status:" - run_pushwork "$ALICE_DIR" "status" "Alice" - - log_success "Phase 6: All verifications passed" - echo "" - - # Success! - echo "======================================" - echo "🎉 DELETION SYNC TEST PASSED! 🎉" - echo "======================================" - echo "✅ Bob deleted file successfully" - echo "✅ Bob's sync propagated deletion" - echo "✅ Alice received deletion correctly" - echo "✅ Both repositories in sync" - echo "" - echo "This test validates that file deletions" - echo "propagate correctly through the sync engine!" -} - -# Check if we're in the right directory -if [ ! -f "package.json" ] || ! grep -q "pushwork" package.json; then - log_error "This script must be run from the pushwork project root directory" - exit 1 -fi - -# Check if dependencies are available -if ! command -v npm &> /dev/null; then - log_error "npm is not installed or not in PATH" - exit 1 -fi - -# Run the test -main - -echo "" -echo "Test completed successfully! 🚀" \ No newline at end of file diff --git a/test/integration/exclude-patterns.test.ts b/test/integration/exclude-patterns.test.ts deleted file mode 100644 index 8061e3e..0000000 --- a/test/integration/exclude-patterns.test.ts +++ /dev/null @@ -1,144 +0,0 @@ -import * as path from "path"; -import * as fs from "fs/promises"; -import { tmpdir } from "os"; -import { ConfigManager } from "../../src/core"; -import { DirectoryConfig } from "../../src/types"; -import { - ensureDirectoryExists, - writeFileContent, - listDirectory, -} from "../../src/utils"; - -describe("Exclude Patterns", () => { - let tmpDir: string; - let syncToolDir: string; - let configManager: ConfigManager; - - beforeEach(async () => { - tmpDir = await fs.mkdtemp(path.join(tmpdir(), "sync-test-")); - syncToolDir = path.join(tmpDir, ".pushwork"); - await ensureDirectoryExists(syncToolDir); - await ensureDirectoryExists(path.join(syncToolDir, "automerge")); - - configManager = new ConfigManager(tmpDir); - }); - - afterEach(async () => { - await fs.rm(tmpDir, { recursive: true, force: true }); - }); - - it("should exclude .pushwork directory from filesystem listing", async () => { - // Create files both inside and outside .pushwork directory - await writeFileContent( - path.join(tmpDir, "regular-file.txt"), - "regular content" - ); - await writeFileContent( - path.join(tmpDir, "another-file.md"), - "markdown content" - ); - await writeFileContent( - path.join(syncToolDir, "snapshot.json"), - '{"timestamp": 123}' - ); - await writeFileContent( - path.join(syncToolDir, "config.json"), - '{"test": true}' - ); - - // Create nested directory with file inside .pushwork - const nestedDir = path.join(syncToolDir, "nested"); - await ensureDirectoryExists(nestedDir); - await writeFileContent( - path.join(nestedDir, "internal.log"), - "internal log data" - ); - - // Test listDirectory with exclude patterns - const excludePatterns = [".pushwork"]; - const entries = await listDirectory(tmpDir, true, excludePatterns); - - // Verify that .pushwork files are excluded - const filePaths = entries.map((entry) => path.relative(tmpDir, entry.path)); - - expect(filePaths).toContain("regular-file.txt"); - expect(filePaths).toContain("another-file.md"); - expect(filePaths).not.toContain(".pushwork/snapshot.json"); - expect(filePaths).not.toContain(".pushwork/config.json"); - expect(filePaths).not.toContain(".pushwork/nested/internal.log"); - }); - - it("should exclude files matching glob patterns", async () => { - // Create files that should and shouldn't be excluded - await writeFileContent(path.join(tmpDir, "include.txt"), "include me"); - await writeFileContent(path.join(tmpDir, "exclude.tmp"), "exclude me"); - await writeFileContent(path.join(tmpDir, "debug.log"), "exclude me too"); - await writeFileContent(path.join(tmpDir, "readme.md"), "include me"); - - // Create node_modules directory with files - const nodeModulesDir = path.join(tmpDir, "node_modules"); - await ensureDirectoryExists(nodeModulesDir); - await writeFileContent( - path.join(nodeModulesDir, "package.json"), - "exclude me" - ); - - // Test listDirectory with various exclude patterns - const excludePatterns = ["*.tmp", "*.log", "node_modules", ".pushwork"]; - const entries = await listDirectory(tmpDir, true, excludePatterns); - - // Verify correct files are included/excluded - const filePaths = entries.map((entry) => path.relative(tmpDir, entry.path)); - - expect(filePaths).toContain("include.txt"); - expect(filePaths).toContain("readme.md"); - expect(filePaths).not.toContain("exclude.tmp"); - expect(filePaths).not.toContain("debug.log"); - expect(filePaths).not.toContain("node_modules/package.json"); - }); - - it("should use merged configuration exclude patterns", async () => { - // Create global config - await configManager.createDefaultGlobal(); - - // Create local config with additional exclude patterns - const localConfig: DirectoryConfig = { - sync_server: "wss://test.server.com", - sync_enabled: true, - exclude_patterns: [".git", "*.tmp", ".pushwork", "*.env"], - artifact_directories: ["dist"], - sync: { - move_detection_threshold: 0.8, - }, - }; - await configManager.save(localConfig); - - // Get merged config - const mergedConfig = await configManager.getMerged(); - - // Verify .pushwork is in the exclude patterns - expect(mergedConfig.exclude_patterns).toContain(".pushwork"); - expect(mergedConfig.exclude_patterns).toContain("*.env"); - expect(mergedConfig.exclude_patterns).toContain(".git"); - - // Create test files - await writeFileContent(path.join(tmpDir, "include.txt"), "include me"); - await writeFileContent(path.join(tmpDir, "secret.env"), "exclude me"); - await writeFileContent( - path.join(syncToolDir, "snapshot.json"), - "exclude me" - ); - - // Test with merged exclude patterns - const entries = await listDirectory( - tmpDir, - true, - mergedConfig.exclude_patterns - ); - const filePaths = entries.map((entry) => path.relative(tmpDir, entry.path)); - - expect(filePaths).toContain("include.txt"); - expect(filePaths).not.toContain("secret.env"); - expect(filePaths).not.toContain(".pushwork/snapshot.json"); - }); -}); diff --git a/test/integration/full-integration-test.sh b/test/integration/full-integration-test.sh deleted file mode 100755 index c3c8e08..0000000 --- a/test/integration/full-integration-test.sh +++ /dev/null @@ -1,363 +0,0 @@ -#!/bin/bash - -# Comprehensive Integration Test for Pushwork -# Tests all major functionality of the sync tool - -set -e # Exit on any error - -# Colors for output -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -BLUE='\033[0;34m' -NC='\033[0m' # No Color - -# Test configuration -TEST_DIR="/tmp/pushwork-integration-test" -PUSHWORK_CMD="node $(pwd)/dist/cli.js" -CUSTOM_SYNC_SERVER="ws://localhost:3030" -CUSTOM_STORAGE_ID="1d89eba7-f7a4-4e8e-80f2-5f4e2406f507" - -# Test counters -TESTS_RUN=0 -TESTS_PASSED=0 -TESTS_FAILED=0 - -# Helper functions -log_info() { - echo -e "${BLUE}[INFO]${NC} $1" -} - -log_success() { - echo -e "${GREEN}[PASS]${NC} $1" - ((TESTS_PASSED++)) -} - -log_error() { - echo -e "${RED}[FAIL]${NC} $1" - ((TESTS_FAILED++)) -} - -log_warning() { - echo -e "${YELLOW}[WARN]${NC} $1" -} - -log_test() { - echo -e "${YELLOW}[TEST]${NC} $1" - ((TESTS_RUN++)) -} - -# Test wrapper function -run_test() { - local test_name="$1" - local test_cmd="$2" - local expected_exit_code="${3:-0}" - - log_test "$test_name" - - if [ "$expected_exit_code" = "0" ]; then - if eval "$test_cmd" > /dev/null 2>&1; then - log_success "$test_name" - return 0 - else - log_error "$test_name (command failed)" - return 1 - fi - else - if eval "$test_cmd" > /dev/null 2>&1; then - log_error "$test_name (expected failure but succeeded)" - return 1 - else - log_success "$test_name (correctly failed)" - return 0 - fi - fi -} - -# Cleanup function -cleanup() { - log_info "Cleaning up test directory..." - rm -rf "$TEST_DIR" -} - -# Setup function -setup() { - log_info "Setting up integration test environment..." - - # Build the project first - log_info "Building pushwork..." - npm run build - - # Clean up any existing test directory - rm -rf "$TEST_DIR" - mkdir -p "$TEST_DIR" - cd "$TEST_DIR" - - log_info "Test directory: $TEST_DIR" -} - -# Test functions - -test_help_commands() { - log_info "=== Testing Help Commands ===" - - run_test "pushwork --help" "$PUSHWORK_CMD --help" - run_test "pushwork init --help" "$PUSHWORK_CMD init --help" - run_test "pushwork clone --help" "$PUSHWORK_CMD clone --help" - run_test "pushwork sync --help" "$PUSHWORK_CMD sync --help" - run_test "pushwork status --help" "$PUSHWORK_CMD status --help" - run_test "pushwork diff --help" "$PUSHWORK_CMD diff --help" - run_test "pushwork commit --help" "$PUSHWORK_CMD commit --help" -} - -test_init_functionality() { - log_info "=== Testing Init Functionality ===" - - # Test init with default settings - mkdir -p test-init-default - cd test-init-default - echo "Hello World" > test.txt - - run_test "init with default settings" "$PUSHWORK_CMD init ." - run_test "check .pushwork directory exists" "[ -d .pushwork ]" - run_test "check config file exists" "[ -f .pushwork/config.json ]" - run_test "check snapshot file exists" "[ -f .pushwork/snapshot.json ]" - - cd .. - - # Test init with custom sync server - mkdir -p test-init-custom - cd test-init-custom - echo "Custom Server Test" > custom.txt - - run_test "init with custom sync server" "$PUSHWORK_CMD init . --sync-server $CUSTOM_SYNC_SERVER --sync-server-storage-id $CUSTOM_STORAGE_ID" - - # Verify custom settings in config - if [ -f .pushwork/config.json ]; then - if grep -q "$CUSTOM_SYNC_SERVER" .pushwork/config.json; then - log_success "custom sync server saved in config" - ((TESTS_PASSED++)) - else - log_error "custom sync server not found in config" - ((TESTS_FAILED++)) - fi - ((TESTS_RUN++)) - - if grep -q "$CUSTOM_STORAGE_ID" .pushwork/config.json; then - log_success "custom storage ID saved in config" - ((TESTS_PASSED++)) - else - log_error "custom storage ID not found in config" - ((TESTS_FAILED++)) - fi - ((TESTS_RUN++)) - fi - - cd .. - - # Test error cases - run_test "init already initialized directory" "$PUSHWORK_CMD init test-init-default" 1 - run_test "init with only sync-server (should fail)" "$PUSHWORK_CMD init test-fail --sync-server $CUSTOM_SYNC_SERVER" 1 - run_test "init with only storage-id (should fail)" "$PUSHWORK_CMD init test-fail --sync-server-storage-id $CUSTOM_STORAGE_ID" 1 -} - -test_status_functionality() { - log_info "=== Testing Status Functionality ===" - - cd test-init-default - run_test "status in initialized directory" "$PUSHWORK_CMD status" - cd .. - - run_test "status in non-initialized directory" "$PUSHWORK_CMD status" 1 -} - -test_commit_functionality() { - log_info "=== Testing Commit Functionality ===" - - cd test-init-default - - # Add some files - echo "New content" > new-file.txt - echo "Modified content" >> test.txt - - run_test "commit with changes" "$PUSHWORK_CMD commit ." - run_test "commit dry-run" "$PUSHWORK_CMD commit . --dry-run" - - cd .. -} - -test_sync_functionality() { - log_info "=== Testing Sync Functionality ===" - - cd test-init-default - - run_test "sync in initialized directory" "$PUSHWORK_CMD sync" - run_test "sync dry-run" "$PUSHWORK_CMD sync --dry-run" - run_test "sync local-only" "$PUSHWORK_CMD sync --local-only" - - cd .. -} - -test_diff_functionality() { - log_info "=== Testing Diff Functionality ===" - - cd test-init-default - - # Make some changes - echo "Diff test content" > diff-test.txt - - run_test "diff command" "$PUSHWORK_CMD diff" - run_test "diff name-only" "$PUSHWORK_CMD diff --name-only" - run_test "diff local-only" "$PUSHWORK_CMD diff --local-only" - - cd .. -} - -test_clone_functionality() { - log_info "=== Testing Clone Functionality ===" - - # First, we need a valid URL to clone from the initialized directory - cd test-init-default - - if [ -f .pushwork/snapshot.json ]; then - ROOT_URL=$($PUSHWORK_CMD url .) - - if [ -n "$ROOT_URL" ]; then - cd .. - - # Test clone with default settings - run_test "clone with default settings" "$PUSHWORK_CMD clone $ROOT_URL test-clone-default" - - if [ -d test-clone-default ]; then - run_test "cloned directory has .pushwork" "[ -d test-clone-default/.pushwork ]" - run_test "cloned directory has files" "[ -f test-clone-default/test.txt ]" - fi - - # Test clone with custom sync server - run_test "clone with custom sync server" "$PUSHWORK_CMD clone $ROOT_URL test-clone-custom --sync-server $CUSTOM_SYNC_SERVER --sync-server-storage-id $CUSTOM_STORAGE_ID" - - # Test clone error cases - mkdir -p existing-dir - echo "existing" > existing-dir/file.txt - run_test "clone to non-empty directory (should fail)" "$PUSHWORK_CMD clone $ROOT_URL existing-dir" 1 - run_test "clone with force to non-empty directory" "$PUSHWORK_CMD clone $ROOT_URL existing-dir --force" - - run_test "clone with only sync-server (should fail)" "$PUSHWORK_CMD clone $ROOT_URL test-fail --sync-server $CUSTOM_SYNC_SERVER" 1 - run_test "clone with only storage-id (should fail)" "$PUSHWORK_CMD clone $ROOT_URL test-fail --sync-server-storage-id $CUSTOM_STORAGE_ID" 1 - else - log_warning "No valid root URL found, skipping clone tests" - fi - else - log_warning "Snapshot missing - repository not properly initialized, skipping clone tests" - fi - - cd .. -} - -test_bidirectional_sync() { - log_info "=== Testing Bidirectional Sync ===" - - # This test requires both directories to be properly initialized - if [ -d test-init-default ] && [ -d test-clone-default ]; then - # Add content in original - cd test-init-default - echo "From original" > sync-test.txt - $PUSHWORK_CMD commit . > /dev/null 2>&1 || true - cd .. - - # Add different content in clone - cd test-clone-default - echo "From clone" > sync-test-clone.txt - $PUSHWORK_CMD commit . > /dev/null 2>&1 || true - cd .. - - # Try to sync both - cd test-init-default - run_test "sync from original" "$PUSHWORK_CMD sync --local-only" - cd .. - - cd test-clone-default - run_test "sync from clone" "$PUSHWORK_CMD sync --local-only" - cd .. - else - log_warning "Clone directories not available, skipping bidirectional sync test" - fi -} - -test_file_operations() { - log_info "=== Testing File Operations ===" - - mkdir -p test-file-ops - cd test-file-ops - - # Initialize - $PUSHWORK_CMD init . > /dev/null 2>&1 - - # Test various file operations - echo "Text file" > text.txt - echo -e "\x89PNG\r\n\x1a\n" > binary.png # Fake PNG header - mkdir -p subdir - echo "Subdirectory file" > subdir/nested.txt - - run_test "commit various file types" "$PUSHWORK_CMD commit ." - run_test "status after file operations" "$PUSHWORK_CMD status" - - # Modify files - echo "Modified text" >> text.txt - rm binary.png - echo "New file" > new.txt - - run_test "diff after modifications" "$PUSHWORK_CMD diff --name-only" - run_test "commit modifications" "$PUSHWORK_CMD commit ." - - cd .. -} - -# Main test execution -main() { - echo "======================================" - echo "Pushwork Integration Test Suite" - echo "======================================" - - # Trap cleanup on exit - trap cleanup EXIT - - # Setup - setup - - # Run all tests - test_help_commands - test_init_functionality - test_status_functionality - test_commit_functionality - test_sync_functionality - test_diff_functionality - test_clone_functionality - test_bidirectional_sync - test_file_operations - - # Summary - echo "" - echo "======================================" - echo "Test Results Summary" - echo "======================================" - echo "Tests Run: $TESTS_RUN" - echo "Tests Passed: $TESTS_PASSED" - echo "Tests Failed: $TESTS_FAILED" - - if [ $TESTS_FAILED -eq 0 ]; then - log_success "All tests passed!" - exit 0 - else - log_error "Some tests failed!" - exit 1 - fi -} - -# Check if jq is available (optional dependency) -if ! command -v jq &> /dev/null; then - log_warning "jq is not installed - some tests may be skipped" -fi - -# Run the tests -main "$@" \ No newline at end of file diff --git a/test/integration/fuzzer.test.ts b/test/integration/fuzzer.test.ts deleted file mode 100644 index c369fda..0000000 --- a/test/integration/fuzzer.test.ts +++ /dev/null @@ -1,818 +0,0 @@ -import * as fs from "fs/promises"; -import * as path from "path"; -import * as tmp from "tmp"; -import { execFile } from "child_process"; -import { promisify } from "util"; -import * as crypto from "crypto"; -import * as fc from "fast-check"; - -const execFilePromise = promisify(execFile); - -// Path to the pushwork CLI -const PUSHWORK_CLI = path.join(__dirname, "../../dist/cli.js"); - -describe("Pushwork Fuzzer", () => { - let tmpDir: string; - let cleanup: () => void; - - beforeEach(() => { - const tmpObj = tmp.dirSync({ unsafeCleanup: true }); - tmpDir = tmpObj.name; - cleanup = tmpObj.removeCallback; - }); - - afterEach(() => { - cleanup(); - }); - - /** - * Helper: Wait for a short time (useful for allowing sync to complete) - */ - async function wait(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); - } - - /** - * Helper: Execute pushwork CLI command with retry logic for transient errors - */ - async function pushwork( - args: string[], - cwd: string, - maxRetries: number = 3 - ): Promise<{ stdout: string; stderr: string }> { - let lastError: Error | null = null; - - for (let attempt = 1; attempt <= maxRetries; attempt++) { - try { - const result = await execFilePromise("node", [PUSHWORK_CLI, ...args], { - cwd, - env: { ...process.env, FORCE_COLOR: "0" }, // Disable color codes for cleaner output - }); - return result; - } catch (error: any) { - lastError = error; - const errorMessage = error.message + (error.stderr || ""); - - // Retry on transient server errors (502, 503, connection refused, unavailable) - const isTransient = - errorMessage.includes("502") || - errorMessage.includes("503") || - errorMessage.includes("ECONNREFUSED") || - errorMessage.includes("ETIMEDOUT") || - errorMessage.includes("unavailable"); - - if (isTransient && attempt < maxRetries) { - // Exponential backoff: 1s, 2s, 4s - const delay = Math.pow(2, attempt - 1) * 1000; - await wait(delay); - continue; - } - - // Non-transient error or exhausted retries - throw new Error( - `pushwork ${args.join(" ")} failed: ${error.message}\nstdout: ${ - error.stdout - }\nstderr: ${error.stderr}` - ); - } - } - - // Should never reach here, but TypeScript needs this - throw lastError; - } - - /** - * Helper: Compute hash of all files in a directory (excluding .pushwork) - */ - async function hashDirectory(dirPath: string): Promise { - const files = await getAllFiles(dirPath); - const hash = crypto.createHash("sha256"); - - // Sort files for consistent hashing - files.sort(); - - for (const file of files) { - // Skip .pushwork directory - if (file.includes(".pushwork")) { - continue; - } - - const fullPath = path.join(dirPath, file); - const content = await fs.readFile(fullPath); - - // Include relative path in hash to catch renames/moves - hash.update(file); - hash.update(content); - } - - return hash.digest("hex"); - } - - /** - * Helper: Recursively get all files in a directory - */ - async function getAllFiles( - dirPath: string, - basePath: string = dirPath - ): Promise { - const entries = await fs.readdir(dirPath, { withFileTypes: true }); - const files: string[] = []; - - for (const entry of entries) { - const fullPath = path.join(dirPath, entry.name); - const relativePath = path.relative(basePath, fullPath); - - if (entry.isDirectory()) { - // Skip .pushwork directory - if (entry.name === ".pushwork") { - continue; - } - const subFiles = await getAllFiles(fullPath, basePath); - files.push(...subFiles); - } else if (entry.isFile()) { - files.push(relativePath); - } - } - - return files; - } - - describe("Basic Setup and Clone", () => { - it("should initialize a repo with a single file and clone it successfully", async () => { - // Create two directories for testing - const repoA = path.join(tmpDir, "repo-a"); - const repoB = path.join(tmpDir, "repo-b"); - await fs.mkdir(repoA); - await fs.mkdir(repoB); - - // Step 1: Create a file in repo A - const testFile = path.join(repoA, "test.txt"); - await fs.writeFile(testFile, "Hello, Pushwork!"); - - // Step 2: Initialize repo A - await pushwork(["init", "."], repoA); - - // Wait a moment for initialization to complete - await wait(1000); - - // Step 3: Get the root URL from repo A - const { stdout: rootUrl } = await pushwork(["url"], repoA); - const cleanRootUrl = rootUrl.trim(); - - expect(cleanRootUrl).toMatch(/^automerge:/); - - // Step 4: Clone repo A to repo B - await pushwork(["clone", cleanRootUrl, repoB], tmpDir); - - // Wait a moment for clone to complete - await wait(1000); - - // Step 5: Verify both repos have the same content - const hashA = await hashDirectory(repoA); - const hashB = await hashDirectory(repoB); - - expect(hashA).toBe(hashB); - - // Step 6: Verify the file exists in both repos - const fileAExists = await pathExists(path.join(repoA, "test.txt")); - const fileBExists = await pathExists(path.join(repoB, "test.txt")); - - expect(fileAExists).toBe(true); - expect(fileBExists).toBe(true); - - // Step 7: Verify the content is the same - const contentA = await fs.readFile(path.join(repoA, "test.txt"), "utf-8"); - const contentB = await fs.readFile(path.join(repoB, "test.txt"), "utf-8"); - - expect(contentA).toBe("Hello, Pushwork!"); - expect(contentB).toBe("Hello, Pushwork!"); - expect(contentA).toBe(contentB); - }, 30000); // 30 second timeout for this test - }); - - describe("Manual Fuzzing Tests", () => { - it.concurrent( - "should handle a simple edit on one side", - async () => { - const tmpObj = tmp.dirSync({ unsafeCleanup: true }); - const testRoot = path.join( - tmpObj.name, - `test-manual-a-${Date.now()}-${Math.random()}` - ); - await fs.mkdir(testRoot, { recursive: true }); - const repoA = path.join(testRoot, "manual-a"); - const repoB = path.join(testRoot, "manual-b"); - await fs.mkdir(repoA); - await fs.mkdir(repoB); - - // Initialize repo A with a file - await fs.writeFile(path.join(repoA, "test.txt"), "initial content"); - await pushwork(["init", "."], repoA); - await wait(500); - - // Clone to B - const { stdout: rootUrl } = await pushwork(["url"], repoA); - await pushwork(["clone", rootUrl.trim(), repoB], tmpDir); - await wait(500); - - // Edit file on A - await fs.writeFile(path.join(repoA, "test.txt"), "modified content"); - - // Sync A - await pushwork(["sync", "--gentle"], repoA); - await wait(1000); - - // Sync B to pull changes - await pushwork(["sync", "--gentle"], repoB); - await wait(1000); - - // Verify they match - const contentA = await fs.readFile( - path.join(repoA, "test.txt"), - "utf-8" - ); - const contentB = await fs.readFile( - path.join(repoB, "test.txt"), - "utf-8" - ); - - expect(contentA).toBe("modified content"); - expect(contentB).toBe("modified content"); - - // Cleanup - tmpObj.removeCallback(); - }, - 30000 - ); - - it.concurrent( - "should handle edit + rename on one side", - async () => { - const tmpObj = tmp.dirSync({ unsafeCleanup: true }); - const testRoot = path.join( - tmpObj.name, - `test-rename-${Date.now()}-${Math.random()}` - ); - await fs.mkdir(testRoot, { recursive: true }); - const repoA = path.join(testRoot, "rename-a"); - const repoB = path.join(testRoot, "rename-b"); - await fs.mkdir(repoA); - await fs.mkdir(repoB); - - // Initialize repo A with a file - await fs.writeFile( - path.join(repoA, "original.txt"), - "original content" - ); - await pushwork(["init", "."], repoA); - await wait(500); - - // Clone to B - const { stdout: rootUrl } = await pushwork(["url"], repoA); - await pushwork(["clone", rootUrl.trim(), repoB], tmpDir); - await wait(500); - - // Edit AND rename file on A (the suspicious operation!) - await fs.writeFile(path.join(repoA, "original.txt"), "edited content"); - await fs.rename( - path.join(repoA, "original.txt"), - path.join(repoA, "renamed.txt") - ); - - // Sync both sides - await pushwork(["sync", "--gentle"], repoA); - await wait(1000); - await pushwork(["sync", "--gentle"], repoB); - await wait(1000); - - // One more round for convergence - await pushwork(["sync", "--gentle"], repoA); - await wait(1000); - await pushwork(["sync", "--gentle"], repoB); - await wait(1000); - - // Verify: original.txt should not exist, renamed.txt should exist with edited content - const originalExistsA = await pathExists( - path.join(repoA, "original.txt") - ); - const originalExistsB = await pathExists( - path.join(repoB, "original.txt") - ); - const renamedExistsA = await pathExists( - path.join(repoA, "renamed.txt") - ); - const renamedExistsB = await pathExists( - path.join(repoB, "renamed.txt") - ); - - expect(originalExistsA).toBe(false); - expect(originalExistsB).toBe(false); - expect(renamedExistsA).toBe(true); - expect(renamedExistsB).toBe(true); - - const contentA = await fs.readFile( - path.join(repoA, "renamed.txt"), - "utf-8" - ); - const contentB = await fs.readFile( - path.join(repoB, "renamed.txt"), - "utf-8" - ); - - expect(contentA).toBe("edited content"); - expect(contentB).toBe("edited content"); - - // Cleanup - tmpObj.removeCallback(); - }, - 120000 - ); // 2 minute timeout - - it.concurrent( - "should handle simplest case: clone then add file", - async () => { - const tmpObj = tmp.dirSync({ unsafeCleanup: true }); - const testRoot = path.join( - tmpObj.name, - `test-simple-${Date.now()}-${Math.random()}` - ); - await fs.mkdir(testRoot, { recursive: true }); - const repoA = path.join(testRoot, "simple-a"); - const repoB = path.join(testRoot, "simple-b"); - await fs.mkdir(repoA); - await fs.mkdir(repoB); - - // Initialize repo A - await fs.writeFile(path.join(repoA, "initial.txt"), "initial"); - await pushwork(["init", "."], repoA); - await wait(1000); - - // Clone to B - const { stdout: rootUrl } = await pushwork(["url"], repoA); - await pushwork(["clone", rootUrl.trim(), repoB], tmpDir); - await wait(1000); - - // B: Create a new file (nothing else happens) - await fs.writeFile(path.join(repoB, "aaa.txt"), ""); - - // B syncs - await pushwork(["sync", "--gentle"], repoB); - await wait(1000); - - // A syncs - await pushwork(["sync", "--gentle"], repoA); - await wait(1000); - - // Check convergence - const filesA = await fs.readdir(repoA); - const filesB = await fs.readdir(repoB); - const filteredFilesA = filesA.filter((f) => !f.startsWith(".")); - const filteredFilesB = filesB.filter((f) => !f.startsWith(".")); - expect(filteredFilesA).toEqual(filteredFilesB); - - expect(await pathExists(path.join(repoA, "aaa.txt"))).toBe(true); - expect(await pathExists(path.join(repoB, "aaa.txt"))).toBe(true); - - // Cleanup - tmpObj.removeCallback(); - }, - 120000 - ); - - it.concurrent( - "should handle minimal shrunk case: editAndRename non-existent + add same file", - async () => { - const tmpObj = tmp.dirSync({ unsafeCleanup: true }); - const testRoot = path.join( - tmpObj.name, - `test-shrunk-${Date.now()}-${Math.random()}` - ); - await fs.mkdir(testRoot, { recursive: true }); - const repoA = path.join(testRoot, "shrunk-a"); - const repoB = path.join(testRoot, "shrunk-b"); - await fs.mkdir(repoA); - await fs.mkdir(repoB); - - // Initialize repo A - await fs.writeFile(path.join(repoA, "initial.txt"), "initial"); - await pushwork(["init", "."], repoA); - await wait(1000); // Match manual test timing - - // Clone to B - const { stdout: rootUrl } = await pushwork(["url"], repoA); - await pushwork(["clone", rootUrl.trim(), repoB], tmpDir); - await wait(1000); // Match manual test timing - - // A: Try to editAndRename a non-existent file (this is from the shrunk test case) - // This operation should be a no-op since aaa.txt doesn't exist - const fromPath = path.join(repoA, "aaa.txt"); - const toPath = path.join(repoA, "aa/aa/aaa.txt"); - if ((await pathExists(fromPath)) && !(await pathExists(toPath))) { - await fs.writeFile(fromPath, ""); - await fs.mkdir(path.dirname(toPath), { recursive: true }); - await fs.rename(fromPath, toPath); - } - - // B: Create the same file that A tried to operate on - await fs.writeFile(path.join(repoB, "aaa.txt"), ""); - - // Sync multiple rounds (use 1s waits for reliable network propagation) - // Pattern: A, B, A (like manual test that worked) - await pushwork(["sync", "--gentle"], repoA); - await wait(1000); - - // Check what B sees before sync - await pushwork(["diff", "--name-only"], repoB); - - await pushwork(["sync", "--gentle"], repoB); - await wait(1000); - - await pushwork(["sync", "--gentle"], repoA); - await wait(1000); - - // Debug: Check what files exist - const filesA = await fs.readdir(repoA); - const filesB = await fs.readdir(repoB); - const filteredFilesA = filesA.filter((f) => !f.startsWith(".")); - const filteredFilesB = filesB.filter((f) => !f.startsWith(".")); - expect(filteredFilesA).toEqual(filteredFilesB); - - // Verify convergence - const hashA = await hashDirectory(repoA); - const hashB = await hashDirectory(repoB); - - expect(hashA).toBe(hashB); - - // Both should have aaa.txt - expect(await pathExists(path.join(repoA, "aaa.txt"))).toBe(true); - expect(await pathExists(path.join(repoB, "aaa.txt"))).toBe(true); - - // Cleanup - tmpObj.removeCallback(); - }, - 120000 - ); - - it.concurrent( - "should handle files in subdirectories and moves between directories", - async () => { - const tmpObj = tmp.dirSync({ unsafeCleanup: true }); - const testRoot = path.join( - tmpObj.name, - `test-subdir-${Date.now()}-${Math.random()}` - ); - await fs.mkdir(testRoot, { recursive: true }); - const repoA = path.join(testRoot, "subdir-a"); - const repoB = path.join(testRoot, "subdir-b"); - await fs.mkdir(repoA); - await fs.mkdir(repoB); - - // Initialize repo A with a file in a subdirectory - await fs.mkdir(path.join(repoA, "dir1"), { recursive: true }); - await fs.writeFile(path.join(repoA, "dir1", "file1.txt"), "in dir1"); - - await pushwork(["init", "."], repoA); - await wait(500); - - // Clone to B - const { stdout: rootUrl } = await pushwork(["url"], repoA); - await pushwork(["clone", rootUrl.trim(), repoB], tmpDir); - await wait(500); - - // Verify B got the subdirectory and file - expect(await pathExists(path.join(repoB, "dir1", "file1.txt"))).toBe( - true - ); - const initialContentB = await fs.readFile( - path.join(repoB, "dir1", "file1.txt"), - "utf-8" - ); - expect(initialContentB).toBe("in dir1"); - - // On A: Create another file in a different subdirectory - await fs.mkdir(path.join(repoA, "dir2"), { recursive: true }); - await fs.writeFile(path.join(repoA, "dir2", "file2.txt"), "in dir2"); - - // Sync both sides - await pushwork(["sync", "--gentle"], repoA); - await wait(1000); - await pushwork(["sync", "--gentle"], repoB); - await wait(1000); - - // Verify B got the new subdirectory and file - expect(await pathExists(path.join(repoB, "dir2", "file2.txt"))).toBe( - true - ); - const file2ContentB = await fs.readFile( - path.join(repoB, "dir2", "file2.txt"), - "utf-8" - ); - expect(file2ContentB).toBe("in dir2"); - - // Cleanup - tmpObj.removeCallback(); - }, - 30000 - ); - }); - - describe("Property-Based Fuzzing with fast-check", () => { - // Define operation types - type FileOperation = - | { type: "add"; path: string; content: string } - | { type: "edit"; path: string; content: string } - | { type: "delete"; path: string } - | { type: "rename"; fromPath: string; toPath: string } - | { - type: "editAndRename"; - fromPath: string; - toPath: string; - content: string; - }; - - /** - * Arbitrary: Generate a directory name - */ - const dirNameArbitrary = fc.stringMatching(/^[a-z]{2,6}$/); - - /** - * Arbitrary: Generate a simple filename (basename + extension) - */ - const baseNameArbitrary = fc - .tuple( - fc.stringMatching(/^[a-z]{3,8}$/), // basename - fc.constantFrom("txt", "md", "json", "ts") // extension - ) - .map(([name, ext]) => `${name}.${ext}`); - - /** - * Arbitrary: Generate a file path (can be in root or in subdirectories) - * Examples: "file.txt", "dir1/file.txt", "dir1/dir2/file.txt" - */ - const filePathArbitrary = fc.oneof( - // File in root directory (60% probability) - baseNameArbitrary, - // File in single subdirectory (30% probability) - fc - .tuple(dirNameArbitrary, baseNameArbitrary) - .map(([dir, file]) => `${dir}/${file}`), - // File in nested subdirectory (10% probability) - fc - .tuple(dirNameArbitrary, dirNameArbitrary, baseNameArbitrary) - .map(([dir1, dir2, file]) => `${dir1}/${dir2}/${file}`) - ); - - /** - * Arbitrary: Generate file content (small strings for now) - */ - const fileContentArbitrary = fc.string({ minLength: 0, maxLength: 100 }); - - /** - * Arbitrary: Generate a file operation - */ - const fileOperationArbitrary: fc.Arbitrary = fc.oneof( - // Add file (can be in subdirectories) - fc.record({ - type: fc.constant("add" as const), - path: filePathArbitrary, - content: fileContentArbitrary, - }), - // Edit file - fc.record({ - type: fc.constant("edit" as const), - path: filePathArbitrary, - content: fileContentArbitrary, - }), - // Delete file - fc.record({ - type: fc.constant("delete" as const), - path: filePathArbitrary, - }), - // Rename file (can move between directories) - fc.record({ - type: fc.constant("rename" as const), - fromPath: filePathArbitrary, - toPath: filePathArbitrary, - }), - // Edit and rename (can move between directories) - fc.record({ - type: fc.constant("editAndRename" as const), - fromPath: filePathArbitrary, - toPath: filePathArbitrary, - content: fileContentArbitrary, - }) - ); - - /** - * Helper: Ensure parent directory exists - */ - async function ensureParentDir(filePath: string): Promise { - const dir = path.dirname(filePath); - await fs.mkdir(dir, { recursive: true }); - } - - /** - * Helper: Apply a file operation to a directory - */ - async function applyOperation( - repoPath: string, - op: FileOperation - ): Promise { - try { - switch (op.type) { - case "add": { - const filePath = path.join(repoPath, op.path); - await ensureParentDir(filePath); - await fs.writeFile(filePath, op.content); - break; - } - case "edit": { - const filePath = path.join(repoPath, op.path); - // Only edit if file exists, otherwise create it - if (await pathExists(filePath)) { - await fs.writeFile(filePath, op.content); - } else { - await ensureParentDir(filePath); - await fs.writeFile(filePath, op.content); - } - break; - } - case "delete": { - const filePath = path.join(repoPath, op.path); - // Only delete if file exists - if (await pathExists(filePath)) { - await fs.unlink(filePath); - } - break; - } - case "rename": { - const fromPath = path.join(repoPath, op.fromPath); - const toPath = path.join(repoPath, op.toPath); - // Only rename if source exists and target doesn't - if ((await pathExists(fromPath)) && !(await pathExists(toPath))) { - await ensureParentDir(toPath); - await fs.rename(fromPath, toPath); - } - break; - } - case "editAndRename": { - const fromPath = path.join(repoPath, op.fromPath); - const toPath = path.join(repoPath, op.toPath); - // Edit then rename: only if source exists and target doesn't - if ((await pathExists(fromPath)) && !(await pathExists(toPath))) { - await fs.writeFile(fromPath, op.content); - await ensureParentDir(toPath); - await fs.rename(fromPath, toPath); - } - break; - } - } - } catch (error) { - // Ignore operation errors (e.g., deleting non-existent file) - // This is expected in fuzzing - } - } - - /** - * Helper: Apply multiple operations - */ - async function applyOperations( - repoPath: string, - operations: FileOperation[] - ): Promise { - for (const op of operations) { - await applyOperation(repoPath, op); - } - } - - it("should converge after random operations on both sides", async () => { - await fc.assert( - fc.asyncProperty( - fc.array(fileOperationArbitrary, { minLength: 1, maxLength: 10 }), // Operations on repo A (1-10 ops) - fc.array(fileOperationArbitrary, { minLength: 1, maxLength: 10 }), // Operations on repo B (1-10 ops) - async (opsA, opsB) => { - // Create two directories for testing - const testRoot = path.join( - tmpDir, - `test-${Date.now()}-${Math.random()}` - ); - await fs.mkdir(testRoot, { recursive: true }); - - const repoA = path.join(testRoot, "repo-a"); - const repoB = path.join(testRoot, "repo-b"); - await fs.mkdir(repoA); - await fs.mkdir(repoB); - - try { - // Initialize repo A with an initial file - await fs.writeFile(path.join(repoA, "initial.txt"), "initial"); - await pushwork(["init", "."], repoA); - // Give sync server time to store and propagate the document - await wait(2000); - - // Get root URL and clone to B - const { stdout: rootUrl } = await pushwork(["url"], repoA); - const cleanRootUrl = rootUrl.trim(); - // Clone with extra retries - document availability can be delayed - await pushwork(["clone", cleanRootUrl, repoB], testRoot, 5); - await wait(1000); - - // Verify initial state matches - const filesA = await getAllFiles(repoA); - const filesB = await getAllFiles(repoB); - const hashBeforeOps = await hashDirectory(repoA); - const hashB1 = await hashDirectory(repoB); - if (hashBeforeOps !== hashB1) { - throw new Error( - `Initial hash mismatch!\n` + - ` repoA (${repoA}):\n files: ${JSON.stringify(filesA)}\n hash: ${hashBeforeOps}\n` + - ` repoB (${repoB}):\n files: ${JSON.stringify(filesB)}\n hash: ${hashB1}` - ); - } - - // Apply operations to both sides - await applyOperations(repoA, opsA); - - await applyOperations(repoB, opsB); - - // Multiple sync rounds for convergence - // Need enough time for network propagation between CLI invocations - // Round 1: A pushes changes - await pushwork(["sync", "--gentle"], repoA); - await wait(1000); - - // Round 2: B pushes changes and pulls A's changes - await pushwork(["sync", "--gentle"], repoB); - await wait(1000); - - // Round 3: A pulls B's changes - await pushwork(["sync", "--gentle"], repoA); - await wait(1000); - - // Round 4: B confirms convergence - await pushwork(["sync", "--gentle"], repoB); - await wait(1000); - - // Round 5: Final convergence check - await pushwork(["sync", "--gentle"], repoA); - await wait(1000); - - // Round 6: Extra convergence check (for aggressive fuzzing) - await pushwork(["sync", "--gentle"], repoB); - await wait(5000); - - // Verify final state matches - - const hashAfterA = await hashDirectory(repoA); - const hashAfterB = await hashDirectory(repoB); - - expect(hashAfterA).toBe(hashAfterB); - - // Verify diff shows no changes - const { stdout: diffOutput } = await pushwork( - ["diff", "--name-only"], - repoA - ); - // Filter out status messages, only check for actual file differences - const diffLines = diffOutput - .split("\n") - .filter( - (line) => - line.trim() && - !line.includes("✓") && - !line.includes("Local-only") && - !line.includes("Root URL") - ); - expect(diffLines.length).toBe(0); - - // Cleanup - await fs.rm(testRoot, { recursive: true, force: true }); - } catch (error) { - // Cleanup on error - await fs - .rm(testRoot, { recursive: true, force: true }) - .catch(() => {}); - throw error; - } - } - ), - { - numRuns: 5, // INTENSE MODE (was 20, then cranked to 50) - timeout: 120000, // 2 minute timeout per run - verbose: true, // Verbose output - endOnFailure: true, // Stop on first failure to debug - } - ); - }, 600000); // 10 minute timeout for the whole test - }); -}); - -// Helper function -async function pathExists(filePath: string): Promise { - try { - await fs.access(filePath); - return true; - } catch { - return false; - } -} diff --git a/test/integration/in-memory-sync.test.ts b/test/integration/in-memory-sync.test.ts deleted file mode 100644 index 3e7df40..0000000 --- a/test/integration/in-memory-sync.test.ts +++ /dev/null @@ -1,830 +0,0 @@ -/** - * Sync Reliability Tests - * - * These tests verify sync reliability using the CLI subprocess pattern - * (same as fuzzer.test.ts) but with convergence-based assertions. - * - * Key difference from fuzzer tests: instead of fixed delays, we use - * convergence detection to know when sync is complete. - */ - -import * as fs from "fs/promises"; -import * as path from "path"; -import * as tmp from "tmp"; -import { execFile } from "child_process"; -import { promisify } from "util"; -import * as crypto from "crypto"; - -const execFilePromise = promisify(execFile); - -// Path to the pushwork CLI -const PUSHWORK_CLI = path.join(__dirname, "../../dist/cli.js"); - -/** - * Execute pushwork CLI command - */ -async function pushwork( - args: string[], - cwd: string -): Promise<{ stdout: string; stderr: string }> { - try { - const result = await execFilePromise("node", [PUSHWORK_CLI, ...args], { - cwd, - env: { ...process.env, FORCE_COLOR: "0" }, - }); - return result; - } catch (error: any) { - throw new Error( - `pushwork ${args.join(" ")} failed: ${error.message}\nstdout: ${error.stdout}\nstderr: ${error.stderr}` - ); - } -} - -/** - * Compute hash of all files in a directory (excluding .pushwork) - */ -async function hashDirectory(dirPath: string): Promise { - const files = await getAllFiles(dirPath); - const hash = crypto.createHash("sha256"); - - files.sort(); - - for (const file of files) { - if (file.includes(".pushwork")) continue; - - const fullPath = path.join(dirPath, file); - const content = await fs.readFile(fullPath); - - hash.update(file); - hash.update(content); - } - - return hash.digest("hex"); -} - -/** - * Recursively get all files in a directory - */ -async function getAllFiles( - dirPath: string, - basePath: string = dirPath -): Promise { - const entries = await fs.readdir(dirPath, { withFileTypes: true }); - const files: string[] = []; - - for (const entry of entries) { - const fullPath = path.join(dirPath, entry.name); - const relativePath = path.relative(basePath, fullPath); - - if (entry.isDirectory()) { - if (entry.name === ".pushwork") continue; - const subFiles = await getAllFiles(fullPath, basePath); - files.push(...subFiles); - } else if (entry.isFile()) { - files.push(relativePath); - } - } - - return files; -} - -/** - * Check if a path exists - */ -async function pathExists(filePath: string): Promise { - try { - await fs.access(filePath); - return true; - } catch { - return false; - } -} - -/** - * Sync until repos converge or max rounds reached. - * Returns the number of rounds it took to converge, or throws if it didn't. - * - * This is the key helper - instead of fixed delays, we sync until convergence. - */ -async function syncUntilConverged( - repoA: string, - repoB: string, - options: { - maxRounds?: number; - timeoutMs?: number; - } = {} -): Promise<{ rounds: number; hashA: string; hashB: string }> { - const { maxRounds = 5, timeoutMs = 60000 } = options; - const startTime = Date.now(); - - for (let round = 1; round <= maxRounds; round++) { - if (Date.now() - startTime > timeoutMs) { - const hashA = await hashDirectory(repoA); - const hashB = await hashDirectory(repoB); - throw new Error( - `Sync timeout after ${round - 1} rounds and ${Date.now() - startTime}ms. ` + - `hashA=${hashA.slice(0, 8)}, hashB=${hashB.slice(0, 8)}` - ); - } - - // Sync both repos (use --gentle for incremental sync) - await pushwork(["sync", "--gentle"], repoA); - await pushwork(["sync", "--gentle"], repoB); - - // Check if converged - const hashA = await hashDirectory(repoA); - const hashB = await hashDirectory(repoB); - - if (hashA === hashB) { - return { rounds: round, hashA, hashB }; - } - } - - const hashA = await hashDirectory(repoA); - const hashB = await hashDirectory(repoB); - throw new Error( - `Failed to converge after ${maxRounds} sync rounds. ` + - `hashA=${hashA.slice(0, 8)}, hashB=${hashB.slice(0, 8)}` - ); -} - -describe("Sync Reliability Tests", () => { - let tmpDir: string; - let cleanup: () => void; - - beforeEach(() => { - const tmpObj = tmp.dirSync({ unsafeCleanup: true }); - tmpDir = tmpObj.name; - cleanup = tmpObj.removeCallback; - }); - - afterEach(() => { - cleanup(); - }); - - describe("Basic Two-Repo Sync", () => { - /** - * STRICT TEST: Check state immediately after clone, no extra syncs. - * This should expose the same issues as the fuzzer. - */ - it("should have matching state immediately after clone (strict)", async () => { - const repoA = path.join(tmpDir, "repo-a"); - const repoB = path.join(tmpDir, "repo-b"); - await fs.mkdir(repoA); - await fs.mkdir(repoB); - - // Create file and init A - await fs.writeFile(path.join(repoA, "test.txt"), "Hello from A"); - await pushwork(["init", "."], repoA); - - // Clone to B (no extra syncs!) - const { stdout: rootUrl } = await pushwork(["url"], repoA); - await pushwork(["clone", rootUrl.trim(), repoB], tmpDir); - - // STRICT: Check immediately, no syncUntilConverged - const hashA = await hashDirectory(repoA); - const hashB = await hashDirectory(repoB); - - // Debug output if they don't match - if (hashA !== hashB) { - const filesA = await getAllFiles(repoA); - const filesB = await getAllFiles(repoB); - console.log("MISMATCH DETECTED:"); - console.log(" repoA files:", filesA.filter(f => !f.includes(".pushwork"))); - console.log(" repoB files:", filesB.filter(f => !f.includes(".pushwork"))); - console.log(" hashA:", hashA.slice(0, 16)); - console.log(" hashB:", hashB.slice(0, 16)); - } - - expect(hashA).toBe(hashB); - - // Verify file exists in both - expect(await pathExists(path.join(repoA, "test.txt"))).toBe(true); - expect(await pathExists(path.join(repoB, "test.txt"))).toBe(true); - - // Verify content matches - const contentA = await fs.readFile(path.join(repoA, "test.txt"), "utf-8"); - const contentB = await fs.readFile(path.join(repoB, "test.txt"), "utf-8"); - expect(contentA).toBe("Hello from A"); - expect(contentB).toBe("Hello from A"); - }, 60000); - - it("should sync a file from A to B (with convergence)", async () => { - const repoA = path.join(tmpDir, "repo-a"); - const repoB = path.join(tmpDir, "repo-b"); - await fs.mkdir(repoA); - await fs.mkdir(repoB); - - // Create file and init A - await fs.writeFile(path.join(repoA, "test.txt"), "Hello from A"); - await pushwork(["init", "."], repoA); - - // Clone to B - const { stdout: rootUrl } = await pushwork(["url"], repoA); - await pushwork(["clone", rootUrl.trim(), repoB], tmpDir); - - // Verify convergence (allows retries) - const { rounds, hashA, hashB } = await syncUntilConverged(repoA, repoB); - - expect(hashA).toBe(hashB); - expect(rounds).toBeLessThanOrEqual(2); // Should converge quickly - - // Verify content - const contentA = await fs.readFile(path.join(repoA, "test.txt"), "utf-8"); - const contentB = await fs.readFile(path.join(repoB, "test.txt"), "utf-8"); - expect(contentA).toBe(contentB); - expect(contentA).toBe("Hello from A"); - }, 60000); - - it("should sync a new file added to B back to A", async () => { - const repoA = path.join(tmpDir, "repo-a"); - const repoB = path.join(tmpDir, "repo-b"); - await fs.mkdir(repoA); - await fs.mkdir(repoB); - - // Init A with initial file - await fs.writeFile(path.join(repoA, "initial.txt"), "initial"); - await pushwork(["init", "."], repoA); - - // Clone to B - const { stdout: rootUrl } = await pushwork(["url"], repoA); - await pushwork(["clone", rootUrl.trim(), repoB], tmpDir); - - // Initial convergence - await syncUntilConverged(repoA, repoB); - - // B creates new file - await fs.writeFile(path.join(repoB, "from-b.txt"), "Created by B"); - - // Sync until converged - const { rounds } = await syncUntilConverged(repoA, repoB); - - expect(rounds).toBeLessThanOrEqual(3); - - // Verify A got B's file - expect(await pathExists(path.join(repoA, "from-b.txt"))).toBe(true); - const content = await fs.readFile(path.join(repoA, "from-b.txt"), "utf-8"); - expect(content).toBe("Created by B"); - }, 60000); - - it("should sync subdirectories correctly", async () => { - const repoA = path.join(tmpDir, "repo-a"); - const repoB = path.join(tmpDir, "repo-b"); - await fs.mkdir(repoA); - await fs.mkdir(repoB); - - // Create nested structure in A - await fs.mkdir(path.join(repoA, "subdir"), { recursive: true }); - await fs.writeFile(path.join(repoA, "subdir", "nested.txt"), "Nested content"); - await pushwork(["init", "."], repoA); - - // Clone to B - const { stdout: rootUrl } = await pushwork(["url"], repoA); - await pushwork(["clone", rootUrl.trim(), repoB], tmpDir); - - // Verify convergence - const { rounds } = await syncUntilConverged(repoA, repoB); - - expect(rounds).toBeLessThanOrEqual(2); - - // Verify B got the nested file - expect(await pathExists(path.join(repoB, "subdir", "nested.txt"))).toBe(true); - const content = await fs.readFile(path.join(repoB, "subdir", "nested.txt"), "utf-8"); - expect(content).toBe("Nested content"); - }, 60000); - }); - - describe("Concurrent Operations", () => { - it("should handle concurrent file creation on both sides", async () => { - const repoA = path.join(tmpDir, "repo-a"); - const repoB = path.join(tmpDir, "repo-b"); - await fs.mkdir(repoA); - await fs.mkdir(repoB); - - // Init A - await fs.writeFile(path.join(repoA, "initial.txt"), "initial"); - await pushwork(["init", "."], repoA); - - // Clone to B - const { stdout: rootUrl } = await pushwork(["url"], repoA); - await pushwork(["clone", rootUrl.trim(), repoB], tmpDir); - - // Initial convergence - await syncUntilConverged(repoA, repoB); - - // Both create files concurrently (before syncing) - await fs.writeFile(path.join(repoA, "file-a.txt"), "From A"); - await fs.writeFile(path.join(repoB, "file-b.txt"), "From B"); - - // Sync until converged - const { rounds } = await syncUntilConverged(repoA, repoB); - - expect(rounds).toBeLessThanOrEqual(3); - - // Both should have both files - expect(await pathExists(path.join(repoA, "file-a.txt"))).toBe(true); - expect(await pathExists(path.join(repoA, "file-b.txt"))).toBe(true); - expect(await pathExists(path.join(repoB, "file-a.txt"))).toBe(true); - expect(await pathExists(path.join(repoB, "file-b.txt"))).toBe(true); - }, 60000); - - it("should handle file modification sync", async () => { - const repoA = path.join(tmpDir, "repo-a"); - const repoB = path.join(tmpDir, "repo-b"); - await fs.mkdir(repoA); - await fs.mkdir(repoB); - - // Init A with file - await fs.writeFile(path.join(repoA, "shared.txt"), "Original"); - await pushwork(["init", "."], repoA); - - // Clone to B - const { stdout: rootUrl } = await pushwork(["url"], repoA); - await pushwork(["clone", rootUrl.trim(), repoB], tmpDir); - - // Initial convergence - await syncUntilConverged(repoA, repoB); - - // A modifies the file - await fs.writeFile(path.join(repoA, "shared.txt"), "Modified by A"); - - // Sync until converged - const { rounds } = await syncUntilConverged(repoA, repoB); - - expect(rounds).toBeLessThanOrEqual(3); - - // B should have the modification - const contentB = await fs.readFile(path.join(repoB, "shared.txt"), "utf-8"); - expect(contentB).toBe("Modified by A"); - }, 60000); - - it("should handle file deletion sync", async () => { - const repoA = path.join(tmpDir, "repo-a"); - const repoB = path.join(tmpDir, "repo-b"); - await fs.mkdir(repoA); - await fs.mkdir(repoB); - - // Init A with file - await fs.writeFile(path.join(repoA, "to-delete.txt"), "Will be deleted"); - await pushwork(["init", "."], repoA); - - // Clone to B - const { stdout: rootUrl } = await pushwork(["url"], repoA); - await pushwork(["clone", rootUrl.trim(), repoB], tmpDir); - - // Initial convergence - await syncUntilConverged(repoA, repoB); - - // Verify B has the file - expect(await pathExists(path.join(repoB, "to-delete.txt"))).toBe(true); - - // A deletes the file - await fs.unlink(path.join(repoA, "to-delete.txt")); - - // Sync until converged - const { rounds } = await syncUntilConverged(repoA, repoB); - - expect(rounds).toBeLessThanOrEqual(3); - - // File should be deleted in B - expect(await pathExists(path.join(repoB, "to-delete.txt"))).toBe(false); - }, 60000); - }); - - describe("Subdirectory File Deletion - Resurrection Bug", () => { - it("deleted file in artifact directory should not resurrect", async () => { - // Files in artifact directories (dist/ by default) resurrect after sync. - // Phase 1 (push) correctly removes the file entry from the directory doc, - // but the Automerge merge with the server's version re-introduces it. - // Phase 2 (pull) then sees it as a "new remote document" and re-creates it. - const repoA = path.join(tmpDir, "repo-a"); - await fs.mkdir(repoA); - - await fs.mkdir(path.join(repoA, "dist", "assets"), { recursive: true }); - await fs.writeFile(path.join(repoA, "dist", "assets", "app.js"), "// build 1"); - await pushwork(["init", "."], repoA); - await pushwork(["sync"], repoA); - - // Delete the file - await fs.unlink(path.join(repoA, "dist", "assets", "app.js")); - - // Sync - push deletion then pull - await pushwork(["sync"], repoA); - - // File should stay deleted - expect(await pathExists(path.join(repoA, "dist", "assets", "app.js"))).toBe(false); - - // Sync again - should NOT come back from server - await pushwork(["sync"], repoA); - expect(await pathExists(path.join(repoA, "dist", "assets", "app.js"))).toBe(false); - }, 60000); - - it("deleted file in depth-1 subdirectory should not resurrect (control)", async () => { - // Control: depth-1 subdirectories work correctly - const repoA = path.join(tmpDir, "repo-a"); - await fs.mkdir(repoA); - - await fs.mkdir(path.join(repoA, "subdir"), { recursive: true }); - await fs.writeFile(path.join(repoA, "subdir", "file.txt"), "content"); - await pushwork(["init", "."], repoA); - await pushwork(["sync"], repoA); - - await fs.unlink(path.join(repoA, "subdir", "file.txt")); - - await pushwork(["sync"], repoA); - expect(await pathExists(path.join(repoA, "subdir", "file.txt"))).toBe(false); - - await pushwork(["sync"], repoA); - expect(await pathExists(path.join(repoA, "subdir", "file.txt"))).toBe(false); - }, 60000); - - it("deleted build artifacts should not resurrect after rebuild cycle", async () => { - // Real-world scenario: build step creates new hashed files and deletes - // old ones in dist/assets/. The deleted files come back from the server. - const repoA = path.join(tmpDir, "repo-a"); - await fs.mkdir(repoA); - - await fs.mkdir(path.join(repoA, "dist", "assets"), { recursive: true }); - await fs.writeFile(path.join(repoA, "dist", "assets", "app-ABC123.js"), "// build 1"); - await fs.writeFile(path.join(repoA, "dist", "assets", "vendor-DEF456.js"), "// vendor 1"); - await fs.writeFile(path.join(repoA, "dist", "index.js"), "// index 1"); - await pushwork(["init", "."], repoA); - await pushwork(["sync"], repoA); - - // Simulate rebuild: new hashed files replace old ones - await fs.unlink(path.join(repoA, "dist", "assets", "app-ABC123.js")); - await fs.unlink(path.join(repoA, "dist", "assets", "vendor-DEF456.js")); - await fs.writeFile(path.join(repoA, "dist", "assets", "app-XYZ789.js"), "// build 2"); - await fs.writeFile(path.join(repoA, "dist", "assets", "vendor-UVW012.js"), "// vendor 2"); - await fs.writeFile(path.join(repoA, "dist", "index.js"), "// index 2"); - - await pushwork(["sync"], repoA); - - // Old files should be gone, new files should exist - expect(await pathExists(path.join(repoA, "dist", "assets", "app-ABC123.js"))).toBe(false); - expect(await pathExists(path.join(repoA, "dist", "assets", "vendor-DEF456.js"))).toBe(false); - expect(await pathExists(path.join(repoA, "dist", "assets", "app-XYZ789.js"))).toBe(true); - expect(await pathExists(path.join(repoA, "dist", "assets", "vendor-UVW012.js"))).toBe(true); - - // Sync again - old files should NOT come back from server - await pushwork(["sync"], repoA); - - expect(await pathExists(path.join(repoA, "dist", "assets", "app-ABC123.js"))).toBe(false); - expect(await pathExists(path.join(repoA, "dist", "assets", "vendor-DEF456.js"))).toBe(false); - }, 60000); - - it("deleted artifact files should not resurrect on clone", async () => { - // Two repos: A deletes files in an artifact directory, B should not - // see the deleted files after syncing. - const repoA = path.join(tmpDir, "repo-a"); - const repoB = path.join(tmpDir, "repo-b"); - await fs.mkdir(repoA); - await fs.mkdir(repoB); - - await fs.mkdir(path.join(repoA, "dist", "assets"), { recursive: true }); - await fs.writeFile(path.join(repoA, "dist", "assets", "app-ABC123.js"), "// build 1"); - await fs.writeFile(path.join(repoA, "dist", "assets", "vendor-DEF456.js"), "// vendor 1"); - await fs.writeFile(path.join(repoA, "dist", "index.js"), "// index 1"); - await pushwork(["init", "."], repoA); - - // Clone to B and converge - const { stdout: rootUrl } = await pushwork(["url"], repoA); - await pushwork(["clone", rootUrl.trim(), repoB], tmpDir); - await syncUntilConverged(repoA, repoB); - - expect(await pathExists(path.join(repoB, "dist", "assets", "app-ABC123.js"))).toBe(true); - - // A rebuilds - await fs.unlink(path.join(repoA, "dist", "assets", "app-ABC123.js")); - await fs.unlink(path.join(repoA, "dist", "assets", "vendor-DEF456.js")); - await fs.writeFile(path.join(repoA, "dist", "assets", "app-XYZ789.js"), "// build 2"); - await fs.writeFile(path.join(repoA, "dist", "assets", "vendor-UVW012.js"), "// vendor 2"); - await fs.writeFile(path.join(repoA, "dist", "index.js"), "// index 2"); - - // Sync A then B - await pushwork(["sync"], repoA); - - // A should not have resurrected files - expect(await pathExists(path.join(repoA, "dist", "assets", "app-ABC123.js"))).toBe(false); - expect(await pathExists(path.join(repoA, "dist", "assets", "vendor-DEF456.js"))).toBe(false); - - await pushwork(["sync"], repoB); - - // B should have new files, NOT old files - expect(await pathExists(path.join(repoB, "dist", "assets", "app-ABC123.js"))).toBe(false); - expect(await pathExists(path.join(repoB, "dist", "assets", "vendor-DEF456.js"))).toBe(false); - expect(await pathExists(path.join(repoB, "dist", "assets", "app-XYZ789.js"))).toBe(true); - }, 90000); - - it("deleted file in depth-3 subdirectory should not resurrect", async () => { - const repoA = path.join(tmpDir, "repo-a"); - await fs.mkdir(repoA); - - await fs.mkdir(path.join(repoA, "a", "b", "c"), { recursive: true }); - await fs.writeFile(path.join(repoA, "a", "b", "c", "deep.txt"), "deep"); - await pushwork(["init", "."], repoA); - await pushwork(["sync"], repoA); - - await fs.unlink(path.join(repoA, "a", "b", "c", "deep.txt")); - - await pushwork(["sync"], repoA); - expect(await pathExists(path.join(repoA, "a", "b", "c", "deep.txt"))).toBe(false); - - await pushwork(["sync"], repoA); - expect(await pathExists(path.join(repoA, "a", "b", "c", "deep.txt"))).toBe(false); - }, 60000); - - it("create+delete in same subdirectory should not resurrect deleted files", async () => { - // Regression guard: simultaneous create+delete in the same non-artifact - // subdirectory should work. This passes today but we don't want it to regress. - const repoA = path.join(tmpDir, "repo-a"); - await fs.mkdir(repoA); - - await fs.mkdir(path.join(repoA, "subdir"), { recursive: true }); - await fs.writeFile(path.join(repoA, "subdir", "old.txt"), "old content"); - await pushwork(["init", "."], repoA); - await pushwork(["sync"], repoA); - - // Simultaneously create new file and delete old file in same dir - await fs.unlink(path.join(repoA, "subdir", "old.txt")); - await fs.writeFile(path.join(repoA, "subdir", "new.txt"), "new content"); - - await pushwork(["sync"], repoA); - - expect(await pathExists(path.join(repoA, "subdir", "old.txt"))).toBe(false); - expect(await pathExists(path.join(repoA, "subdir", "new.txt"))).toBe(true); - - // Sync again - old file should NOT come back - await pushwork(["sync"], repoA); - - expect(await pathExists(path.join(repoA, "subdir", "old.txt"))).toBe(false); - expect(await pathExists(path.join(repoA, "subdir", "new.txt"))).toBe(true); - }, 60000); - - it("deleted file in depth-2 with sibling dirs should not resurrect", async () => { - // The depth-3 test has intermediate dirs (a/b/c) with only one child each. - // The dist/assets test has dist/ containing both assets/ (subdir) and - // index.js (file). Test if having a file sibling alongside the subdir matters. - const repoA = path.join(tmpDir, "repo-a"); - await fs.mkdir(repoA); - - await fs.mkdir(path.join(repoA, "parent", "child"), { recursive: true }); - await fs.writeFile(path.join(repoA, "parent", "sibling.txt"), "sibling at parent level"); - await fs.writeFile(path.join(repoA, "parent", "child", "target.txt"), "will be deleted"); - await pushwork(["init", "."], repoA); - await pushwork(["sync"], repoA); - - await fs.unlink(path.join(repoA, "parent", "child", "target.txt")); - - await pushwork(["sync"], repoA); - expect(await pathExists(path.join(repoA, "parent", "child", "target.txt"))).toBe(false); - expect(await pathExists(path.join(repoA, "parent", "sibling.txt"))).toBe(true); - - await pushwork(["sync"], repoA); - expect(await pathExists(path.join(repoA, "parent", "child", "target.txt"))).toBe(false); - }, 60000); - - it("deleted file in root directory should not resurrect", async () => { - const repoA = path.join(tmpDir, "repo-a"); - await fs.mkdir(repoA); - - await fs.writeFile(path.join(repoA, "root-file.txt"), "root content"); - await fs.writeFile(path.join(repoA, "keep.txt"), "keep this"); - await pushwork(["init", "."], repoA); - await pushwork(["sync"], repoA); - - // Delete file in root - await fs.unlink(path.join(repoA, "root-file.txt")); - - await pushwork(["sync"], repoA); - expect(await pathExists(path.join(repoA, "root-file.txt"))).toBe(false); - expect(await pathExists(path.join(repoA, "keep.txt"))).toBe(true); - - // Sync again - should NOT come back - await pushwork(["sync"], repoA); - expect(await pathExists(path.join(repoA, "root-file.txt"))).toBe(false); - }, 60000); - - it("deleted file in non-artifact subdirectory (src/) should not resurrect", async () => { - const repoA = path.join(tmpDir, "repo-a"); - await fs.mkdir(repoA); - - await fs.mkdir(path.join(repoA, "src"), { recursive: true }); - await fs.writeFile(path.join(repoA, "src", "index.ts"), "export default 1"); - await fs.writeFile(path.join(repoA, "src", "helper.ts"), "export function help() {}"); - await pushwork(["init", "."], repoA); - await pushwork(["sync"], repoA); - - // Delete one file in src/ - await fs.unlink(path.join(repoA, "src", "helper.ts")); - - await pushwork(["sync"], repoA); - expect(await pathExists(path.join(repoA, "src", "helper.ts"))).toBe(false); - expect(await pathExists(path.join(repoA, "src", "index.ts"))).toBe(true); - - // Sync again - should NOT come back - await pushwork(["sync"], repoA); - expect(await pathExists(path.join(repoA, "src", "helper.ts"))).toBe(false); - }, 60000); - - it("deleted files should not resurrect after multiple sync cycles", async () => { - // Simulate real-world usage: multiple syncs over time with deletions - const repoA = path.join(tmpDir, "repo-a"); - await fs.mkdir(repoA); - - await fs.mkdir(path.join(repoA, "src"), { recursive: true }); - await fs.writeFile(path.join(repoA, "readme.txt"), "readme"); - await fs.writeFile(path.join(repoA, "src", "app.ts"), "app"); - await fs.writeFile(path.join(repoA, "src", "old.ts"), "old"); - await pushwork(["init", "."], repoA); - await pushwork(["sync"], repoA); - - // Cycle 1: delete root file - await fs.unlink(path.join(repoA, "readme.txt")); - await pushwork(["sync"], repoA); - expect(await pathExists(path.join(repoA, "readme.txt"))).toBe(false); - - // Cycle 2: delete src file - await fs.unlink(path.join(repoA, "src", "old.ts")); - await pushwork(["sync"], repoA); - expect(await pathExists(path.join(repoA, "src", "old.ts"))).toBe(false); - - // Cycle 3: just sync - nothing should come back - await pushwork(["sync"], repoA); - expect(await pathExists(path.join(repoA, "readme.txt"))).toBe(false); - expect(await pathExists(path.join(repoA, "src", "old.ts"))).toBe(false); - expect(await pathExists(path.join(repoA, "src", "app.ts"))).toBe(true); - }, 90000); - - it("peer B should not see files deleted by peer A (root)", async () => { - const repoA = path.join(tmpDir, "repo-a"); - const repoB = path.join(tmpDir, "repo-b"); - await fs.mkdir(repoA); - await fs.mkdir(repoB); - - await fs.writeFile(path.join(repoA, "keep.txt"), "keep"); - await fs.writeFile(path.join(repoA, "delete-me.txt"), "gone"); - await pushwork(["init", "."], repoA); - - // Clone to B and converge - const { stdout: rootUrl } = await pushwork(["url"], repoA); - await pushwork(["clone", rootUrl.trim(), repoB], tmpDir); - await syncUntilConverged(repoA, repoB); - - expect(await pathExists(path.join(repoB, "delete-me.txt"))).toBe(true); - - // A deletes a root file - await fs.unlink(path.join(repoA, "delete-me.txt")); - await pushwork(["sync"], repoA); - - // B syncs - should see the deletion - await pushwork(["sync"], repoB); - expect(await pathExists(path.join(repoB, "delete-me.txt"))).toBe(false); - expect(await pathExists(path.join(repoB, "keep.txt"))).toBe(true); - - // B syncs again - should stay deleted - await pushwork(["sync"], repoB); - expect(await pathExists(path.join(repoB, "delete-me.txt"))).toBe(false); - }, 90000); - - it("peer B should not see files deleted by peer A (src/)", async () => { - const repoA = path.join(tmpDir, "repo-a"); - const repoB = path.join(tmpDir, "repo-b"); - await fs.mkdir(repoA); - await fs.mkdir(repoB); - - await fs.mkdir(path.join(repoA, "src"), { recursive: true }); - await fs.writeFile(path.join(repoA, "src", "index.ts"), "export default 1"); - await fs.writeFile(path.join(repoA, "src", "old.ts"), "old code"); - await pushwork(["init", "."], repoA); - - const { stdout: rootUrl } = await pushwork(["url"], repoA); - await pushwork(["clone", rootUrl.trim(), repoB], tmpDir); - await syncUntilConverged(repoA, repoB); - - expect(await pathExists(path.join(repoB, "src", "old.ts"))).toBe(true); - - // A deletes a file in src/ - await fs.unlink(path.join(repoA, "src", "old.ts")); - await pushwork(["sync"], repoA); - - // B syncs - should see the deletion - await pushwork(["sync"], repoB); - expect(await pathExists(path.join(repoB, "src", "old.ts"))).toBe(false); - expect(await pathExists(path.join(repoB, "src", "index.ts"))).toBe(true); - - // B syncs again - should stay deleted - await pushwork(["sync"], repoB); - expect(await pathExists(path.join(repoB, "src", "old.ts"))).toBe(false); - }, 90000); - - it("peer B should not see files deleted by peer A (dist/)", async () => { - const repoA = path.join(tmpDir, "repo-a"); - const repoB = path.join(tmpDir, "repo-b"); - await fs.mkdir(repoA); - await fs.mkdir(repoB); - - await fs.mkdir(path.join(repoA, "dist", "assets"), { recursive: true }); - await fs.writeFile(path.join(repoA, "dist", "index.js"), "// index"); - await fs.writeFile(path.join(repoA, "dist", "assets", "app-ABC.js"), "// build 1"); - await pushwork(["init", "."], repoA); - - const { stdout: rootUrl } = await pushwork(["url"], repoA); - await pushwork(["clone", rootUrl.trim(), repoB], tmpDir); - await syncUntilConverged(repoA, repoB); - - expect(await pathExists(path.join(repoB, "dist", "assets", "app-ABC.js"))).toBe(true); - - // A rebuilds: delete old artifact, create new one - await fs.unlink(path.join(repoA, "dist", "assets", "app-ABC.js")); - await fs.writeFile(path.join(repoA, "dist", "assets", "app-XYZ.js"), "// build 2"); - await pushwork(["sync"], repoA); - - // A should not have resurrected - expect(await pathExists(path.join(repoA, "dist", "assets", "app-ABC.js"))).toBe(false); - - // B syncs - should see new file, NOT old file - await pushwork(["sync"], repoB); - expect(await pathExists(path.join(repoB, "dist", "assets", "app-ABC.js"))).toBe(false); - expect(await pathExists(path.join(repoB, "dist", "assets", "app-XYZ.js"))).toBe(true); - - // B syncs again - old file should stay gone - await pushwork(["sync"], repoB); - expect(await pathExists(path.join(repoB, "dist", "assets", "app-ABC.js"))).toBe(false); - }, 90000); - - it("peer B should see artifact file content update after URL replacement", async () => { - // When peer A modifies an artifact file, the document is replaced entirely - // (new Automerge doc with a new URL). Peer B's snapshot still points to the - // old (now orphaned) URL. detectRemoteChanges sees no head change on the old - // doc, and detectNewRemoteDocuments skips paths already in the snapshot. - // Without URL replacement detection, B never sees the update. - const repoA = path.join(tmpDir, "repo-a"); - const repoB = path.join(tmpDir, "repo-b"); - await fs.mkdir(repoA); - await fs.mkdir(repoB); - - await fs.mkdir(path.join(repoA, "dist"), { recursive: true }); - await fs.writeFile(path.join(repoA, "dist", "app.js"), "// version 1"); - await pushwork(["init", "."], repoA); - - const { stdout: rootUrl } = await pushwork(["url"], repoA); - await pushwork(["clone", rootUrl.trim(), repoB], tmpDir); - await syncUntilConverged(repoA, repoB); - - const bContentV1 = await fs.readFile(path.join(repoB, "dist", "app.js"), "utf-8"); - expect(bContentV1).toBe("// version 1"); - - // A modifies the artifact file — this triggers nuclear replacement (new URL) - await fs.writeFile(path.join(repoA, "dist", "app.js"), "// version 2"); - await pushwork(["sync"], repoA); - - // B syncs — should pick up the new content despite the URL change - await pushwork(["sync"], repoB); - const bContentV2 = await fs.readFile(path.join(repoB, "dist", "app.js"), "utf-8"); - expect(bContentV2).toBe("// version 2"); - }, 90000); - }); - - describe("Move/Rename Detection", () => { - it("should handle file rename", async () => { - const repoA = path.join(tmpDir, "repo-a"); - const repoB = path.join(tmpDir, "repo-b"); - await fs.mkdir(repoA); - await fs.mkdir(repoB); - - // Init A with file - const content = "This content will be used for similarity detection during move"; - await fs.writeFile(path.join(repoA, "original.txt"), content); - await pushwork(["init", "."], repoA); - - // Clone to B - const { stdout: rootUrl } = await pushwork(["url"], repoA); - await pushwork(["clone", rootUrl.trim(), repoB], tmpDir); - - // Initial convergence - await syncUntilConverged(repoA, repoB); - - // A renames the file - await fs.rename( - path.join(repoA, "original.txt"), - path.join(repoA, "renamed.txt") - ); - - // Sync until converged - const { rounds } = await syncUntilConverged(repoA, repoB); - - expect(rounds).toBeLessThanOrEqual(3); - - // Verify both repos have renamed.txt and not original.txt - expect(await pathExists(path.join(repoA, "original.txt"))).toBe(false); - expect(await pathExists(path.join(repoA, "renamed.txt"))).toBe(true); - expect(await pathExists(path.join(repoB, "original.txt"))).toBe(false); - expect(await pathExists(path.join(repoB, "renamed.txt"))).toBe(true); - - // Verify content preserved - const contentB = await fs.readFile(path.join(repoB, "renamed.txt"), "utf-8"); - expect(contentB).toBe(content); - }, 60000); - }); -}); diff --git a/test/integration/init-sync.test.ts b/test/integration/init-sync.test.ts deleted file mode 100644 index 676d21c..0000000 --- a/test/integration/init-sync.test.ts +++ /dev/null @@ -1,89 +0,0 @@ -import * as fs from "fs/promises"; -import * as path from "path"; -import * as tmp from "tmp"; -import { execSync } from "child_process"; -import { SnapshotManager } from "../../src/core"; - -describe("Init Command Integration", () => { - let tmpDir: string; - let cleanup: () => void; - const pushworkCmd = `node "${path.join(__dirname, "../../dist/cli.js")}"`; - - beforeAll(() => { - // Build the project before running tests - execSync("pnpm build", { cwd: path.join(__dirname, "../.."), stdio: "pipe" }); - }); - - beforeEach(() => { - const tmpObj = tmp.dirSync({ unsafeCleanup: true }); - tmpDir = tmpObj.name; - cleanup = tmpObj.removeCallback; - }); - - afterEach(async () => { - cleanup(); - }); - - describe("Initial Sync", () => { - it("should sync existing files during init", async () => { - // Create some files before initializing - await fs.writeFile(path.join(tmpDir, "file1.txt"), "Hello, World!"); - await fs.writeFile(path.join(tmpDir, "file2.txt"), "Another file"); - await fs.mkdir(path.join(tmpDir, "subdir")); - await fs.writeFile( - path.join(tmpDir, "subdir", "nested.txt"), - "Nested content" - ); - - // Run pushwork init - execSync(`${pushworkCmd} init "${tmpDir}"`, { stdio: "pipe" }); - - // Verify snapshot was created with file entries - const snapshotManager = new SnapshotManager(tmpDir); - const snapshot = await snapshotManager.load(); - expect(snapshot).not.toBeNull(); - expect(snapshot!.files.size).toBeGreaterThanOrEqual(3); - expect(snapshot!.files.has("file1.txt")).toBe(true); - expect(snapshot!.files.has("file2.txt")).toBe(true); - expect(snapshot!.files.has("subdir/nested.txt")).toBe(true); - }); - - it("should handle empty directory during init", async () => { - // Run pushwork init on empty directory - execSync(`${pushworkCmd} init "${tmpDir}"`, { stdio: "pipe" }); - - // Verify snapshot was created (even if empty) - const snapshotManager = new SnapshotManager(tmpDir); - const snapshot = await snapshotManager.load(); - expect(snapshot).not.toBeNull(); - expect(snapshot!.files.size).toBe(0); - }); - - it("should respect exclude patterns during initial sync", async () => { - // Create files, including some that should be excluded by default - await fs.writeFile(path.join(tmpDir, "included.txt"), "Include me"); - await fs.mkdir(path.join(tmpDir, "node_modules")); - await fs.writeFile( - path.join(tmpDir, "node_modules", "package.json"), - "{}" - ); - await fs.mkdir(path.join(tmpDir, ".git")); - await fs.writeFile( - path.join(tmpDir, ".git", "config"), - "[core]" - ); - - // Run pushwork init - execSync(`${pushworkCmd} init "${tmpDir}"`, { stdio: "pipe" }); - - // Verify snapshot only contains included file - const snapshotManager = new SnapshotManager(tmpDir); - const snapshot = await snapshotManager.load(); - expect(snapshot).not.toBeNull(); - expect(snapshot!.files.has("included.txt")).toBe(true); - // node_modules and .git should be excluded by default - expect(snapshot!.files.has("node_modules/package.json")).toBe(false); - expect(snapshot!.files.has(".git/config")).toBe(false); - }); - }); -}); diff --git a/test/integration/manual-sync-test.sh b/test/integration/manual-sync-test.sh deleted file mode 100755 index 451ce37..0000000 --- a/test/integration/manual-sync-test.sh +++ /dev/null @@ -1,84 +0,0 @@ -#!/bin/bash -set -x # Print commands as they execute -set -e # Exit on error - -# Get absolute path to pushwork CLI -PUSHWORK_ROOT="$(cd "$(dirname "$0")/../.." && pwd)" -PUSHWORK_CLI="$PUSHWORK_ROOT/dist/cli.js" - -echo "Pushwork CLI: $PUSHWORK_CLI" - -# Create temp directory -TESTDIR=$(mktemp -d) -echo "Test directory: $TESTDIR" - -REPO_A="$TESTDIR/repo-a" -REPO_B="$TESTDIR/repo-b" - -mkdir -p "$REPO_A" -mkdir -p "$REPO_B" - -# Step 1: Create initial file in repo A -echo "=== Step 1: Creating initial file in repo A ===" -echo "initial content" > "$REPO_A/test.txt" -cat "$REPO_A/test.txt" - -# Step 2: Initialize repo A -echo "=== Step 2: Initializing repo A ===" -cd "$REPO_A" -node "$PUSHWORK_CLI" init . -sleep 1 - -# Step 3: Get root URL -echo "=== Step 3: Getting root URL from repo A ===" -ROOT_URL=$(node "$PUSHWORK_CLI" url) -echo "Root URL: $ROOT_URL" - -# Step 4: Clone to repo B -echo "=== Step 4: Cloning to repo B ===" -cd "$TESTDIR" -node "$PUSHWORK_CLI" clone "$ROOT_URL" "$REPO_B" -sleep 1 - -# Step 5: Verify initial state -echo "=== Step 5: Verifying initial state ===" -echo "Content in A:" -cat "$REPO_A/test.txt" -echo "Content in B:" -cat "$REPO_B/test.txt" - -# Step 6: Modify file in repo A -echo "=== Step 6: Modifying file in repo A ===" -echo "modified content" > "$REPO_A/test.txt" -echo "New content in A:" -cat "$REPO_A/test.txt" - -# Step 7: Sync repo A (THIS IS WHERE IT MIGHT HANG) -echo "=== Step 7: Syncing repo A ===" -cd "$REPO_A" -echo "Running sync in A at $(date)..." -timeout 10 node "$PUSHWORK_CLI" sync || echo "SYNC A TIMED OUT!" -echo "Sync A completed at $(date)" -sleep 1 - -# Step 8: Sync repo B -echo "=== Step 8: Syncing repo B ===" -cd "$REPO_B" -echo "Running sync in B at $(date)..." -timeout 10 node "$PUSHWORK_CLI" sync || echo "SYNC B TIMED OUT!" -echo "Sync B completed at $(date)" -sleep 1 - -# Step 9: Verify final state -echo "=== Step 9: Verifying final state ===" -echo "Final content in A:" -cat "$REPO_A/test.txt" -echo "Final content in B:" -cat "$REPO_B/test.txt" - -# Cleanup -echo "=== Cleanup ===" -echo "Test directory: $TESTDIR" -echo "To inspect manually: cd $TESTDIR" -# rm -rf "$TESTDIR" - diff --git a/test/integration/pushwork.test.ts b/test/integration/pushwork.test.ts new file mode 100644 index 0000000..5b4343e --- /dev/null +++ b/test/integration/pushwork.test.ts @@ -0,0 +1,547 @@ +/** + * Integration tests for pushwork. + * + * Black-box: drive the CLI as a subprocess, observe filesystem and stdout. + * No imports from src/ — these tests are the spec for what pushwork does, + * not how it does it. + * + * Run against both supported sync backends: + * - "legacy" → default WebSocket sync server (no flag) + * - "subduction" → --sub flag on init/clone (persisted in config; used + * automatically by subsequent sync runs) + * + * Tests hit the public sync servers, so each test allows generous time + * for network roundtrips. + */ + +import * as fs from "fs/promises"; +import * as path from "path"; +import * as crypto from "crypto"; +import * as tmp from "tmp"; +import { execFile } from "child_process"; +import { promisify } from "util"; + +const execFileP = promisify(execFile); +const CLI = path.join(__dirname, "..", "..", "dist", "cli.js"); + +const TEST_TIMEOUT = 120_000; + +type Backend = { name: string; flags: string[] }; +const BACKENDS: Backend[] = [ + { name: "legacy", flags: [] }, + { name: "subduction", flags: ["--sub"] }, +]; + +async function pushwork( + args: string[], + cwd?: string, +): Promise<{ stdout: string; stderr: string }> { + try { + return await execFileP("node", [CLI, ...args], { + cwd, + env: { ...process.env, FORCE_COLOR: "0", NO_COLOR: "1" }, + timeout: 90_000, + maxBuffer: 16 * 1024 * 1024, + }); + } catch (err: any) { + const detail = [ + `pushwork ${args.join(" ")} failed (cwd=${cwd ?? process.cwd()})`, + err.message, + err.stdout ? `stdout: ${err.stdout}` : "", + err.stderr ? `stderr: ${err.stderr}` : "", + ] + .filter(Boolean) + .join("\n"); + throw new Error(detail); + } +} + +async function pathExists(p: string): Promise { + try { + await fs.access(p); + return true; + } catch { + return false; + } +} + +async function readText(p: string): Promise { + return fs.readFile(p, "utf8"); +} + +async function listUserFiles(dir: string): Promise { + const out: string[] = []; + async function walk(current: string) { + const entries = await fs.readdir(current, { withFileTypes: true }); + for (const entry of entries) { + if (entry.name === ".pushwork") continue; + const full = path.join(current, entry.name); + if (entry.isDirectory()) { + await walk(full); + } else if (entry.isFile()) { + out.push(path.relative(dir, full)); + } + } + } + await walk(dir); + return out.sort(); +} + +async function hashUserContent(dir: string): Promise { + const files = await listUserFiles(dir); + const h = crypto.createHash("sha256"); + for (const f of files) { + h.update(f); + h.update("\0"); + h.update(await fs.readFile(path.join(dir, f))); + h.update("\0"); + } + return h.digest("hex"); +} + +async function syncOnce(repos: string[]): Promise { + for (const r of repos) await pushwork(["sync"], r); +} + +async function syncUntilConverged( + repos: string[], + maxRounds = 6, +): Promise { + for (let round = 1; round <= maxRounds; round++) { + await syncOnce(repos); + const hashes = await Promise.all(repos.map(hashUserContent)); + if (hashes.every((h) => h === hashes[0])) return round; + } + const hashes = await Promise.all(repos.map(hashUserContent)); + const debug = await Promise.all( + repos.map(async (r, i) => `${i}: ${(await listUserFiles(r)).join(",")}`), + ); + throw new Error( + `failed to converge after ${maxRounds} rounds:\n hashes: ${hashes + .map((h) => h.slice(0, 12)) + .join(" / ")}\n files:\n ${debug.join("\n ")}`, + ); +} + +beforeAll(async () => { + // Build once for the entire suite. + await execFileP("pnpm", ["build"], { + cwd: path.join(__dirname, "..", ".."), + timeout: 120_000, + }); +}, 120_000); + +describe.each(BACKENDS)("pushwork — $name backend", ({ flags }) => { + let workRoot: string; + let cleanup: () => void; + + beforeEach(() => { + const t = tmp.dirSync({ unsafeCleanup: true }); + workRoot = t.name; + cleanup = t.removeCallback; + }); + + afterEach(() => cleanup()); + + describe("init", () => { + it( + "succeeds on an empty directory", + async () => { + await pushwork(["init", ...flags, workRoot]); + expect(await listUserFiles(workRoot)).toEqual([]); + }, + TEST_TIMEOUT, + ); + + it( + "succeeds on a directory containing files", + async () => { + await fs.writeFile(path.join(workRoot, "a.txt"), "hello"); + await fs.writeFile(path.join(workRoot, "b.md"), "# B"); + await pushwork(["init", ...flags, workRoot]); + }, + TEST_TIMEOUT, + ); + + it( + "does not destroy or alter pre-existing user files", + async () => { + await fs.writeFile(path.join(workRoot, "keep.txt"), "do not touch"); + await fs.mkdir(path.join(workRoot, "subdir")); + await fs.writeFile( + path.join(workRoot, "subdir", "nested.txt"), + "nested", + ); + + await pushwork(["init", ...flags, workRoot]); + + expect(await readText(path.join(workRoot, "keep.txt"))).toBe( + "do not touch", + ); + expect( + await readText(path.join(workRoot, "subdir", "nested.txt")), + ).toBe("nested"); + }, + TEST_TIMEOUT, + ); + }); + + describe("url", () => { + it( + "prints an automerge: URL after init", + async () => { + await pushwork(["init", ...flags, workRoot]); + const { stdout } = await pushwork(["url"], workRoot); + expect(stdout.trim()).toMatch(/^automerge:[A-Za-z0-9]+/); + }, + TEST_TIMEOUT, + ); + + it( + "is stable across calls within one repo", + async () => { + await pushwork(["init", ...flags, workRoot]); + const a = (await pushwork(["url"], workRoot)).stdout.trim(); + const b = (await pushwork(["url"], workRoot)).stdout.trim(); + expect(a).toBe(b); + }, + TEST_TIMEOUT, + ); + + it( + "differs between two independently initialized repos", + async () => { + const r1 = path.join(workRoot, "r1"); + const r2 = path.join(workRoot, "r2"); + await fs.mkdir(r1); + await fs.mkdir(r2); + await pushwork(["init", ...flags], r1); + await pushwork(["init", ...flags], r2); + const u1 = (await pushwork(["url"], r1)).stdout.trim(); + const u2 = (await pushwork(["url"], r2)).stdout.trim(); + expect(u1).not.toBe(u2); + }, + TEST_TIMEOUT, + ); + }); + + describe("clone", () => { + it( + "reproduces a single text file", + async () => { + const a = path.join(workRoot, "a"); + const b = path.join(workRoot, "b"); + await fs.mkdir(a); + + await fs.writeFile(path.join(a, "hello.txt"), "Hello, World!"); + await pushwork(["init", ...flags], a); + + const url = (await pushwork(["url"], a)).stdout.trim(); + await pushwork(["clone", ...flags, url, b]); + + expect(await readText(path.join(b, "hello.txt"))).toBe("Hello, World!"); + }, + TEST_TIMEOUT, + ); + + it( + "reproduces a nested directory tree", + async () => { + const a = path.join(workRoot, "a"); + const b = path.join(workRoot, "b"); + await fs.mkdir(a); + await fs.mkdir(path.join(a, "src", "components"), { + recursive: true, + }); + await fs.writeFile(path.join(a, "package.json"), '{"name":"x"}'); + await fs.writeFile(path.join(a, "src", "index.ts"), "export {}"); + await fs.writeFile( + path.join(a, "src", "components", "Button.tsx"), + "export const Button = () => null", + ); + + await pushwork(["init", ...flags], a); + const url = (await pushwork(["url"], a)).stdout.trim(); + await pushwork(["clone", ...flags, url, b]); + + expect(await readText(path.join(b, "package.json"))).toBe( + '{"name":"x"}', + ); + expect(await readText(path.join(b, "src", "index.ts"))).toBe( + "export {}", + ); + expect( + await readText(path.join(b, "src", "components", "Button.tsx")), + ).toBe("export const Button = () => null"); + }, + TEST_TIMEOUT, + ); + + it( + "reproduces binary file content byte-for-byte", + async () => { + const a = path.join(workRoot, "a"); + const b = path.join(workRoot, "b"); + await fs.mkdir(a); + + const bytes = Buffer.from([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x01, 0x02, + 0xff, 0xfe, 0x10, 0x42, + ]); + await fs.writeFile(path.join(a, "image.png"), bytes); + + await pushwork(["init", ...flags], a); + const url = (await pushwork(["url"], a)).stdout.trim(); + await pushwork(["clone", ...flags, url, b]); + + const out = await fs.readFile(path.join(b, "image.png")); + expect(out.equals(bytes)).toBe(true); + }, + TEST_TIMEOUT, + ); + + it( + "a fresh clone reports the same URL as the source", + async () => { + const a = path.join(workRoot, "a"); + const b = path.join(workRoot, "b"); + await fs.mkdir(a); + await fs.writeFile(path.join(a, "x.txt"), "x"); + + await pushwork(["init", ...flags], a); + const urlA = (await pushwork(["url"], a)).stdout.trim(); + await pushwork(["clone", ...flags, urlA, b]); + + const urlB = (await pushwork(["url"], b)).stdout.trim(); + expect(urlB).toBe(urlA); + }, + TEST_TIMEOUT, + ); + }); + + describe("sync — propagation between two repos", () => { + async function setupPair() { + const a = path.join(workRoot, "a"); + const b = path.join(workRoot, "b"); + await fs.mkdir(a); + await pushwork(["init", ...flags], a); + const url = (await pushwork(["url"], a)).stdout.trim(); + await pushwork(["clone", ...flags, url, b]); + return { a, b }; + } + + it( + "propagates a new file from A to B", + async () => { + const { a, b } = await setupPair(); + + await fs.writeFile(path.join(a, "added.txt"), "new in A"); + await syncUntilConverged([a, b]); + + expect(await readText(path.join(b, "added.txt"))).toBe("new in A"); + }, + TEST_TIMEOUT, + ); + + it( + "propagates a new file from B to A", + async () => { + const { a, b } = await setupPair(); + + await fs.writeFile(path.join(b, "from-b.txt"), "new in B"); + await syncUntilConverged([a, b]); + + expect(await readText(path.join(a, "from-b.txt"))).toBe("new in B"); + }, + TEST_TIMEOUT, + ); + + it( + "propagates a modification", + async () => { + const { a, b } = await setupPair(); + + await fs.writeFile(path.join(a, "x.txt"), "v1"); + await syncUntilConverged([a, b]); + expect(await readText(path.join(b, "x.txt"))).toBe("v1"); + + await fs.writeFile(path.join(a, "x.txt"), "v2"); + await syncUntilConverged([a, b]); + expect(await readText(path.join(b, "x.txt"))).toBe("v2"); + }, + TEST_TIMEOUT, + ); + + it( + "propagates a deletion", + async () => { + const { a, b } = await setupPair(); + + await fs.writeFile(path.join(a, "doomed.txt"), "doomed"); + await fs.writeFile(path.join(a, "kept.txt"), "kept"); + await syncUntilConverged([a, b]); + expect(await pathExists(path.join(b, "doomed.txt"))).toBe(true); + + await fs.unlink(path.join(a, "doomed.txt")); + await syncUntilConverged([a, b]); + + expect(await pathExists(path.join(b, "doomed.txt"))).toBe(false); + expect(await readText(path.join(b, "kept.txt"))).toBe("kept"); + }, + TEST_TIMEOUT, + ); + + it( + "propagates changes inside a nested directory", + async () => { + const { a, b } = await setupPair(); + + await fs.mkdir(path.join(a, "deep", "deeper"), { recursive: true }); + await fs.writeFile( + path.join(a, "deep", "deeper", "leaf.txt"), + "leaf v1", + ); + await syncUntilConverged([a, b]); + + expect( + await readText(path.join(b, "deep", "deeper", "leaf.txt")), + ).toBe("leaf v1"); + + await fs.writeFile( + path.join(a, "deep", "deeper", "leaf.txt"), + "leaf v2", + ); + await syncUntilConverged([a, b]); + + expect( + await readText(path.join(b, "deep", "deeper", "leaf.txt")), + ).toBe("leaf v2"); + }, + TEST_TIMEOUT, + ); + + it( + "converges concurrent disjoint edits", + async () => { + const { a, b } = await setupPair(); + + await fs.writeFile(path.join(a, "from-a.txt"), "A"); + await fs.writeFile(path.join(b, "from-b.txt"), "B"); + + await syncUntilConverged([a, b]); + + expect(await readText(path.join(a, "from-a.txt"))).toBe("A"); + expect(await readText(path.join(a, "from-b.txt"))).toBe("B"); + expect(await readText(path.join(b, "from-a.txt"))).toBe("A"); + expect(await readText(path.join(b, "from-b.txt"))).toBe("B"); + }, + TEST_TIMEOUT, + ); + + it( + "a third clone catches up to the current state", + async () => { + const { a, b } = await setupPair(); + + await fs.writeFile(path.join(a, "shared.txt"), "shared content"); + await syncUntilConverged([a, b]); + + const c = path.join(workRoot, "c"); + const url = (await pushwork(["url"], a)).stdout.trim(); + await pushwork(["clone", ...flags, url, c]); + + expect(await readText(path.join(c, "shared.txt"))).toBe( + "shared content", + ); + }, + TEST_TIMEOUT, + ); + }); + + describe("default exclusions", () => { + it( + "does not sync .git or node_modules to a clone", + async () => { + const a = path.join(workRoot, "a"); + const b = path.join(workRoot, "b"); + await fs.mkdir(a); + + await fs.writeFile(path.join(a, "ok.txt"), "ok"); + + await fs.mkdir(path.join(a, "node_modules")); + await fs.writeFile(path.join(a, "node_modules", "lib.js"), "lib"); + + await fs.mkdir(path.join(a, ".git")); + await fs.writeFile(path.join(a, ".git", "HEAD"), "ref"); + + await pushwork(["init", ...flags], a); + const url = (await pushwork(["url"], a)).stdout.trim(); + await pushwork(["clone", ...flags, url, b]); + + expect(await pathExists(path.join(b, "ok.txt"))).toBe(true); + expect(await pathExists(path.join(b, "node_modules"))).toBe(false); + expect(await pathExists(path.join(b, ".git"))).toBe(false); + }, + TEST_TIMEOUT, + ); + }); + + describe("end-to-end session", () => { + it( + "supports a realistic two-user collaboration session", + async () => { + // Alice initializes a project with several files. + const alice = path.join(workRoot, "alice"); + await fs.mkdir(alice); + await fs.writeFile(path.join(alice, "README"), "# Project"); + await fs.mkdir(path.join(alice, "src")); + await fs.writeFile(path.join(alice, "src", "main.ts"), "// v1"); + await pushwork(["init", ...flags], alice); + + // Bob clones Alice's project. + const bob = path.join(workRoot, "bob"); + const url = (await pushwork(["url"], alice)).stdout.trim(); + await pushwork(["clone", ...flags, url, bob]); + + expect(await readText(path.join(bob, "README"))).toBe("# Project"); + expect(await readText(path.join(bob, "src", "main.ts"))).toBe("// v1"); + + // Bob edits a file and adds a new one. Alice edits a different file. + await fs.writeFile(path.join(bob, "src", "main.ts"), "// v2 (bob)"); + await fs.writeFile( + path.join(bob, "src", "util.ts"), + "export const x = 1", + ); + await fs.writeFile(path.join(alice, "README"), "# Project\n\nNotes"); + + await syncUntilConverged([alice, bob]); + + // Both should see all changes. + expect(await readText(path.join(alice, "src", "main.ts"))).toBe( + "// v2 (bob)", + ); + expect(await readText(path.join(alice, "src", "util.ts"))).toBe( + "export const x = 1", + ); + expect(await readText(path.join(bob, "README"))).toBe( + "# Project\n\nNotes", + ); + + // Bob deletes the util file. + await fs.unlink(path.join(bob, "src", "util.ts")); + await syncUntilConverged([alice, bob]); + + expect(await pathExists(path.join(alice, "src", "util.ts"))).toBe( + false, + ); + expect(await pathExists(path.join(bob, "src", "util.ts"))).toBe(false); + + // Final state must be byte-for-byte identical. + expect(await hashUserContent(alice)).toBe( + await hashUserContent(bob), + ); + }, + TEST_TIMEOUT * 2, + ); + }); +}); diff --git a/test/integration/sub-flag.test.ts b/test/integration/sub-flag.test.ts deleted file mode 100644 index f2f80a7..0000000 --- a/test/integration/sub-flag.test.ts +++ /dev/null @@ -1,185 +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 (after init --sub)", () => { - // `--sub` is only accepted on init/clone; subsequent `sync` calls read - // the subduction flag from .pushwork/config.json. - it("should sync after init --sub", async () => { - await fs.writeFile(path.join(tmpDir, "file1.txt"), "initial content"); - - await pushwork(["init", "--sub", tmpDir]); - - // Add a new file - await fs.writeFile(path.join(tmpDir, "file2.txt"), "new file"); - - await pushwork(["sync", 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", 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"); - - await pushwork(["sync", 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", 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")); - - await pushwork(["sync", 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/integration/sync-deletion.test.ts b/test/integration/sync-deletion.test.ts deleted file mode 100644 index d5239e3..0000000 --- a/test/integration/sync-deletion.test.ts +++ /dev/null @@ -1,280 +0,0 @@ -import * as fs from "fs/promises"; -import * as path from "path"; -import { tmpdir } from "os"; -import { - readFileContent, - writeFileContent, - removePath, - pathExists, -} from "../../src/utils"; -import { SnapshotManager } from "../../src/core/snapshot"; - -describe("Sync Engine Deletion Integration", () => { - let testDir: string; - let snapshotManager: SnapshotManager; - - beforeEach(async () => { - testDir = await fs.mkdtemp(path.join(tmpdir(), "sync-deletion-test-")); - snapshotManager = new SnapshotManager(testDir); - }); - - afterEach(async () => { - await fs.rm(testDir, { recursive: true, force: true }); - }); - - describe("Deletion Detection Logic", () => { - it("should properly detect local file deletions", async () => { - // Create initial state - const filePath = path.join(testDir, "will-be-deleted.ts"); - const content = "interface ToDelete { id: number; }"; - await writeFileContent(filePath, content); - - // Create snapshot representing the "before" state - const snapshot = snapshotManager.createEmpty(); - snapshotManager.updateFileEntry(snapshot, "will-be-deleted.ts", { - path: filePath, - url: "automerge:deletion-test" as any, - head: ["before-deletion"] as any, - extension: "ts", - mimeType: "text/typescript", - }); - - // Verify initial state - expect(await pathExists(filePath)).toBe(true); - expect(snapshot.files.has("will-be-deleted.ts")).toBe(true); - - // Simulate user deleting the file - await removePath(filePath); - - // File should be gone from filesystem but still in snapshot - expect(await pathExists(filePath)).toBe(false); - expect(snapshot.files.has("will-be-deleted.ts")).toBe(true); - }); - - it("should handle multiple file deletions correctly", async () => { - const testFiles = [ - { name: "delete1.ts", content: "interface One { x: number; }" }, - { name: "delete2.js", content: "const two = 'value';" }, - { name: "delete3.json", content: '{"three": true}' }, - ]; - - const snapshot = snapshotManager.createEmpty(); - - // Create all files and add to snapshot - for (const file of testFiles) { - const filePath = path.join(testDir, file.name); - await writeFileContent(filePath, file.content); - - snapshotManager.updateFileEntry(snapshot, file.name, { - path: filePath, - url: `automerge:${file.name}` as any, - head: [`head-${file.name}`] as any, - extension: path.extname(file.name).slice(1), - mimeType: file.name.endsWith(".ts") - ? "text/typescript" - : "text/plain", - }); - } - - expect(snapshot.files.size).toBe(3); - - // Delete all files - for (const file of testFiles) { - const filePath = path.join(testDir, file.name); - await removePath(filePath); - } - - // Verify all files are gone from filesystem - for (const file of testFiles) { - const filePath = path.join(testDir, file.name); - expect(await pathExists(filePath)).toBe(false); - } - - // Snapshot should still have entries (until sync processes them) - expect(snapshot.files.size).toBe(3); - - // Simulate sync engine processing the deletions - for (const file of testFiles) { - snapshotManager.removeFileEntry(snapshot, file.name); - } - - expect(snapshot.files.size).toBe(0); - }); - }); - - describe("Deletion Timing and Race Conditions", () => { - it("should handle rapid create-modify-delete sequences", async () => { - const filePath = path.join(testDir, "rapid-changes.ts"); - const snapshot = snapshotManager.createEmpty(); - - for (let i = 0; i < 3; i++) { - // Create - const content = `interface Cycle${i} { value: ${i}; }`; - await writeFileContent(filePath, content); - - // Add to snapshot - snapshotManager.updateFileEntry(snapshot, "rapid-changes.ts", { - path: filePath, - url: `automerge:cycle-${i}` as any, - head: [`head-${i}`] as any, - extension: "ts", - mimeType: "text/typescript", - }); - - // Modify - const modifiedContent = content + `\n// Modified in cycle ${i}`; - await writeFileContent(filePath, modifiedContent); - - // Delete - await removePath(filePath); - - // Verify deletion - expect(await pathExists(filePath)).toBe(false); - - // Clean up snapshot - snapshotManager.removeFileEntry(snapshot, "rapid-changes.ts"); - } - }); - - it("should handle deletion during content modification attempts", async () => { - const filePath = path.join(testDir, "modify-delete-race.ts"); - const initialContent = "interface Race { test: boolean; }"; - - // Create initial file - await writeFileContent(filePath, initialContent); - - // Start modification and deletion concurrently - const modifyPromise = writeFileContent( - filePath, - initialContent + "\n// Modified" - ); - const deletePromise = (async () => { - // Small delay to let modification start - await new Promise((resolve) => setTimeout(resolve, 1)); - await removePath(filePath); - })(); - - // Wait for both operations to complete - await Promise.allSettled([modifyPromise, deletePromise]); - - // File should be deleted regardless of modification timing - expect(await pathExists(filePath)).toBe(false); - }); - }); - - describe("Directory Structure Impact", () => { - it("should handle deletion of files in nested directories", async () => { - // Create nested structure - const nestedDir = path.join(testDir, "src", "components"); - const filePath = path.join(nestedDir, "Button.tsx"); - const content = "export const Button = () => ;"; - - await fs.mkdir(nestedDir, { recursive: true }); - await writeFileContent(filePath, content); - - const snapshot = snapshotManager.createEmpty(); - snapshotManager.updateFileEntry(snapshot, "src/components/Button.tsx", { - path: filePath, - url: "automerge:nested-button" as any, - head: ["nested-head"] as any, - extension: "tsx", - mimeType: "text/tsx", - }); - - // Delete just the file (not the directories) - await removePath(filePath); - - // File should be gone, directories should remain - expect(await pathExists(filePath)).toBe(false); - expect(await pathExists(nestedDir)).toBe(true); - expect(await pathExists(path.join(testDir, "src"))).toBe(true); - - // Simulate snapshot cleanup - snapshotManager.removeFileEntry(snapshot, "src/components/Button.tsx"); - expect(snapshot.files.size).toBe(0); - }); - - it("should handle deletion of entire directory trees", async () => { - // Create multiple files in nested structure - const testStructure = [ - "src/utils/helpers.ts", - "src/utils/constants.ts", - "src/components/Button.tsx", - "src/components/Input.tsx", - "src/types/index.ts", - ]; - - const snapshot = snapshotManager.createEmpty(); - - for (const relativePath of testStructure) { - const fullPath = path.join(testDir, relativePath); - await fs.mkdir(path.dirname(fullPath), { recursive: true }); - await writeFileContent(fullPath, `// Content for ${relativePath}`); - - snapshotManager.updateFileEntry(snapshot, relativePath, { - path: fullPath, - url: `automerge:${relativePath.replace(/[\/\.]/g, "-")}` as any, - head: [`head-${relativePath}`] as any, - extension: path.extname(relativePath).slice(1), - mimeType: "text/typescript", - }); - } - - expect(snapshot.files.size).toBe(5); - - // Delete entire src directory - await removePath(path.join(testDir, "src")); - - // Verify all files and directories are gone - for (const relativePath of testStructure) { - const fullPath = path.join(testDir, relativePath); - expect(await pathExists(fullPath)).toBe(false); - } - expect(await pathExists(path.join(testDir, "src"))).toBe(false); - - // Simulate snapshot cleanup for all files - for (const relativePath of testStructure) { - snapshotManager.removeFileEntry(snapshot, relativePath); - } - - expect(snapshot.files.size).toBe(0); - }); - }); - - describe("Error Recovery and Edge Cases", () => { - it("should handle deletion of non-existent files gracefully", async () => { - const nonExistentPath = path.join(testDir, "never-existed.ts"); - - // Attempt to delete non-existent file (should not throw) - await expect(removePath(nonExistentPath)).resolves.not.toThrow(); - - // Attempt to remove from snapshot (should not throw) - const snapshot = snapshotManager.createEmpty(); - expect(() => { - snapshotManager.removeFileEntry(snapshot, "never-existed.ts"); - }).not.toThrow(); - }); - - it("should provide debugging info for deletion failures", async () => { - const debugFilePath = path.join(testDir, "debug-deletion.ts"); - const content = "interface Debug { info: string; }"; - - try { - // Create file - await writeFileContent(debugFilePath, content); - - // Verify file exists and is readable - const readBack = await readFileContent(debugFilePath); - expect(readBack).toBe(content); - - // Delete file - await removePath(debugFilePath); - - expect(await pathExists(debugFilePath)).toBe(false); - } catch (error) { - console.error(`❌ Deletion test failed:`, error); - throw error; - } - }); - }); -}); diff --git a/test/integration/sync-flow.test.ts b/test/integration/sync-flow.test.ts deleted file mode 100644 index 6b67e34..0000000 --- a/test/integration/sync-flow.test.ts +++ /dev/null @@ -1,291 +0,0 @@ -import * as fs from "fs/promises"; -import * as path from "path"; -import * as tmp from "tmp"; -import { ConfigManager } from "../../src/core"; -import { DirectoryConfig } from "../../src/types"; - -describe("Sync Flow Integration", () => { - let tmpDir: string; - let cleanup: () => void; - - beforeEach(() => { - const tmpObj = tmp.dirSync({ unsafeCleanup: true }); - tmpDir = tmpObj.name; - cleanup = tmpObj.removeCallback; - }); - - afterEach(() => { - cleanup(); - }); - - describe("Configuration Management", () => { - it("should create and load configuration", async () => { - const configManager = new ConfigManager(tmpDir); - - // Create test config - const testConfig: DirectoryConfig = { - sync_server: "wss://test.server.com", - sync_enabled: true, - exclude_patterns: [".git", "*.tmp"], - artifact_directories: ["dist"], - sync: { - move_detection_threshold: 0.8, - }, - }; - - await configManager.save(testConfig); - - const loadedConfig = await configManager.load(); - expect(loadedConfig).toEqual(testConfig); - }); - - it("should merge global and local configurations", async () => { - const configManager = new ConfigManager(tmpDir); - - // Create default global config - await configManager.createDefaultGlobal(); - - // Test directory config - const localConfig: DirectoryConfig = { - sync_server: "wss://local.server.com", - sync_enabled: true, - exclude_patterns: [".git", "*.tmp"], - artifact_directories: ["dist"], - sync: { - move_detection_threshold: 0.9, - }, - }; - - await configManager.save(localConfig); - - // Verify merged config - const mergedConfig = await configManager.getMerged(); - expect(mergedConfig.sync_server).toBe("wss://local.server.com"); - expect(mergedConfig.exclude_patterns).toContain(".git"); - expect(mergedConfig.sync?.move_detection_threshold).toBe(0.9); - }); - }); - - describe("File System Operations", () => { - it("should handle file creation and modification", async () => { - // Create initial file structure - await fs.mkdir(path.join(tmpDir, "subdir")); - await fs.writeFile(path.join(tmpDir, "file1.txt"), "Initial content"); - await fs.writeFile( - path.join(tmpDir, "subdir", "file2.txt"), - "Nested content" - ); - - // Modify files - await fs.writeFile(path.join(tmpDir, "file1.txt"), "Modified content"); - await fs.writeFile(path.join(tmpDir, "new-file.txt"), "New file content"); - - // Delete file - await fs.unlink(path.join(tmpDir, "subdir", "file2.txt")); - - // Verify final state - const file1Content = await fs.readFile( - path.join(tmpDir, "file1.txt"), - "utf8" - ); - expect(file1Content).toBe("Modified content"); - - const newFileContent = await fs.readFile( - path.join(tmpDir, "new-file.txt"), - "utf8" - ); - expect(newFileContent).toBe("New file content"); - - try { - await fs.access(path.join(tmpDir, "subdir", "file2.txt")); - throw new Error("Deleted file should not exist"); - } catch (error: any) { - // Expected - file should not exist - expect(error.code).toBe("ENOENT"); - } - }); - - it("should handle binary files", async () => { - const binaryData = new Uint8Array([ - 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, - ]); // PNG header - - await fs.writeFile(path.join(tmpDir, "image.png"), binaryData); - - const readData = await fs.readFile(path.join(tmpDir, "image.png")); - expect(Array.from(readData)).toEqual(Array.from(binaryData)); - }); - }); - - describe("Directory Structure Scenarios", () => { - it("should handle complex directory structures", async () => { - const structure = { - src: { - components: { - "Button.tsx": "export const Button = () =>