@rigxyz/tapd 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.
@@ -0,0 +1,1452 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/binding-config.ts
4
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from "fs";
5
+ import { dirname, join } from "path";
6
+ var BINDING_CONFIG_PATH = ".rig/tap-binding.local.json";
7
+ function bindingConfigFile(rootDir) {
8
+ return join(rootDir, BINDING_CONFIG_PATH);
9
+ }
10
+ function readBindingConfig(rootDir) {
11
+ const path = bindingConfigFile(rootDir);
12
+ if (!existsSync(path)) return null;
13
+ const raw = readFileSync(path, "utf8");
14
+ const parsed = JSON.parse(raw);
15
+ if (typeof parsed.bindingId !== "string" || typeof parsed.relayUrl !== "string" || typeof parsed.deviceId !== "string" || typeof parsed.token !== "string") {
16
+ throw new Error(`malformed binding config at ${path}`);
17
+ }
18
+ return {
19
+ bindingId: parsed.bindingId,
20
+ relayUrl: parsed.relayUrl,
21
+ deviceId: parsed.deviceId,
22
+ token: parsed.token
23
+ };
24
+ }
25
+ function writeBindingConfig(rootDir, config) {
26
+ const path = bindingConfigFile(rootDir);
27
+ mkdirSync(dirname(path), { recursive: true });
28
+ writeFileSync(path, JSON.stringify(config, null, 2) + "\n", { mode: 384 });
29
+ }
30
+ var NotInitializedError = class extends Error {
31
+ constructor(rootDir) {
32
+ super(`not initialized \u2014 no binding config at ${rootDir}/${BINDING_CONFIG_PATH}`);
33
+ this.rootDir = rootDir;
34
+ this.name = "NotInitializedError";
35
+ }
36
+ rootDir;
37
+ };
38
+
39
+ // src/relay-client.ts
40
+ var RelayError = class extends Error {
41
+ constructor(route, status, body) {
42
+ const bodySummary = typeof body === "object" && body !== null && "error" in body ? String(body.error) : `status ${status}`;
43
+ super(`relay ${route}: ${bodySummary}`);
44
+ this.route = route;
45
+ this.status = status;
46
+ this.body = body;
47
+ this.name = "RelayError";
48
+ }
49
+ route;
50
+ status;
51
+ body;
52
+ };
53
+ async function readJson(res) {
54
+ const ct = res.headers.get("content-type") ?? "";
55
+ if (!ct.includes("application/json")) return void 0;
56
+ try {
57
+ return await res.json();
58
+ } catch {
59
+ return void 0;
60
+ }
61
+ }
62
+ async function expectJson(route, res) {
63
+ const body = await readJson(res);
64
+ if (!res.ok) {
65
+ throw new RelayError(route, res.status, body);
66
+ }
67
+ return body;
68
+ }
69
+ async function acceptInvite(opts) {
70
+ const fn = opts.fetch ?? fetch;
71
+ const route = "POST /v1/invites/:secret/accept";
72
+ const headers = {
73
+ "content-type": "application/json",
74
+ "x-tap-user-id": opts.userId
75
+ };
76
+ if (opts.email) headers["x-tap-user-email"] = opts.email;
77
+ const res = await fn(
78
+ joinUrl(opts.baseUrl, `/v1/invites/${encodeURIComponent(opts.secret)}/accept`),
79
+ {
80
+ method: "POST",
81
+ headers,
82
+ body: JSON.stringify(opts.request ?? {})
83
+ }
84
+ );
85
+ return expectJson(route, res);
86
+ }
87
+ async function createBinding(opts) {
88
+ const fn = opts.fetch ?? fetch;
89
+ const route = "POST /v1/bindings";
90
+ const headers = {
91
+ "content-type": "application/json",
92
+ "x-tap-user-id": opts.userId
93
+ };
94
+ if (opts.email) headers["x-tap-user-email"] = opts.email;
95
+ const res = await fn(joinUrl(opts.baseUrl, "/v1/bindings"), {
96
+ method: "POST",
97
+ headers,
98
+ body: JSON.stringify(opts.request)
99
+ });
100
+ return expectJson(route, res);
101
+ }
102
+ var RelayClient = class {
103
+ baseUrl;
104
+ bindingId;
105
+ token;
106
+ fetch;
107
+ constructor(opts) {
108
+ this.baseUrl = opts.baseUrl;
109
+ this.bindingId = opts.bindingId;
110
+ this.token = opts.token;
111
+ this.fetch = opts.fetch ?? fetch;
112
+ }
113
+ authHeaders(extra) {
114
+ return { authorization: `Bearer ${this.token}`, ...extra };
115
+ }
116
+ bindingPath(suffix) {
117
+ return joinUrl(
118
+ this.baseUrl,
119
+ `/v1/bindings/${encodeURIComponent(this.bindingId)}${suffix}`
120
+ );
121
+ }
122
+ async getBinding() {
123
+ const route = "GET /v1/bindings/:id";
124
+ const res = await this.fetch(this.bindingPath(""), {
125
+ headers: this.authHeaders()
126
+ });
127
+ return expectJson(route, res);
128
+ }
129
+ // ────────── objects ──────────
130
+ async prepareUpload(body) {
131
+ const route = "POST /v1/bindings/:id/objects/prepare-upload";
132
+ const res = await this.fetch(this.bindingPath("/objects/prepare-upload"), {
133
+ method: "POST",
134
+ headers: this.authHeaders({ "content-type": "application/json" }),
135
+ body: JSON.stringify(body)
136
+ });
137
+ return expectJson(route, res);
138
+ }
139
+ async completeUpload(body) {
140
+ const route = "POST /v1/bindings/:id/objects/complete-upload";
141
+ const res = await this.fetch(this.bindingPath("/objects/complete-upload"), {
142
+ method: "POST",
143
+ headers: this.authHeaders({ "content-type": "application/json" }),
144
+ body: JSON.stringify(body)
145
+ });
146
+ return expectJson(route, res);
147
+ }
148
+ async downloadUrl(hash) {
149
+ const route = "GET /v1/bindings/:id/objects/:hash/download-url";
150
+ const res = await this.fetch(
151
+ this.bindingPath(`/objects/${encodeURIComponent(hash)}/download-url`),
152
+ { headers: this.authHeaders() }
153
+ );
154
+ return expectJson(route, res);
155
+ }
156
+ /** PUT bytes to a presigned URL. Throws on non-2xx. */
157
+ async putObjectBytes(uploadUrl, bytes) {
158
+ const route = "PUT <uploadUrl>";
159
+ const res = await this.fetch(uploadUrl, {
160
+ method: "PUT",
161
+ body: new Uint8Array(bytes),
162
+ headers: { "content-length": String(bytes.byteLength) }
163
+ });
164
+ if (!res.ok) {
165
+ throw new RelayError(route, res.status, await readJson(res));
166
+ }
167
+ }
168
+ /** GET bytes from a presigned URL. Throws on non-2xx. */
169
+ async getObjectBytes(downloadUrl) {
170
+ const route = "GET <downloadUrl>";
171
+ const res = await this.fetch(downloadUrl);
172
+ if (!res.ok) {
173
+ throw new RelayError(route, res.status, await readJson(res));
174
+ }
175
+ return Buffer.from(await res.arrayBuffer());
176
+ }
177
+ // ────────── changes ──────────
178
+ async submitChanges(events) {
179
+ const route = "POST /v1/bindings/:id/changes";
180
+ const body = { events };
181
+ const res = await this.fetch(this.bindingPath("/changes"), {
182
+ method: "POST",
183
+ headers: this.authHeaders({ "content-type": "application/json" }),
184
+ body: JSON.stringify(body)
185
+ });
186
+ return expectJson(route, res);
187
+ }
188
+ async listChanges(opts) {
189
+ const route = "GET /v1/bindings/:id/changes";
190
+ const params = new URLSearchParams();
191
+ if (opts?.after) params.set("after", opts.after);
192
+ if (opts?.limit) params.set("limit", String(opts.limit));
193
+ const qs = params.toString();
194
+ const res = await this.fetch(
195
+ this.bindingPath("/changes") + (qs ? `?${qs}` : ""),
196
+ { headers: this.authHeaders() }
197
+ );
198
+ return expectJson(route, res);
199
+ }
200
+ // ────────── invites ──────────
201
+ /**
202
+ * Mint a new invite (owner-only). Returns the secret + a ready-to-share
203
+ * accept URL, exactly once. Caller must surface to the user immediately
204
+ * and rely on `listInvites()` later if they want to refer back without
205
+ * the secret.
206
+ */
207
+ async mintInvite(body) {
208
+ const route = "POST /v1/bindings/:id/invites";
209
+ const res = await this.fetch(this.bindingPath("/invites"), {
210
+ method: "POST",
211
+ headers: this.authHeaders({ "content-type": "application/json" }),
212
+ body: JSON.stringify(body)
213
+ });
214
+ return expectJson(route, res);
215
+ }
216
+ async listInvites() {
217
+ const route = "GET /v1/bindings/:id/invites";
218
+ const res = await this.fetch(this.bindingPath("/invites"), {
219
+ headers: this.authHeaders()
220
+ });
221
+ return expectJson(route, res);
222
+ }
223
+ async revokeInvite(inviteId) {
224
+ const route = "DELETE /v1/bindings/:id/invites/:inviteId";
225
+ const res = await this.fetch(
226
+ this.bindingPath(`/invites/${encodeURIComponent(inviteId)}`),
227
+ { method: "DELETE", headers: this.authHeaders() }
228
+ );
229
+ return expectJson(route, res);
230
+ }
231
+ // ────────── manifest ──────────
232
+ async getManifest(opts) {
233
+ const route = "GET /v1/bindings/:id/manifest";
234
+ const params = new URLSearchParams();
235
+ if (opts?.after) params.set("after", opts.after);
236
+ if (opts?.limit) params.set("limit", String(opts.limit));
237
+ const qs = params.toString();
238
+ const res = await this.fetch(
239
+ this.bindingPath("/manifest") + (qs ? `?${qs}` : ""),
240
+ { headers: this.authHeaders() }
241
+ );
242
+ return expectJson(route, res);
243
+ }
244
+ };
245
+ function joinUrl(base, suffix) {
246
+ const b = base.replace(/\/+$/, "");
247
+ const s = suffix.startsWith("/") ? suffix : `/${suffix}`;
248
+ return b + s;
249
+ }
250
+
251
+ // src/state-db.ts
252
+ import { mkdirSync as mkdirSync2 } from "fs";
253
+ import { dirname as dirname2 } from "path";
254
+ import Database from "better-sqlite3";
255
+ var CURSOR_PREFIX = "chg_";
256
+ function cursorGreaterThan(a, b) {
257
+ const an = a.startsWith(CURSOR_PREFIX) ? safeBigInt(a.slice(CURSOR_PREFIX.length)) : null;
258
+ const bn = b.startsWith(CURSOR_PREFIX) ? safeBigInt(b.slice(CURSOR_PREFIX.length)) : null;
259
+ if (an === null) return false;
260
+ if (bn === null) return true;
261
+ return an > bn;
262
+ }
263
+ function safeBigInt(s) {
264
+ try {
265
+ return BigInt(s);
266
+ } catch {
267
+ return null;
268
+ }
269
+ }
270
+ var SCHEMA = `
271
+ CREATE TABLE IF NOT EXISTS paths (
272
+ path TEXT PRIMARY KEY,
273
+ last_applied_change_id TEXT,
274
+ last_seen_hash TEXT,
275
+ local_dirty INTEGER NOT NULL DEFAULT 0,
276
+ last_scan_at TEXT
277
+ );
278
+
279
+ CREATE TABLE IF NOT EXISTS meta (
280
+ key TEXT PRIMARY KEY,
281
+ value TEXT NOT NULL
282
+ );
283
+ `;
284
+ function openStateDb(dbPath) {
285
+ mkdirSync2(dirname2(dbPath), { recursive: true });
286
+ const db = new Database(dbPath);
287
+ db.pragma("journal_mode = WAL");
288
+ db.exec(SCHEMA);
289
+ const stmtUpsert = db.prepare(`
290
+ INSERT INTO paths (path, last_applied_change_id, last_seen_hash, local_dirty, last_scan_at)
291
+ VALUES (@path, @last_applied_change_id, @last_seen_hash, @local_dirty, @last_scan_at)
292
+ ON CONFLICT(path) DO UPDATE SET
293
+ last_applied_change_id = excluded.last_applied_change_id,
294
+ last_seen_hash = excluded.last_seen_hash,
295
+ local_dirty = excluded.local_dirty,
296
+ last_scan_at = excluded.last_scan_at
297
+ `);
298
+ const stmtGet = db.prepare(`SELECT * FROM paths WHERE path = ?`);
299
+ const stmtDelete = db.prepare(`DELETE FROM paths WHERE path = ?`);
300
+ const stmtListPaths = db.prepare(`SELECT path FROM paths`);
301
+ const stmtSetMeta = db.prepare(`
302
+ INSERT INTO meta (key, value) VALUES (?, ?)
303
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value
304
+ `);
305
+ const stmtGetMeta = db.prepare(`SELECT value FROM meta WHERE key = ?`);
306
+ return {
307
+ getCursor() {
308
+ const row = stmtGetMeta.get("cursor");
309
+ return row?.value ?? "chg_0";
310
+ },
311
+ setCursor(cursor) {
312
+ const current = stmtGetMeta.get("cursor")?.value;
313
+ if (current && !cursorGreaterThan(cursor, current)) return;
314
+ stmtSetMeta.run("cursor", cursor);
315
+ },
316
+ upsertPath(state) {
317
+ stmtUpsert.run({
318
+ path: state.path,
319
+ last_applied_change_id: state.lastAppliedChangeId,
320
+ last_seen_hash: state.lastSeenHash,
321
+ local_dirty: state.localDirty ? 1 : 0,
322
+ last_scan_at: state.lastScanAt
323
+ });
324
+ },
325
+ getPath(path) {
326
+ const row = stmtGet.get(path);
327
+ if (!row) return null;
328
+ return {
329
+ path: row.path,
330
+ lastAppliedChangeId: row.last_applied_change_id,
331
+ lastSeenHash: row.last_seen_hash,
332
+ localDirty: row.local_dirty !== 0,
333
+ lastScanAt: row.last_scan_at
334
+ };
335
+ },
336
+ deletePath(path) {
337
+ stmtDelete.run(path);
338
+ },
339
+ listPaths() {
340
+ return stmtListPaths.all().map((r) => r.path);
341
+ },
342
+ setMeta(key, value) {
343
+ stmtSetMeta.run(key, value);
344
+ },
345
+ getMeta(key) {
346
+ const row = stmtGetMeta.get(key);
347
+ return row?.value ?? null;
348
+ },
349
+ close() {
350
+ db.close();
351
+ }
352
+ };
353
+ }
354
+
355
+ // src/tapignore.ts
356
+ import { existsSync as existsSync2, readFileSync as readFileSync2 } from "fs";
357
+ import { join as join2 } from "path";
358
+ import ignore from "ignore";
359
+ var BUILTIN_IGNORE_LINES = [
360
+ "# Secrets and machine-local",
361
+ ".env",
362
+ ".env.*",
363
+ "*.local.*",
364
+ "",
365
+ "# Dependencies and build output",
366
+ "node_modules/",
367
+ "dist/",
368
+ ".venv/",
369
+ "__pycache__/",
370
+ "*.pyc",
371
+ "",
372
+ "# Git and OS noise",
373
+ ".git/",
374
+ ".DS_Store",
375
+ "",
376
+ "# Bulky generated artifacts",
377
+ "*.mov",
378
+ "*.mp4",
379
+ "*.zip",
380
+ "*.tar.gz",
381
+ "*.sqlite",
382
+ "",
383
+ "# Tap conflict sidecars \u2014 local-only by default",
384
+ "*.conflict-from.*"
385
+ ];
386
+ var ALWAYS_EXCLUDED_PREFIXES = [".rig/"];
387
+ function loadIgnore(rootDir) {
388
+ const ig = ignore();
389
+ ig.add(BUILTIN_IGNORE_LINES.join("\n"));
390
+ const projectIgnore = join2(rootDir, ".tapignore");
391
+ if (existsSync2(projectIgnore)) {
392
+ ig.add(readFileSync2(projectIgnore, "utf8"));
393
+ }
394
+ return ig;
395
+ }
396
+ function isAlwaysExcluded(posixPath) {
397
+ for (const prefix of ALWAYS_EXCLUDED_PREFIXES) {
398
+ const exact = prefix.endsWith("/") ? prefix.slice(0, -1) : prefix;
399
+ if (posixPath === exact) return true;
400
+ if (posixPath.startsWith(prefix)) return true;
401
+ }
402
+ return false;
403
+ }
404
+
405
+ // src/walker.ts
406
+ import { readdirSync, statSync } from "fs";
407
+ import { join as join3, sep } from "path";
408
+
409
+ // ../core/dist/index.js
410
+ var MAX_FILE_BYTES = 100 * 1024 * 1024;
411
+ var RESERVED_PATHS = [
412
+ ".rig/tap/",
413
+ ".rig/tap-binding.local.json"
414
+ ];
415
+ function validateRigPath(path) {
416
+ if (path.length === 0) return { ok: false, reason: "empty" };
417
+ if (path.startsWith("/")) return { ok: false, reason: "absolute" };
418
+ if (path.endsWith("/")) return { ok: false, reason: "trailing_slash" };
419
+ if (path.includes("\\")) return { ok: false, reason: "backslash" };
420
+ if (/[-]/.test(path)) return { ok: false, reason: "control_char" };
421
+ const segments = path.split("/");
422
+ for (const seg of segments) {
423
+ if (seg.length === 0) return { ok: false, reason: "empty_segment" };
424
+ if (seg === "..") return { ok: false, reason: "parent_segment" };
425
+ }
426
+ for (const reserved of RESERVED_PATHS) {
427
+ if (path === reserved.replace(/\/$/, "") || path.startsWith(reserved)) {
428
+ return { ok: false, reason: "reserved" };
429
+ }
430
+ }
431
+ return { ok: true };
432
+ }
433
+
434
+ // src/walker.ts
435
+ function walk(rootDir, opts) {
436
+ const files = [];
437
+ const emptyDirs = [];
438
+ const directoriesScanned = [];
439
+ const warnings = [];
440
+ walkDir(".");
441
+ return { files, emptyDirs, directoriesScanned, warnings };
442
+ function walkDir(rel) {
443
+ const abs = join3(rootDir, rel);
444
+ let entries;
445
+ try {
446
+ entries = readdirSync(abs, { withFileTypes: true });
447
+ } catch {
448
+ warnings.push({ path: posixOf(rel), reason: "unreadable" });
449
+ return false;
450
+ }
451
+ let hasVisible = false;
452
+ for (const ent of entries) {
453
+ const childRel = rel === "." ? ent.name : join3(rel, ent.name);
454
+ const posix = posixOf(childRel);
455
+ const isDir = ent.isDirectory();
456
+ if (isAlwaysExcluded(posix)) continue;
457
+ if (opts.ignore.ignores(posix + (isDir ? "/" : ""))) continue;
458
+ if (ent.isSymbolicLink()) {
459
+ warnings.push({ path: posix, reason: "symlink" });
460
+ continue;
461
+ }
462
+ const pathOk = validateRigPath(posix);
463
+ if (!pathOk.ok) {
464
+ warnings.push({ path: posix, reason: "invalid_path" });
465
+ continue;
466
+ }
467
+ if (isDir) {
468
+ directoriesScanned.push(posix);
469
+ const subHas = walkDir(childRel);
470
+ if (!subHas) emptyDirs.push(posix);
471
+ hasVisible = true;
472
+ continue;
473
+ }
474
+ if (ent.isFile()) {
475
+ let size;
476
+ try {
477
+ size = statSync(join3(abs, ent.name)).size;
478
+ } catch {
479
+ warnings.push({ path: posix, reason: "unreadable" });
480
+ continue;
481
+ }
482
+ if (size > MAX_FILE_BYTES) {
483
+ warnings.push({ path: posix, reason: "too_large" });
484
+ continue;
485
+ }
486
+ files.push({ path: posix, size });
487
+ hasVisible = true;
488
+ }
489
+ }
490
+ return hasVisible;
491
+ }
492
+ }
493
+ function posixOf(s) {
494
+ return sep === "/" ? s : s.replaceAll(sep, "/");
495
+ }
496
+
497
+ // src/hash.ts
498
+ import { createHash } from "crypto";
499
+ import { createReadStream } from "fs";
500
+ async function hashFile(absPath) {
501
+ const hash = createHash("sha256");
502
+ await new Promise((resolve, reject) => {
503
+ const stream = createReadStream(absPath);
504
+ stream.on("data", (chunk) => hash.update(chunk));
505
+ stream.on("end", () => resolve());
506
+ stream.on("error", (err) => reject(err));
507
+ });
508
+ return `sha256:${hash.digest("hex")}`;
509
+ }
510
+
511
+ // src/init.ts
512
+ import { createHash as createHash2 } from "crypto";
513
+ import { readFileSync as readFileSync3 } from "fs";
514
+ import { join as join4 } from "path";
515
+ function sha256OfBuffer(bytes) {
516
+ return `sha256:${createHash2("sha256").update(bytes).digest("hex")}`;
517
+ }
518
+ var AlreadyInitializedError = class extends Error {
519
+ constructor(path) {
520
+ super(`already initialized \u2014 ${path} exists`);
521
+ this.path = path;
522
+ this.name = "AlreadyInitializedError";
523
+ }
524
+ path;
525
+ };
526
+ async function init(opts) {
527
+ if (readBindingConfig(opts.rootDir)) {
528
+ throw new AlreadyInitializedError(join4(opts.rootDir, BINDING_CONFIG_PATH));
529
+ }
530
+ const ig = loadIgnore(opts.rootDir);
531
+ const scan = walk(opts.rootDir, { ignore: ig });
532
+ const hashed = [];
533
+ for (const f of scan.files) {
534
+ const h = await hashFile(join4(opts.rootDir, f.path));
535
+ hashed.push({ path: f.path, size: f.size, hash: h });
536
+ }
537
+ const created = await createBinding({
538
+ baseUrl: opts.relayUrl,
539
+ request: { name: opts.bindingName, deviceLabel: opts.deviceLabel },
540
+ userId: opts.ownerUserId,
541
+ fetch: opts.fetch
542
+ });
543
+ writeBindingConfig(opts.rootDir, {
544
+ bindingId: created.binding.id,
545
+ relayUrl: opts.relayUrl,
546
+ deviceId: created.device.id,
547
+ token: created.token.secret
548
+ });
549
+ const client = new RelayClient({
550
+ baseUrl: opts.relayUrl,
551
+ bindingId: created.binding.id,
552
+ token: created.token.secret,
553
+ fetch: opts.fetch
554
+ });
555
+ const uniqueHashes = /* @__PURE__ */ new Map();
556
+ for (const f of hashed) {
557
+ if (!uniqueHashes.has(f.hash)) uniqueHashes.set(f.hash, f);
558
+ }
559
+ const mutatedDuringScan = /* @__PURE__ */ new Set();
560
+ let uploaded = 0;
561
+ let reused = 0;
562
+ for (const [hash, f] of uniqueHashes) {
563
+ const prep = await client.prepareUpload({ hash, size: f.size });
564
+ if (prep.exists) {
565
+ reused += 1;
566
+ continue;
567
+ }
568
+ const bytes = readFileSync3(join4(opts.rootDir, f.path));
569
+ const actual = sha256OfBuffer(bytes);
570
+ if (actual !== hash) {
571
+ mutatedDuringScan.add(f.path);
572
+ scan.warnings.push({
573
+ path: f.path,
574
+ reason: "invalid_path"
575
+ // closest existing warning kind; semantics are file-mutated-mid-scan
576
+ });
577
+ continue;
578
+ }
579
+ await client.putObjectBytes(prep.uploadUrl, bytes);
580
+ await client.completeUpload({ hash, size: f.size });
581
+ uploaded += 1;
582
+ }
583
+ const events = [
584
+ ...hashed.filter((f) => !mutatedDuringScan.has(f.path)).map((f) => ({
585
+ op: "write",
586
+ path: f.path,
587
+ hash: f.hash,
588
+ size: f.size
589
+ })),
590
+ ...scan.emptyDirs.map((path) => ({ op: "mkdir", path }))
591
+ ];
592
+ let cursor = "chg_0";
593
+ let submittedEvents = [];
594
+ if (events.length > 0) {
595
+ const submit = await client.submitChanges(events);
596
+ submittedEvents = submit.events;
597
+ cursor = submit.events[submit.events.length - 1].id;
598
+ }
599
+ const stateDb = openStateDb(
600
+ join4(opts.rootDir, ".rig", "tap", "state.local.db")
601
+ );
602
+ try {
603
+ const now = (/* @__PURE__ */ new Date()).toISOString();
604
+ for (const ev of submittedEvents) {
605
+ const state = {
606
+ path: ev.path,
607
+ lastAppliedChangeId: ev.id,
608
+ lastSeenHash: ev.hash,
609
+ // null for mkdirs
610
+ localDirty: false,
611
+ lastScanAt: now
612
+ };
613
+ stateDb.upsertPath(state);
614
+ }
615
+ stateDb.setCursor(cursor);
616
+ stateDb.setMeta("device_id", created.device.id);
617
+ stateDb.setMeta("binding_id", created.binding.id);
618
+ } finally {
619
+ stateDb.close();
620
+ }
621
+ return {
622
+ binding: created.binding,
623
+ device: created.device,
624
+ ownerSecret: created.token.secret,
625
+ uploadedHashes: uploaded,
626
+ reusedHashes: reused,
627
+ submittedEvents: submittedEvents.length,
628
+ warnings: scan.warnings,
629
+ cursor
630
+ };
631
+ }
632
+
633
+ // src/invite.ts
634
+ function clientFor(rootDir, fetchImpl) {
635
+ const cfg = readBindingConfig(rootDir);
636
+ if (!cfg) throw new NotInitializedError(rootDir);
637
+ return new RelayClient({
638
+ baseUrl: cfg.relayUrl,
639
+ bindingId: cfg.bindingId,
640
+ token: cfg.token,
641
+ fetch: fetchImpl
642
+ });
643
+ }
644
+ async function mint(opts) {
645
+ const client = clientFor(opts.rootDir, opts.fetch);
646
+ const body = {
647
+ ops: opts.ops,
648
+ ...opts.role !== void 0 ? { role: opts.role } : {},
649
+ ...opts.pathGlobs ? { pathGlobs: opts.pathGlobs } : {},
650
+ ...opts.ttlSeconds !== void 0 ? { ttlSeconds: opts.ttlSeconds } : {},
651
+ ...opts.maxUses !== void 0 ? { maxUses: opts.maxUses } : {},
652
+ ...opts.emailConstraint ? { emailConstraint: opts.emailConstraint } : {},
653
+ ...opts.label ? { label: opts.label } : {}
654
+ };
655
+ return client.mintInvite(body);
656
+ }
657
+ async function list(opts) {
658
+ const client = clientFor(opts.rootDir, opts.fetch);
659
+ const res = await client.listInvites();
660
+ return res.invites;
661
+ }
662
+ async function revoke(opts) {
663
+ const client = clientFor(opts.rootDir, opts.fetch);
664
+ return client.revokeInvite(opts.inviteId);
665
+ }
666
+
667
+ // src/join.ts
668
+ import { join as joinPath } from "path";
669
+ var AlreadyJoinedError = class extends Error {
670
+ constructor(path) {
671
+ super(`already initialized \u2014 ${path} exists`);
672
+ this.path = path;
673
+ this.name = "AlreadyJoinedError";
674
+ }
675
+ path;
676
+ };
677
+ var InvalidInviteUrlError = class extends Error {
678
+ constructor(url) {
679
+ super(`invalid invite URL: ${url} (expected <baseUrl>/v1/invites/<secret>/accept)`);
680
+ this.url = url;
681
+ this.name = "InvalidInviteUrlError";
682
+ }
683
+ url;
684
+ };
685
+ function parseInviteUrl(url) {
686
+ let parsed;
687
+ try {
688
+ parsed = new URL(url);
689
+ } catch {
690
+ throw new InvalidInviteUrlError(url);
691
+ }
692
+ const match = /^\/v1\/invites\/([^/]+)(?:\/accept)?\/?$/.exec(parsed.pathname);
693
+ if (!match) throw new InvalidInviteUrlError(url);
694
+ return {
695
+ baseUrl: `${parsed.protocol}//${parsed.host}`,
696
+ secret: decodeURIComponent(match[1])
697
+ };
698
+ }
699
+ async function join5(opts) {
700
+ if (readBindingConfig(opts.rootDir)) {
701
+ throw new AlreadyJoinedError(joinPath(opts.rootDir, BINDING_CONFIG_PATH));
702
+ }
703
+ const { baseUrl, secret } = parseInviteUrl(opts.inviteUrl);
704
+ const accepted = await acceptInvite({
705
+ baseUrl,
706
+ secret,
707
+ userId: opts.userId,
708
+ email: opts.email,
709
+ request: opts.deviceLabel ? { deviceLabel: opts.deviceLabel } : void 0,
710
+ fetch: opts.fetch
711
+ });
712
+ writeBindingConfig(opts.rootDir, {
713
+ bindingId: accepted.bindingId,
714
+ relayUrl: baseUrl,
715
+ deviceId: accepted.device.id,
716
+ token: accepted.token.secret
717
+ });
718
+ const stateDb = openStateDb(
719
+ joinPath(opts.rootDir, ".rig", "tap", "state.local.db")
720
+ );
721
+ try {
722
+ stateDb.setMeta("binding_id", accepted.bindingId);
723
+ stateDb.setMeta("device_id", accepted.device.id);
724
+ } finally {
725
+ stateDb.close();
726
+ }
727
+ return {
728
+ bindingId: accepted.bindingId,
729
+ device: accepted.device,
730
+ tokenSecret: accepted.token.secret,
731
+ becameMember: accepted.member !== null
732
+ };
733
+ }
734
+
735
+ // src/applier.ts
736
+ import { createHash as createHash3, randomBytes } from "crypto";
737
+ import {
738
+ existsSync as existsSync3,
739
+ lstatSync,
740
+ mkdirSync as mkdirSync3,
741
+ readdirSync as readdirSync2,
742
+ readFileSync as readFileSync4,
743
+ renameSync,
744
+ rmdirSync,
745
+ rmSync,
746
+ writeFileSync as writeFileSync2
747
+ } from "fs";
748
+ import { basename, dirname as dirname3, extname, join as join6, sep as sep2 } from "path";
749
+ var STATE_KEY_CONFLICT_COUNT = "conflict_count";
750
+ var HashMismatchError = class extends Error {
751
+ constructor(path, expectedHash, actualHash) {
752
+ super(
753
+ `hash mismatch at ${path}: expected ${expectedHash}, got ${actualHash}`
754
+ );
755
+ this.path = path;
756
+ this.expectedHash = expectedHash;
757
+ this.actualHash = actualHash;
758
+ this.name = "HashMismatchError";
759
+ }
760
+ path;
761
+ expectedHash;
762
+ actualHash;
763
+ };
764
+ function sha256Hex(bytes) {
765
+ return createHash3("sha256").update(bytes).digest("hex");
766
+ }
767
+ function sha256OfFile(absPath) {
768
+ return `sha256:${sha256Hex(readFileSync4(absPath))}`;
769
+ }
770
+ function conflictSidecarPath(path, ev) {
771
+ const dir = dirname3(path);
772
+ const ext = extname(path);
773
+ const stem = basename(path, ext);
774
+ const actor = ev.actorDeviceId ?? "unknown";
775
+ const sidecar = `${stem}.conflict-from.${actor}.${ev.id}${ext}`;
776
+ return dir === "." ? sidecar : `${dir}/${sidecar}`;
777
+ }
778
+ var SIDECAR_RE = /^(.*)\.conflict-from\.[^.]+\.(chg_\d+)(\.[^.]+)?$/;
779
+ function parseConflictSidecar(filename) {
780
+ const m = SIDECAR_RE.exec(filename);
781
+ if (!m) return null;
782
+ return { stem: m[1], chgId: m[2], ext: m[3] ?? "" };
783
+ }
784
+ var SCAN_SKIP_DIRS = /* @__PURE__ */ new Set([".git", "node_modules", ".rig"]);
785
+ function findConflictSidecars(rootDir) {
786
+ const out = [];
787
+ walk2(".");
788
+ return out;
789
+ function walk2(rel) {
790
+ const abs = rel === "." ? rootDir : join6(rootDir, rel);
791
+ let entries;
792
+ try {
793
+ entries = readdirSync2(abs, { withFileTypes: true });
794
+ } catch {
795
+ return;
796
+ }
797
+ for (const ent of entries) {
798
+ if (ent.isSymbolicLink()) continue;
799
+ const childRel = rel === "." ? ent.name : join6(rel, ent.name);
800
+ if (ent.isDirectory()) {
801
+ if (SCAN_SKIP_DIRS.has(ent.name)) continue;
802
+ walk2(childRel);
803
+ continue;
804
+ }
805
+ if (!ent.isFile()) continue;
806
+ const parsed = parseConflictSidecar(ent.name);
807
+ if (!parsed) continue;
808
+ const sidecarPosix = sep2 === "/" ? childRel : childRel.replaceAll(sep2, "/");
809
+ const dir = dirname3(sidecarPosix);
810
+ const original = dir === "." ? `${parsed.stem}${parsed.ext}` : `${dir}/${parsed.stem}${parsed.ext}`;
811
+ out.push({ path: original, sidecarPath: sidecarPosix });
812
+ }
813
+ }
814
+ }
815
+ function bumpConflictCount(stateDb) {
816
+ const current = Number.parseInt(
817
+ stateDb.getMeta(STATE_KEY_CONFLICT_COUNT) ?? "0",
818
+ 10
819
+ );
820
+ stateDb.setMeta(STATE_KEY_CONFLICT_COUNT, String(current + 1));
821
+ }
822
+ var STATE_KEY_LAST_APPLY_ERROR = "last_apply_error";
823
+ var STATE_KEY_LAST_APPLY_ERROR_AT = "last_apply_error_at";
824
+ async function applyOnce(opts) {
825
+ const cursor = opts.stateDb.getCursor();
826
+ const since = await opts.client.listChanges({ after: cursor, limit: opts.limit });
827
+ let applied = 0;
828
+ let echoSkipped = 0;
829
+ let errored = 0;
830
+ const errors = [];
831
+ for (const ev of since.events) {
832
+ try {
833
+ const wasEcho = await applyEvent({
834
+ rootDir: opts.rootDir,
835
+ client: opts.client,
836
+ stateDb: opts.stateDb,
837
+ event: ev
838
+ });
839
+ if (wasEcho) echoSkipped += 1;
840
+ else applied += 1;
841
+ } catch (err) {
842
+ const reason = err instanceof Error ? err.message : String(err);
843
+ const recorded = {
844
+ eventId: ev.id,
845
+ path: ev.path,
846
+ op: ev.op,
847
+ reason
848
+ };
849
+ errors.push(recorded);
850
+ errored += 1;
851
+ opts.stateDb.setMeta(STATE_KEY_LAST_APPLY_ERROR, JSON.stringify(recorded));
852
+ opts.stateDb.setMeta(STATE_KEY_LAST_APPLY_ERROR_AT, (/* @__PURE__ */ new Date()).toISOString());
853
+ console.error(
854
+ `tapd apply: skipping ${ev.id} (${ev.op} ${ev.path}): ${reason}`
855
+ );
856
+ }
857
+ opts.stateDb.setCursor(ev.id);
858
+ }
859
+ return { applied, echoSkipped, errored, events: since.events, errors };
860
+ }
861
+ async function applyEvent(opts) {
862
+ const { rootDir, client, stateDb, event: ev } = opts;
863
+ const abs = join6(rootDir, ev.path);
864
+ switch (ev.op) {
865
+ case "write": {
866
+ if (!ev.hash) throw new Error(`write event ${ev.id} missing hash`);
867
+ const local = stateDb.getPath(ev.path);
868
+ if (local?.lastSeenHash === ev.hash && existsSync3(abs)) {
869
+ stateDb.upsertPath({
870
+ path: ev.path,
871
+ lastAppliedChangeId: ev.id,
872
+ lastSeenHash: ev.hash,
873
+ localDirty: false,
874
+ lastScanAt: (/* @__PURE__ */ new Date()).toISOString()
875
+ });
876
+ return true;
877
+ }
878
+ if (existsSync3(abs) && lstatSync(abs).isDirectory()) {
879
+ throw new Error(
880
+ `file_vs_dir_collision: ${ev.path} is a local directory; refusing to overwrite with a file`
881
+ );
882
+ }
883
+ const localDirty = existsSync3(abs) && local?.lastSeenHash != null && sha256OfFile(abs) !== local.lastSeenHash;
884
+ const dl = await client.downloadUrl(ev.hash);
885
+ const bytes = await client.getObjectBytes(dl.downloadUrl);
886
+ const actualHash = `sha256:${sha256Hex(bytes)}`;
887
+ if (actualHash !== ev.hash) {
888
+ throw new HashMismatchError(ev.path, ev.hash, actualHash);
889
+ }
890
+ if (localDirty) {
891
+ const sidecarPath = conflictSidecarPath(ev.path, ev);
892
+ const sidecarAbs = join6(rootDir, sidecarPath);
893
+ mkdirSync3(dirname3(sidecarAbs), { recursive: true });
894
+ const tmp2 = `${sidecarAbs}.tap-tmp.${randomBytes(6).toString("hex")}`;
895
+ writeFileSync2(tmp2, bytes, { mode: ev.executable ? 493 : 420 });
896
+ renameSync(tmp2, sidecarAbs);
897
+ stateDb.upsertPath({
898
+ path: ev.path,
899
+ lastAppliedChangeId: ev.id,
900
+ lastSeenHash: local.lastSeenHash,
901
+ localDirty: true,
902
+ lastScanAt: (/* @__PURE__ */ new Date()).toISOString()
903
+ });
904
+ bumpConflictCount(stateDb);
905
+ return false;
906
+ }
907
+ mkdirSync3(dirname3(abs), { recursive: true });
908
+ const tmp = `${abs}.tap-tmp.${randomBytes(6).toString("hex")}`;
909
+ writeFileSync2(tmp, bytes, { mode: ev.executable ? 493 : 420 });
910
+ renameSync(tmp, abs);
911
+ stateDb.upsertPath({
912
+ path: ev.path,
913
+ lastAppliedChangeId: ev.id,
914
+ lastSeenHash: ev.hash,
915
+ localDirty: false,
916
+ lastScanAt: (/* @__PURE__ */ new Date()).toISOString()
917
+ });
918
+ return false;
919
+ }
920
+ case "delete": {
921
+ if (existsSync3(abs)) {
922
+ if (lstatSync(abs).isDirectory()) {
923
+ throw new Error(
924
+ `delete_vs_dir: ${ev.path} is a local directory; refusing recursive removal`
925
+ );
926
+ }
927
+ const localStateRow = stateDb.getPath(ev.path);
928
+ const localBytesHash = sha256OfFile(abs);
929
+ const localDirty = localStateRow?.lastSeenHash != null && localBytesHash !== localStateRow.lastSeenHash;
930
+ const localUntracked = localStateRow?.lastSeenHash == null;
931
+ if (localDirty || localUntracked) {
932
+ stateDb.upsertPath({
933
+ path: ev.path,
934
+ lastAppliedChangeId: ev.id,
935
+ // null so scanAndPush's `prev?.lastSeenHash === hash` check
936
+ // falls through and the file gets re-submitted as a write.
937
+ lastSeenHash: null,
938
+ localDirty: true,
939
+ lastScanAt: (/* @__PURE__ */ new Date()).toISOString()
940
+ });
941
+ bumpConflictCount(stateDb);
942
+ return false;
943
+ }
944
+ rmSync(abs, { force: true });
945
+ }
946
+ stateDb.deletePath(ev.path);
947
+ return false;
948
+ }
949
+ case "mkdir": {
950
+ mkdirSync3(abs, { recursive: true });
951
+ stateDb.upsertPath({
952
+ path: ev.path,
953
+ lastAppliedChangeId: ev.id,
954
+ lastSeenHash: null,
955
+ localDirty: false,
956
+ lastScanAt: (/* @__PURE__ */ new Date()).toISOString()
957
+ });
958
+ return false;
959
+ }
960
+ case "rmdir": {
961
+ try {
962
+ rmdirSync(abs);
963
+ } catch {
964
+ }
965
+ stateDb.deletePath(ev.path);
966
+ return false;
967
+ }
968
+ }
969
+ }
970
+
971
+ // src/sse.ts
972
+ var DEFAULT_HEARTBEAT_TIMEOUT_MS = 45e3;
973
+ function defaultBackoff(attempt) {
974
+ return Math.min(3e4, 1e3 * 2 ** attempt);
975
+ }
976
+ function subscribe(opts) {
977
+ const fetchImpl = opts.fetch ?? fetch;
978
+ const heartbeatTimeoutMs = opts.heartbeatTimeoutMs ?? DEFAULT_HEARTBEAT_TIMEOUT_MS;
979
+ const backoff = opts.reconnectDelayMs ?? defaultBackoff;
980
+ let stopped = false;
981
+ let attempt = 0;
982
+ let activeAbort = null;
983
+ let reconnectTimer = null;
984
+ let loop;
985
+ const surface = (err) => {
986
+ if (opts.onError) opts.onError(err);
987
+ else console.error("tapd sse:", err.message);
988
+ };
989
+ const connect = async () => {
990
+ if (stopped) return;
991
+ const ac = new AbortController();
992
+ activeAbort = ac;
993
+ const url = `${opts.baseUrl.replace(/\/+$/, "")}/v1/bindings/${encodeURIComponent(opts.bindingId)}/stream`;
994
+ let res;
995
+ try {
996
+ res = await fetchImpl(url, {
997
+ method: "GET",
998
+ headers: {
999
+ accept: "text/event-stream",
1000
+ authorization: `Bearer ${opts.token}`
1001
+ },
1002
+ signal: ac.signal
1003
+ });
1004
+ } catch (err) {
1005
+ if (stopped) return;
1006
+ surface(asError(err, "sse_connect_failed"));
1007
+ scheduleReconnect();
1008
+ return;
1009
+ }
1010
+ if (!res.ok || !res.body) {
1011
+ surface(new Error(`sse_bad_status:${res.status}`));
1012
+ scheduleReconnect();
1013
+ return;
1014
+ }
1015
+ attempt = 0;
1016
+ await pumpStream(res.body, ac);
1017
+ if (stopped) return;
1018
+ scheduleReconnect();
1019
+ };
1020
+ const pumpStream = async (body, ac) => {
1021
+ const reader = body.getReader();
1022
+ const decoder = new TextDecoder();
1023
+ let buffer = "";
1024
+ let lastTick = Date.now();
1025
+ const watchdog = setInterval(() => {
1026
+ if (Date.now() - lastTick > heartbeatTimeoutMs) {
1027
+ surface(new Error("sse_heartbeat_timeout"));
1028
+ ac.abort();
1029
+ }
1030
+ }, Math.max(1e3, heartbeatTimeoutMs / 5));
1031
+ try {
1032
+ while (!stopped) {
1033
+ let result;
1034
+ try {
1035
+ result = await reader.read();
1036
+ } catch (err) {
1037
+ if (stopped) break;
1038
+ surface(asError(err, "sse_read_failed"));
1039
+ break;
1040
+ }
1041
+ if (result.done) break;
1042
+ lastTick = Date.now();
1043
+ buffer += decoder.decode(result.value, { stream: true });
1044
+ let idx;
1045
+ while ((idx = buffer.indexOf("\n\n")) >= 0) {
1046
+ const frame = buffer.slice(0, idx);
1047
+ buffer = buffer.slice(idx + 2);
1048
+ await dispatchFrame(frame);
1049
+ }
1050
+ }
1051
+ } finally {
1052
+ clearInterval(watchdog);
1053
+ try {
1054
+ await reader.cancel();
1055
+ } catch {
1056
+ }
1057
+ }
1058
+ };
1059
+ const dispatchFrame = async (frame) => {
1060
+ if (frame.trim().length === 0) return;
1061
+ let event;
1062
+ let data;
1063
+ for (const line of frame.split("\n")) {
1064
+ if (line.startsWith(":")) continue;
1065
+ if (line.startsWith("event: ")) event = line.slice(7);
1066
+ else if (line.startsWith("data: ")) data = line.slice(6);
1067
+ }
1068
+ if (!event || !data) return;
1069
+ try {
1070
+ if (event === "subscribed" && opts.onSubscribed) {
1071
+ opts.onSubscribed(JSON.parse(data));
1072
+ } else if (event === "change_created") {
1073
+ await opts.onChange(JSON.parse(data));
1074
+ } else if (event === "heartbeat") {
1075
+ }
1076
+ } catch (err) {
1077
+ surface(asError(err, `sse_handler_failed:${event}`));
1078
+ }
1079
+ };
1080
+ const scheduleReconnect = () => {
1081
+ if (stopped) return;
1082
+ const delay = backoff(attempt++);
1083
+ reconnectTimer = setTimeout(() => {
1084
+ reconnectTimer = null;
1085
+ void connect();
1086
+ }, delay);
1087
+ };
1088
+ loop = connect();
1089
+ return {
1090
+ async stop() {
1091
+ stopped = true;
1092
+ if (reconnectTimer) {
1093
+ clearTimeout(reconnectTimer);
1094
+ reconnectTimer = null;
1095
+ }
1096
+ if (activeAbort) activeAbort.abort();
1097
+ await loop.catch(() => {
1098
+ });
1099
+ }
1100
+ };
1101
+ }
1102
+ function asError(err, fallbackName) {
1103
+ if (err instanceof Error) return err;
1104
+ const e = new Error(String(err));
1105
+ e.name = fallbackName;
1106
+ return e;
1107
+ }
1108
+
1109
+ // src/uploader.ts
1110
+ import { createHash as createHash4 } from "crypto";
1111
+ import { readFileSync as readFileSync5 } from "fs";
1112
+ import { join as join7 } from "path";
1113
+ function sha256OfBuffer2(bytes) {
1114
+ return `sha256:${createHash4("sha256").update(bytes).digest("hex")}`;
1115
+ }
1116
+ async function computeLocalDiff(opts) {
1117
+ const { rootDir, stateDb, ignore: ignore2 } = opts;
1118
+ const scan = walk(rootDir, { ignore: ignore2 });
1119
+ const writes = [];
1120
+ for (const f of scan.files) {
1121
+ const hash = await hashFile(join7(rootDir, f.path));
1122
+ const prev = stateDb.getPath(f.path);
1123
+ if (prev?.lastSeenHash === hash) continue;
1124
+ const baseChangeId = prev?.lastAppliedChangeId ?? void 0;
1125
+ writes.push({ path: f.path, size: f.size, hash, ...baseChangeId ? { baseChangeId } : {} });
1126
+ }
1127
+ const filesNow = new Set(scan.files.map((f) => f.path));
1128
+ const dirsNow = new Set(scan.directoriesScanned);
1129
+ const deletes = [];
1130
+ const explicitRmdirs = [];
1131
+ const removedPaths = /* @__PURE__ */ new Set();
1132
+ for (const path of stateDb.listPaths()) {
1133
+ const prev = stateDb.getPath(path);
1134
+ if (!prev) continue;
1135
+ const stillOnDisk = filesNow.has(path) || dirsNow.has(path);
1136
+ if (stillOnDisk) continue;
1137
+ const baseChangeId = prev.lastAppliedChangeId ?? void 0;
1138
+ if (prev.lastSeenHash === null) {
1139
+ explicitRmdirs.push({ path, ...baseChangeId ? { baseChangeId } : {} });
1140
+ } else {
1141
+ deletes.push({ path, ...baseChangeId ? { baseChangeId } : {} });
1142
+ }
1143
+ removedPaths.add(path);
1144
+ }
1145
+ const synthesizedRmdirs = /* @__PURE__ */ new Set();
1146
+ for (const path of removedPaths) {
1147
+ let parent = parentDir(path);
1148
+ while (parent !== null) {
1149
+ if (filesNow.has(parent) || dirsNow.has(parent)) break;
1150
+ if (synthesizedRmdirs.has(parent)) break;
1151
+ if (!removedPaths.has(parent)) synthesizedRmdirs.add(parent);
1152
+ parent = parentDir(parent);
1153
+ }
1154
+ }
1155
+ const synthDeepest = [...synthesizedRmdirs].sort(
1156
+ (a, b) => b.split("/").length - a.split("/").length
1157
+ );
1158
+ const mkdirs = [];
1159
+ for (const dir of scan.emptyDirs) {
1160
+ if (stateDb.getPath(dir)) continue;
1161
+ mkdirs.push(dir);
1162
+ }
1163
+ const rmdirs = [
1164
+ ...explicitRmdirs,
1165
+ ...synthDeepest.map((path) => ({ path }))
1166
+ ];
1167
+ return {
1168
+ writes,
1169
+ deletes,
1170
+ mkdirs,
1171
+ rmdirs,
1172
+ warnings: scan.warnings.map((w) => ({ path: w.path, reason: w.reason }))
1173
+ };
1174
+ }
1175
+ async function scanAndPush(opts) {
1176
+ const { rootDir, client, stateDb } = opts;
1177
+ const diff = await computeLocalDiff(opts);
1178
+ const uniqueHashes = /* @__PURE__ */ new Map();
1179
+ for (const w of diff.writes) if (!uniqueHashes.has(w.hash)) uniqueHashes.set(w.hash, w);
1180
+ const mutatedPaths = /* @__PURE__ */ new Set();
1181
+ const skipWarnings = [];
1182
+ let uploaded = 0;
1183
+ let reused = 0;
1184
+ for (const [hash, f] of uniqueHashes) {
1185
+ const prep = await client.prepareUpload({ hash, size: f.size });
1186
+ if (prep.exists) {
1187
+ reused += 1;
1188
+ continue;
1189
+ }
1190
+ const bytes = readFileSync5(join7(rootDir, f.path));
1191
+ const actual = sha256OfBuffer2(bytes);
1192
+ if (actual !== hash) {
1193
+ mutatedPaths.add(f.path);
1194
+ skipWarnings.push({ path: f.path, reason: `mutated_during_scan:${actual}` });
1195
+ continue;
1196
+ }
1197
+ await client.putObjectBytes(prep.uploadUrl, bytes);
1198
+ await client.completeUpload({ hash, size: f.size });
1199
+ uploaded += 1;
1200
+ }
1201
+ const events = [];
1202
+ for (const w of diff.writes) {
1203
+ if (mutatedPaths.has(w.path)) continue;
1204
+ const repPath = (uniqueHashes.get(w.hash) ?? w).path;
1205
+ if (mutatedPaths.has(repPath)) continue;
1206
+ events.push({
1207
+ op: "write",
1208
+ path: w.path,
1209
+ hash: w.hash,
1210
+ size: w.size,
1211
+ ...w.baseChangeId ? { baseChangeId: w.baseChangeId } : {}
1212
+ });
1213
+ }
1214
+ for (const d of diff.deletes) {
1215
+ events.push({
1216
+ op: "delete",
1217
+ path: d.path,
1218
+ ...d.baseChangeId ? { baseChangeId: d.baseChangeId } : {}
1219
+ });
1220
+ }
1221
+ for (const r of diff.rmdirs) {
1222
+ events.push({ op: "rmdir", path: r.path, ...r.baseChangeId ? { baseChangeId: r.baseChangeId } : {} });
1223
+ }
1224
+ for (const path of diff.mkdirs) {
1225
+ events.push({ op: "mkdir", path });
1226
+ }
1227
+ let submitted = [];
1228
+ if (events.length > 0) {
1229
+ const result = await client.submitChanges(events);
1230
+ submitted = result.events;
1231
+ const now = (/* @__PURE__ */ new Date()).toISOString();
1232
+ for (const ev of submitted) {
1233
+ if (ev.op === "delete" || ev.op === "rmdir") {
1234
+ stateDb.deletePath(ev.path);
1235
+ continue;
1236
+ }
1237
+ stateDb.upsertPath({
1238
+ path: ev.path,
1239
+ lastAppliedChangeId: ev.id,
1240
+ lastSeenHash: ev.op === "write" ? ev.hash : null,
1241
+ localDirty: false,
1242
+ lastScanAt: now
1243
+ });
1244
+ }
1245
+ const lastId = submitted[submitted.length - 1].id;
1246
+ if (cursorLessThan(stateDb.getCursor(), lastId)) {
1247
+ stateDb.setCursor(lastId);
1248
+ }
1249
+ }
1250
+ return {
1251
+ uploaded,
1252
+ reused,
1253
+ submitted,
1254
+ warnings: [...diff.warnings, ...skipWarnings]
1255
+ };
1256
+ }
1257
+ function parentDir(posix) {
1258
+ const i = posix.lastIndexOf("/");
1259
+ if (i < 0) return null;
1260
+ return posix.slice(0, i);
1261
+ }
1262
+ function cursorLessThan(a, b) {
1263
+ const prefix = "chg_";
1264
+ const an = a.startsWith(prefix) ? BigInt(a.slice(prefix.length)) : 0n;
1265
+ const bn = b.startsWith(prefix) ? BigInt(b.slice(prefix.length)) : 0n;
1266
+ return an < bn;
1267
+ }
1268
+
1269
+ // src/start.ts
1270
+ import { join as join8 } from "path";
1271
+ import chokidar from "chokidar";
1272
+ var DEFAULT_POLL_SECONDS = 3;
1273
+ var DEFAULT_DEBOUNCE_MS = 300;
1274
+ async function start(opts) {
1275
+ const config = readBindingConfig(opts.rootDir);
1276
+ if (!config) {
1277
+ throw new Error(
1278
+ `no binding config at ${opts.rootDir}/.rig/tap-binding.local.json \u2014 run \`tapd init\` first`
1279
+ );
1280
+ }
1281
+ const stateDb = openStateDb(
1282
+ join8(opts.rootDir, ".rig", "tap", "state.local.db")
1283
+ );
1284
+ const ignore2 = loadIgnore(opts.rootDir);
1285
+ const client = new RelayClient({
1286
+ baseUrl: config.relayUrl,
1287
+ bindingId: config.bindingId,
1288
+ token: config.token,
1289
+ fetch: opts.fetch
1290
+ });
1291
+ const pollSeconds = opts.pollSeconds ?? DEFAULT_POLL_SECONDS;
1292
+ const debounceMs = opts.debounceMs ?? DEFAULT_DEBOUNCE_MS;
1293
+ let stopped = false;
1294
+ let pollTimer;
1295
+ let scanTimer;
1296
+ let sseApplyTimer;
1297
+ let watcher;
1298
+ let scanInFlight = Promise.resolve();
1299
+ let sseSubscriber;
1300
+ let applyInFlight = null;
1301
+ let rerunRequested = false;
1302
+ let lastApplyResult;
1303
+ const triggerApply = () => {
1304
+ if (applyInFlight) {
1305
+ rerunRequested = true;
1306
+ return applyInFlight;
1307
+ }
1308
+ const loop = (async () => {
1309
+ try {
1310
+ do {
1311
+ rerunRequested = false;
1312
+ try {
1313
+ lastApplyResult = await applyOnce({
1314
+ rootDir: opts.rootDir,
1315
+ client,
1316
+ stateDb
1317
+ });
1318
+ } catch (err) {
1319
+ console.error("tapd apply error:", err);
1320
+ }
1321
+ } while (rerunRequested && !stopped);
1322
+ return lastApplyResult;
1323
+ } finally {
1324
+ applyInFlight = null;
1325
+ }
1326
+ })();
1327
+ applyInFlight = loop;
1328
+ return loop;
1329
+ };
1330
+ const schedulePoll = () => {
1331
+ if (stopped) return;
1332
+ pollTimer = setTimeout(() => {
1333
+ triggerApply().finally(() => schedulePoll());
1334
+ }, pollSeconds * 1e3);
1335
+ };
1336
+ const scheduleScan = () => {
1337
+ if (stopped) return;
1338
+ if (scanTimer) clearTimeout(scanTimer);
1339
+ scanTimer = setTimeout(() => {
1340
+ scanInFlight = scanAndPush({ rootDir: opts.rootDir, client, stateDb, ignore: ignore2 }).catch((err) => {
1341
+ console.error("tapd scan error:", err);
1342
+ });
1343
+ }, debounceMs);
1344
+ };
1345
+ const SSE_APPLY_DEBOUNCE_MS = 50;
1346
+ const scheduleSseApply = () => {
1347
+ if (stopped) return;
1348
+ if (sseApplyTimer) clearTimeout(sseApplyTimer);
1349
+ sseApplyTimer = setTimeout(() => {
1350
+ sseApplyTimer = void 0;
1351
+ void triggerApply();
1352
+ }, SSE_APPLY_DEBOUNCE_MS);
1353
+ };
1354
+ if (!opts.noSse) {
1355
+ sseSubscriber = subscribe({
1356
+ baseUrl: config.relayUrl,
1357
+ bindingId: config.bindingId,
1358
+ token: config.token,
1359
+ fetch: opts.fetch,
1360
+ heartbeatTimeoutMs: opts.sseHeartbeatTimeoutMs,
1361
+ onChange: () => scheduleSseApply(),
1362
+ onError: (err) => {
1363
+ console.error("tapd sse:", err.message);
1364
+ }
1365
+ });
1366
+ }
1367
+ if (!opts.noWatch) {
1368
+ watcher = chokidar.watch(opts.rootDir, {
1369
+ ignoreInitial: true,
1370
+ // Ignore tap's own state files at the watcher level too — saves
1371
+ // wakeups from sqlite WAL churn.
1372
+ ignored: (path) => {
1373
+ const rel = path.startsWith(opts.rootDir) ? path.slice(opts.rootDir.length).replace(/^[/\\]/, "") : path;
1374
+ if (isAlwaysExcluded(rel)) return true;
1375
+ return false;
1376
+ },
1377
+ // Reasonable defaults for laptop workloads.
1378
+ awaitWriteFinish: { stabilityThreshold: 200, pollInterval: 50 },
1379
+ atomic: true
1380
+ });
1381
+ for (const ev of ["add", "change", "unlink", "addDir", "unlinkDir"]) {
1382
+ watcher.on(ev, () => scheduleScan());
1383
+ }
1384
+ }
1385
+ await triggerApply();
1386
+ scanInFlight = scanAndPush({ rootDir: opts.rootDir, client, stateDb, ignore: ignore2 }).catch((err) => {
1387
+ console.error("tapd initial scan error:", err);
1388
+ });
1389
+ await scanInFlight;
1390
+ schedulePoll();
1391
+ return {
1392
+ async stop() {
1393
+ stopped = true;
1394
+ if (pollTimer) clearTimeout(pollTimer);
1395
+ if (scanTimer) clearTimeout(scanTimer);
1396
+ if (sseApplyTimer) clearTimeout(sseApplyTimer);
1397
+ if (sseSubscriber) await sseSubscriber.stop();
1398
+ await Promise.allSettled([applyInFlight ?? Promise.resolve(), scanInFlight]);
1399
+ if (watcher) await watcher.close();
1400
+ stateDb.close();
1401
+ },
1402
+ async applyNow() {
1403
+ const result = await triggerApply();
1404
+ return result ?? {
1405
+ applied: 0,
1406
+ echoSkipped: 0,
1407
+ errored: 0,
1408
+ events: [],
1409
+ errors: []
1410
+ };
1411
+ },
1412
+ async scanNow() {
1413
+ return scanAndPush({ rootDir: opts.rootDir, client, stateDb, ignore: ignore2 });
1414
+ },
1415
+ stateDb
1416
+ };
1417
+ }
1418
+
1419
+ export {
1420
+ BINDING_CONFIG_PATH,
1421
+ bindingConfigFile,
1422
+ readBindingConfig,
1423
+ writeBindingConfig,
1424
+ NotInitializedError,
1425
+ RelayError,
1426
+ createBinding,
1427
+ RelayClient,
1428
+ openStateDb,
1429
+ BUILTIN_IGNORE_LINES,
1430
+ loadIgnore,
1431
+ isAlwaysExcluded,
1432
+ walk,
1433
+ hashFile,
1434
+ AlreadyInitializedError,
1435
+ init,
1436
+ mint,
1437
+ list,
1438
+ revoke,
1439
+ AlreadyJoinedError,
1440
+ InvalidInviteUrlError,
1441
+ parseInviteUrl,
1442
+ join5 as join,
1443
+ STATE_KEY_CONFLICT_COUNT,
1444
+ conflictSidecarPath,
1445
+ findConflictSidecars,
1446
+ applyOnce,
1447
+ subscribe,
1448
+ computeLocalDiff,
1449
+ scanAndPush,
1450
+ start
1451
+ };
1452
+ //# sourceMappingURL=chunk-RQC73B5Y.js.map