wehandoff 0.0.1 → 0.1.0
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/README.md +12 -6
- package/bin/_whf/http.mjs +60 -0
- package/bin/_whf/registry.mjs +503 -0
- package/bin/_whf/shared-asset.mjs +124 -0
- package/bin/wehandoff.mjs +1682 -0
- package/package.json +22 -20
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// shared-asset working-copy store + 3-way merge (shared-asset P1.4 / spec §4).
|
|
2
|
+
//
|
|
3
|
+
// `whf share/pull/push` keep a LOCAL working copy of a cloud `shared_asset` under
|
|
4
|
+
// `~/.wehandoff/shared/<id>/`:
|
|
5
|
+
// issue.md — the editable working copy (what the user edits between syncs)
|
|
6
|
+
// .meta.json — { cloud_rev, base_body, sha } sync bookkeeping
|
|
7
|
+
// .merged — written ONLY on an unresolved 3-way conflict (git-style markers)
|
|
8
|
+
//
|
|
9
|
+
// `cloud_rev` is the server-assigned optimistic-lock counter (spec §4 / blocker ②):
|
|
10
|
+
// the client carries the rev its `base_body` was last synced to and sends it as
|
|
11
|
+
// `expected_rev`; it NEVER picks rev itself. `base_body` is the common ancestor
|
|
12
|
+
// for the diff3 merge and advances at exactly three points (spec §4):
|
|
13
|
+
// - clean push success → base := the body just pushed, rev := server rev
|
|
14
|
+
// - push 409 → base does NOT advance; re-pull remote → 3-way merge
|
|
15
|
+
// - resolved + re-push → base := the body actually pushed, rev := re-push rev
|
|
16
|
+
//
|
|
17
|
+
// The merge engine is node-diff3 (`merge`, excludeFalseConflicts:true by default)
|
|
18
|
+
// — a pure-JS three-way merge with no system dependency (blocker ①). We never
|
|
19
|
+
// reimplement CAS here: the server returns 409 and we react.
|
|
20
|
+
|
|
21
|
+
import { createHash } from 'node:crypto';
|
|
22
|
+
import {
|
|
23
|
+
existsSync,
|
|
24
|
+
mkdirSync,
|
|
25
|
+
readFileSync,
|
|
26
|
+
rmSync,
|
|
27
|
+
writeFileSync,
|
|
28
|
+
} from 'node:fs';
|
|
29
|
+
import { homedir } from 'node:os';
|
|
30
|
+
import { join } from 'node:path';
|
|
31
|
+
|
|
32
|
+
import { merge as diff3Merge } from 'node-diff3';
|
|
33
|
+
|
|
34
|
+
const WORKING_FILE = 'issue.md';
|
|
35
|
+
const META_FILE = '.meta.json';
|
|
36
|
+
const MERGED_FILE = '.merged';
|
|
37
|
+
|
|
38
|
+
/** Root of the per-asset working-copy store: `<home>/.wehandoff/shared/<id>/`. */
|
|
39
|
+
export function assetDir(assetId, home = homedir()) {
|
|
40
|
+
return join(home, '.wehandoff', 'shared', String(assetId));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** content sha — used to detect a dirty working copy (working !== base). */
|
|
44
|
+
export function sha256(body) {
|
|
45
|
+
return createHash('sha256').update(body ?? '', 'utf8').digest('hex');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Persist a working copy + its sync metadata for `assetId`. `baseBody` is the
|
|
50
|
+
* common-ancestor body the next push/merge diffs against; `cloudRev` is the rev
|
|
51
|
+
* that base corresponds to. The working copy is seeded to `baseBody` on a fresh
|
|
52
|
+
* share/pull (caller may overwrite issue.md afterwards while editing).
|
|
53
|
+
*/
|
|
54
|
+
export function writeWorkingCopy({ assetId, home = homedir(), body, cloudRev }) {
|
|
55
|
+
const dir = assetDir(assetId, home);
|
|
56
|
+
mkdirSync(dir, { recursive: true });
|
|
57
|
+
writeFileSync(join(dir, WORKING_FILE), body, 'utf8');
|
|
58
|
+
writeMeta({ assetId, home, cloudRev, baseBody: body });
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function writeMeta({ assetId, home = homedir(), cloudRev, baseBody }) {
|
|
62
|
+
const dir = assetDir(assetId, home);
|
|
63
|
+
mkdirSync(dir, { recursive: true });
|
|
64
|
+
const meta = { cloud_rev: cloudRev, base_body: baseBody, sha: sha256(baseBody) };
|
|
65
|
+
writeFileSync(join(dir, META_FILE), `${JSON.stringify(meta, null, 2)}\n`, 'utf8');
|
|
66
|
+
return meta;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Read `.meta.json`, or null if this asset has no local working copy yet. */
|
|
70
|
+
export function readMeta({ assetId, home = homedir() }) {
|
|
71
|
+
const path = join(assetDir(assetId, home), META_FILE);
|
|
72
|
+
if (!existsSync(path)) return null;
|
|
73
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Read the editable working copy (`issue.md`), or null if absent. */
|
|
77
|
+
export function readWorkingCopy({ assetId, home = homedir() }) {
|
|
78
|
+
const path = join(assetDir(assetId, home), WORKING_FILE);
|
|
79
|
+
if (!existsSync(path)) return null;
|
|
80
|
+
return readFileSync(path, 'utf8');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function writeWorkingBody({ assetId, home = homedir(), body }) {
|
|
84
|
+
const dir = assetDir(assetId, home);
|
|
85
|
+
mkdirSync(dir, { recursive: true });
|
|
86
|
+
writeFileSync(join(dir, WORKING_FILE), body, 'utf8');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Write the conflict-marked `.merged` so the user can resolve it by hand. */
|
|
90
|
+
export function writeMergedConflict({ assetId, home = homedir(), body }) {
|
|
91
|
+
const dir = assetDir(assetId, home);
|
|
92
|
+
mkdirSync(dir, { recursive: true });
|
|
93
|
+
const path = join(dir, MERGED_FILE);
|
|
94
|
+
writeFileSync(path, body, 'utf8');
|
|
95
|
+
return path;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Clear a stale `.merged` once a sync resolves cleanly. */
|
|
99
|
+
export function clearMerged({ assetId, home = homedir() }) {
|
|
100
|
+
const path = join(assetDir(assetId, home), MERGED_FILE);
|
|
101
|
+
if (existsSync(path)) rmSync(path);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** working copy differs from the synced base → has un-pushed local edits. */
|
|
105
|
+
export function isDirty({ assetId, home = homedir() }) {
|
|
106
|
+
const meta = readMeta({ assetId, home });
|
|
107
|
+
const working = readWorkingCopy({ assetId, home });
|
|
108
|
+
if (meta == null || working == null) return false;
|
|
109
|
+
return sha256(working) !== meta.sha;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Three-way merge {base (ancestor), local (mine), remote (theirs)} via node-diff3.
|
|
114
|
+
* Returns { conflict, body } where `body` carries git-style conflict markers
|
|
115
|
+
* (<<<<<<< local / ======= / >>>>>>> remote) when `conflict` is true. Newline
|
|
116
|
+
* granularity (stringSeparator:'\n') so issue-md merges line-by-line.
|
|
117
|
+
*/
|
|
118
|
+
export function threeWayMerge(base, local, remote) {
|
|
119
|
+
const { conflict, result } = diff3Merge(local, base, remote, {
|
|
120
|
+
stringSeparator: '\n',
|
|
121
|
+
label: { a: 'local', o: 'base', b: 'remote' },
|
|
122
|
+
});
|
|
123
|
+
return { conflict, body: result.join('\n') };
|
|
124
|
+
}
|