syndes 0.1.0 → 0.2.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.
@@ -0,0 +1,203 @@
1
+ /**
2
+ * How the pool is reached: a git remote, or a folder something else syncs.
3
+ *
4
+ * Both transports rest on one rule that makes the whole scheme work without a
5
+ * server, a lock or a merge strategy:
6
+ *
7
+ * A device writes ONLY inside devices/<its own id>/ and reads everyone's.
8
+ *
9
+ * Because no two devices ever touch the same path, a concurrent push is a
10
+ * rebase over disjoint files, which always applies cleanly, and a Dropbox-style
11
+ * folder has nothing to conflict over. The distributed-writer problem is not
12
+ * solved here; it is removed.
13
+ *
14
+ * git is reached through execFile with an argv array and a hard timeout, never
15
+ * a shell string — a repository path with a space in it must not become a
16
+ * command injection, and a network hang must not wedge the dashboard.
17
+ *
18
+ * Credentials are deliberately NOT handled. The user's own git already knows how
19
+ * to authenticate to their remote; re-implementing that would mean holding a
20
+ * token we have no business holding. A push that fails for auth reports the
21
+ * failure verbatim, which is what makes it fixable.
22
+ */
23
+
24
+ import { execFileSync } from 'node:child_process';
25
+ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync, rmSync } from 'node:fs';
26
+ import { join } from 'node:path';
27
+ import { which } from '../runtime/platform.mjs';
28
+ import { debug } from '../runtime/log.mjs';
29
+
30
+ const NET_TIMEOUT_MS = 25_000;
31
+ const LOCAL_TIMEOUT_MS = 8_000;
32
+
33
+ /** @returns {{ok: boolean, out: string, error: string|null}} */
34
+ function git(cwd, args, timeout = LOCAL_TIMEOUT_MS) {
35
+ const binary = which('git');
36
+ if (!binary) return { ok: false, out: '', error: 'git is not installed or not on PATH' };
37
+
38
+ try {
39
+ const out = execFileSync(binary, args, {
40
+ cwd,
41
+ encoding: 'utf8',
42
+ timeout,
43
+ stdio: ['ignore', 'pipe', 'pipe'],
44
+ windowsHide: true,
45
+ // A prompt inside a detached poll would hang forever with nobody to answer.
46
+ env: { ...process.env, GIT_TERMINAL_PROMPT: '0', GIT_ASKPASS: 'echo' },
47
+ });
48
+ return { ok: true, out: out.trim(), error: null };
49
+ } catch (error) {
50
+ const stderr = String(error.stderr ?? '').trim();
51
+ debug('git failed', args.join(' '), stderr || error.message);
52
+ return { ok: false, out: '', error: (stderr || error.message).split('\n').slice(-3).join(' ').slice(0, 300) };
53
+ }
54
+ }
55
+
56
+ // ── git transport ───────────────────────────────────────────────────────────
57
+
58
+ export const gitTransport = {
59
+ id: 'git',
60
+
61
+ /**
62
+ * Clone, or adopt an existing working copy.
63
+ *
64
+ * An empty remote has no branch to check out, so a fresh `main` is created
65
+ * rather than reporting a clone that "failed" on a repository the user just
66
+ * made and has every right to expect to work.
67
+ */
68
+ init(root, { repo }) {
69
+ if (!repo) return { ok: false, error: 'no repository url' };
70
+
71
+ if (existsSync(join(root, '.git'))) {
72
+ const set = git(root, ['remote', 'set-url', 'origin', repo]);
73
+ if (!set.ok) git(root, ['remote', 'add', 'origin', repo]);
74
+ return identify(root);
75
+ }
76
+
77
+ mkdirSync(root, { recursive: true });
78
+ const cloned = git(root, ['clone', '--depth', '50', repo, '.'], NET_TIMEOUT_MS);
79
+ if (!cloned.ok) {
80
+ // A brand-new empty repository clones with a warning and no HEAD.
81
+ const fresh = git(root, ['init']);
82
+ if (!fresh.ok) return { ok: false, error: cloned.error };
83
+ git(root, ['remote', 'add', 'origin', repo]);
84
+ }
85
+ if (!git(root, ['rev-parse', '--abbrev-ref', 'HEAD']).ok) git(root, ['checkout', '-b', 'main']);
86
+ return identify(root);
87
+ },
88
+
89
+ pull(root) {
90
+ if (!existsSync(join(root, '.git'))) return { ok: false, error: 'not initialised' };
91
+ // --autostash so a half-written publish never blocks the incoming update.
92
+ const pulled = git(root, ['pull', '--rebase', '--autostash', 'origin', branchOf(root)], NET_TIMEOUT_MS);
93
+ if (pulled.ok) return { ok: true, error: null };
94
+ // An empty remote has nothing to pull, which is not an error worth surfacing.
95
+ if (/couldn't find remote ref|does not appear to be a git repository|no such ref/i.test(pulled.error ?? '')) {
96
+ return { ok: true, error: null };
97
+ }
98
+ return { ok: false, error: pulled.error };
99
+ },
100
+
101
+ /** Commit and push our shelf. Silent when nothing under it changed. */
102
+ push(root, { deviceId, message }) {
103
+ const shelf = join('devices', deviceId);
104
+ const added = git(root, ['add', '--', shelf]);
105
+ if (!added.ok) return { ok: false, error: added.error, pushed: false };
106
+
107
+ const dirty = git(root, ['diff', '--cached', '--quiet', '--', shelf]);
108
+ if (dirty.ok) return { ok: true, error: null, pushed: false }; // exit 0 = no change
109
+
110
+ const committed = git(root, ['commit', '-m', message, '--', shelf]);
111
+ if (!committed.ok) return { ok: false, error: committed.error, pushed: false };
112
+
113
+ let pushed = git(root, ['push', 'origin', `HEAD:${branchOf(root)}`], NET_TIMEOUT_MS);
114
+ if (!pushed.ok) {
115
+ // Somebody else pushed between our pull and our push. Files are disjoint,
116
+ // so a rebase resolves it with no possibility of a content conflict.
117
+ this.pull(root);
118
+ pushed = git(root, ['push', 'origin', `HEAD:${branchOf(root)}`], NET_TIMEOUT_MS);
119
+ }
120
+ return { ok: pushed.ok, error: pushed.error, pushed: pushed.ok };
121
+ },
122
+
123
+ describe(root) {
124
+ const remote = git(root, ['remote', 'get-url', 'origin']);
125
+ return remote.ok ? remote.out : null;
126
+ },
127
+ };
128
+
129
+ function branchOf(root) {
130
+ const head = git(root, ['rev-parse', '--abbrev-ref', 'HEAD']);
131
+ return head.ok && head.out && head.out !== 'HEAD' ? head.out : 'main';
132
+ }
133
+
134
+ /** git refuses to commit without an identity, and a global one may not exist. */
135
+ function identify(root) {
136
+ if (!git(root, ['config', 'user.email']).ok) {
137
+ git(root, ['config', 'user.email', 'syndes@localhost']);
138
+ git(root, ['config', 'user.name', 'syndes']);
139
+ }
140
+ return { ok: true, error: null };
141
+ }
142
+
143
+ // ── folder transport ────────────────────────────────────────────────────────
144
+
145
+ /**
146
+ * A directory something else keeps in sync — Dropbox, iCloud, a network share.
147
+ *
148
+ * Nothing to do on pull or push: the files are already where everyone can see
149
+ * them. This exists because it is the correct answer for two people who do not
150
+ * want to run anything, and because having a second transport is what proves the
151
+ * pool logic does not secretly depend on git.
152
+ */
153
+ export const folderTransport = {
154
+ id: 'folder',
155
+ init(root) {
156
+ try {
157
+ mkdirSync(root, { recursive: true });
158
+ return { ok: true, error: null };
159
+ } catch (error) {
160
+ return { ok: false, error: error.message };
161
+ }
162
+ },
163
+ pull() { return { ok: true, error: null }; },
164
+ push() { return { ok: true, error: null, pushed: true }; },
165
+ describe(root) { return root; },
166
+ };
167
+
168
+ export function transportFor(kind) {
169
+ if (kind === 'git') return gitTransport;
170
+ if (kind === 'folder') return folderTransport;
171
+ return null;
172
+ }
173
+
174
+ // ── shelf io, shared by both transports ─────────────────────────────────────
175
+
176
+ export function listDevices(root) {
177
+ try {
178
+ return readdirSync(join(root, 'devices'), { withFileTypes: true })
179
+ .filter((entry) => entry.isDirectory())
180
+ .map((entry) => entry.name)
181
+ .sort();
182
+ } catch {
183
+ return [];
184
+ }
185
+ }
186
+
187
+ export function readJsonFile(file) {
188
+ try {
189
+ return JSON.parse(readFileSync(file, 'utf8'));
190
+ } catch {
191
+ return null;
192
+ }
193
+ }
194
+
195
+ export function writeJsonFile(file, data) {
196
+ writeFileSync(file, `${JSON.stringify(data)}\n`);
197
+ }
198
+
199
+ export function removeFile(file) {
200
+ try { rmSync(file, { force: true }); } catch { /* already gone */ }
201
+ }
202
+
203
+ export { git };