@rigxyz/tapd 0.3.0 → 0.4.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.
@@ -94,6 +94,17 @@ async function createBinding(opts) {
94
94
  });
95
95
  return expectJson(route, res);
96
96
  }
97
+ async function revokeImportEdge(opts) {
98
+ const fn = opts.fetch ?? fetch;
99
+ const res = await fn(
100
+ joinUrl(opts.baseUrl, `/v1/me/imports/${encodeURIComponent(opts.edgeId)}`),
101
+ { method: "DELETE", headers: { authorization: `Bearer ${opts.accessToken}` } }
102
+ );
103
+ if (res.ok) return { revoked: true, status: res.status };
104
+ const body = await readJson(res);
105
+ const reason = typeof body === "object" && body !== null && "reason" in body ? String(body.reason) : void 0;
106
+ return { revoked: false, status: res.status, ...reason ? { reason } : {} };
107
+ }
97
108
  var RelayClient = class {
98
109
  baseUrl;
99
110
  bindingId;
@@ -426,6 +437,15 @@ function isAlwaysExcluded(posixPath) {
426
437
  }
427
438
  return false;
428
439
  }
440
+ function isUnderAny(posixPath, prefixes) {
441
+ for (const raw of prefixes) {
442
+ const prefix = raw.replace(/\/+$/, "");
443
+ if (prefix === "") continue;
444
+ if (posixPath === prefix) return true;
445
+ if (posixPath.startsWith(prefix + "/")) return true;
446
+ }
447
+ return false;
448
+ }
429
449
 
430
450
  // src/walker.ts
431
451
  import { readdirSync, statSync } from "fs";
