pushwork 2.0.0-preview → 2.0.0-preview.3

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.
Files changed (169) hide show
  1. package/dist/branches.d.ts +1 -0
  2. package/dist/branches.d.ts.map +1 -1
  3. package/dist/cli/commands.d.ts +71 -0
  4. package/dist/cli/commands.d.ts.map +1 -0
  5. package/dist/cli/commands.js +794 -0
  6. package/dist/cli/commands.js.map +1 -0
  7. package/dist/cli/index.d.ts +2 -0
  8. package/dist/cli/index.d.ts.map +1 -0
  9. package/dist/cli/index.js +19 -0
  10. package/dist/cli/index.js.map +1 -0
  11. package/dist/cli.js +67 -112
  12. package/dist/cli.js.map +1 -1
  13. package/dist/commands.d.ts +58 -0
  14. package/dist/commands.d.ts.map +1 -0
  15. package/dist/commands.js +975 -0
  16. package/dist/commands.js.map +1 -0
  17. package/dist/config/index.d.ts +71 -0
  18. package/dist/config/index.d.ts.map +1 -0
  19. package/dist/config/index.js +314 -0
  20. package/dist/config/index.js.map +1 -0
  21. package/dist/config.d.ts +1 -2
  22. package/dist/config.d.ts.map +1 -1
  23. package/dist/config.js +1 -2
  24. package/dist/config.js.map +1 -1
  25. package/dist/core/change-detection.d.ts +80 -0
  26. package/dist/core/change-detection.d.ts.map +1 -0
  27. package/dist/core/change-detection.js +560 -0
  28. package/dist/core/change-detection.js.map +1 -0
  29. package/dist/core/config.d.ts +81 -0
  30. package/dist/core/config.d.ts.map +1 -0
  31. package/dist/core/config.js +304 -0
  32. package/dist/core/config.js.map +1 -0
  33. package/dist/core/index.d.ts +6 -0
  34. package/dist/core/index.d.ts.map +1 -0
  35. package/dist/core/index.js +22 -0
  36. package/dist/core/index.js.map +1 -0
  37. package/dist/core/move-detection.d.ts +34 -0
  38. package/dist/core/move-detection.d.ts.map +1 -0
  39. package/dist/core/move-detection.js +128 -0
  40. package/dist/core/move-detection.js.map +1 -0
  41. package/dist/core/snapshot.d.ts +105 -0
  42. package/dist/core/snapshot.d.ts.map +1 -0
  43. package/dist/core/snapshot.js +254 -0
  44. package/dist/core/snapshot.js.map +1 -0
  45. package/dist/core/sync-engine.d.ts +177 -0
  46. package/dist/core/sync-engine.d.ts.map +1 -0
  47. package/dist/core/sync-engine.js +1471 -0
  48. package/dist/core/sync-engine.js.map +1 -0
  49. package/dist/index.d.ts +2 -4
  50. package/dist/index.d.ts.map +1 -1
  51. package/dist/index.js +4 -14
  52. package/dist/index.js.map +1 -1
  53. package/dist/pushwork.d.ts +28 -61
  54. package/dist/pushwork.d.ts.map +1 -1
  55. package/dist/pushwork.js +127 -445
  56. package/dist/pushwork.js.map +1 -1
  57. package/dist/shapes/types.d.ts +1 -0
  58. package/dist/shapes/types.d.ts.map +1 -1
  59. package/dist/shapes/types.js.map +1 -1
  60. package/dist/shapes/vfs.d.ts.map +1 -1
  61. package/dist/shapes/vfs.js +6 -2
  62. package/dist/shapes/vfs.js.map +1 -1
  63. package/dist/snarf.d.ts +21 -0
  64. package/dist/snarf.d.ts.map +1 -0
  65. package/dist/snarf.js +117 -0
  66. package/dist/snarf.js.map +1 -0
  67. package/dist/stash.d.ts +0 -2
  68. package/dist/stash.d.ts.map +1 -1
  69. package/dist/stash.js +0 -1
  70. package/dist/stash.js.map +1 -1
  71. package/dist/types/config.d.ts +102 -0
  72. package/dist/types/config.d.ts.map +1 -0
  73. package/dist/types/config.js +10 -0
  74. package/dist/types/config.js.map +1 -0
  75. package/dist/types/documents.d.ts +88 -0
  76. package/dist/types/documents.d.ts.map +1 -0
  77. package/dist/types/documents.js +23 -0
  78. package/dist/types/documents.js.map +1 -0
  79. package/dist/types/index.d.ts +4 -0
  80. package/dist/types/index.d.ts.map +1 -0
  81. package/dist/types/index.js +20 -0
  82. package/dist/types/index.js.map +1 -0
  83. package/dist/types/snapshot.d.ts +64 -0
  84. package/dist/types/snapshot.d.ts.map +1 -0
  85. package/dist/types/snapshot.js +3 -0
  86. package/dist/types/snapshot.js.map +1 -0
  87. package/dist/utils/content-similarity.d.ts +53 -0
  88. package/dist/utils/content-similarity.d.ts.map +1 -0
  89. package/dist/utils/content-similarity.js +155 -0
  90. package/dist/utils/content-similarity.js.map +1 -0
  91. package/dist/utils/content.d.ts +10 -0
  92. package/dist/utils/content.d.ts.map +1 -0
  93. package/dist/utils/content.js +35 -0
  94. package/dist/utils/content.js.map +1 -0
  95. package/dist/utils/directory.d.ts +24 -0
  96. package/dist/utils/directory.d.ts.map +1 -0
  97. package/dist/utils/directory.js +56 -0
  98. package/dist/utils/directory.js.map +1 -0
  99. package/dist/utils/fs.d.ts +74 -0
  100. package/dist/utils/fs.d.ts.map +1 -0
  101. package/dist/utils/fs.js +298 -0
  102. package/dist/utils/fs.js.map +1 -0
  103. package/dist/utils/index.d.ts +5 -0
  104. package/dist/utils/index.d.ts.map +1 -0
  105. package/dist/utils/index.js +21 -0
  106. package/dist/utils/index.js.map +1 -0
  107. package/dist/utils/mime-types.d.ts +13 -0
  108. package/dist/utils/mime-types.d.ts.map +1 -0
  109. package/dist/utils/mime-types.js +247 -0
  110. package/dist/utils/mime-types.js.map +1 -0
  111. package/dist/utils/network-sync.d.ts +30 -0
  112. package/dist/utils/network-sync.d.ts.map +1 -0
  113. package/dist/utils/network-sync.js +391 -0
  114. package/dist/utils/network-sync.js.map +1 -0
  115. package/dist/utils/node-polyfills.d.ts +9 -0
  116. package/dist/utils/node-polyfills.d.ts.map +1 -0
  117. package/dist/utils/node-polyfills.js +9 -0
  118. package/dist/utils/node-polyfills.js.map +1 -0
  119. package/dist/utils/output.d.ts +129 -0
  120. package/dist/utils/output.d.ts.map +1 -0
  121. package/dist/utils/output.js +375 -0
  122. package/dist/utils/output.js.map +1 -0
  123. package/dist/utils/repo-factory.d.ts +15 -0
  124. package/dist/utils/repo-factory.d.ts.map +1 -0
  125. package/dist/utils/repo-factory.js +156 -0
  126. package/dist/utils/repo-factory.js.map +1 -0
  127. package/dist/utils/string-similarity.d.ts +14 -0
  128. package/dist/utils/string-similarity.d.ts.map +1 -0
  129. package/dist/utils/string-similarity.js +43 -0
  130. package/dist/utils/string-similarity.js.map +1 -0
  131. package/dist/utils/text-diff.d.ts +37 -0
  132. package/dist/utils/text-diff.d.ts.map +1 -0
  133. package/dist/utils/text-diff.js +131 -0
  134. package/dist/utils/text-diff.js.map +1 -0
  135. package/dist/utils/trace.d.ts +19 -0
  136. package/dist/utils/trace.d.ts.map +1 -0
  137. package/dist/utils/trace.js +68 -0
  138. package/dist/utils/trace.js.map +1 -0
  139. package/dist/version.d.ts +11 -0
  140. package/dist/version.d.ts.map +1 -0
  141. package/dist/version.js +93 -0
  142. package/dist/version.js.map +1 -0
  143. package/package.json +5 -1
  144. package/.prettierrc +0 -9
  145. package/flake.lock +0 -128
  146. package/flake.nix +0 -66
  147. package/pnpm-workspace.yaml +0 -5
  148. package/src/branches.ts +0 -93
  149. package/src/cli.ts +0 -292
  150. package/src/config.ts +0 -64
  151. package/src/fs-tree.ts +0 -70
  152. package/src/ignore.ts +0 -33
  153. package/src/index.ts +0 -38
  154. package/src/log.ts +0 -8
  155. package/src/pushwork.ts +0 -1055
  156. package/src/repo.ts +0 -76
  157. package/src/shapes/custom.ts +0 -29
  158. package/src/shapes/file.ts +0 -115
  159. package/src/shapes/index.ts +0 -19
  160. package/src/shapes/patchwork-folder.ts +0 -156
  161. package/src/shapes/types.ts +0 -79
  162. package/src/shapes/vfs.ts +0 -93
  163. package/src/stash.ts +0 -106
  164. package/test/integration/branches.test.ts +0 -389
  165. package/test/integration/pushwork.test.ts +0 -547
  166. package/test/setup.ts +0 -29
  167. package/test/unit/doc-shape.test.ts +0 -612
  168. package/tsconfig.json +0 -22
  169. package/vitest.config.ts +0 -14
