pushwork 1.1.6 → 1.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ARCHITECTURE-ACCORDING-TO-CLAUDE.md +17 -11
- package/CLAUDE.md +46 -1
- package/README.md +18 -4
- package/dist/cli.js +45 -4
- package/dist/cli.js.map +1 -1
- package/dist/commands.d.ts +1 -0
- package/dist/commands.d.ts.map +1 -1
- package/dist/commands.js +151 -38
- package/dist/commands.js.map +1 -1
- package/dist/core/change-detection.js +2 -2
- package/dist/core/change-detection.js.map +1 -1
- package/dist/core/config.d.ts.map +1 -1
- package/dist/core/config.js +3 -0
- package/dist/core/config.js.map +1 -1
- package/dist/core/move-detection.d.ts.map +1 -1
- package/dist/core/move-detection.js +4 -1
- package/dist/core/move-detection.js.map +1 -1
- package/dist/core/sync-engine.d.ts +24 -4
- package/dist/core/sync-engine.d.ts.map +1 -1
- package/dist/core/sync-engine.js +85 -50
- package/dist/core/sync-engine.js.map +1 -1
- package/dist/types/config.d.ts +4 -0
- package/dist/types/config.d.ts.map +1 -1
- package/dist/types/config.js +2 -1
- package/dist/types/config.js.map +1 -1
- package/dist/utils/content.js +1 -1
- package/dist/utils/content.js.map +1 -1
- package/dist/utils/network-sync.d.ts +1 -2
- package/dist/utils/network-sync.d.ts.map +1 -1
- package/dist/utils/network-sync.js +76 -7
- package/dist/utils/network-sync.js.map +1 -1
- package/dist/utils/output.js +7 -7
- package/dist/utils/output.js.map +1 -1
- package/dist/utils/repo-factory.d.ts +11 -3
- package/dist/utils/repo-factory.d.ts.map +1 -1
- package/dist/utils/repo-factory.js +112 -8
- package/dist/utils/repo-factory.js.map +1 -1
- package/flake.lock +128 -0
- package/flake.nix +66 -0
- package/package.json +98 -97
- package/scripts/roundtrip-test.sh +35 -0
- package/src/cli.ts +53 -6
- package/src/commands.ts +150 -26
- package/src/core/change-detection.ts +2 -2
- package/src/core/config.ts +4 -0
- package/src/core/move-detection.ts +3 -1
- package/src/core/sync-engine.ts +99 -59
- package/src/types/config.ts +4 -0
- package/src/utils/content.ts +1 -1
- package/src/utils/network-sync.ts +92 -8
- package/src/utils/output.ts +7 -7
- package/src/utils/repo-factory.ts +124 -10
- package/test/integration/clone-test.sh +0 -0
- package/test/integration/conflict-resolution-test.sh +0 -0
- package/test/integration/deletion-behavior-test.sh +0 -0
- package/test/integration/deletion-sync-test-simple.sh +0 -0
- package/test/integration/deletion-sync-test.sh +0 -0
- package/test/integration/full-integration-test.sh +0 -0
- package/test/integration/manual-sync-test.sh +0 -0
- package/test/integration/sub-flag.test.ts +187 -0
- package/test/run-tests.sh +0 -0
- package/test/unit/artifact-nuke-reinsert.test.ts +80 -0
- package/test/unit/network-sync-sub.test.ts +144 -0
- package/test/unit/repo-factory.test.ts +111 -0
- package/test/unit/subduction-config.test.ts +69 -0
- package/dist/cli/commands.d.ts +0 -71
- package/dist/cli/commands.d.ts.map +0 -1
- package/dist/cli/commands.js +0 -794
- package/dist/cli/commands.js.map +0 -1
- package/dist/cli/index.d.ts +0 -2
- package/dist/cli/index.d.ts.map +0 -1
- package/dist/cli/index.js +0 -19
- package/dist/cli/index.js.map +0 -1
- package/dist/config/index.d.ts +0 -71
- package/dist/config/index.d.ts.map +0 -1
- package/dist/config/index.js +0 -314
- package/dist/config/index.js.map +0 -1
- package/dist/utils/content-similarity.d.ts +0 -53
- package/dist/utils/content-similarity.d.ts.map +0 -1
- package/dist/utils/content-similarity.js +0 -155
- package/dist/utils/content-similarity.js.map +0 -1
- package/dist/utils/node-polyfills.d.ts +0 -9
- package/dist/utils/node-polyfills.d.ts.map +0 -1
- package/dist/utils/node-polyfills.js +0 -9
- package/dist/utils/node-polyfills.js.map +0 -1
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import * as fs from "fs/promises";
|
|
2
|
+
import * as path from "path";
|
|
3
|
+
import * as tmp from "tmp";
|
|
4
|
+
import { execSync, execFile as execFileCb } from "child_process";
|
|
5
|
+
import { promisify } from "util";
|
|
6
|
+
import { SnapshotManager } from "../../src/core";
|
|
7
|
+
|
|
8
|
+
const execFile = promisify(execFileCb);
|
|
9
|
+
|
|
10
|
+
describe("--sub flag integration", () => {
|
|
11
|
+
let tmpDir: string;
|
|
12
|
+
let cleanup: () => void;
|
|
13
|
+
const cliPath = path.join(__dirname, "../../dist/cli.js");
|
|
14
|
+
|
|
15
|
+
beforeAll(() => {
|
|
16
|
+
execSync("pnpm build", { cwd: path.join(__dirname, "../.."), stdio: "pipe" });
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
beforeEach(() => {
|
|
20
|
+
const tmpObj = tmp.dirSync({ unsafeCleanup: true });
|
|
21
|
+
tmpDir = tmpObj.name;
|
|
22
|
+
cleanup = tmpObj.removeCallback;
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
afterEach(() => {
|
|
26
|
+
cleanup();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Run pushwork CLI command and return stdout.
|
|
31
|
+
* Throws on non-zero exit code.
|
|
32
|
+
*/
|
|
33
|
+
async function pushwork(args: string[], timeoutMs = 30000): Promise<string> {
|
|
34
|
+
const { stdout } = await execFile("node", [cliPath, ...args], {
|
|
35
|
+
timeout: timeoutMs,
|
|
36
|
+
env: { ...process.env, NO_COLOR: "1" },
|
|
37
|
+
});
|
|
38
|
+
return stdout;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
describe("init --sub", () => {
|
|
42
|
+
it("should initialize a directory with --sub flag", async () => {
|
|
43
|
+
await fs.writeFile(path.join(tmpDir, "hello.txt"), "Hello from sub!");
|
|
44
|
+
|
|
45
|
+
await pushwork(["init", "--sub", tmpDir]);
|
|
46
|
+
|
|
47
|
+
// Verify .pushwork was created
|
|
48
|
+
const pushworkDir = path.join(tmpDir, ".pushwork");
|
|
49
|
+
const stat = await fs.stat(pushworkDir);
|
|
50
|
+
expect(stat.isDirectory()).toBe(true);
|
|
51
|
+
|
|
52
|
+
// Verify snapshot exists and tracks the file
|
|
53
|
+
const snapshotManager = new SnapshotManager(tmpDir);
|
|
54
|
+
const snapshot = await snapshotManager.load();
|
|
55
|
+
expect(snapshot).not.toBeNull();
|
|
56
|
+
expect(snapshot!.rootDirectoryUrl).toBeDefined();
|
|
57
|
+
expect(snapshot!.rootDirectoryUrl).toMatch(/^automerge:/);
|
|
58
|
+
expect(snapshot!.files.has("hello.txt")).toBe(true);
|
|
59
|
+
}, 60000);
|
|
60
|
+
|
|
61
|
+
it("should track files in subdirectories", async () => {
|
|
62
|
+
await fs.mkdir(path.join(tmpDir, "src"), { recursive: true });
|
|
63
|
+
await fs.writeFile(path.join(tmpDir, "src", "index.ts"), "export default {}");
|
|
64
|
+
await fs.writeFile(path.join(tmpDir, "package.json"), '{"name": "test"}');
|
|
65
|
+
|
|
66
|
+
await pushwork(["init", "--sub", tmpDir]);
|
|
67
|
+
|
|
68
|
+
const snapshotManager = new SnapshotManager(tmpDir);
|
|
69
|
+
const snapshot = await snapshotManager.load();
|
|
70
|
+
expect(snapshot).not.toBeNull();
|
|
71
|
+
expect(snapshot!.files.has("src/index.ts")).toBe(true);
|
|
72
|
+
expect(snapshot!.files.has("package.json")).toBe(true);
|
|
73
|
+
}, 60000);
|
|
74
|
+
|
|
75
|
+
it("should respect default exclude patterns with --sub", async () => {
|
|
76
|
+
await fs.writeFile(path.join(tmpDir, "included.txt"), "keep me");
|
|
77
|
+
await fs.mkdir(path.join(tmpDir, "node_modules"));
|
|
78
|
+
await fs.writeFile(path.join(tmpDir, "node_modules", "dep.js"), "module");
|
|
79
|
+
await fs.mkdir(path.join(tmpDir, ".git"));
|
|
80
|
+
await fs.writeFile(path.join(tmpDir, ".git", "HEAD"), "ref: refs/heads/main");
|
|
81
|
+
|
|
82
|
+
await pushwork(["init", "--sub", tmpDir]);
|
|
83
|
+
|
|
84
|
+
const snapshotManager = new SnapshotManager(tmpDir);
|
|
85
|
+
const snapshot = await snapshotManager.load();
|
|
86
|
+
expect(snapshot).not.toBeNull();
|
|
87
|
+
expect(snapshot!.files.has("included.txt")).toBe(true);
|
|
88
|
+
expect(snapshot!.files.has("node_modules/dep.js")).toBe(false);
|
|
89
|
+
expect(snapshot!.files.has(".git/HEAD")).toBe(false);
|
|
90
|
+
}, 60000);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe("sync --sub", () => {
|
|
94
|
+
it("should sync after init --sub", async () => {
|
|
95
|
+
await fs.writeFile(path.join(tmpDir, "file1.txt"), "initial content");
|
|
96
|
+
|
|
97
|
+
// Init with --sub
|
|
98
|
+
await pushwork(["init", "--sub", tmpDir]);
|
|
99
|
+
|
|
100
|
+
// Add a new file
|
|
101
|
+
await fs.writeFile(path.join(tmpDir, "file2.txt"), "new file");
|
|
102
|
+
|
|
103
|
+
// Sync with --sub
|
|
104
|
+
await pushwork(["sync", "--sub", tmpDir]);
|
|
105
|
+
|
|
106
|
+
// Verify the new file is now tracked
|
|
107
|
+
const snapshotManager = new SnapshotManager(tmpDir);
|
|
108
|
+
const snapshot = await snapshotManager.load();
|
|
109
|
+
expect(snapshot).not.toBeNull();
|
|
110
|
+
expect(snapshot!.files.has("file1.txt")).toBe(true);
|
|
111
|
+
expect(snapshot!.files.has("file2.txt")).toBe(true);
|
|
112
|
+
}, 60000);
|
|
113
|
+
|
|
114
|
+
it("should detect file modifications on sync --sub", async () => {
|
|
115
|
+
await fs.writeFile(path.join(tmpDir, "mutable.txt"), "version 1");
|
|
116
|
+
|
|
117
|
+
await pushwork(["init", "--sub", tmpDir]);
|
|
118
|
+
|
|
119
|
+
// Record initial heads
|
|
120
|
+
const snapshotManager = new SnapshotManager(tmpDir);
|
|
121
|
+
const snapshot1 = await snapshotManager.load();
|
|
122
|
+
const initialHead = snapshot1!.files.get("mutable.txt")!.head;
|
|
123
|
+
|
|
124
|
+
// Modify the file
|
|
125
|
+
await fs.writeFile(path.join(tmpDir, "mutable.txt"), "version 2");
|
|
126
|
+
|
|
127
|
+
// Sync
|
|
128
|
+
await pushwork(["sync", "--sub", tmpDir]);
|
|
129
|
+
|
|
130
|
+
// Heads should have changed
|
|
131
|
+
const snapshot2 = await snapshotManager.load();
|
|
132
|
+
const updatedHead = snapshot2!.files.get("mutable.txt")!.head;
|
|
133
|
+
expect(updatedHead).not.toEqual(initialHead);
|
|
134
|
+
}, 60000);
|
|
135
|
+
|
|
136
|
+
it("should handle file deletions on sync --sub", async () => {
|
|
137
|
+
await fs.writeFile(path.join(tmpDir, "ephemeral.txt"), "delete me");
|
|
138
|
+
await fs.writeFile(path.join(tmpDir, "keeper.txt"), "keep me");
|
|
139
|
+
|
|
140
|
+
await pushwork(["init", "--sub", tmpDir]);
|
|
141
|
+
|
|
142
|
+
// Delete a file
|
|
143
|
+
await fs.unlink(path.join(tmpDir, "ephemeral.txt"));
|
|
144
|
+
|
|
145
|
+
// Sync
|
|
146
|
+
await pushwork(["sync", "--sub", tmpDir]);
|
|
147
|
+
|
|
148
|
+
// Deleted file should be gone from snapshot
|
|
149
|
+
const snapshotManager = new SnapshotManager(tmpDir);
|
|
150
|
+
const snapshot = await snapshotManager.load();
|
|
151
|
+
expect(snapshot).not.toBeNull();
|
|
152
|
+
expect(snapshot!.files.has("ephemeral.txt")).toBe(false);
|
|
153
|
+
expect(snapshot!.files.has("keeper.txt")).toBe(true);
|
|
154
|
+
}, 60000);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
describe("url after init --sub", () => {
|
|
158
|
+
it("should print a valid automerge URL", async () => {
|
|
159
|
+
await pushwork(["init", "--sub", tmpDir]);
|
|
160
|
+
|
|
161
|
+
const stdout = await pushwork(["url", tmpDir]);
|
|
162
|
+
expect(stdout.trim()).toMatch(/^automerge:/);
|
|
163
|
+
}, 60000);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
describe("status after init --sub", () => {
|
|
167
|
+
it("should report status without errors", async () => {
|
|
168
|
+
await fs.writeFile(path.join(tmpDir, "test.txt"), "status check");
|
|
169
|
+
await pushwork(["init", "--sub", tmpDir]);
|
|
170
|
+
|
|
171
|
+
// status should not throw
|
|
172
|
+
const stdout = await pushwork(["status", tmpDir]);
|
|
173
|
+
expect(stdout).toBeDefined();
|
|
174
|
+
}, 60000);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
describe("diff after init --sub", () => {
|
|
178
|
+
it("should show no changes immediately after init", async () => {
|
|
179
|
+
await fs.writeFile(path.join(tmpDir, "stable.txt"), "no changes");
|
|
180
|
+
await pushwork(["init", "--sub", tmpDir]);
|
|
181
|
+
|
|
182
|
+
const stdout = await pushwork(["diff", tmpDir]);
|
|
183
|
+
// After a fresh init+sync, there should be no pending changes
|
|
184
|
+
expect(stdout).not.toContain("modified");
|
|
185
|
+
}, 60000);
|
|
186
|
+
});
|
|
187
|
+
});
|
package/test/run-tests.sh
CHANGED
|
File without changes
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test that nukeAndRebuildDocs (the actual production function used by
|
|
3
|
+
* batchUpdateDirectory for artifact directories) doesn't throw
|
|
4
|
+
* "Cannot create a reference to an existing document object" when
|
|
5
|
+
* unchanged entries are spliced out and pushed back.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import * as A from "@automerge/automerge"
|
|
9
|
+
import { AutomergeUrl } from "@automerge/automerge-repo"
|
|
10
|
+
import { nukeAndRebuildDocs } from "../../src/core/sync-engine"
|
|
11
|
+
import { DirectoryDocument } from "../../src/types/documents"
|
|
12
|
+
|
|
13
|
+
describe("nukeAndRebuildDocs", () => {
|
|
14
|
+
let doc: A.Doc<DirectoryDocument>
|
|
15
|
+
|
|
16
|
+
beforeEach(() => {
|
|
17
|
+
doc = A.from({
|
|
18
|
+
"@patchwork": {type: "folder"},
|
|
19
|
+
name: "dist",
|
|
20
|
+
title: "dist",
|
|
21
|
+
docs: [
|
|
22
|
+
{name: "index.js", type: "file", url: "automerge:abc123"},
|
|
23
|
+
{name: "utils.js", type: "file", url: "automerge:def456"},
|
|
24
|
+
{name: "styles.css", type: "file", url: "automerge:ghi789"},
|
|
25
|
+
],
|
|
26
|
+
}) as A.Doc<DirectoryDocument>
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it("does not throw when some entries are unchanged", () => {
|
|
30
|
+
// Update only 1 of 3 entries — the other 2 are unchanged and must be
|
|
31
|
+
// spread into plain objects to avoid the Automerge proxy reinsert error.
|
|
32
|
+
const newUrl = "automerge:newurl123" as AutomergeUrl
|
|
33
|
+
doc = A.change(doc, d => {
|
|
34
|
+
nukeAndRebuildDocs(
|
|
35
|
+
d,
|
|
36
|
+
"dist",
|
|
37
|
+
[],
|
|
38
|
+
[{name: "index.js", url: newUrl}],
|
|
39
|
+
[],
|
|
40
|
+
[],
|
|
41
|
+
)
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
expect(doc.docs).toHaveLength(3)
|
|
45
|
+
expect(doc.docs[0].name).toBe("index.js")
|
|
46
|
+
expect(doc.docs[0].url).toBe(newUrl)
|
|
47
|
+
expect(doc.docs[1].name).toBe("utils.js")
|
|
48
|
+
expect(doc.docs[1].url).toBe("automerge:def456")
|
|
49
|
+
expect(doc.docs[2].name).toBe("styles.css")
|
|
50
|
+
expect(doc.docs[2].url).toBe("automerge:ghi789")
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it("handles deletes mixed with unchanged entries", () => {
|
|
54
|
+
doc = A.change(doc, d => {
|
|
55
|
+
nukeAndRebuildDocs(d, "dist", [], [], ["utils.js"], [])
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
expect(doc.docs).toHaveLength(2)
|
|
59
|
+
expect(doc.docs[0].name).toBe("index.js")
|
|
60
|
+
expect(doc.docs[1].name).toBe("styles.css")
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it("handles new entries alongside unchanged entries", () => {
|
|
64
|
+
const newUrl = "automerge:new999" as AutomergeUrl
|
|
65
|
+
doc = A.change(doc, d => {
|
|
66
|
+
nukeAndRebuildDocs(
|
|
67
|
+
d,
|
|
68
|
+
"dist",
|
|
69
|
+
[{name: "app.js", url: newUrl}],
|
|
70
|
+
[],
|
|
71
|
+
[],
|
|
72
|
+
[],
|
|
73
|
+
)
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
expect(doc.docs).toHaveLength(4)
|
|
77
|
+
expect(doc.docs[3].name).toBe("app.js")
|
|
78
|
+
expect(doc.docs[3].url).toBe(newUrl)
|
|
79
|
+
})
|
|
80
|
+
})
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { waitForSync } from "../../src/utils/network-sync";
|
|
2
|
+
import { DocHandle, StorageId } from "@automerge/automerge-repo";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Create a mock DocHandle with controllable heads.
|
|
6
|
+
*
|
|
7
|
+
* @param headSequence - An array of head values the handle returns on
|
|
8
|
+
* successive calls to heads(). Once exhausted, the last value repeats.
|
|
9
|
+
* This lets us simulate heads that change (sync in progress) and then
|
|
10
|
+
* stabilize (sync complete).
|
|
11
|
+
*/
|
|
12
|
+
function mockHandle(headSequence: string[][]): DocHandle<unknown> {
|
|
13
|
+
let callCount = 0;
|
|
14
|
+
|
|
15
|
+
return {
|
|
16
|
+
url: `automerge:mock-${Math.random().toString(36).slice(2)}`,
|
|
17
|
+
heads: () => {
|
|
18
|
+
const idx = Math.min(callCount++, headSequence.length - 1);
|
|
19
|
+
return headSequence[idx];
|
|
20
|
+
},
|
|
21
|
+
// getSyncInfo is only called in the StorageId path, not the head-stability path
|
|
22
|
+
getSyncInfo: jest.fn(),
|
|
23
|
+
on: jest.fn(),
|
|
24
|
+
off: jest.fn(),
|
|
25
|
+
} as unknown as DocHandle<unknown>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe("waitForSync (Subduction / head-stability mode)", () => {
|
|
29
|
+
// When syncServerStorageId is undefined, waitForSync should use the
|
|
30
|
+
// head-stability polling path instead of the getSyncInfo-based path.
|
|
31
|
+
|
|
32
|
+
it("should return immediately for empty handle list", async () => {
|
|
33
|
+
const result = await waitForSync([], undefined);
|
|
34
|
+
expect(result.failed).toHaveLength(0);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("should resolve when handle heads are already stable", async () => {
|
|
38
|
+
// Heads never change — stable from the start
|
|
39
|
+
const handle = mockHandle([["head-a", "head-b"]]);
|
|
40
|
+
const result = await waitForSync([handle], undefined, 5000);
|
|
41
|
+
|
|
42
|
+
expect(result.failed).toHaveLength(0);
|
|
43
|
+
// getSyncInfo should never be called in head-stability mode
|
|
44
|
+
expect(handle.getSyncInfo).not.toHaveBeenCalled();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("should resolve after heads stabilize", async () => {
|
|
48
|
+
// Heads change for the first few polls, then stabilize
|
|
49
|
+
const handle = mockHandle([
|
|
50
|
+
["head-1"], // poll 1: initial
|
|
51
|
+
["head-2"], // poll 2: changed (reset stable count)
|
|
52
|
+
["head-3"], // poll 3: changed again
|
|
53
|
+
["head-3"], // poll 4: stable check 1
|
|
54
|
+
["head-3"], // poll 5: stable check 2
|
|
55
|
+
["head-3"], // poll 6: stable check 3 → converged
|
|
56
|
+
]);
|
|
57
|
+
|
|
58
|
+
const result = await waitForSync([handle], undefined, 10000);
|
|
59
|
+
expect(result.failed).toHaveLength(0);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("should report handle as failed on timeout", async () => {
|
|
63
|
+
// Heads keep changing — never stabilize
|
|
64
|
+
let counter = 0;
|
|
65
|
+
const neverStable = {
|
|
66
|
+
url: "automerge:never-stable",
|
|
67
|
+
heads: () => [`head-${counter++}`],
|
|
68
|
+
getSyncInfo: jest.fn(),
|
|
69
|
+
on: jest.fn(),
|
|
70
|
+
off: jest.fn(),
|
|
71
|
+
} as unknown as DocHandle<unknown>;
|
|
72
|
+
|
|
73
|
+
const result = await waitForSync([neverStable], undefined, 500);
|
|
74
|
+
expect(result.failed).toHaveLength(1);
|
|
75
|
+
expect(result.failed[0]).toBe(neverStable);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("should handle a mix of stable and unstable handles", async () => {
|
|
79
|
+
const stable = mockHandle([["stable-head"]]);
|
|
80
|
+
|
|
81
|
+
let counter = 0;
|
|
82
|
+
const unstable = {
|
|
83
|
+
url: "automerge:unstable",
|
|
84
|
+
heads: () => [`changing-${counter++}`],
|
|
85
|
+
getSyncInfo: jest.fn(),
|
|
86
|
+
on: jest.fn(),
|
|
87
|
+
off: jest.fn(),
|
|
88
|
+
} as unknown as DocHandle<unknown>;
|
|
89
|
+
|
|
90
|
+
const result = await waitForSync([stable, unstable], undefined, 500);
|
|
91
|
+
|
|
92
|
+
// The stable handle should succeed, the unstable one should fail
|
|
93
|
+
expect(result.failed).toHaveLength(1);
|
|
94
|
+
expect(result.failed[0]).toBe(unstable);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("should not use getSyncInfo when no StorageId is provided", async () => {
|
|
98
|
+
const handle = mockHandle([["head-a"]]);
|
|
99
|
+
await waitForSync([handle], undefined, 5000);
|
|
100
|
+
|
|
101
|
+
// The head-stability path does not call getSyncInfo at all
|
|
102
|
+
expect(handle.getSyncInfo).not.toHaveBeenCalled();
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
describe("waitForSync (WebSocket / StorageId mode)", () => {
|
|
107
|
+
// When a StorageId IS provided, waitForSync should use getSyncInfo-based
|
|
108
|
+
// verification instead of head-stability polling.
|
|
109
|
+
|
|
110
|
+
it("should use getSyncInfo when StorageId is provided", async () => {
|
|
111
|
+
const storageId = "test-storage-id" as StorageId;
|
|
112
|
+
const heads = ["head-a"];
|
|
113
|
+
|
|
114
|
+
const handle = {
|
|
115
|
+
url: "automerge:ws-handle",
|
|
116
|
+
heads: () => heads,
|
|
117
|
+
getSyncInfo: jest.fn().mockReturnValue({ lastHeads: heads }),
|
|
118
|
+
on: jest.fn(),
|
|
119
|
+
off: jest.fn(),
|
|
120
|
+
} as unknown as DocHandle<unknown>;
|
|
121
|
+
|
|
122
|
+
const result = await waitForSync([handle], storageId, 5000);
|
|
123
|
+
|
|
124
|
+
expect(result.failed).toHaveLength(0);
|
|
125
|
+
expect(handle.getSyncInfo).toHaveBeenCalledWith(storageId);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("should detect already-synced handles via getSyncInfo", async () => {
|
|
129
|
+
const storageId = "test-storage-id" as StorageId;
|
|
130
|
+
const heads = ["same-head"];
|
|
131
|
+
|
|
132
|
+
const handle = {
|
|
133
|
+
url: "automerge:already-synced",
|
|
134
|
+
heads: () => heads,
|
|
135
|
+
// getSyncInfo returns matching heads → already synced
|
|
136
|
+
getSyncInfo: jest.fn().mockReturnValue({ lastHeads: heads }),
|
|
137
|
+
on: jest.fn(),
|
|
138
|
+
off: jest.fn(),
|
|
139
|
+
} as unknown as DocHandle<unknown>;
|
|
140
|
+
|
|
141
|
+
const result = await waitForSync([handle], storageId, 5000);
|
|
142
|
+
expect(result.failed).toHaveLength(0);
|
|
143
|
+
});
|
|
144
|
+
});
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for repo-factory.ts Subduction configuration.
|
|
3
|
+
*
|
|
4
|
+
* The actual Repo construction requires Wasm initialization via real ESM
|
|
5
|
+
* dynamic imports. We test by invoking the CLI as a subprocess (which runs
|
|
6
|
+
* in a real Node.js context) and inspecting the results.
|
|
7
|
+
*
|
|
8
|
+
* Non-sub (WebSocket) init is tested elsewhere (init-sync.test.ts).
|
|
9
|
+
* These tests focus on the --sub path.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import * as path from "path";
|
|
13
|
+
import * as fs from "fs/promises";
|
|
14
|
+
import * as tmp from "tmp";
|
|
15
|
+
import { execSync } from "child_process";
|
|
16
|
+
|
|
17
|
+
describe("createRepo with --sub", () => {
|
|
18
|
+
let tmpDir: string;
|
|
19
|
+
let cleanup: () => void;
|
|
20
|
+
const cliPath = path.join(__dirname, "../../dist/cli.js");
|
|
21
|
+
|
|
22
|
+
beforeAll(() => {
|
|
23
|
+
execSync("pnpm build", { cwd: path.join(__dirname, "../.."), stdio: "pipe" });
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
beforeEach(async () => {
|
|
27
|
+
const tmpObj = tmp.dirSync({ unsafeCleanup: true });
|
|
28
|
+
tmpDir = tmpObj.name;
|
|
29
|
+
cleanup = tmpObj.removeCallback;
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
afterEach(() => {
|
|
33
|
+
cleanup();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("should create a working repo with --sub flag", async () => {
|
|
37
|
+
await fs.writeFile(path.join(tmpDir, "test.txt"), "hello");
|
|
38
|
+
|
|
39
|
+
execSync(`node "${cliPath}" init --sub "${tmpDir}"`, {
|
|
40
|
+
stdio: "pipe",
|
|
41
|
+
timeout: 30000,
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const snapshotPath = path.join(tmpDir, ".pushwork", "snapshot.json");
|
|
45
|
+
const stat = await fs.stat(snapshotPath);
|
|
46
|
+
expect(stat.isFile()).toBe(true);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("should produce a valid automerge URL", async () => {
|
|
50
|
+
await fs.writeFile(path.join(tmpDir, "test.txt"), "hello");
|
|
51
|
+
|
|
52
|
+
execSync(`node "${cliPath}" init --sub "${tmpDir}"`, {
|
|
53
|
+
stdio: "pipe",
|
|
54
|
+
timeout: 30000,
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const url = execSync(`node "${cliPath}" url "${tmpDir}"`, {
|
|
58
|
+
encoding: "utf8",
|
|
59
|
+
timeout: 10000,
|
|
60
|
+
}).trim();
|
|
61
|
+
|
|
62
|
+
expect(url).toMatch(/^automerge:/);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("should track files in the snapshot", async () => {
|
|
66
|
+
await fs.writeFile(path.join(tmpDir, "a.txt"), "aaa");
|
|
67
|
+
await fs.mkdir(path.join(tmpDir, "sub"), { recursive: true });
|
|
68
|
+
await fs.writeFile(path.join(tmpDir, "sub", "b.txt"), "bbb");
|
|
69
|
+
|
|
70
|
+
execSync(`node "${cliPath}" init --sub "${tmpDir}"`, {
|
|
71
|
+
stdio: "pipe",
|
|
72
|
+
timeout: 30000,
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
const ls = execSync(`node "${cliPath}" ls "${tmpDir}"`, {
|
|
76
|
+
encoding: "utf8",
|
|
77
|
+
timeout: 10000,
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
expect(ls).toContain("a.txt");
|
|
81
|
+
expect(ls).toContain("b.txt");
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("should be able to sync after init", async () => {
|
|
85
|
+
await fs.writeFile(path.join(tmpDir, "initial.txt"), "first");
|
|
86
|
+
|
|
87
|
+
execSync(`node "${cliPath}" init --sub "${tmpDir}"`, {
|
|
88
|
+
stdio: "pipe",
|
|
89
|
+
timeout: 30000,
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// Add a new file
|
|
93
|
+
await fs.writeFile(path.join(tmpDir, "added.txt"), "second");
|
|
94
|
+
|
|
95
|
+
// Sync should not throw. The `sync` command has no --sub flag — it
|
|
96
|
+
// reads the backend choice from .pushwork/config.json (persisted by
|
|
97
|
+
// the init --sub above).
|
|
98
|
+
execSync(`node "${cliPath}" sync "${tmpDir}"`, {
|
|
99
|
+
stdio: "pipe",
|
|
100
|
+
timeout: 30000,
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
const ls = execSync(`node "${cliPath}" ls "${tmpDir}"`, {
|
|
104
|
+
encoding: "utf8",
|
|
105
|
+
timeout: 10000,
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
expect(ls).toContain("initial.txt");
|
|
109
|
+
expect(ls).toContain("added.txt");
|
|
110
|
+
});
|
|
111
|
+
});
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import * as path from "path";
|
|
2
|
+
import * as fs from "fs/promises";
|
|
3
|
+
import * as tmp from "tmp";
|
|
4
|
+
import { ConfigManager } from "../../src/core/config";
|
|
5
|
+
import { DEFAULT_SUBDUCTION_SERVER, DEFAULT_SYNC_SERVER } from "../../src/types/config";
|
|
6
|
+
|
|
7
|
+
describe("Subduction configuration", () => {
|
|
8
|
+
let tmpDir: string;
|
|
9
|
+
let cleanup: () => void;
|
|
10
|
+
|
|
11
|
+
beforeEach(async () => {
|
|
12
|
+
const tmpObj = tmp.dirSync({ unsafeCleanup: true });
|
|
13
|
+
tmpDir = tmpObj.name;
|
|
14
|
+
cleanup = tmpObj.removeCallback;
|
|
15
|
+
|
|
16
|
+
// Set up .pushwork directory structure
|
|
17
|
+
await fs.mkdir(path.join(tmpDir, ".pushwork", "automerge"), { recursive: true });
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
afterEach(() => {
|
|
21
|
+
cleanup();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe("DEFAULT_SUBDUCTION_SERVER", () => {
|
|
25
|
+
it("should be the subduction sync endpoint", () => {
|
|
26
|
+
expect(DEFAULT_SUBDUCTION_SERVER).toBe("wss://subduction.sync.inkandswitch.com");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("should differ from the default WebSocket sync server", () => {
|
|
30
|
+
expect(DEFAULT_SUBDUCTION_SERVER).not.toBe(DEFAULT_SYNC_SERVER);
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
describe("ConfigManager defaults", () => {
|
|
35
|
+
it("should use the WebSocket server as default sync_server", async () => {
|
|
36
|
+
const configManager = new ConfigManager(tmpDir);
|
|
37
|
+
const config = configManager.getDefaultDirectoryConfig();
|
|
38
|
+
expect(config.sync_server).toBe(DEFAULT_SYNC_SERVER);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("should not default to the subduction server", async () => {
|
|
42
|
+
const configManager = new ConfigManager(tmpDir);
|
|
43
|
+
const config = configManager.getDefaultDirectoryConfig();
|
|
44
|
+
expect(config.sync_server).not.toBe(DEFAULT_SUBDUCTION_SERVER);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe("sub flag option types", () => {
|
|
49
|
+
// These tests verify that the option interfaces accept `sub` on the
|
|
50
|
+
// commands that actually have a --sub flag (init and clone). The flag
|
|
51
|
+
// is NOT on SyncOptions or WatchOptions because sync/watch read the
|
|
52
|
+
// backend choice from persisted config (see `setupCommandContext`).
|
|
53
|
+
// If the type definitions are wrong, these will fail at compile time.
|
|
54
|
+
it("should accept sub on InitOptions", () => {
|
|
55
|
+
const opts: import("../../src/types/config").InitOptions = { sub: true };
|
|
56
|
+
expect(opts.sub).toBe(true);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("should accept sub on CloneOptions", () => {
|
|
60
|
+
const opts: import("../../src/types/config").CloneOptions = { sub: true };
|
|
61
|
+
expect(opts.sub).toBe(true);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("should default sub to undefined (not required) on InitOptions", () => {
|
|
65
|
+
const opts: import("../../src/types/config").InitOptions = {};
|
|
66
|
+
expect(opts.sub).toBeUndefined();
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
});
|
package/dist/cli/commands.d.ts
DELETED
|
@@ -1,71 +0,0 @@
|
|
|
1
|
-
import { Repo } from "@automerge/automerge-repo";
|
|
2
|
-
import { CloneOptions, SyncOptions, DiffOptions, LogOptions, CheckoutOptions, DirectoryConfig } from "../types";
|
|
3
|
-
import { SyncEngine } from "../core";
|
|
4
|
-
/**
|
|
5
|
-
* Shared context that commands can use
|
|
6
|
-
*/
|
|
7
|
-
export interface CommandContext {
|
|
8
|
-
repo: Repo;
|
|
9
|
-
syncEngine: SyncEngine;
|
|
10
|
-
config: DirectoryConfig;
|
|
11
|
-
workingDir: string;
|
|
12
|
-
}
|
|
13
|
-
/**
|
|
14
|
-
* Shared pre-action that ensures repository and sync engine are properly initialized
|
|
15
|
-
* This function always works, with or without network connectivity
|
|
16
|
-
*/
|
|
17
|
-
export declare function setupCommandContext(workingDir?: string, customSyncServer?: string, customStorageId?: string, enableNetwork?: boolean): Promise<CommandContext>;
|
|
18
|
-
/**
|
|
19
|
-
* Safely shutdown a repository with proper error handling
|
|
20
|
-
*/
|
|
21
|
-
export declare function safeRepoShutdown(repo: Repo, context?: string): Promise<void>;
|
|
22
|
-
/**
|
|
23
|
-
* Common progress message helpers
|
|
24
|
-
*/
|
|
25
|
-
export declare const ProgressMessages: {
|
|
26
|
-
directoryFound: () => void;
|
|
27
|
-
configLoaded: () => void;
|
|
28
|
-
repoConnected: () => void;
|
|
29
|
-
syncServer: (server: string) => void;
|
|
30
|
-
storageId: (id: string) => void;
|
|
31
|
-
rootUrl: (url: string) => void;
|
|
32
|
-
changesWritten: () => void;
|
|
33
|
-
syncCompleted: (duration: number) => void;
|
|
34
|
-
directoryStructureCreated: () => void;
|
|
35
|
-
configSaved: () => void;
|
|
36
|
-
repoCreated: () => void;
|
|
37
|
-
};
|
|
38
|
-
/**
|
|
39
|
-
* Initialize sync in a directory
|
|
40
|
-
*/
|
|
41
|
-
export declare function init(targetPath: string, syncServer?: string, syncServerStorageId?: string): Promise<void>;
|
|
42
|
-
/**
|
|
43
|
-
* Run bidirectional sync
|
|
44
|
-
*/
|
|
45
|
-
export declare function sync(options: SyncOptions): Promise<void>;
|
|
46
|
-
/**
|
|
47
|
-
* Show differences between local and remote
|
|
48
|
-
*/
|
|
49
|
-
export declare function diff(targetPath: string | undefined, options: DiffOptions): Promise<void>;
|
|
50
|
-
/**
|
|
51
|
-
* Show sync status
|
|
52
|
-
*/
|
|
53
|
-
export declare function status(): Promise<void>;
|
|
54
|
-
/**
|
|
55
|
-
* Show sync history
|
|
56
|
-
*/
|
|
57
|
-
export declare function log(targetPath: string | undefined, options: LogOptions): Promise<void>;
|
|
58
|
-
/**
|
|
59
|
-
* Checkout/restore from previous sync
|
|
60
|
-
*/
|
|
61
|
-
export declare function checkout(syncId: string, targetPath: string | undefined, options: CheckoutOptions): Promise<void>;
|
|
62
|
-
/**
|
|
63
|
-
* Clone an existing synced directory from an AutomergeUrl
|
|
64
|
-
*/
|
|
65
|
-
export declare function clone(rootUrl: string, targetPath: string, options: CloneOptions): Promise<void>;
|
|
66
|
-
/**
|
|
67
|
-
* Get the root URL for the current pushwork repository
|
|
68
|
-
*/
|
|
69
|
-
export declare function url(targetPath?: string): Promise<void>;
|
|
70
|
-
export declare function commit(targetPath: string, dryRun?: boolean): Promise<void>;
|
|
71
|
-
//# sourceMappingURL=commands.d.ts.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"commands.d.ts","sourceRoot":"","sources":["../../src/cli/commands.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,IAAI,EAA2B,MAAM,2BAA2B,CAAC;AAI1E,OAAO,EAEL,YAAY,EACZ,WAAW,EACX,WAAW,EACX,UAAU,EACV,eAAe,EACf,eAAe,EAEhB,MAAM,UAAU,CAAC;AAClB,OAAO,EAAE,UAAU,EAAE,MAAM,SAAS,CAAC;AAMrC;;GAEG;AACH,MAAM,WAAW,cAAc;IAC7B,IAAI,EAAE,IAAI,CAAC;IACX,UAAU,EAAE,UAAU,CAAC;IACvB,MAAM,EAAE,eAAe,CAAC;IACxB,UAAU,EAAE,MAAM,CAAC;CACpB;AAED;;;GAGG;AACH,wBAAsB,mBAAmB,CACvC,UAAU,GAAE,MAAsB,EAClC,gBAAgB,CAAC,EAAE,MAAM,EACzB,eAAe,CAAC,EAAE,MAAM,EACxB,aAAa,GAAE,OAAc,GAC5B,OAAO,CAAC,cAAc,CAAC,CAqCzB;AAED;;GAEG;AACH,wBAAsB,gBAAgB,CACpC,IAAI,EAAE,IAAI,EACV,OAAO,CAAC,EAAE,MAAM,GACf,OAAO,CAAC,IAAI,CAAC,CAqBf;AAED;;GAEG;AACH,eAAO,MAAM,gBAAgB;;;;yBAON,MAAM;oBAEX,MAAM;mBACP,MAAM;;8BAKK,MAAM;;;;CAOjC,CAAC;AA+DF;;GAEG;AACH,wBAAsB,IAAI,CACxB,UAAU,EAAE,MAAM,EAClB,UAAU,CAAC,EAAE,MAAM,EACnB,mBAAmB,CAAC,EAAE,MAAM,GAC3B,OAAO,CAAC,IAAI,CAAC,CA6Hf;AAED;;GAEG;AACH,wBAAsB,IAAI,CAAC,OAAO,EAAE,WAAW,GAAG,OAAO,CAAC,IAAI,CAAC,CAgM9D;AAED;;GAEG;AACH,wBAAsB,IAAI,CACxB,UAAU,oBAAM,EAChB,OAAO,EAAE,WAAW,GACnB,OAAO,CAAC,IAAI,CAAC,CA6Df;AAED;;GAEG;AACH,wBAAsB,MAAM,IAAI,OAAO,CAAC,IAAI,CAAC,CA6H5C;AAED;;GAEG;AACH,wBAAsB,GAAG,CACvB,UAAU,oBAAM,EAChB,OAAO,EAAE,UAAU,GAClB,OAAO,CAAC,IAAI,CAAC,CA2Cf;AAED;;GAEG;AACH,wBAAsB,QAAQ,CAC5B,MAAM,EAAE,MAAM,EACd,UAAU,oBAAM,EAChB,OAAO,EAAE,eAAe,GACvB,OAAO,CAAC,IAAI,CAAC,CAkBf;AAED;;GAEG;AACH,wBAAsB,KAAK,CACzB,OAAO,EAAE,MAAM,EACf,UAAU,EAAE,MAAM,EAClB,OAAO,EAAE,YAAY,GACpB,OAAO,CAAC,IAAI,CAAC,CA6Hf;AAED;;GAEG;AACH,wBAAsB,GAAG,CAAC,UAAU,SAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAuCzD;AAED,wBAAsB,MAAM,CAC1B,UAAU,EAAE,MAAM,EAClB,MAAM,GAAE,OAAe,GACtB,OAAO,CAAC,IAAI,CAAC,CAgEf"}
|