@@ -462,6 +482,7 @@ function walk(rootDir, opts) {
462
482
  const emptyDirs = [];
463
483
  const directoriesScanned = [];
464
484
  const warnings = [];
485
+ const excludePaths = opts.excludePaths ?? [];
465
486
  walkDir(".");
466
487
  return { files, emptyDirs, directoriesScanned, warnings };
467
488
  function walkDir(rel) {
@@ -479,6 +500,10 @@ function walk(rootDir, opts) {
479
500
  const posix = posixOf(childRel);
480
501
  const isDir = ent.isDirectory();
481
502
  if (isAlwaysExcluded(posix)) continue;
503
+ if (excludePaths.length > 0 && isUnderAny(posix, excludePaths)) {
504
+ hasVisible = true;
505
+ continue;
506
+ }
482
507
  if (opts.ignore.ignores(posix + (isDir ? "/" : ""))) continue;
483
508
  if (ent.isSymbolicLink()) {
484
509
  warnings.push({ path: posix, reason: "symlink" });
@@ -756,20 +781,347 @@ async function join5(opts) {
756
781
  };
757
782
  }
758
783
 
759
- // src/applier.ts
760
- import { createHash as createHash3, randomBytes } from "crypto";
784
+ // src/imports.ts
761
785
  import {
762
786
  existsSync as existsSync3,
763
- lstatSync,
764
787
  mkdirSync as mkdirSync3,
765
788
  readdirSync as readdirSync2,
766
789
  readFileSync as readFileSync4,
790
+ realpathSync,
791
+ statSync as statSync2,
792
+ writeFileSync as writeFileSync2
793
+ } from "fs";
794
+ import { dirname as dirname3, isAbsolute, join as join6, relative } from "path";
795
+ var NAME_RE = /^[a-z0-9][a-z0-9_-]*$/;
796
+ function validateImportName(name) {
797
+ if (!name) return "empty_name";
798
+ if (!NAME_RE.test(name)) {
799
+ return "invalid_name (must match ^[a-z0-9][a-z0-9_-]*$ \u2014 lowercase slug, no dots/slashes/..)";
800
+ }
801
+ return null;
802
+ }
803
+ function normalizeImportPath(p) {
804
+ return p.replace(/\\/g, "/").split("/").filter((s) => s !== "" && s !== ".").join("/");
805
+ }
806
+ function validateImportPath(rootDir, importPath) {
807
+ if (!importPath) return "empty_path";
808
+ if (isAbsolute(importPath)) return "absolute_path";
809
+ const norm = normalizeImportPath(importPath);
810
+ if (norm === "" || norm === ".") return "root_path (cannot mount over the rig root)";
811
+ const segs = norm.split("/");
812
+ if (segs.some((s) => s === "..")) return "dotdot_in_path";
813
+ if (segs.some((s) => s === "")) return "empty_segment";
814
+ if (segs[0] === ".rig") return "reserved_rig_path";
815
+ const realRoot = realpathSync(rootDir);
816
+ const abs = join6(realRoot, norm);
817
+ if (!isWithin(realRoot, abs)) return "escapes_root";
818
+ const realMount = realpathNearestExisting(abs);
819
+ if (!isWithin(realRoot, realMount)) return "symlink_escape";
820
+ const fileAncestor = firstExistingFileOnPath(abs);
821
+ if (fileAncestor) {
822
+ return fileAncestor === abs ? "mount_path_is_file" : "ancestor_is_file";
823
+ }
824
+ return null;
825
+ }
826
+ function validateImports(opts) {
827
+ const valid = [];
828
+ const errors = [];
829
+ const seenNames = /* @__PURE__ */ new Set();
830
+ const acceptedPaths = [];
831
+ for (const imp of opts.imports) {
832
+ const reject = (reason) => errors.push({ name: imp.name, path: imp.path, reason });
833
+ const nameErr = validateImportName(imp.name);
834
+ if (nameErr) {
835
+ reject(nameErr);
836
+ continue;
837
+ }
838
+ if (seenNames.has(imp.name)) {
839
+ reject("duplicate_name");
840
+ continue;
841
+ }
842
+ const pathErr = validateImportPath(opts.rootDir, imp.path);
843
+ if (pathErr) {
844
+ reject(pathErr);
845
+ continue;
846
+ }
847
+ const norm = normalizeImportPath(imp.path);
848
+ if (acceptedPaths.some((p) => pathsOverlap(p, norm))) {
849
+ reject("overlaps_other_import");
850
+ continue;
851
+ }
852
+ if (opts.primaryTrackedPaths.some((tp) => tp === norm || tp.startsWith(norm + "/"))) {
853
+ reject("over_primary_content");
854
+ continue;
855
+ }
856
+ seenNames.add(imp.name);
857
+ acceptedPaths.push(norm);
858
+ valid.push({ ...imp, path: norm });
859
+ }
860
+ return { valid, errors };
861
+ }
862
+ function primaryStateDbPath(rootDir) {
863
+ return join6(rootDir, ".rig", "tap", "state.local.db");
864
+ }
865
+ function importStateDbPath(rootDir, name) {
866
+ return join6(rootDir, ".rig", "tap", "imports", name, "state.local.db");
867
+ }
868
+ function importsDir(rootDir) {
869
+ return join6(rootDir, ".rig", "tap", "imports");
870
+ }
871
+ function importMountFile(rootDir, name) {
872
+ return join6(importsDir(rootDir), `${name}.local.json`);
873
+ }
874
+ var MOUNT_FILE_SUFFIX = ".local.json";
875
+ function writeImportMount(rootDir, name, mount) {
876
+ const path = importMountFile(rootDir, name);
877
+ mkdirSync3(dirname3(path), { recursive: true });
878
+ writeFileSync2(path, JSON.stringify(mount, null, 2) + "\n", { mode: 384 });
879
+ }
880
+ function readImportMount(rootDir, name) {
881
+ const file = importMountFile(rootDir, name);
882
+ if (!existsSync3(file)) return { ok: false, reason: "missing_token_file" };
883
+ let parsed;
884
+ try {
885
+ parsed = JSON.parse(readFileSync4(file, "utf8"));
886
+ } catch {
887
+ return { ok: false, reason: "malformed_json" };
888
+ }
889
+ if (typeof parsed.bindingId !== "string" || parsed.bindingId === "") {
890
+ return { ok: false, reason: "missing_bindingId" };
891
+ }
892
+ if (typeof parsed.relayUrl !== "string" || parsed.relayUrl === "") {
893
+ return { ok: false, reason: "missing_relayUrl" };
894
+ }
895
+ if (typeof parsed.token !== "string" || parsed.token === "") {
896
+ return { ok: false, reason: "missing_token" };
897
+ }
898
+ if (typeof parsed.path !== "string" || parsed.path === "") {
899
+ return { ok: false, reason: "missing_path" };
900
+ }
901
+ const ops = Array.isArray(parsed.ops) ? parsed.ops : [];
902
+ if (!ops.includes("read") || !ops.includes("subscribe")) {
903
+ return { ok: false, reason: "insufficient_ops (need read+subscribe)" };
904
+ }
905
+ return {
906
+ ok: true,
907
+ spec: {
908
+ name,
909
+ bindingId: parsed.bindingId,
910
+ relayUrl: parsed.relayUrl,
911
+ token: parsed.token,
912
+ path: parsed.path
913
+ }
914
+ };
915
+ }
916
+ function loadImportMounts(rootDir) {
917
+ const dir = importsDir(rootDir);
918
+ const ready = [];
919
+ const broken = [];
920
+ if (!existsSync3(dir)) return { ready, broken };
921
+ for (const ent of readdirSync2(dir, { withFileTypes: true })) {
922
+ if (!ent.isFile() || !ent.name.endsWith(MOUNT_FILE_SUFFIX)) continue;
923
+ const name = ent.name.slice(0, -MOUNT_FILE_SUFFIX.length);
924
+ const nameErr = validateImportName(name);
925
+ if (nameErr) {
926
+ broken.push({ name, path: "", reason: nameErr });
927
+ continue;
928
+ }
929
+ const res = readImportMount(rootDir, name);
930
+ if (!res.ok) {
931
+ broken.push({ name, path: "", reason: res.reason });
932
+ continue;
933
+ }
934
+ ready.push(res.spec);
935
+ }
936
+ return { ready, broken };
937
+ }
938
+ function isWithin(parent, child) {
939
+ const rel = relative(parent, child);
940
+ return rel === "" || !rel.startsWith("..") && !isAbsolute(rel);
941
+ }
942
+ function realpathNearestExisting(abs) {
943
+ let cur = abs;
944
+ while (!existsSync3(cur)) {
945
+ const parent = dirname3(cur);
946
+ if (parent === cur) break;
947
+ cur = parent;
948
+ }
949
+ const realExisting = realpathSync(cur);
950
+ const remainder = relative(cur, abs);
951
+ return remainder ? join6(realExisting, remainder) : realExisting;
952
+ }
953
+ function firstExistingFileOnPath(abs) {
954
+ let cur = abs;
955
+ for (; ; ) {
956
+ if (existsSync3(cur)) {
957
+ return statSync2(cur).isFile() ? cur : null;
958
+ }
959
+ const parent = dirname3(cur);
960
+ if (parent === cur) return null;
961
+ cur = parent;
962
+ }
963
+ }
964
+ function pathsOverlap(a, b) {
965
+ return a === b || a.startsWith(b + "/") || b.startsWith(a + "/");
966
+ }
967
+
968
+ // src/import-invite.ts
969
+ import { existsSync as existsSync4, readFileSync as readFileSync5, rmSync } from "fs";
970
+ import { dirname as dirname4, join as joinPath2 } from "path";
971
+ var ImportValidationFailed = class extends Error {
972
+ constructor(reason) {
973
+ super(`import rejected: ${reason}`);
974
+ this.reason = reason;
975
+ this.name = "ImportValidationFailed";
976
+ }
977
+ reason;
978
+ };
979
+ var ImportTokenScopeError = class extends Error {
980
+ constructor(ops) {
981
+ super(
982
+ ops ? `invite token has ops [${ops.join(", ")}] \u2014 a mirror needs read+subscribe` : `invite can't drive a mirror \u2014 it needs read+subscribe`
983
+ );
984
+ this.ops = ops;
985
+ this.name = "ImportTokenScopeError";
986
+ }
987
+ ops;
988
+ };
989
+ function isRecord(v) {
990
+ return typeof v === "object" && v !== null;
991
+ }
992
+ function deriveName(canonicalPath) {
993
+ const segs = canonicalPath.split("/").filter(Boolean);
994
+ return segs[segs.length - 1] ?? "";
995
+ }
996
+ function readPrimaryTrackedPaths(rootDir) {
997
+ const dbPath = primaryStateDbPath(rootDir);
998
+ if (!existsSync4(dbPath)) return [];
999
+ const db = openStateDb(dbPath);
1000
+ try {
1001
+ return db.listPaths();
1002
+ } finally {
1003
+ db.close();
1004
+ }
1005
+ }
1006
+ async function addImport(opts) {
1007
+ const path = normalizeImportPath(opts.path);
1008
+ const name = opts.name && opts.name.length > 0 ? opts.name : deriveName(path);
1009
+ const existing = loadImportMounts(opts.rootDir).ready;
1010
+ const candidate = { name, bindingId: "", relayUrl: "", token: "", path };
1011
+ const { valid, errors } = validateImports({
1012
+ rootDir: opts.rootDir,
1013
+ imports: [...existing, candidate],
1014
+ primaryTrackedPaths: readPrimaryTrackedPaths(opts.rootDir)
1015
+ });
1016
+ if (!valid.some((v) => v.name === name && v.path === path)) {
1017
+ const err = errors.find((e) => e.name === name);
1018
+ throw new ImportValidationFailed(err?.reason ?? "invalid_import");
1019
+ }
1020
+ const primaryBindingId = readBindingConfig(opts.rootDir)?.bindingId;
1021
+ const { baseUrl, secret } = parseInviteUrl(opts.inviteUrl);
1022
+ let accepted;
1023
+ try {
1024
+ accepted = await acceptInvite({
1025
+ baseUrl,
1026
+ secret,
1027
+ accessToken: opts.accessToken ?? null,
1028
+ request: {
1029
+ ...opts.deviceLabel ? { deviceLabel: opts.deviceLabel } : {},
1030
+ importer: { ...primaryBindingId ? { bindingId: primaryBindingId } : {}, path }
1031
+ },
1032
+ fetch: opts.fetch
1033
+ });
1034
+ } catch (err) {
1035
+ if (err instanceof RelayError && isRecord(err.body) && err.body.reason === "import_requires_read_subscribe") {
1036
+ throw new ImportTokenScopeError();
1037
+ }
1038
+ throw err;
1039
+ }
1040
+ const ops = accepted.token.ops;
1041
+ if (!ops.includes("read") || !ops.includes("subscribe")) {
1042
+ throw new ImportTokenScopeError(ops);
1043
+ }
1044
+ writeImportMount(opts.rootDir, name, {
1045
+ bindingId: accepted.bindingId,
1046
+ relayUrl: baseUrl,
1047
+ token: accepted.token.secret,
1048
+ ops,
1049
+ path,
1050
+ ...accepted.importEdgeId ? { edgeId: accepted.importEdgeId } : {}
1051
+ });
1052
+ return {
1053
+ name,
1054
+ path,
1055
+ bindingId: accepted.bindingId,
1056
+ ops,
1057
+ ...accepted.importEdgeId ? { edgeId: accepted.importEdgeId } : {},
1058
+ // Only meaningful when we actually sent a binding to verify.
1059
+ ...primaryBindingId !== void 0 ? { importerBindingVerified: accepted.importerBindingVerified ?? false } : {}
1060
+ };
1061
+ }
1062
+ function readRawMount(rootDir, name) {
1063
+ const file = importMountFile(rootDir, name);
1064
+ if (!existsSync4(file)) return null;
1065
+ try {
1066
+ return JSON.parse(readFileSync5(file, "utf8"));
1067
+ } catch {
1068
+ return null;
1069
+ }
1070
+ }
1071
+ async function removeImport(opts) {
1072
+ const mountPath = importMountFile(opts.rootDir, opts.name);
1073
+ const res = readImportMount(opts.rootDir, opts.name);
1074
+ const raw = readRawMount(opts.rootDir, opts.name);
1075
+ const path = res.ok ? res.spec.path : raw?.path ?? null;
1076
+ let revoke2;
1077
+ if (!raw?.edgeId || !raw.relayUrl) {
1078
+ revoke2 = "no-edge";
1079
+ } else if (!opts.accessToken) {
1080
+ revoke2 = "no-token";
1081
+ } else {
1082
+ try {
1083
+ const r = await revokeImportEdge({
1084
+ baseUrl: raw.relayUrl,
1085
+ edgeId: raw.edgeId,
1086
+ accessToken: opts.accessToken,
1087
+ fetch: opts.fetch
1088
+ });
1089
+ revoke2 = r.revoked ? "revoked" : r.status === 403 ? "not-creator" : r.status === 404 ? "gone" : "offline";
1090
+ } catch {
1091
+ revoke2 = "offline";
1092
+ }
1093
+ }
1094
+ let removed = false;
1095
+ if (existsSync4(mountPath)) {
1096
+ rmSync(mountPath, { force: true });
1097
+ removed = true;
1098
+ }
1099
+ rmSync(dirname4(importStateDbPath(opts.rootDir, opts.name)), {
1100
+ recursive: true,
1101
+ force: true
1102
+ });
1103
+ let purged = false;
1104
+ if (opts.purge && path) {
1105
+ rmSync(joinPath2(opts.rootDir, path), { recursive: true, force: true });
1106
+ purged = true;
1107
+ }
1108
+ return { removed, path, purged, revoke: revoke2 };
1109
+ }
1110
+
1111
+ // src/applier.ts
1112
+ import { createHash as createHash3, randomBytes } from "crypto";
1113
+ import {
1114
+ existsSync as existsSync5,
1115
+ lstatSync,
1116
+ mkdirSync as mkdirSync4,
1117
+ readdirSync as readdirSync3,
1118
+ readFileSync as readFileSync6,
767
1119
  renameSync,
768
1120
  rmdirSync,
769
- rmSync,
770
- writeFileSync as writeFileSync2
1121
+ rmSync as rmSync2,
1122
+ writeFileSync as writeFileSync3
771
1123
  } from "fs";
772
- import { basename, dirname as dirname3, extname, join as join6, sep as sep2 } from "path";
1124
+ import { basename, dirname as dirname5, extname, join as join7, sep as sep2 } from "path";
773
1125
  var STATE_KEY_CONFLICT_COUNT = "conflict_count";
774
1126
  var HashMismatchError = class extends Error {
775
1127
  constructor(path, expectedHash, actualHash) {
@@ -789,10 +1141,10 @@ function sha256Hex(bytes) {
789
1141
  return createHash3("sha256").update(bytes).digest("hex");
790
1142
  }
791
1143
  function sha256OfFile(absPath) {
792
- return `sha256:${sha256Hex(readFileSync4(absPath))}`;
1144
+ return `sha256:${sha256Hex(readFileSync6(absPath))}`;
793
1145
  }
794
1146
  function conflictSidecarPath(path, ev) {
795
- const dir = dirname3(path);
1147
+ const dir = dirname5(path);
796
1148
  const ext = extname(path);
797
1149
  const stem = basename(path, ext);
798
1150
  const actor = ev.actorDeviceId ?? "unknown";
@@ -811,16 +1163,16 @@ function findConflictSidecars(rootDir) {
811
1163
  walk2(".");
812
1164
  return out;
813
1165
  function walk2(rel) {
814
- const abs = rel === "." ? rootDir : join6(rootDir, rel);
1166
+ const abs = rel === "." ? rootDir : join7(rootDir, rel);
815
1167
  let entries;
816
1168
  try {
817
- entries = readdirSync2(abs, { withFileTypes: true });
1169
+ entries = readdirSync3(abs, { withFileTypes: true });
818
1170
  } catch {
819
1171
  return;
820
1172
  }
821
1173
  for (const ent of entries) {
822
1174
  if (ent.isSymbolicLink()) continue;
823
- const childRel = rel === "." ? ent.name : join6(rel, ent.name);
1175
+ const childRel = rel === "." ? ent.name : join7(rel, ent.name);
824
1176
  if (ent.isDirectory()) {
825
1177
  if (SCAN_SKIP_DIRS.has(ent.name)) continue;
826
1178
  walk2(childRel);
@@ -830,7 +1182,7 @@ function findConflictSidecars(rootDir) {
830
1182
  const parsed = parseConflictSidecar(ent.name);
831
1183
  if (!parsed) continue;
832
1184
  const sidecarPosix = sep2 === "/" ? childRel : childRel.replaceAll(sep2, "/");
833
- const dir = dirname3(sidecarPosix);
1185
+ const dir = dirname5(sidecarPosix);
834
1186
  const original = dir === "." ? `${parsed.stem}${parsed.ext}` : `${dir}/${parsed.stem}${parsed.ext}`;
835
1187
  out.push({ path: original, sidecarPath: sidecarPosix });
836
1188
  }
@@ -846,6 +1198,7 @@ function bumpConflictCount(stateDb) {
846
1198
  var STATE_KEY_LAST_APPLY_ERROR = "last_apply_error";
847
1199
  var STATE_KEY_LAST_APPLY_ERROR_AT = "last_apply_error_at";
848
1200
  async function applyOnce(opts) {
1201
+ const mode = opts.mode ?? "primary";
849
1202
  const cursor = opts.stateDb.getCursor();
850
1203
  const since = await opts.client.listChanges({ after: cursor, limit: opts.limit });
851
1204
  let applied = 0;
@@ -858,7 +1211,8 @@ async function applyOnce(opts) {
858
1211
  rootDir: opts.rootDir,
859
1212
  client: opts.client,
860
1213
  stateDb: opts.stateDb,
861
- event: ev
1214
+ event: ev,
1215
+ mode
862
1216
  });
863
1217
  if (wasEcho) echoSkipped += 1;
864
1218
  else applied += 1;
@@ -883,13 +1237,14 @@ async function applyOnce(opts) {
883
1237
  return { applied, echoSkipped, errored, events: since.events, errors };
884
1238
  }
885
1239
  async function applyEvent(opts) {
886
- const { rootDir, client, stateDb, event: ev } = opts;
887
- const abs = join6(rootDir, ev.path);
1240
+ const { rootDir, client, stateDb, event: ev, mode } = opts;
1241
+ const mirror = mode === "mirror";
1242
+ const abs = join7(rootDir, ev.path);
888
1243
  switch (ev.op) {
889
1244
  case "write": {
890
1245
  if (!ev.hash) throw new Error(`write event ${ev.id} missing hash`);
891
1246
  const local = stateDb.getPath(ev.path);
892
- if (local?.lastSeenHash === ev.hash && existsSync3(abs)) {
1247
+ if (local?.lastSeenHash === ev.hash && existsSync5(abs)) {
893
1248
  stateDb.upsertPath({
894
1249
  path: ev.path,
895
1250
  lastAppliedChangeId: ev.id,
@@ -899,12 +1254,15 @@ async function applyEvent(opts) {
899
1254
  });
900
1255
  return true;
901
1256
  }
902
- if (existsSync3(abs) && lstatSync(abs).isDirectory()) {
903
- throw new Error(
904
- `file_vs_dir_collision: ${ev.path} is a local directory; refusing to overwrite with a file`
905
- );
1257
+ if (existsSync5(abs) && lstatSync(abs).isDirectory()) {
1258
+ if (!mirror) {
1259
+ throw new Error(
1260
+ `file_vs_dir_collision: ${ev.path} is a local directory; refusing to overwrite with a file`
1261
+ );
1262
+ }
1263
+ rmSync2(abs, { recursive: true, force: true });
906
1264
  }
907
- const localDirty = existsSync3(abs) && local?.lastSeenHash != null && sha256OfFile(abs) !== local.lastSeenHash;
1265
+ const localDirty = !mirror && existsSync5(abs) && local?.lastSeenHash != null && sha256OfFile(abs) !== local.lastSeenHash;
908
1266
  const dl = await client.downloadUrl(ev.hash);
909
1267
  const bytes = await client.getObjectBytes(dl.downloadUrl);
910
1268
  const actualHash = `sha256:${sha256Hex(bytes)}`;
@@ -913,10 +1271,10 @@ async function applyEvent(opts) {
913
1271
  }
914
1272
  if (localDirty) {
915
1273
  const sidecarPath = conflictSidecarPath(ev.path, ev);
916
- const sidecarAbs = join6(rootDir, sidecarPath);
917
- mkdirSync3(dirname3(sidecarAbs), { recursive: true });
1274
+ const sidecarAbs = join7(rootDir, sidecarPath);
1275
+ mkdirSync4(dirname5(sidecarAbs), { recursive: true });
918
1276
  const tmp2 = `${sidecarAbs}.tap-tmp.${randomBytes(6).toString("hex")}`;
919
- writeFileSync2(tmp2, bytes, { mode: ev.executable ? 493 : 420 });
1277
+ writeFileSync3(tmp2, bytes, { mode: ev.executable ? 493 : 420 });
920
1278
  renameSync(tmp2, sidecarAbs);
921
1279
  stateDb.upsertPath({
922
1280
  path: ev.path,
@@ -928,9 +1286,9 @@ async function applyEvent(opts) {
928
1286
  bumpConflictCount(stateDb);
929
1287
  return false;
930
1288
  }
931
- mkdirSync3(dirname3(abs), { recursive: true });
1289
+ mkdirSync4(dirname5(abs), { recursive: true });
932
1290
  const tmp = `${abs}.tap-tmp.${randomBytes(6).toString("hex")}`;
933
- writeFileSync2(tmp, bytes, { mode: ev.executable ? 493 : 420 });
1291
+ writeFileSync3(tmp, bytes, { mode: ev.executable ? 493 : 420 });
934
1292
  renameSync(tmp, abs);
935
1293
  stateDb.upsertPath({
936
1294
  path: ev.path,
@@ -942,36 +1300,43 @@ async function applyEvent(opts) {
942
1300
  return false;
943
1301
  }
944
1302
  case "delete": {
945
- if (existsSync3(abs)) {
1303
+ if (existsSync5(abs)) {
946
1304
  if (lstatSync(abs).isDirectory()) {
947
- throw new Error(
948
- `delete_vs_dir: ${ev.path} is a local directory; refusing recursive removal`
949
- );
950
- }
951
- const localStateRow = stateDb.getPath(ev.path);
952
- const localBytesHash = sha256OfFile(abs);
953
- const localDirty = localStateRow?.lastSeenHash != null && localBytesHash !== localStateRow.lastSeenHash;
954
- const localUntracked = localStateRow?.lastSeenHash == null;
955
- if (localDirty || localUntracked) {
956
- stateDb.upsertPath({
957
- path: ev.path,
958
- lastAppliedChangeId: ev.id,
959
- // null so scanAndPush's `prev?.lastSeenHash === hash` check
960
- // falls through and the file gets re-submitted as a write.
961
- lastSeenHash: null,
962
- localDirty: true,
963
- lastScanAt: (/* @__PURE__ */ new Date()).toISOString()
964
- });
965
- bumpConflictCount(stateDb);
1305
+ if (!mirror) {
1306
+ throw new Error(
1307
+ `delete_vs_dir: ${ev.path} is a local directory; refusing recursive removal`
1308
+ );
1309
+ }
1310
+ rmSync2(abs, { recursive: true, force: true });
1311
+ stateDb.deletePath(ev.path);
966
1312
  return false;
967
1313
  }
968
- rmSync(abs, { force: true });
1314
+ if (!mirror) {
1315
+ const localStateRow = stateDb.getPath(ev.path);
1316
+ const localBytesHash = sha256OfFile(abs);
1317
+ const localDirty = localStateRow?.lastSeenHash != null && localBytesHash !== localStateRow.lastSeenHash;
1318
+ const localUntracked = localStateRow?.lastSeenHash == null;
1319
+ if (localDirty || localUntracked) {
1320
+ stateDb.upsertPath({
1321
+ path: ev.path,
1322
+ lastAppliedChangeId: ev.id,
1323
+ // null so scanAndPush's `prev?.lastSeenHash === hash` check
1324
+ // falls through and the file gets re-submitted as a write.
1325
+ lastSeenHash: null,
1326
+ localDirty: true,
1327
+ lastScanAt: (/* @__PURE__ */ new Date()).toISOString()
1328
+ });
1329
+ bumpConflictCount(stateDb);
1330
+ return false;
1331
+ }
1332
+ }
1333
+ rmSync2(abs, { force: true });
969
1334
  }
970
1335
  stateDb.deletePath(ev.path);
971
1336
  return false;
972
1337
  }
973
1338
  case "mkdir": {
974
- mkdirSync3(abs, { recursive: true });
1339
+ mkdirSync4(abs, { recursive: true });
975
1340
  stateDb.upsertPath({
976
1341
  path: ev.path,
977
1342
  lastAppliedChangeId: ev.id,
@@ -982,9 +1347,13 @@ async function applyEvent(opts) {
982
1347
  return false;
983
1348
  }
984
1349
  case "rmdir": {
985
- try {
986
- rmdirSync(abs);
987
- } catch {
1350
+ if (mirror) {
1351
+ rmSync2(abs, { recursive: true, force: true });
1352
+ } else {
1353
+ try {
1354
+ rmdirSync(abs);
1355
+ } catch {
1356
+ }
988
1357
  }
989
1358
  stateDb.deletePath(ev.path);
990
1359
  return false;
@@ -1130,19 +1499,119 @@ function asError(err, fallbackName) {
1130
1499
  return e;
1131
1500
  }
1132
1501
 
1502
+ // src/subscription.ts
1503
+ var DEFAULT_POLL_SECONDS = 3;
1504
+ var SSE_APPLY_DEBOUNCE_MS = 50;
1505
+ async function startSubscription(opts) {
1506
+ const { mode } = opts;
1507
+ const stateDb = openStateDb(opts.stateDbPath);
1508
+ const client = new RelayClient({
1509
+ baseUrl: opts.config.relayUrl,
1510
+ bindingId: opts.config.bindingId,
1511
+ token: opts.config.token,
1512
+ fetch: opts.fetch
1513
+ });
1514
+ const pollSeconds = opts.pollSeconds ?? DEFAULT_POLL_SECONDS;
1515
+ let stopped = false;
1516
+ let pollTimer;
1517
+ let sseApplyTimer;
1518
+ let sseSubscriber;
1519
+ let applyInFlight = null;
1520
+ let rerunRequested = false;
1521
+ let lastApplyResult;
1522
+ const triggerApply = () => {
1523
+ if (applyInFlight) {
1524
+ rerunRequested = true;
1525
+ return applyInFlight;
1526
+ }
1527
+ const loop = (async () => {
1528
+ try {
1529
+ do {
1530
+ rerunRequested = false;
1531
+ try {
1532
+ lastApplyResult = await applyOnce({
1533
+ rootDir: opts.rootDir,
1534
+ client,
1535
+ stateDb,
1536
+ mode
1537
+ });
1538
+ } catch (err) {
1539
+ console.error("tapd apply error:", err);
1540
+ }
1541
+ } while (rerunRequested && !stopped);
1542
+ return lastApplyResult;
1543
+ } finally {
1544
+ applyInFlight = null;
1545
+ }
1546
+ })();
1547
+ applyInFlight = loop;
1548
+ return loop;
1549
+ };
1550
+ const schedulePoll = () => {
1551
+ if (stopped) return;
1552
+ pollTimer = setTimeout(() => {
1553
+ triggerApply().finally(() => schedulePoll());
1554
+ }, pollSeconds * 1e3);
1555
+ };
1556
+ const scheduleSseApply = () => {
1557
+ if (stopped) return;
1558
+ if (sseApplyTimer) clearTimeout(sseApplyTimer);
1559
+ sseApplyTimer = setTimeout(() => {
1560
+ sseApplyTimer = void 0;
1561
+ void triggerApply();
1562
+ }, SSE_APPLY_DEBOUNCE_MS);
1563
+ };
1564
+ if (!opts.noSse) {
1565
+ sseSubscriber = subscribe({
1566
+ baseUrl: opts.config.relayUrl,
1567
+ bindingId: opts.config.bindingId,
1568
+ token: opts.config.token,
1569
+ fetch: opts.fetch,
1570
+ heartbeatTimeoutMs: opts.sseHeartbeatTimeoutMs,
1571
+ onChange: () => scheduleSseApply(),
1572
+ onError: (err) => {
1573
+ console.error("tapd sse:", err.message);
1574
+ }
1575
+ });
1576
+ }
1577
+ await triggerApply();
1578
+ schedulePoll();
1579
+ return {
1580
+ async stop() {
1581
+ stopped = true;
1582
+ if (pollTimer) clearTimeout(pollTimer);
1583
+ if (sseApplyTimer) clearTimeout(sseApplyTimer);
1584
+ if (sseSubscriber) await sseSubscriber.stop();
1585
+ await Promise.allSettled([applyInFlight ?? Promise.resolve()]);
1586
+ stateDb.close();
1587
+ },
1588
+ async applyNow() {
1589
+ const result = await triggerApply();
1590
+ return result ?? {
1591
+ applied: 0,
1592
+ echoSkipped: 0,
1593
+ errored: 0,
1594
+ events: [],
1595
+ errors: []
1596
+ };
1597
+ },
1598
+ stateDb
1599
+ };
1600
+ }
1601
+
1133
1602
  // src/uploader.ts
1134
1603
  import { createHash as createHash4 } from "crypto";
1135
- import { readFileSync as readFileSync5 } from "fs";
1136
- import { join as join7 } from "path";
1604
+ import { readFileSync as readFileSync7 } from "fs";
1605
+ import { join as join8 } from "path";
1137
1606
  function sha256OfBuffer2(bytes) {
1138
1607
  return `sha256:${createHash4("sha256").update(bytes).digest("hex")}`;
1139
1608
  }
1140
1609
  async function computeLocalDiff(opts) {
1141
- const { rootDir, stateDb, ignore: ignore2 } = opts;
1142
- const scan = walk(rootDir, { ignore: ignore2 });
1610
+ const { rootDir, stateDb, ignore: ignore2, excludePaths } = opts;
1611
+ const scan = walk(rootDir, { ignore: ignore2, excludePaths });
1143
1612
  const writes = [];
1144
1613
  for (const f of scan.files) {
1145
- const hash = await hashFile(join7(rootDir, f.path));
1614
+ const hash = await hashFile(join8(rootDir, f.path));
1146
1615
  const prev = stateDb.getPath(f.path);
1147
1616
  if (prev?.lastSeenHash === hash) continue;
1148
1617
  const baseChangeId = prev?.lastAppliedChangeId ?? void 0;
@@ -1211,7 +1680,7 @@ async function scanAndPush(opts) {
1211
1680
  reused += 1;
1212
1681
  continue;
1213
1682
  }
1214
- const bytes = readFileSync5(join7(rootDir, f.path));
1683
+ const bytes = readFileSync7(join8(rootDir, f.path));
1215
1684
  const actual = sha256OfBuffer2(bytes);
1216
1685
  if (actual !== hash) {
1217
1686
  mutatedPaths.add(f.path);
@@ -1291,152 +1760,183 @@ function cursorLessThan(a, b) {
1291
1760
  }
1292
1761
 
1293
1762
  // src/start.ts
1294
- import { join as join8 } from "path";
1763
+ import { join as join9 } from "path";
1295
1764
  import chokidar from "chokidar";
1296
- var DEFAULT_POLL_SECONDS = 3;
1765
+ var DEFAULT_POLL_SECONDS2 = 3;
1297
1766
  var DEFAULT_DEBOUNCE_MS = 300;
1767
+ var NOOP_APPLY = {
1768
+ applied: 0,
1769
+ echoSkipped: 0,
1770
+ errored: 0,
1771
+ events: [],
1772
+ errors: []
1773
+ };
1774
+ var NOOP_SCAN = {
1775
+ uploaded: 0,
1776
+ reused: 0,
1777
+ submitted: [],
1778
+ warnings: []
1779
+ };
1298
1780
  async function start(opts) {
1299
1781
  const config = readBindingConfig(opts.rootDir);
1300
- if (!config) {
1782
+ let importSpecs;
1783
+ let loaderBroken = [];
1784
+ if (opts.imports) {
1785
+ importSpecs = opts.imports;
1786
+ } else {
1787
+ const loaded = loadImportMounts(opts.rootDir);
1788
+ importSpecs = loaded.ready;
1789
+ loaderBroken = loaded.broken;
1790
+ }
1791
+ for (const e of loaderBroken) {
1792
+ console.error(`tapd import "${e.name}": skipped \u2014 ${e.reason}`);
1793
+ }
1794
+ if (!config && importSpecs.length === 0) {
1301
1795
  throw new Error(
1302
- `no binding config at ${opts.rootDir}/.rig/tap-binding.local.json \u2014 run \`tapd init\` first`
1796
+ `no binding config at ${opts.rootDir}/.rig/tap-binding.local.json and no imports \u2014 run \`tapd init\` first`
1303
1797
  );
1304
1798
  }
1305
- const stateDb = openStateDb(
1306
- join8(opts.rootDir, ".rig", "tap", "state.local.db")
1307
- );
1308
- const ignore2 = loadIgnore(opts.rootDir);
1309
- const client = new RelayClient({
1310
- baseUrl: config.relayUrl,
1311
- bindingId: config.bindingId,
1312
- token: config.token,
1313
- fetch: opts.fetch
1314
- });
1315
- const pollSeconds = opts.pollSeconds ?? DEFAULT_POLL_SECONDS;
1799
+ const pollSeconds = opts.pollSeconds ?? DEFAULT_POLL_SECONDS2;
1316
1800
  const debounceMs = opts.debounceMs ?? DEFAULT_DEBOUNCE_MS;
1317
1801
  let stopped = false;
1318
- let pollTimer;
1319
- let scanTimer;
1320
- let sseApplyTimer;
1802
+ let primary;
1321
1803
  let watcher;
1804
+ let scanTimer;
1322
1805
  let scanInFlight = Promise.resolve();
1323
- let sseSubscriber;
1324
- let applyInFlight = null;
1325
- let rerunRequested = false;
1326
- let lastApplyResult;
1327
- const triggerApply = () => {
1328
- if (applyInFlight) {
1329
- rerunRequested = true;
1330
- return applyInFlight;
1331
- }
1332
- const loop = (async () => {
1333
- try {
1334
- do {
1335
- rerunRequested = false;
1336
- try {
1337
- lastApplyResult = await applyOnce({
1338
- rootDir: opts.rootDir,
1339
- client,
1340
- stateDb
1341
- });
1342
- } catch (err) {
1343
- console.error("tapd apply error:", err);
1344
- }
1345
- } while (rerunRequested && !stopped);
1346
- return lastApplyResult;
1347
- } finally {
1348
- applyInFlight = null;
1349
- }
1350
- })();
1351
- applyInFlight = loop;
1352
- return loop;
1353
- };
1354
- const schedulePoll = () => {
1355
- if (stopped) return;
1356
- pollTimer = setTimeout(() => {
1357
- triggerApply().finally(() => schedulePoll());
1358
- }, pollSeconds * 1e3);
1359
- };
1360
- const scheduleScan = () => {
1361
- if (stopped) return;
1362
- if (scanTimer) clearTimeout(scanTimer);
1363
- scanTimer = setTimeout(() => {
1364
- scanInFlight = scanAndPush({ rootDir: opts.rootDir, client, stateDb, ignore: ignore2 }).catch((err) => {
1365
- console.error("tapd scan error:", err);
1366
- });
1367
- }, debounceMs);
1368
- };
1369
- const SSE_APPLY_DEBOUNCE_MS = 50;
1370
- const scheduleSseApply = () => {
1371
- if (stopped) return;
1372
- if (sseApplyTimer) clearTimeout(sseApplyTimer);
1373
- sseApplyTimer = setTimeout(() => {
1374
- sseApplyTimer = void 0;
1375
- void triggerApply();
1376
- }, SSE_APPLY_DEBOUNCE_MS);
1377
- };
1378
- if (!opts.noSse) {
1379
- sseSubscriber = subscribe({
1806
+ let writerClient;
1807
+ const ignore2 = loadIgnore(opts.rootDir);
1808
+ let importPaths = [];
1809
+ if (config) {
1810
+ primary = await startSubscription({
1811
+ rootDir: opts.rootDir,
1812
+ config,
1813
+ stateDbPath: primaryStateDbPath(opts.rootDir),
1814
+ mode: "primary",
1815
+ fetch: opts.fetch,
1816
+ pollSeconds,
1817
+ noSse: opts.noSse,
1818
+ sseHeartbeatTimeoutMs: opts.sseHeartbeatTimeoutMs
1819
+ });
1820
+ }
1821
+ const primaryTrackedPaths = primary ? primary.stateDb.listPaths() : [];
1822
+ const { valid: validImports, errors: importErrors } = validateImports({
1823
+ rootDir: opts.rootDir,
1824
+ imports: importSpecs,
1825
+ primaryTrackedPaths
1826
+ });
1827
+ for (const e of importErrors) {
1828
+ console.error(`tapd import "${e.name}" (${e.path}): skipped \u2014 ${e.reason}`);
1829
+ }
1830
+ importPaths = validImports.map((i) => i.path);
1831
+ if (config && primary) {
1832
+ const primaryStateDb = primary.stateDb;
1833
+ writerClient = new RelayClient({
1380
1834
  baseUrl: config.relayUrl,
1381
1835
  bindingId: config.bindingId,
1382
1836
  token: config.token,
1383
- fetch: opts.fetch,
1384
- heartbeatTimeoutMs: opts.sseHeartbeatTimeoutMs,
1385
- onChange: () => scheduleSseApply(),
1386
- onError: (err) => {
1387
- console.error("tapd sse:", err.message);
1837
+ fetch: opts.fetch
1838
+ });
1839
+ const scheduleScan = () => {
1840
+ if (stopped) return;
1841
+ if (scanTimer) clearTimeout(scanTimer);
1842
+ scanTimer = setTimeout(() => {
1843
+ scanInFlight = scanAndPush({
1844
+ rootDir: opts.rootDir,
1845
+ client: writerClient,
1846
+ stateDb: primaryStateDb,
1847
+ ignore: ignore2,
1848
+ excludePaths: importPaths
1849
+ }).catch((err) => {
1850
+ console.error("tapd scan error:", err);
1851
+ });
1852
+ }, debounceMs);
1853
+ };
1854
+ if (!opts.noWatch) {
1855
+ watcher = chokidar.watch(opts.rootDir, {
1856
+ ignoreInitial: true,
1857
+ // Hard-exclude tap metadata AND every import mount point — the
1858
+ // latter so a mirrored subtree never triggers a re-upload into
1859
+ // the primary binding.
1860
+ ignored: (path) => {
1861
+ const rel = path.startsWith(opts.rootDir) ? path.slice(opts.rootDir.length).replace(/^[/\\]/, "") : path;
1862
+ if (isAlwaysExcluded(rel)) return true;
1863
+ if (importPaths.length > 0 && isUnderAny(rel, importPaths)) return true;
1864
+ return false;
1865
+ },
1866
+ awaitWriteFinish: { stabilityThreshold: 200, pollInterval: 50 },
1867
+ atomic: true
1868
+ });
1869
+ for (const ev of ["add", "change", "unlink", "addDir", "unlinkDir"]) {
1870
+ watcher.on(ev, () => scheduleScan());
1388
1871
  }
1872
+ }
1873
+ scanInFlight = scanAndPush({
1874
+ rootDir: opts.rootDir,
1875
+ client: writerClient,
1876
+ stateDb: primaryStateDb,
1877
+ ignore: ignore2,
1878
+ excludePaths: importPaths
1879
+ }).catch((err) => {
1880
+ console.error("tapd initial scan error:", err);
1389
1881
  });
1882
+ await scanInFlight;
1390
1883
  }
1391
- if (!opts.noWatch) {
1392
- watcher = chokidar.watch(opts.rootDir, {
1393
- ignoreInitial: true,
1394
- // Ignore tap's own state files at the watcher level too — saves
1395
- // wakeups from sqlite WAL churn.
1396
- ignored: (path) => {
1397
- const rel = path.startsWith(opts.rootDir) ? path.slice(opts.rootDir.length).replace(/^[/\\]/, "") : path;
1398
- if (isAlwaysExcluded(rel)) return true;
1399
- return false;
1400
- },
1401
- // Reasonable defaults for laptop workloads.
1402
- awaitWriteFinish: { stabilityThreshold: 200, pollInterval: 50 },
1403
- atomic: true
1404
- });
1405
- for (const ev of ["add", "change", "unlink", "addDir", "unlinkDir"]) {
1406
- watcher.on(ev, () => scheduleScan());
1884
+ const imports = [];
1885
+ const mirrorSubs = [];
1886
+ for (const imp of validImports) {
1887
+ try {
1888
+ const sub = await startSubscription({
1889
+ rootDir: join9(opts.rootDir, imp.path),
1890
+ config: { bindingId: imp.bindingId, relayUrl: imp.relayUrl, token: imp.token },
1891
+ stateDbPath: importStateDbPath(opts.rootDir, imp.name),
1892
+ mode: "mirror",
1893
+ fetch: opts.fetch,
1894
+ pollSeconds,
1895
+ noSse: opts.noSse,
1896
+ sseHeartbeatTimeoutMs: opts.sseHeartbeatTimeoutMs
1897
+ });
1898
+ mirrorSubs.push(sub);
1899
+ imports.push({
1900
+ name: imp.name,
1901
+ path: imp.path,
1902
+ applyNow: () => sub.applyNow(),
1903
+ stateDb: sub.stateDb
1904
+ });
1905
+ } catch (err) {
1906
+ const reason = err instanceof Error ? err.message : String(err);
1907
+ console.error(`tapd import "${imp.name}" (${imp.path}): failed to start \u2014 ${reason}`);
1407
1908
  }
1408
1909
  }
1409
- await triggerApply();
1410
- scanInFlight = scanAndPush({ rootDir: opts.rootDir, client, stateDb, ignore: ignore2 }).catch((err) => {
1411
- console.error("tapd initial scan error:", err);
1412
- });
1413
- await scanInFlight;
1414
- schedulePoll();
1910
+ const stateDb = primary?.stateDb ?? imports[0]?.stateDb;
1911
+ if (!stateDb) {
1912
+ throw new Error("tapd start: no primary binding and no import mounted successfully");
1913
+ }
1415
1914
  return {
1416
1915
  async stop() {
1417
1916
  stopped = true;
1418
- if (pollTimer) clearTimeout(pollTimer);
1419
1917
  if (scanTimer) clearTimeout(scanTimer);
1420
- if (sseApplyTimer) clearTimeout(sseApplyTimer);
1421
- if (sseSubscriber) await sseSubscriber.stop();
1422
- await Promise.allSettled([applyInFlight ?? Promise.resolve(), scanInFlight]);
1918
+ await Promise.allSettled([scanInFlight]);
1423
1919
  if (watcher) await watcher.close();
1424
- stateDb.close();
1920
+ const subs = [];
1921
+ if (primary) subs.push(primary.stop());
1922
+ for (const sub of mirrorSubs) subs.push(sub.stop());
1923
+ await Promise.allSettled(subs);
1425
1924
  },
1426
1925
  async applyNow() {
1427
- const result = await triggerApply();
1428
- return result ?? {
1429
- applied: 0,
1430
- echoSkipped: 0,
1431
- errored: 0,
1432
- events: [],
1433
- errors: []
1434
- };
1926
+ return primary ? primary.applyNow() : NOOP_APPLY;
1435
1927
  },
1436
1928
  async scanNow() {
1437
- return scanAndPush({ rootDir: opts.rootDir, client, stateDb, ignore: ignore2 });
1929
+ if (!config || !primary || !writerClient) return NOOP_SCAN;
1930
+ return scanAndPush({
1931
+ rootDir: opts.rootDir,
1932
+ client: writerClient,
1933
+ stateDb: primary.stateDb,
1934
+ ignore: ignore2,
1935
+ excludePaths: importPaths
1936
+ });
1438
1937
  },
1439
- stateDb
1938
+ stateDb,
1939
+ imports
1440
1940
  };
1441
1941
  }
1442
1942
 
@@ -1464,13 +1964,29 @@ export {
1464
1964
  InvalidInviteUrlError,
1465
1965
  parseInviteUrl,
1466
1966
  join5 as join,
1967
+ validateImportName,
1968
+ normalizeImportPath,
1969
+ validateImportPath,
1970
+ validateImports,
1971
+ primaryStateDbPath,
1972
+ importStateDbPath,
1973
+ importsDir,
1974
+ importMountFile,
1975
+ writeImportMount,
1976
+ readImportMount,
1977
+ loadImportMounts,
1978
+ ImportValidationFailed,
1979
+ ImportTokenScopeError,
1980
+ addImport,
1981
+ removeImport,
1467
1982
  STATE_KEY_CONFLICT_COUNT,
1468
1983
  conflictSidecarPath,
1469
1984
  findConflictSidecars,
1470
1985
  applyOnce,
1471
1986
  subscribe,
1987
+ startSubscription,
1472
1988
  computeLocalDiff,
1473
1989
  scanAndPush,
1474
1990
  start
1475
1991
  };
1476
- //# sourceMappingURL=chunk-N3KRCOBG.js.map
1992
+ //# sourceMappingURL=chunk-YPZ2UNI5.js.map