package/src/repo.ts DELETED
@@ -1,76 +0,0 @@
1
- import {
2
- Repo,
3
- initSubduction,
4
- type DocHandle,
5
- type NetworkAdapterInterface,
6
- } from "@automerge/automerge-repo";
7
- import { WebSocketClientAdapter } from "@automerge/automerge-repo-network-websocket";
8
- import { NodeFSStorageAdapter } from "@automerge/automerge-repo-storage-nodefs";
9
- import type { Backend } from "./config.js";
10
- import { log } from "./log.js";
11
-
12
- const dlog = log("repo");
13
-
14
- const DEFAULT_LEGACY = "wss://sync3.automerge.org";
15
- const DEFAULT_SUBDUCTION = "wss://subduction.sync.inkandswitch.com";
16
-
17
- export const legacyUrl = () =>
18
- process.env.PUSHWORK_LEGACY_SERVER || DEFAULT_LEGACY;
19
- export const subductionUrl = () =>
20
- process.env.PUSHWORK_SUBDUCTION_SERVER || DEFAULT_SUBDUCTION;
21
-
22
- export async function openRepo(
23
- backend: Backend,
24
- storageDir: string,
25
- opts: { offline?: boolean } = {},
26
- ): Promise<Repo> {
27
- dlog("openRepo backend=%s storage=%s offline=%s", backend, storageDir, !!opts.offline);
28
- await initSubduction();
29
- const storage = new NodeFSStorageAdapter(storageDir);
30
- if (opts.offline) {
31
- return new Repo({ storage, network: [] });
32
- }
33
- if (backend === "legacy") {
34
- const endpoint = legacyUrl();
35
- dlog("legacy ws endpoint=%s", endpoint);
36
- const adapter = new WebSocketClientAdapter(
37
- endpoint,
38
- ) as unknown as NetworkAdapterInterface;
39
- return new Repo({ storage, network: [adapter] });
40
- }
41
- const endpoint = subductionUrl();
42
- dlog("subduction ws endpoint=%s", endpoint);
43
- return new Repo({
44
- storage,
45
- network: [],
46
- subductionWebsocketEndpoints: [endpoint],
47
- });
48
- }
49
-
50
- const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
51
-
52
- export async function waitForSync<T>(
53
- handle: DocHandle<T>,
54
- { minMs = 0, idleMs = 1500, maxMs = 15000, pollMs = 200 } = {},
55
- ): Promise<void> {
56
- dlog("waitForSync url=%s minMs=%d idleMs=%d maxMs=%d", handle.url, minMs, idleMs, maxMs);
57
- const headsKey = () => JSON.stringify(handle.heads());
58
- let last = headsKey();
59
- let lastChange = Date.now();
60
- const start = Date.now();
61
- while (Date.now() - start < maxMs) {
62
- await sleep(pollMs);
63
- const next = headsKey();
64
- if (next !== last) {
65
- last = next;
66
- lastChange = Date.now();
67
- } else if (
68
- Date.now() - lastChange >= idleMs &&
69
- Date.now() - start >= minMs
70
- ) {
71
- dlog("waitForSync settled url=%s elapsed=%dms", handle.url, Date.now() - start);
72
- return;
73
- }
74
- }
75
- dlog("waitForSync timed out url=%s after %dms", handle.url, maxMs);
76
- }
@@ -1,29 +0,0 @@
1
- import * as path from "path";
2
- import { pathToFileURL } from "url";
3
- import { log } from "../log.js";
4
- import type { Shape } from "./types.js";
5
-
6
- const dlog = log("shapes:custom");
7
-
8
- export async function loadCustomShape(filePath: string): Promise<Shape> {
9
- const absolute = path.isAbsolute(filePath)
10
- ? filePath
11
- : path.resolve(process.cwd(), filePath);
12
- dlog("loading custom shape from %s", absolute);
13
- const url = pathToFileURL(absolute).href;
14
- const mod = (await import(url)) as {
15
- default?: Partial<Shape>;
16
- };
17
- const candidate = mod.default;
18
- if (
19
- !candidate ||
20
- typeof candidate.encode !== "function" ||
21
- typeof candidate.decode !== "function"
22
- ) {
23
- throw new Error(
24
- `shape ${filePath} must export default { encode, decode }`,
25
- );
26
- }
27
- dlog("custom shape loaded ok");
28
- return candidate as Shape;
29
- }
@@ -1,115 +0,0 @@
1
- import * as path from "path";
2
- import mime from "mime-types";
3
- import {
4
- ImmutableString,
5
- isImmutableString,
6
- parseAutomergeUrl,
7
- stringifyAutomergeUrl,
8
- type AutomergeUrl,
9
- type DocHandle,
10
- type Repo,
11
- } from "@automerge/automerge-repo";
12
- import type { UnixFileEntry } from "./types.js";
13
-
14
- export type Content = string | Uint8Array | ImmutableString;
15
-
16
- export function bytesToContent(
17
- bytes: Uint8Array,
18
- isArtifact: boolean,
19
- ): Content {
20
- if (bytes.includes(0)) return bytes;
21
- let text: string;
22
- try {
23
- text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
24
- } catch {
25
- return bytes;
26
- }
27
- const reencoded = new TextEncoder().encode(text);
28
- if (reencoded.length !== bytes.length) return bytes;
29
- for (let i = 0; i < bytes.length; i++) {
30
- if (reencoded[i] !== bytes[i]) return bytes;
31
- }
32
- return isArtifact ? new ImmutableString(text) : text;
33
- }
34
-
35
- export function contentToBytes(content: Content): Uint8Array {
36
- if (typeof content === "string") return new TextEncoder().encode(content);
37
- if (isImmutableString(content)) {
38
- return new TextEncoder().encode(String(content));
39
- }
40
- return content instanceof Uint8Array ? content : new Uint8Array(content);
41
- }
42
-
43
- export function contentEquals(a: Content, b: Content): boolean {
44
- const av = a instanceof Uint8Array ? a : contentToBytes(a);
45
- const bv = b instanceof Uint8Array ? b : contentToBytes(b);
46
- if (av.length !== bv.length) return false;
47
- for (let i = 0; i < av.length; i++) if (av[i] !== bv[i]) return false;
48
- return true;
49
- }
50
-
51
- export function makeFileEntry(
52
- relativePath: string,
53
- bytes: Uint8Array,
54
- isArtifact: boolean,
55
- ): UnixFileEntry {
56
- const name = path.posix.basename(relativePath);
57
- const ext = path.posix.extname(name).replace(/^\./, "");
58
- return {
59
- "@patchwork": { type: "file" },
60
- content: bytesToContent(bytes, isArtifact),
61
- extension: ext,
62
- mimeType: mime.lookup(name) || "application/octet-stream",
63
- name,
64
- };
65
- }
66
-
67
- export function readFileEntry(handle: DocHandle<unknown>): {
68
- bytes: Uint8Array;
69
- entry: UnixFileEntry;
70
- } {
71
- const doc = handle.doc() as Partial<UnixFileEntry> | undefined;
72
- if (!doc || typeof doc !== "object" || !("content" in doc)) {
73
- throw new Error(`document ${handle.url} is not a UnixFileEntry`);
74
- }
75
- const entry = doc as UnixFileEntry;
76
- return { bytes: contentToBytes(entry.content), entry };
77
- }
78
-
79
- export async function findFileEntry(
80
- repo: Repo,
81
- url: AutomergeUrl,
82
- ): Promise<{ handle: DocHandle<UnixFileEntry>; bytes: Uint8Array }> {
83
- const handle = await repo.find<UnixFileEntry>(url);
84
- const { bytes } = readFileEntry(handle as DocHandle<unknown>);
85
- return { handle, bytes };
86
- }
87
-
88
- export function stripHeads(url: AutomergeUrl): AutomergeUrl {
89
- const { documentId } = parseAutomergeUrl(url);
90
- return stringifyAutomergeUrl({ documentId });
91
- }
92
-
93
- export function pinUrl(handle: DocHandle<unknown>): AutomergeUrl {
94
- const { documentId } = parseAutomergeUrl(handle.url);
95
- return stringifyAutomergeUrl({ documentId, heads: handle.heads() });
96
- }
97
-
98
- export function normalizeArtifactDir(dir: string): string {
99
- let out = dir.replace(/\\/g, "/");
100
- while (out.startsWith("./")) out = out.slice(2);
101
- while (out.endsWith("/")) out = out.slice(0, -1);
102
- return out;
103
- }
104
-
105
- export function isInArtifactDir(
106
- posixPath: string,
107
- artifactDirs: readonly string[],
108
- ): boolean {
109
- for (const d of artifactDirs) {
110
- if (!d) continue;
111
- if (posixPath === d) return true;
112
- if (posixPath.startsWith(d + "/")) return true;
113
- }
114
- return false;
115
- }
@@ -1,19 +0,0 @@
1
- import { loadCustomShape } from "./custom.js";
2
- import { patchworkFolderShape } from "./patchwork-folder.js";
3
- import { vfsShape } from "./vfs.js";
4
- import type { Shape } from "./types.js";
5
-
6
- export type ShapeName = "vfs" | "patchwork-folder" | string;
7
-
8
- export const isBuiltinShape = (name: string): boolean =>
9
- name === "vfs" || name === "patchwork-folder";
10
-
11
- export async function resolveShape(name: ShapeName): Promise<Shape> {
12
- if (name === "vfs") return vfsShape;
13
- if (name === "patchwork-folder") return patchworkFolderShape;
14
- return loadCustomShape(name);
15
- }
16
-
17
- export { vfsShape, patchworkFolderShape };
18
- export * from "./types.js";
19
- export * from "./file.js";
@@ -1,156 +0,0 @@
1
- import * as path from "path";
2
- import {
3
- isValidAutomergeUrl,
4
- type AutomergeUrl,
5
- type DocHandle,
6
- type Repo,
7
- } from "@automerge/automerge-repo";
8
- import { log } from "../log.js";
9
- import { newDir, type Shape, type VfsNode } from "./types.js";
10
-
11
- const dlog = log("shapes:folder");
12
-
13
- const META = "@patchwork";
14
-
15
- type DocLink = {
16
- name: string;
17
- type: string;
18
- url: AutomergeUrl;
19
- icon?: string;
20
- };
21
-
22
- type FolderDoc = {
23
- "@patchwork": { type: "folder" };
24
- title: string;
25
- docs: DocLink[];
26
- lastSyncAt?: number;
27
- };
28
-
29
- const isFolderDoc = (doc: unknown): doc is FolderDoc => {
30
- if (!doc || typeof doc !== "object") return false;
31
- const meta = (doc as Record<string, unknown>)[META];
32
- return (
33
- !!meta &&
34
- typeof meta === "object" &&
35
- (meta as Record<string, unknown>).type === "folder"
36
- );
37
- };
38
-
39
- const linkFileType = (filename: string): string => {
40
- const ext = path.posix.extname(filename).replace(/^\./, "");
41
- return ext || "file";
42
- };
43
-
44
- export const patchworkFolderShape: Shape = {
45
- async encode({ repo, tree, previousRoot }) {
46
- if (tree.kind !== "dir") throw new Error("folder: root must be a dir");
47
-
48
- if (previousRoot) {
49
- dlog("encode reusing root=%s", previousRoot.url);
50
- const handle = previousRoot as DocHandle<FolderDoc>;
51
- await syncFolder(repo, handle, tree);
52
- return handle.url;
53
- }
54
- dlog("encode creating new root");
55
- const handle = await createFolder(repo, tree, "pushwork");
56
- dlog("encode new root=%s", handle.url);
57
- return handle.url;
58
- },
59
-
60
- async decode({ repo, root }) {
61
- const doc = root.doc();
62
- if (!isFolderDoc(doc)) {
63
- throw new Error(`expected folder doc at ${root.url}`);
64
- }
65
- dlog("decode root=%s", root.url);
66
- return readFolder(repo, root as DocHandle<FolderDoc>);
67
- },
68
- };
69
-
70
- async function createFolder(
71
- repo: Repo,
72
- tree: VfsNode,
73
- title: string,
74
- ): Promise<DocHandle<FolderDoc>> {
75
- if (tree.kind !== "dir") throw new Error("createFolder: not a dir");
76
- const links: DocLink[] = [];
77
- for (const [name, child] of tree.entries) {
78
- if (child.kind === "file") {
79
- links.push({ name, type: linkFileType(name), url: child.url });
80
- } else {
81
- const sub = await createFolder(repo, child, name);
82
- links.push({ name, type: "folder", url: sub.url });
83
- }
84
- }
85
- const handle = repo.create<FolderDoc>({
86
- "@patchwork": { type: "folder" },
87
- title,
88
- docs: links,
89
- });
90
- dlog("createFolder title=%s docs=%d url=%s", title, links.length, handle.url);
91
- return handle;
92
- }
93
-
94
- async function syncFolder(
95
- repo: Repo,
96
- handle: DocHandle<FolderDoc>,
97
- tree: VfsNode,
98
- ): Promise<void> {
99
- if (tree.kind !== "dir") throw new Error("syncFolder: not a dir");
100
-
101
- const desired = new Map<string, VfsNode>(tree.entries);
102
- const existingLinks = new Map<string, DocLink>();
103
- for (const link of handle.doc().docs) existingLinks.set(link.name, link);
104
-
105
- const nextLinks: DocLink[] = [];
106
-
107
- for (const [name, child] of desired) {
108
- const existing = existingLinks.get(name);
109
- if (child.kind === "file") {
110
- nextLinks.push({ name, type: linkFileType(name), url: child.url });
111
- continue;
112
- }
113
- // child is a dir
114
- if (existing && existing.type === "folder") {
115
- const subHandle = await repo.find<FolderDoc>(existing.url);
116
- if (isFolderDoc(subHandle.doc())) {
117
- await syncFolder(repo, subHandle, child);
118
- nextLinks.push({ name, type: "folder", url: subHandle.url });
119
- continue;
120
- }
121
- }
122
- const sub = await createFolder(repo, child, name);
123
- nextLinks.push({ name, type: "folder", url: sub.url });
124
- }
125
-
126
- handle.change((d: FolderDoc) => {
127
- if (!d["@patchwork"]) d["@patchwork"] = { type: "folder" };
128
- if (typeof d.title !== "string") d.title = "pushwork";
129
- d.docs = nextLinks;
130
- });
131
- }
132
-
133
- async function readFolder(
134
- repo: Repo,
135
- handle: DocHandle<FolderDoc>,
136
- ): Promise<VfsNode> {
137
- const doc = handle.doc();
138
- const tree: VfsNode = newDir();
139
- if (!doc?.docs) return tree;
140
- for (const link of doc.docs) {
141
- if (!link?.name) continue;
142
- if (!isValidAutomergeUrl(link.url)) continue;
143
- if (link.type === "folder") {
144
- const sub = await repo.find<FolderDoc>(link.url);
145
- if (isFolderDoc(sub.doc())) {
146
- const subTree = await readFolder(repo, sub);
147
- if (tree.kind === "dir") tree.entries.set(link.name, subTree);
148
- }
149
- } else {
150
- if (tree.kind === "dir") {
151
- tree.entries.set(link.name, { kind: "file", url: link.url });
152
- }
153
- }
154
- }
155
- return tree;
156
- }
@@ -1,79 +0,0 @@
1
- import type {
2
- AutomergeUrl,
3
- DocHandle,
4
- ImmutableString,
5
- Repo,
6
- } from "@automerge/automerge-repo";
7
-
8
- export type VfsNode =
9
- | { kind: "dir"; entries: Map<string, VfsNode> }
10
- | { kind: "file"; url: AutomergeUrl };
11
-
12
- export type UnixFileEntry = {
13
- "@patchwork": { type: "file" };
14
- content: string | Uint8Array | ImmutableString;
15
- extension: string;
16
- mimeType: string;
17
- name: string;
18
- };
19
-
20
- export interface Shape {
21
- encode(args: {
22
- repo: Repo;
23
- tree: VfsNode;
24
- previousRoot?: DocHandle<unknown>;
25
- }): Promise<AutomergeUrl>;
26
- decode(args: {
27
- repo: Repo;
28
- root: DocHandle<unknown>;
29
- }): Promise<VfsNode>;
30
- }
31
-
32
- export const newDir = (): VfsNode => ({ kind: "dir", entries: new Map() });
33
-
34
- export function* walkLeaves(
35
- node: VfsNode,
36
- prefix: string[] = [],
37
- ): Generator<{ path: string[]; url: AutomergeUrl }> {
38
- if (node.kind === "file") {
39
- yield { path: prefix, url: node.url };
40
- return;
41
- }
42
- for (const [name, child] of node.entries) {
43
- yield* walkLeaves(child, [...prefix, name]);
44
- }
45
- }
46
-
47
- export function flattenLeaves(node: VfsNode): Map<string, AutomergeUrl> {
48
- const out = new Map<string, AutomergeUrl>();
49
- for (const { path, url } of walkLeaves(node)) out.set(path.join("/"), url);
50
- return out;
51
- }
52
-
53
- export function ensureDirAt(root: VfsNode, segments: string[]): VfsNode {
54
- if (root.kind !== "dir") throw new Error("ensureDirAt: root must be a dir");
55
- let current: VfsNode = root;
56
- for (const seg of segments) {
57
- if (current.kind !== "dir") throw new Error(`not a dir: ${seg}`);
58
- const existing = current.entries.get(seg);
59
- if (existing && existing.kind === "dir") {
60
- current = existing;
61
- } else {
62
- const fresh = newDir();
63
- current.entries.set(seg, fresh);
64
- current = fresh;
65
- }
66
- }
67
- return current;
68
- }
69
-
70
- export function setFileAt(
71
- root: VfsNode,
72
- path: string[],
73
- url: AutomergeUrl,
74
- ): void {
75
- if (path.length === 0) throw new Error("setFileAt: empty path");
76
- const parent = ensureDirAt(root, path.slice(0, -1));
77
- if (parent.kind !== "dir") throw new Error("setFileAt: parent not a dir");
78
- parent.entries.set(path[path.length - 1], { kind: "file", url });
79
- }
package/src/shapes/vfs.ts DELETED
@@ -1,93 +0,0 @@
1
- import {
2
- isValidAutomergeUrl,
3
- type AutomergeUrl,
4
- type DocHandle,
5
- } from "@automerge/automerge-repo";
6
- import { log } from "../log.js";
7
- import { flattenLeaves, newDir, type Shape, type VfsNode } from "./types.js";
8
-
9
- const dlog = log("shapes:vfs");
10
-
11
- const META = "@patchwork";
12
-
13
- type DirectoryDoc = {
14
- "@patchwork": { type: "directory" };
15
- lastSyncAt?: number;
16
- [key: string]: unknown;
17
- };
18
-
19
- const isDirectoryDoc = (doc: unknown): doc is DirectoryDoc => {
20
- if (!doc || typeof doc !== "object") return false;
21
- const meta = (doc as Record<string, unknown>)[META];
22
- return (
23
- !!meta &&
24
- typeof meta === "object" &&
25
- (meta as Record<string, unknown>).type === "directory"
26
- );
27
- };
28
-
29
- const RESERVED = new Set([META, "lastSyncAt"]);
30
-
31
- export const vfsShape: Shape = {
32
- async encode({ repo, tree, previousRoot }) {
33
- if (tree.kind !== "dir") throw new Error("vfs: root must be a dir");
34
- const flat = flattenLeaves(tree);
35
- dlog("encode keys=%d previousRoot=%s", flat.size, previousRoot?.url ?? "<new>");
36
-
37
- const handle = (previousRoot as DocHandle<DirectoryDoc> | undefined) ??
38
- repo.create<DirectoryDoc>({ "@patchwork": { type: "directory" } });
39
-
40
- handle.change((d: DirectoryDoc) => {
41
- if (!d["@patchwork"]) d["@patchwork"] = { type: "directory" };
42
- for (const k of Object.keys(d)) {
43
- if (RESERVED.has(k)) continue;
44
- if (!flat.has(k)) delete d[k];
45
- }
46
- for (const [k, url] of flat) {
47
- d[k] = url;
48
- }
49
- });
50
-
51
- dlog("encode complete url=%s", handle.url);
52
- return handle.url;
53
- },
54
-
55
- async decode({ root }) {
56
- const doc = root.doc();
57
- if (!isDirectoryDoc(doc)) {
58
- throw new Error(`expected directory doc at ${root.url}`);
59
- }
60
- const tree: VfsNode = newDir();
61
- let count = 0;
62
- for (const [key, value] of Object.entries(doc)) {
63
- if (RESERVED.has(key)) continue;
64
- if (typeof value !== "string") continue;
65
- if (!isValidAutomergeUrl(value)) continue;
66
- const segments = key.split("/").filter(Boolean);
67
- if (segments.length === 0) continue;
68
- setLeaf(tree, segments, value as AutomergeUrl);
69
- count++;
70
- }
71
- dlog("decode url=%s leaves=%d", root.url, count);
72
- return tree;
73
- },
74
- };
75
-
76
- function setLeaf(root: VfsNode, segments: string[], url: AutomergeUrl): void {
77
- if (root.kind !== "dir") throw new Error("setLeaf: root must be a dir");
78
- let cur: VfsNode = root;
79
- for (let i = 0; i < segments.length - 1; i++) {
80
- if (cur.kind !== "dir") return;
81
- const name = segments[i];
82
- const existing = cur.entries.get(name);
83
- if (existing && existing.kind === "dir") {
84
- cur = existing;
85
- } else {
86
- const fresh = newDir();
87
- cur.entries.set(name, fresh);
88
- cur = fresh;
89
- }
90
- }
91
- if (cur.kind !== "dir") return;
92
- cur.entries.set(segments[segments.length - 1], { kind: "file", url });
93
- }
package/src/stash.ts DELETED
@@ -1,106 +0,0 @@
1
- import * as fs from "fs/promises";
2
- import * as path from "path";
3
- import { log } from "./log.js";
4
-
5
- const dlog = log("stash");
6
-
7
- export type StashKind = "modified" | "added" | "deleted";
8
-
9
- export interface StashEntry {
10
- path: string;
11
- kind: StashKind;
12
- contentBase64?: string; // omitted for deleted
13
- }
14
-
15
- export interface Stash {
16
- id: number;
17
- name?: string;
18
- branch: string | null;
19
- createdAt: number;
20
- entries: StashEntry[];
21
- }
22
-
23
- const SNARF_DIR = path.join(".pushwork", "snarf");
24
- const SNARF_INDEX = path.join(SNARF_DIR, "index.json");
25
-
26
- async function readStashes(root: string): Promise<Stash[]> {
27
- try {
28
- const text = await fs.readFile(path.join(root, SNARF_INDEX), "utf8");
29
- const parsed = JSON.parse(text) as Stash[];
30
- if (!Array.isArray(parsed)) return [];
31
- return parsed;
32
- } catch (err) {
33
- if ((err as NodeJS.ErrnoException).code === "ENOENT") return [];
34
- throw err;
35
- }
36
- }
37
-
38
- async function writeStashes(root: string, stashes: Stash[]): Promise<void> {
39
- await fs.mkdir(path.join(root, SNARF_DIR), { recursive: true });
40
- await fs.writeFile(
41
- path.join(root, SNARF_INDEX),
42
- JSON.stringify(stashes, null, 2) + "\n",
43
- );
44
- }
45
-
46
- function nextId(stashes: Stash[]): number {
47
- let max = 0;
48
- for (const s of stashes) if (s.id > max) max = s.id;
49
- return max + 1;
50
- }
51
-
52
- export async function listStashes(root: string): Promise<Stash[]> {
53
- const stashes = await readStashes(root);
54
- stashes.sort((a, b) => b.id - a.id); // newest first
55
- return stashes;
56
- }
57
-
58
- export async function appendStash(
59
- root: string,
60
- args: { name?: string; branch: string | null; entries: StashEntry[] },
61
- ): Promise<Stash> {
62
- const stashes = await readStashes(root);
63
- const id = nextId(stashes);
64
- const stash: Stash = {
65
- id,
66
- name: args.name,
67
- branch: args.branch,
68
- createdAt: Date.now(),
69
- entries: args.entries,
70
- };
71
- stashes.push(stash);
72
- await writeStashes(root, stashes);
73
- dlog("appendStash id=%d name=%s entries=%d", id, args.name ?? "(unnamed)", args.entries.length);
74
- return stash;
75
- }
76
-
77
- export async function takeStash(
78
- root: string,
79
- selector?: string,
80
- ): Promise<Stash | null> {
81
- const stashes = await readStashes(root);
82
- if (stashes.length === 0) return null;
83
- let idx: number;
84
- if (!selector) {
85
- // most recent
86
- idx = stashes.reduce((best, s, i) => (s.id > stashes[best].id ? i : best), 0);
87
- } else {
88
- const asInt = Number(selector);
89
- idx = stashes.findIndex((s) =>
90
- (!Number.isNaN(asInt) && s.id === asInt) || s.name === selector,
91
- );
92
- if (idx < 0) return null;
93
- }
94
- const [taken] = stashes.splice(idx, 1);
95
- await writeStashes(root, stashes);
96
- dlog("takeStash id=%d name=%s", taken.id, taken.name ?? "(unnamed)");
97
- return taken;
98
- }
99
-
100
- export function encodeBytes(bytes: Uint8Array): string {
101
- return Buffer.from(bytes).toString("base64");
102
- }
103
-
104
- export function decodeBytes(b64: string): Uint8Array {
105
- return new Uint8Array(Buffer.from(b64, "base64"));
106
- }