@venturewild/workspace 0.1.2 → 0.1.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.
@@ -1,248 +1,248 @@
1
- // bmo-sync folder sharing — wild-workspace's control plane for the daemon.
2
- //
3
- // wild-workspace does not run the sync engine; the bmo-sync daemon does
4
- // (a separate local process — see bmo-sync/daemon/README.md). This module is
5
- // the thin HTTP client wild-workspace uses to drive it:
6
- //
7
- // - pair / detach / list a workspace folder against the local daemon
8
- // (http://127.0.0.1:8320);
9
- // - mint an invite against the central server's admin API — but only when
10
- // an admin key is configured. Most installs only ever *redeem* invites,
11
- // and that path runs entirely through the daemon and needs no secret.
12
- //
13
- // The daemon may not be running. Read paths (health / listWorkspaces /
14
- // status) degrade to an "offline" result instead of throwing; explicit
15
- // actions (pair / detach / createInvite) throw a readable error for the UI.
16
-
17
- const DAEMON_TIMEOUT_MS = 4000;
18
- // Pairing and invite creation reach the central server on Fly.io, which can
19
- // cold-start (~1s+) when idle — give those calls a longer leash.
20
- const SERVER_TIMEOUT_MS = 12000;
21
-
22
- export class SyncControl {
23
- /**
24
- * @param {object} opts
25
- * @param {string} opts.daemonHttpUrl daemon HTTP origin, e.g. http://127.0.0.1:8320
26
- * @param {string} opts.bmoSyncServerUrl central server, e.g. https://sync.venturewild.llc
27
- * @param {string|null} [opts.adminKey] central-server X-Admin-Key; null = redeem-only
28
- * @param {typeof fetch} [opts.fetchImpl] injectable fetch (tests)
29
- */
30
- constructor({ daemonHttpUrl, bmoSyncServerUrl, adminKey = null, fetchImpl } = {}) {
31
- this.daemonBase = trimSlash(daemonHttpUrl) || 'http://127.0.0.1:8320';
32
- this.serverBase = trimSlash(bmoSyncServerUrl) || 'https://sync.venturewild.llc';
33
- this.adminKey = adminKey || null;
34
- this._fetch = fetchImpl || globalThis.fetch;
35
- }
36
-
37
- /** True when this install can mint invites (an admin key is configured). */
38
- get canInvite() {
39
- return Boolean(this.adminKey);
40
- }
41
-
42
- /** Liveness of the local daemon. Never throws. */
43
- async health() {
44
- try {
45
- const res = await this._fetch(`${this.daemonBase}/health`, {
46
- signal: AbortSignal.timeout(DAEMON_TIMEOUT_MS),
47
- });
48
- if (!res.ok) return { running: false };
49
- const body = await res.json().catch(() => ({}));
50
- return { running: body?.status === 'ok' };
51
- } catch {
52
- return { running: false };
53
- }
54
- }
55
-
56
- /** Paired workspaces the daemon is syncing. [] when the daemon is down. */
57
- async listWorkspaces() {
58
- try {
59
- const res = await this._fetch(`${this.daemonBase}/api/workspaces`, {
60
- signal: AbortSignal.timeout(DAEMON_TIMEOUT_MS),
61
- });
62
- if (!res.ok) return [];
63
- const body = await res.json().catch(() => ({}));
64
- return Array.isArray(body?.workspaces) ? body.workspaces : [];
65
- } catch {
66
- return [];
67
- }
68
- }
69
-
70
- /**
71
- * Snapshot for the Sync panel: daemon liveness, paired workspaces, and
72
- * whether this install can mint invites. Never throws — a missing daemon
73
- * is a normal, displayable state.
74
- */
75
- async status() {
76
- const [health, workspaces] = await Promise.all([
77
- this.health(),
78
- this.listWorkspaces(),
79
- ]);
80
- return {
81
- daemon: health,
82
- workspaces,
83
- paired: workspaces.length > 0,
84
- canInvite: this.canInvite,
85
- };
86
- }
87
-
88
- /**
89
- * Pair this workspace folder with a shared project by redeeming an invite.
90
- * The daemon redeems against the central server, persists the pairing, and
91
- * starts syncing `rootPath`. Throws a readable error on a bad invite or an
92
- * unreachable daemon.
93
- */
94
- async pair(inviteCode, rootPath) {
95
- const code = String(inviteCode || '').trim();
96
- if (!code) throw new Error('An invite code is required.');
97
- if (!rootPath) throw new Error('No workspace folder to pair.');
98
- const res = await this._daemonFetch(`${this.daemonBase}/api/pair`, {
99
- method: 'POST',
100
- headers: { 'content-type': 'application/json' },
101
- body: JSON.stringify({ inviteCode: code, rootPath }),
102
- // Pairing redeems against the central server — allow for a cold start.
103
- signal: AbortSignal.timeout(SERVER_TIMEOUT_MS),
104
- });
105
- const body = await res.json().catch(() => ({}));
106
- if (!res.ok) {
107
- throw new Error(body?.error || `Pairing failed (HTTP ${res.status}).`);
108
- }
109
- return body.workspace || body;
110
- }
111
-
112
- /** Stop syncing a workspace and forget the pairing. */
113
- async detach(workspaceId) {
114
- const id = String(workspaceId || '').trim();
115
- if (!id) throw new Error('A workspace id is required.');
116
- const res = await this._daemonFetch(
117
- `${this.daemonBase}/api/workspaces/${encodeURIComponent(id)}`,
118
- { method: 'DELETE', signal: AbortSignal.timeout(DAEMON_TIMEOUT_MS) },
119
- );
120
- const body = await res.json().catch(() => ({}));
121
- if (!res.ok) {
122
- throw new Error(body?.error || `Disconnect failed (HTTP ${res.status}).`);
123
- }
124
- return { detached: Boolean(body.detached) };
125
- }
126
-
127
- /**
128
- * Mint an invite code for a project on the central server. Requires an
129
- * admin key; callers should check `canInvite` first and fall back to the
130
- * paste-a-code flow when it is false.
131
- */
132
- async createInvite({ projectCode, displayName, expiresHours = 168 } = {}) {
133
- if (!this.adminKey) {
134
- throw new Error(
135
- 'Creating invites needs a bmo-sync admin key (set BMO_SYNC_ADMIN_KEY). ' +
136
- 'Without one, ask whoever owns the shared folder to send you a code.',
137
- );
138
- }
139
- if (!projectCode) throw new Error('A paired project is required to invite into.');
140
- let res;
141
- try {
142
- res = await this._fetch(`${this.serverBase}/api/admin/invites`, {
143
- method: 'POST',
144
- headers: { 'content-type': 'application/json', 'x-admin-key': this.adminKey },
145
- body: JSON.stringify({
146
- project_code: projectCode,
147
- display_name: displayName || 'Collaborator',
148
- expires_hours: Number(expiresHours) || 168,
149
- }),
150
- signal: AbortSignal.timeout(SERVER_TIMEOUT_MS),
151
- });
152
- } catch {
153
- throw new Error('Could not reach the bmo-sync server. Check your connection.');
154
- }
155
- const body = await res.json().catch(() => ({}));
156
- if (!res.ok) {
157
- throw new Error(
158
- body?.message || body?.error || `Invite creation failed (HTTP ${res.status}).`,
159
- );
160
- }
161
- return { code: body.code, projectCode: body.project_code, expiresAt: body.expires_at };
162
- }
163
-
164
- /** A daemon fetch that turns a connection failure into a readable error. */
165
- async _daemonFetch(url, init) {
166
- try {
167
- return await this._fetch(url, init);
168
- } catch {
169
- throw new Error("The bmo-sync daemon isn't running. Start it, then try again.");
170
- }
171
- }
172
-
173
- // ── C12-e: conflict surface ──────────────────────────────────────────
174
- // The daemon emits SyncEvent::Conflict via /api/events (already piped
175
- // into the wild-workspace ActivityBus by DaemonBridge); these methods
176
- // are the explicit "give me / resolve" operations behind the badge
177
- // and CLI.
178
-
179
- /** All open conflicts across every paired workspace. [] when daemon down. */
180
- async listConflicts() {
181
- try {
182
- const res = await this._fetch(`${this.daemonBase}/api/conflicts`, {
183
- signal: AbortSignal.timeout(DAEMON_TIMEOUT_MS),
184
- });
185
- if (!res.ok) return [];
186
- const body = await res.json().catch(() => ({}));
187
- return Array.isArray(body?.conflicts) ? body.conflicts : [];
188
- } catch {
189
- return [];
190
- }
191
- }
192
-
193
- /**
194
- * Fetch one conflict's mine/theirs bytes (base64) plus the row. Used by
195
- * the human-fallback diff panel + the `wild_conflicts_view` agent tool.
196
- */
197
- async viewConflict(workspaceId, path) {
198
- if (!workspaceId) throw new Error('workspaceId is required');
199
- if (!path) throw new Error('path is required');
200
- const url =
201
- `${this.daemonBase}/api/conflicts/` +
202
- `${encodeURIComponent(workspaceId)}/` +
203
- path.split('/').map(encodeURIComponent).join('/');
204
- const res = await this._daemonFetch(url, {
205
- signal: AbortSignal.timeout(DAEMON_TIMEOUT_MS),
206
- });
207
- const body = await res.json().catch(() => ({}));
208
- if (res.status === 404) return null;
209
- if (!res.ok) {
210
- throw new Error(body?.error || `Conflict view failed (HTTP ${res.status}).`);
211
- }
212
- return body;
213
- }
214
-
215
- /**
216
- * Resolve a conflict. `action` is `keep_mine` or `take_theirs`. The
217
- * server-mediated multi-peer broadcast is a V1.1 follow-up; for V1
218
- * this is local-only.
219
- */
220
- async resolveConflict(workspaceId, path, action) {
221
- const verb = String(action || '').trim();
222
- if (!['keep_mine', 'take_theirs'].includes(verb)) {
223
- throw new Error('action must be "keep_mine" or "take_theirs".');
224
- }
225
- // GET = view, POST = resolve on the same URL — the daemon's route was
226
- // collapsed because axum 0.8 disallows a literal segment after a
227
- // `{*path}` catchall.
228
- const url =
229
- `${this.daemonBase}/api/conflicts/` +
230
- `${encodeURIComponent(workspaceId)}/` +
231
- path.split('/').map(encodeURIComponent).join('/');
232
- const res = await this._daemonFetch(url, {
233
- method: 'POST',
234
- headers: { 'content-type': 'application/json' },
235
- body: JSON.stringify({ action: verb }),
236
- signal: AbortSignal.timeout(DAEMON_TIMEOUT_MS),
237
- });
238
- const body = await res.json().catch(() => ({}));
239
- if (!res.ok) {
240
- throw new Error(body?.error || `Resolve failed (HTTP ${res.status}).`);
241
- }
242
- return { ok: true };
243
- }
244
- }
245
-
246
- function trimSlash(u) {
247
- return typeof u === 'string' ? u.replace(/\/+$/, '') : '';
248
- }
1
+ // bmo-sync folder sharing — wild-workspace's control plane for the daemon.
2
+ //
3
+ // wild-workspace does not run the sync engine; the bmo-sync daemon does
4
+ // (a separate local process — see bmo-sync/daemon/README.md). This module is
5
+ // the thin HTTP client wild-workspace uses to drive it:
6
+ //
7
+ // - pair / detach / list a workspace folder against the local daemon
8
+ // (http://127.0.0.1:8320);
9
+ // - mint an invite against the central server's admin API — but only when
10
+ // an admin key is configured. Most installs only ever *redeem* invites,
11
+ // and that path runs entirely through the daemon and needs no secret.
12
+ //
13
+ // The daemon may not be running. Read paths (health / listWorkspaces /
14
+ // status) degrade to an "offline" result instead of throwing; explicit
15
+ // actions (pair / detach / createInvite) throw a readable error for the UI.
16
+
17
+ const DAEMON_TIMEOUT_MS = 4000;
18
+ // Pairing and invite creation reach the central server on Fly.io, which can
19
+ // cold-start (~1s+) when idle — give those calls a longer leash.
20
+ const SERVER_TIMEOUT_MS = 12000;
21
+
22
+ export class SyncControl {
23
+ /**
24
+ * @param {object} opts
25
+ * @param {string} opts.daemonHttpUrl daemon HTTP origin, e.g. http://127.0.0.1:8320
26
+ * @param {string} opts.bmoSyncServerUrl central server, e.g. https://sync.venturewild.llc
27
+ * @param {string|null} [opts.adminKey] central-server X-Admin-Key; null = redeem-only
28
+ * @param {typeof fetch} [opts.fetchImpl] injectable fetch (tests)
29
+ */
30
+ constructor({ daemonHttpUrl, bmoSyncServerUrl, adminKey = null, fetchImpl } = {}) {
31
+ this.daemonBase = trimSlash(daemonHttpUrl) || 'http://127.0.0.1:8320';
32
+ this.serverBase = trimSlash(bmoSyncServerUrl) || 'https://sync.venturewild.llc';
33
+ this.adminKey = adminKey || null;
34
+ this._fetch = fetchImpl || globalThis.fetch;
35
+ }
36
+
37
+ /** True when this install can mint invites (an admin key is configured). */
38
+ get canInvite() {
39
+ return Boolean(this.adminKey);
40
+ }
41
+
42
+ /** Liveness of the local daemon. Never throws. */
43
+ async health() {
44
+ try {
45
+ const res = await this._fetch(`${this.daemonBase}/health`, {
46
+ signal: AbortSignal.timeout(DAEMON_TIMEOUT_MS),
47
+ });
48
+ if (!res.ok) return { running: false };
49
+ const body = await res.json().catch(() => ({}));
50
+ return { running: body?.status === 'ok' };
51
+ } catch {
52
+ return { running: false };
53
+ }
54
+ }
55
+
56
+ /** Paired workspaces the daemon is syncing. [] when the daemon is down. */
57
+ async listWorkspaces() {
58
+ try {
59
+ const res = await this._fetch(`${this.daemonBase}/api/workspaces`, {
60
+ signal: AbortSignal.timeout(DAEMON_TIMEOUT_MS),
61
+ });
62
+ if (!res.ok) return [];
63
+ const body = await res.json().catch(() => ({}));
64
+ return Array.isArray(body?.workspaces) ? body.workspaces : [];
65
+ } catch {
66
+ return [];
67
+ }
68
+ }
69
+
70
+ /**
71
+ * Snapshot for the Sync panel: daemon liveness, paired workspaces, and
72
+ * whether this install can mint invites. Never throws — a missing daemon
73
+ * is a normal, displayable state.
74
+ */
75
+ async status() {
76
+ const [health, workspaces] = await Promise.all([
77
+ this.health(),
78
+ this.listWorkspaces(),
79
+ ]);
80
+ return {
81
+ daemon: health,
82
+ workspaces,
83
+ paired: workspaces.length > 0,
84
+ canInvite: this.canInvite,
85
+ };
86
+ }
87
+
88
+ /**
89
+ * Pair this workspace folder with a shared project by redeeming an invite.
90
+ * The daemon redeems against the central server, persists the pairing, and
91
+ * starts syncing `rootPath`. Throws a readable error on a bad invite or an
92
+ * unreachable daemon.
93
+ */
94
+ async pair(inviteCode, rootPath) {
95
+ const code = String(inviteCode || '').trim();
96
+ if (!code) throw new Error('An invite code is required.');
97
+ if (!rootPath) throw new Error('No workspace folder to pair.');
98
+ const res = await this._daemonFetch(`${this.daemonBase}/api/pair`, {
99
+ method: 'POST',
100
+ headers: { 'content-type': 'application/json' },
101
+ body: JSON.stringify({ inviteCode: code, rootPath }),
102
+ // Pairing redeems against the central server — allow for a cold start.
103
+ signal: AbortSignal.timeout(SERVER_TIMEOUT_MS),
104
+ });
105
+ const body = await res.json().catch(() => ({}));
106
+ if (!res.ok) {
107
+ throw new Error(body?.error || `Pairing failed (HTTP ${res.status}).`);
108
+ }
109
+ return body.workspace || body;
110
+ }
111
+
112
+ /** Stop syncing a workspace and forget the pairing. */
113
+ async detach(workspaceId) {
114
+ const id = String(workspaceId || '').trim();
115
+ if (!id) throw new Error('A workspace id is required.');
116
+ const res = await this._daemonFetch(
117
+ `${this.daemonBase}/api/workspaces/${encodeURIComponent(id)}`,
118
+ { method: 'DELETE', signal: AbortSignal.timeout(DAEMON_TIMEOUT_MS) },
119
+ );
120
+ const body = await res.json().catch(() => ({}));
121
+ if (!res.ok) {
122
+ throw new Error(body?.error || `Disconnect failed (HTTP ${res.status}).`);
123
+ }
124
+ return { detached: Boolean(body.detached) };
125
+ }
126
+
127
+ /**
128
+ * Mint an invite code for a project on the central server. Requires an
129
+ * admin key; callers should check `canInvite` first and fall back to the
130
+ * paste-a-code flow when it is false.
131
+ */
132
+ async createInvite({ projectCode, displayName, expiresHours = 168 } = {}) {
133
+ if (!this.adminKey) {
134
+ throw new Error(
135
+ 'Creating invites needs a bmo-sync admin key (set BMO_SYNC_ADMIN_KEY). ' +
136
+ 'Without one, ask whoever owns the shared folder to send you a code.',
137
+ );
138
+ }
139
+ if (!projectCode) throw new Error('A paired project is required to invite into.');
140
+ let res;
141
+ try {
142
+ res = await this._fetch(`${this.serverBase}/api/admin/invites`, {
143
+ method: 'POST',
144
+ headers: { 'content-type': 'application/json', 'x-admin-key': this.adminKey },
145
+ body: JSON.stringify({
146
+ project_code: projectCode,
147
+ display_name: displayName || 'Collaborator',
148
+ expires_hours: Number(expiresHours) || 168,
149
+ }),
150
+ signal: AbortSignal.timeout(SERVER_TIMEOUT_MS),
151
+ });
152
+ } catch {
153
+ throw new Error('Could not reach the bmo-sync server. Check your connection.');
154
+ }
155
+ const body = await res.json().catch(() => ({}));
156
+ if (!res.ok) {
157
+ throw new Error(
158
+ body?.message || body?.error || `Invite creation failed (HTTP ${res.status}).`,
159
+ );
160
+ }
161
+ return { code: body.code, projectCode: body.project_code, expiresAt: body.expires_at };
162
+ }
163
+
164
+ /** A daemon fetch that turns a connection failure into a readable error. */
165
+ async _daemonFetch(url, init) {
166
+ try {
167
+ return await this._fetch(url, init);
168
+ } catch {
169
+ throw new Error("The bmo-sync daemon isn't running. Start it, then try again.");
170
+ }
171
+ }
172
+
173
+ // ── C12-e: conflict surface ──────────────────────────────────────────
174
+ // The daemon emits SyncEvent::Conflict via /api/events (already piped
175
+ // into the wild-workspace ActivityBus by DaemonBridge); these methods
176
+ // are the explicit "give me / resolve" operations behind the badge
177
+ // and CLI.
178
+
179
+ /** All open conflicts across every paired workspace. [] when daemon down. */
180
+ async listConflicts() {
181
+ try {
182
+ const res = await this._fetch(`${this.daemonBase}/api/conflicts`, {
183
+ signal: AbortSignal.timeout(DAEMON_TIMEOUT_MS),
184
+ });
185
+ if (!res.ok) return [];
186
+ const body = await res.json().catch(() => ({}));
187
+ return Array.isArray(body?.conflicts) ? body.conflicts : [];
188
+ } catch {
189
+ return [];
190
+ }
191
+ }
192
+
193
+ /**
194
+ * Fetch one conflict's mine/theirs bytes (base64) plus the row. Used by
195
+ * the human-fallback diff panel + the `wild_conflicts_view` agent tool.
196
+ */
197
+ async viewConflict(workspaceId, path) {
198
+ if (!workspaceId) throw new Error('workspaceId is required');
199
+ if (!path) throw new Error('path is required');
200
+ const url =
201
+ `${this.daemonBase}/api/conflicts/` +
202
+ `${encodeURIComponent(workspaceId)}/` +
203
+ path.split('/').map(encodeURIComponent).join('/');
204
+ const res = await this._daemonFetch(url, {
205
+ signal: AbortSignal.timeout(DAEMON_TIMEOUT_MS),
206
+ });
207
+ const body = await res.json().catch(() => ({}));
208
+ if (res.status === 404) return null;
209
+ if (!res.ok) {
210
+ throw new Error(body?.error || `Conflict view failed (HTTP ${res.status}).`);
211
+ }
212
+ return body;
213
+ }
214
+
215
+ /**
216
+ * Resolve a conflict. `action` is `keep_mine` or `take_theirs`. The
217
+ * server-mediated multi-peer broadcast is a V1.1 follow-up; for V1
218
+ * this is local-only.
219
+ */
220
+ async resolveConflict(workspaceId, path, action) {
221
+ const verb = String(action || '').trim();
222
+ if (!['keep_mine', 'take_theirs'].includes(verb)) {
223
+ throw new Error('action must be "keep_mine" or "take_theirs".');
224
+ }
225
+ // GET = view, POST = resolve on the same URL — the daemon's route was
226
+ // collapsed because axum 0.8 disallows a literal segment after a
227
+ // `{*path}` catchall.
228
+ const url =
229
+ `${this.daemonBase}/api/conflicts/` +
230
+ `${encodeURIComponent(workspaceId)}/` +
231
+ path.split('/').map(encodeURIComponent).join('/');
232
+ const res = await this._daemonFetch(url, {
233
+ method: 'POST',
234
+ headers: { 'content-type': 'application/json' },
235
+ body: JSON.stringify({ action: verb }),
236
+ signal: AbortSignal.timeout(DAEMON_TIMEOUT_MS),
237
+ });
238
+ const body = await res.json().catch(() => ({}));
239
+ if (!res.ok) {
240
+ throw new Error(body?.error || `Resolve failed (HTTP ${res.status}).`);
241
+ }
242
+ return { ok: true };
243
+ }
244
+ }
245
+
246
+ function trimSlash(u) {
247
+ return typeof u === 'string' ? u.replace(/\/+$/, '') : '';
248
+ }