@rigxyz/tapd 0.1.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.
package/dist/bin.js CHANGED
@@ -2,33 +2,99 @@
2
2
  import {
3
3
  AlreadyInitializedError,
4
4
  AlreadyJoinedError,
5
+ ImportTokenScopeError,
6
+ ImportValidationFailed,
5
7
  InvalidInviteUrlError,
6
8
  NotInitializedError,
7
9
  RelayClient,
8
10
  STATE_KEY_CONFLICT_COUNT,
11
+ addImport,
9
12
  bindingConfigFile,
10
13
  computeLocalDiff,
11
14
  findConflictSidecars,
15
+ importStateDbPath,
12
16
  init,
13
17
  join,
14
18
  list,
15
19
  loadIgnore,
20
+ loadImportMounts,
16
21
  mint,
17
22
  openStateDb,
18
23
  readBindingConfig,
24
+ removeImport,
19
25
  revoke,
20
26
  start
21
- } from "./chunk-RQC73B5Y.js";
27
+ } from "./chunk-YPZ2UNI5.js";
22
28
 
23
29
  // src/bin.ts
24
- import { join as pathJoin, resolve } from "path";
30
+ import { join as pathJoin, resolve as resolve2 } from "path";
25
31
  import kleur from "kleur";
26
32
 
33
+ // src/defaults.ts
34
+ var DEFAULT_RELAY_URL = "https://tap-relay.fly.dev";
35
+
36
+ // src/hub-token.ts
37
+ import { readFileSync, existsSync } from "fs";
38
+ import { homedir } from "os";
39
+ import { join as join2 } from "path";
40
+ function rigConfigPath() {
41
+ const xdg = process.env.XDG_CONFIG_HOME;
42
+ const base = xdg && xdg.trim() !== "" ? xdg : join2(homedir(), ".config");
43
+ return join2(base, "rig", "config.json");
44
+ }
45
+ var HubTokenMissingError = class extends Error {
46
+ constructor() {
47
+ super(
48
+ "no hub token \u2014 run `rig login` first, or set RIG_HUB_TOKEN. tapd needs an authenticated session to create/join bindings."
49
+ );
50
+ this.name = "HubTokenMissingError";
51
+ }
52
+ };
53
+ function readHubToken(opts = {}) {
54
+ const fromEnv = process.env.RIG_HUB_TOKEN?.trim();
55
+ if (fromEnv) return fromEnv;
56
+ const path = opts.configPath ?? rigConfigPath();
57
+ if (!existsSync(path)) throw new HubTokenMissingError();
58
+ let parsed;
59
+ try {
60
+ parsed = JSON.parse(readFileSync(path, "utf8"));
61
+ } catch {
62
+ throw new HubTokenMissingError();
63
+ }
64
+ if (parsed && typeof parsed === "object" && "hub_token" in parsed) {
65
+ const t = parsed.hub_token;
66
+ if (typeof t === "string" && t.trim() !== "") return t.trim();
67
+ }
68
+ throw new HubTokenMissingError();
69
+ }
70
+
71
+ // src/members.ts
72
+ function clientFor(rootDir, fetchImpl) {
73
+ const cfg = readBindingConfig(rootDir);
74
+ if (!cfg) throw new NotInitializedError(rootDir);
75
+ return new RelayClient({
76
+ baseUrl: cfg.relayUrl,
77
+ bindingId: cfg.bindingId,
78
+ token: cfg.token,
79
+ fetch: fetchImpl
80
+ });
81
+ }
82
+ async function list2(opts) {
83
+ const res = await clientFor(opts.rootDir, opts.fetch).listMembers();
84
+ return res.members;
85
+ }
86
+ async function remove(opts) {
87
+ return clientFor(opts.rootDir, opts.fetch).removeMember(opts.userId);
88
+ }
89
+ async function setRole(opts) {
90
+ return clientFor(opts.rootDir, opts.fetch).setMemberRole(opts.userId, opts.role);
91
+ }
92
+
27
93
  // src/pidfile.ts
28
94
  import {
29
- existsSync,
95
+ existsSync as existsSync2,
30
96
  mkdirSync,
31
- readFileSync,
97
+ readFileSync as readFileSync2,
32
98
  unlinkSync,
33
99
  writeFileSync
34
100
  } from "fs";
@@ -53,10 +119,10 @@ function isProcessAlive(pid) {
53
119
  }
54
120
  }
55
121
  function readLivePid(path) {
56
- if (!existsSync(path)) return null;
122
+ if (!existsSync2(path)) return null;
57
123
  let raw;
58
124
  try {
59
- raw = readFileSync(path, "utf8").trim();
125
+ raw = readFileSync2(path, "utf8").trim();
60
126
  } catch {
61
127
  return null;
62
128
  }
@@ -73,10 +139,10 @@ function acquire(path) {
73
139
  writeFileSync(path, String(process.pid), { mode: 384 });
74
140
  }
75
141
  function release(path) {
76
- if (!existsSync(path)) return;
142
+ if (!existsSync2(path)) return;
77
143
  let raw;
78
144
  try {
79
- raw = readFileSync(path, "utf8").trim();
145
+ raw = readFileSync2(path, "utf8").trim();
80
146
  } catch {
81
147
  return;
82
148
  }
@@ -88,12 +154,131 @@ function release(path) {
88
154
  }
89
155
  }
90
156
 
157
+ // src/history.ts
158
+ var PAGE_LIMIT = 500;
159
+ function clientFor2(rootDir, fetchImpl) {
160
+ const cfg = readBindingConfig(rootDir);
161
+ if (!cfg) throw new NotInitializedError(rootDir);
162
+ return new RelayClient({
163
+ baseUrl: cfg.relayUrl,
164
+ bindingId: cfg.bindingId,
165
+ token: cfg.token,
166
+ fetch: fetchImpl
167
+ });
168
+ }
169
+ async function history(opts) {
170
+ const client = clientFor2(opts.rootDir, opts.fetch);
171
+ const events = [];
172
+ let after = void 0;
173
+ while (true) {
174
+ const page = await client.listChanges({
175
+ after,
176
+ limit: PAGE_LIMIT,
177
+ path: opts.path,
178
+ ...opts.untilInclusive ? { untilInclusive: opts.untilInclusive } : {}
179
+ });
180
+ events.push(...page.events);
181
+ if (page.events.length < PAGE_LIMIT) break;
182
+ after = page.cursor;
183
+ }
184
+ return events.reverse();
185
+ }
186
+
187
+ // src/restore.ts
188
+ import { createHash } from "crypto";
189
+ import { existsSync as existsSync3, mkdirSync as mkdirSync2, renameSync, writeFileSync as writeFileSync2 } from "fs";
190
+ import { dirname as dirname2, join as join3, resolve } from "path";
191
+ var PathNeverExistedError = class extends Error {
192
+ constructor(path) {
193
+ super(`No events for path "${path}" \u2014 never written through this binding.`);
194
+ this.path = path;
195
+ this.name = "PathNeverExistedError";
196
+ }
197
+ path;
198
+ };
199
+ var PathCurrentlyDeletedError = class extends Error {
200
+ constructor(path, lastCursor) {
201
+ super(
202
+ `Path "${path}" is currently deleted (last event: ${lastCursor}). Pass --as-of <cursor> with a cursor before the delete to restore an earlier version.`
203
+ );
204
+ this.path = path;
205
+ this.lastCursor = lastCursor;
206
+ this.name = "PathCurrentlyDeletedError";
207
+ }
208
+ path;
209
+ lastCursor;
210
+ };
211
+ var HashMismatchError = class extends Error {
212
+ constructor(path, expected, actual) {
213
+ super(`Hash mismatch restoring "${path}": expected ${expected}, got ${actual}`);
214
+ this.path = path;
215
+ this.expected = expected;
216
+ this.actual = actual;
217
+ this.name = "HashMismatchError";
218
+ }
219
+ path;
220
+ expected;
221
+ actual;
222
+ };
223
+ function clientFor3(rootDir, fetchImpl) {
224
+ const cfg = readBindingConfig(rootDir);
225
+ if (!cfg) throw new NotInitializedError(rootDir);
226
+ return new RelayClient({
227
+ baseUrl: cfg.relayUrl,
228
+ bindingId: cfg.bindingId,
229
+ token: cfg.token,
230
+ fetch: fetchImpl
231
+ });
232
+ }
233
+ function sha256Hex(bytes) {
234
+ return createHash("sha256").update(bytes).digest("hex");
235
+ }
236
+ async function restore(opts) {
237
+ const events = await history({
238
+ rootDir: opts.rootDir,
239
+ path: opts.path,
240
+ ...opts.asOf ? { untilInclusive: opts.asOf } : {},
241
+ ...opts.fetch ? { fetch: opts.fetch } : {}
242
+ });
243
+ if (events.length === 0) {
244
+ throw new PathNeverExistedError(opts.path);
245
+ }
246
+ if (!opts.asOf && events[0].op === "delete") {
247
+ throw new PathCurrentlyDeletedError(opts.path, events[0].id);
248
+ }
249
+ const writeEvent = events.find((e) => e.op === "write" && e.hash != null);
250
+ if (!writeEvent || !writeEvent.hash) {
251
+ throw new PathNeverExistedError(opts.path);
252
+ }
253
+ const client = clientFor3(opts.rootDir, opts.fetch);
254
+ const dl = await client.downloadUrl(writeEvent.hash);
255
+ const bytes = await client.getObjectBytes(dl.downloadUrl);
256
+ const actualHash = `sha256:${sha256Hex(bytes)}`;
257
+ if (actualHash !== writeEvent.hash) {
258
+ throw new HashMismatchError(opts.path, writeEvent.hash, actualHash);
259
+ }
260
+ const targetAbs = resolve(opts.rootDir, opts.path);
261
+ const overwrote = existsSync3(targetAbs);
262
+ mkdirSync2(dirname2(targetAbs), { recursive: true });
263
+ const tmp = `${targetAbs}.tap-restore-tmp.${Date.now().toString(36)}`;
264
+ const mode = writeEvent.executable ? 493 : 420;
265
+ writeFileSync2(tmp, bytes, { mode });
266
+ renameSync(tmp, targetAbs);
267
+ return {
268
+ path: opts.path,
269
+ restoredFromCursor: writeEvent.id,
270
+ hash: writeEvent.hash,
271
+ size: bytes.length,
272
+ overwrote
273
+ };
274
+ }
275
+
91
276
  // src/status.ts
92
- import { existsSync as existsSync2 } from "fs";
93
- import { join as join2 } from "path";
277
+ import { existsSync as existsSync4 } from "fs";
278
+ import { join as join4 } from "path";
94
279
 
95
280
  // src/version.ts
96
- var TAPD_VERSION = "0.1.0";
281
+ var TAPD_VERSION = "0.4.0";
97
282
 
98
283
  // src/status.ts
99
284
  var STATE_KEY_LAST_APPLY_ERROR = "last_apply_error";
@@ -105,9 +290,9 @@ function statusToJson(report) {
105
290
  return {
106
291
  protocolVersion: STATUS_PROTOCOL_VERSION,
107
292
  version: TAPD_VERSION,
108
- bindingId: report.config.bindingId,
109
- relayUrl: report.config.relayUrl,
110
- deviceId: report.config.deviceId,
293
+ bindingId: report.config?.bindingId ?? null,
294
+ relayUrl: report.config?.relayUrl ?? null,
295
+ deviceId: report.config?.deviceId ?? null,
111
296
  daemon: report.daemon,
112
297
  cursor: report.cursor,
113
298
  remoteCursor: report.remoteCursor,
@@ -116,15 +301,67 @@ function statusToJson(report) {
116
301
  pendingUploads: report.pendingUploads,
117
302
  conflicts: report.conflicts,
118
303
  lastApplyError: report.lastApplyError,
119
- offline: report.offline
304
+ offline: report.offline,
305
+ mounts: report.mounts
120
306
  };
121
307
  }
308
+ function readMounts(rootDir) {
309
+ const loaded = loadImportMounts(rootDir);
310
+ const mounts = [];
311
+ const readyPaths = [];
312
+ for (const spec of loaded.ready) {
313
+ let cursor;
314
+ const dbPath = importStateDbPath(rootDir, spec.name);
315
+ if (existsSync4(dbPath)) {
316
+ const db = openStateDb(dbPath);
317
+ try {
318
+ cursor = db.getCursor();
319
+ } finally {
320
+ db.close();
321
+ }
322
+ }
323
+ mounts.push({
324
+ name: spec.name,
325
+ path: spec.path,
326
+ state: "ok",
327
+ bindingId: spec.bindingId,
328
+ ...cursor ? { cursor } : {}
329
+ });
330
+ readyPaths.push(spec.path);
331
+ }
332
+ for (const b of loaded.broken) {
333
+ mounts.push({ name: b.name, path: b.path, state: "misconfigured", reason: b.reason });
334
+ }
335
+ mounts.sort((a, b) => a.name.localeCompare(b.name));
336
+ return { mounts, readyPaths };
337
+ }
122
338
  async function status(opts) {
123
339
  const config = readBindingConfig(opts.rootDir);
340
+ const { mounts, readyPaths } = readMounts(opts.rootDir);
341
+ const pidfilePath = join4(opts.rootDir, DAEMON_PIDFILE_REL);
342
+ const livePid = readLivePid(pidfilePath);
343
+ const daemon = livePid !== null ? { running: true, pid: livePid } : { running: false };
124
344
  if (!config) {
125
- throw new NotInitializedError(opts.rootDir);
345
+ if (mounts.length === 0) {
346
+ throw new NotInitializedError(opts.rootDir);
347
+ }
348
+ return {
349
+ config: null,
350
+ cursor: "chg_0",
351
+ remoteCursor: "chg_0",
352
+ trackedPaths: 0,
353
+ pendingPeek: [],
354
+ pendingApplies: 0,
355
+ pendingUploads: 0,
356
+ conflicts: [],
357
+ conflictCount: 0,
358
+ lastApplyError: null,
359
+ daemon,
360
+ offline: false,
361
+ mounts
362
+ };
126
363
  }
127
- const db = openStateDb(join2(opts.rootDir, ".rig", "tap", "state.local.db"));
364
+ const db = openStateDb(join4(opts.rootDir, ".rig", "tap", "state.local.db"));
128
365
  try {
129
366
  const cursor = db.getCursor();
130
367
  const tracked = db.listPaths().length;
@@ -135,9 +372,6 @@ async function status(opts) {
135
372
  const lastErrJson = db.getMeta(STATE_KEY_LAST_APPLY_ERROR);
136
373
  const lastErrAt = db.getMeta(STATE_KEY_LAST_APPLY_ERROR_AT);
137
374
  const lastApplyError = lastErrJson ? safeParseLastError(lastErrJson, lastErrAt) : null;
138
- const pidfilePath = join2(opts.rootDir, DAEMON_PIDFILE_REL);
139
- const livePid = readLivePid(pidfilePath);
140
- const daemon = livePid !== null ? { running: true, pid: livePid } : { running: false };
141
375
  const client = new RelayClient({
142
376
  baseUrl: config.relayUrl,
143
377
  bindingId: config.bindingId,
@@ -160,9 +394,14 @@ async function status(opts) {
160
394
  const pendingApplies = remoteEvents.length;
161
395
  const remoteCursor = remoteEvents.length > 0 ? remoteEvents[remoteEvents.length - 1].id : cursor;
162
396
  let pendingUploads = 0;
163
- if (existsSync2(opts.rootDir)) {
397
+ if (existsSync4(opts.rootDir)) {
164
398
  const ignore = loadIgnore(opts.rootDir);
165
- const diff = await computeLocalDiff({ rootDir: opts.rootDir, stateDb: db, ignore });
399
+ const diff = await computeLocalDiff({
400
+ rootDir: opts.rootDir,
401
+ stateDb: db,
402
+ ignore,
403
+ excludePaths: readyPaths
404
+ });
166
405
  pendingUploads = diff.writes.length + diff.deletes.length + diff.mkdirs.length + diff.rmdirs.length;
167
406
  }
168
407
  const conflicts = findConflictSidecars(opts.rootDir);
@@ -178,7 +417,8 @@ async function status(opts) {
178
417
  conflictCount,
179
418
  lastApplyError,
180
419
  daemon,
181
- offline
420
+ offline,
421
+ mounts
182
422
  };
183
423
  } finally {
184
424
  db.close();
@@ -204,13 +444,13 @@ function safeParseLastError(json, at) {
204
444
 
205
445
  // src/uninit.ts
206
446
  import {
207
- existsSync as existsSync3,
447
+ existsSync as existsSync5,
208
448
  rmSync,
209
449
  rmdirSync,
210
450
  statSync,
211
451
  unlinkSync as unlinkSync2
212
452
  } from "fs";
213
- import { join as join3 } from "path";
453
+ import { join as join5 } from "path";
214
454
  var DAEMON_PIDFILE_REL2 = ".rig/tap/daemon.pid";
215
455
  var STATE_DIR_REL = ".rig/tap";
216
456
  var DaemonStillRunningError = class extends Error {
@@ -226,14 +466,14 @@ function uninit(opts) {
226
466
  if (!config) {
227
467
  throw new NotInitializedError(opts.rootDir);
228
468
  }
229
- const pidfilePath = join3(opts.rootDir, DAEMON_PIDFILE_REL2);
469
+ const pidfilePath = join5(opts.rootDir, DAEMON_PIDFILE_REL2);
230
470
  const livePid = readLivePid(pidfilePath);
231
471
  if (livePid !== null) {
232
472
  throw new DaemonStillRunningError(livePid);
233
473
  }
234
- const stateDbPath = join3(opts.rootDir, ".rig", "tap", "state.local.db");
474
+ const stateDbPath = join5(opts.rootDir, ".rig", "tap", "state.local.db");
235
475
  const tracked = [];
236
- if (existsSync3(stateDbPath)) {
476
+ if (existsSync5(stateDbPath)) {
237
477
  const db = openStateDb(stateDbPath);
238
478
  try {
239
479
  for (const p of db.listPaths()) {
@@ -253,7 +493,7 @@ function uninit(opts) {
253
493
  const files = tracked.filter((t) => !t.isDir);
254
494
  const dirs = tracked.filter((t) => t.isDir).sort((a, b) => b.path.split("/").length - a.path.split("/").length);
255
495
  for (const f of files) {
256
- const abs = join3(opts.rootDir, f.path);
496
+ const abs = join5(opts.rootDir, f.path);
257
497
  try {
258
498
  const st = statSync(abs);
259
499
  if (st.isFile()) {
@@ -271,7 +511,7 @@ function uninit(opts) {
271
511
  }
272
512
  }
273
513
  for (const d of dirs) {
274
- const abs = join3(opts.rootDir, d.path);
514
+ const abs = join5(opts.rootDir, d.path);
275
515
  try {
276
516
  rmdirSync(abs);
277
517
  dirsRemoved += 1;
@@ -288,15 +528,15 @@ function uninit(opts) {
288
528
  }
289
529
  }
290
530
  const cfgPath = bindingConfigFile(opts.rootDir);
291
- if (existsSync3(cfgPath)) {
531
+ if (existsSync5(cfgPath)) {
292
532
  try {
293
533
  unlinkSync2(cfgPath);
294
534
  } catch (err) {
295
535
  if (err.code !== "ENOENT") throw err;
296
536
  }
297
537
  }
298
- const stateDir = join3(opts.rootDir, STATE_DIR_REL);
299
- if (existsSync3(stateDir)) {
538
+ const stateDir = join5(opts.rootDir, STATE_DIR_REL);
539
+ if (existsSync5(stateDir)) {
300
540
  rmSync(stateDir, { recursive: true, force: true });
301
541
  }
302
542
  return {
@@ -325,32 +565,43 @@ function help() {
325
565
  ${C.bold().cyan("COMMANDS")}
326
566
  ${C.green("tapd init")} bootstrap a binding in the current directory
327
567
  ${C.green("tapd invite")} mint / list / revoke invite URLs (owner only)
568
+ ${C.green("tapd members")} list / remove / set-role for binding members (owner only for write ops)
328
569
  ${C.green("tapd join")} accept an invite URL into the current directory
570
+ ${C.green("tapd import")} mount another binding read-only into a subdir (meta-rig)
329
571
  ${C.green("tapd start")} run the watch + apply daemon (foreground)
330
572
  ${C.green("tapd status")} show binding + cursor + pending remote events
573
+ ${C.green("tapd history")} list ChangeEvents for a single path (recovery)
574
+ ${C.green("tapd restore")} bring a file back from the relay's event log
331
575
  ${C.green("tapd uninit")} disconnect this checkout (optionally --purge tracked files)
332
576
  ${C.green("tapd help")} this message
333
577
 
578
+ ${C.dim("Identity:")} tapd doesn't manage its own credentials \u2014 init/join
579
+ read a Clerk JWT from RIG_HUB_TOKEN (set by rig when shelling out) or
580
+ from ~/.config/rig/config.json (set by ${C.green("rig login")}).
581
+
334
582
  ${C.dim("--version / -v")} print the tapd binary version on stdout and exit.
335
583
  ${C.dim("--json")} on init / invite / join / status / uninit emits a versioned
336
584
  JSON payload to stdout (protocolVersion: 1) for programmatic consumers (rig).
337
585
 
338
586
  ${C.bold().cyan("tapd init OPTIONS")}
339
587
  ${C.dim("--name <n>")} binding display name (required)
340
- ${C.dim("--owner-user-id <u>")} placeholder user id (Phase 3 sources from Clerk)
341
- ${C.dim("--relay <url>")} default http://127.0.0.1:4030
588
+ ${C.dim("--relay <url>")} override (defaults to the logged-in relay)
342
589
  ${C.dim("--device-label <l>")} default 'owner'
343
590
  ${C.dim("--dir <path>")} default cwd
344
591
 
345
592
  ${C.bold().cyan("tapd join OPTIONS")}
346
593
  ${C.dim("<invite-url>")} positional \u2014 full URL from \`rig invite\` /
347
- relay (.../v1/invites/<secret>/accept)
348
- ${C.dim("--user-id <u>")} Phase 3 placeholder until Clerk
349
- ${C.dim("--email <e>")} optional; required if the invite has
350
- an emailConstraint
594
+ relay or dashboard
595
+ ${C.dim("--anonymous")} accept a pure-capability invite without an account
351
596
  ${C.dim("--device-label <l>")} default 'device'
352
597
  ${C.dim("--dir <path>")} default cwd
353
598
 
599
+ ${C.bold().cyan("tapd members SUBCOMMANDS")}
600
+ ${C.green("tapd members list")} list members (any active member can read)
601
+ ${C.green("tapd members remove <userId>")} remove + cascade-revoke their tokens (owner)
602
+ ${C.green("tapd members set-role <userId> <role>")} change role: owner | editor | viewer (owner)
603
+ ${C.dim("--dir <path>")} default cwd
604
+
354
605
  ${C.bold().cyan("tapd invite SUBCOMMANDS")}
355
606
  ${C.green("tapd invite create")} mint a new invite + print the URL once
356
607
  ${C.dim("--ops <read,write,subscribe>")} default read,write,subscribe
@@ -409,10 +660,106 @@ function collectPathGlobs(flags2) {
409
660
  const parts = raw.split(",").map((s) => s.trim()).filter(Boolean);
410
661
  return parts.length > 0 ? parts : void 0;
411
662
  }
663
+ async function runMembers(positional2, flags2) {
664
+ const json = flags2.get("json") === true;
665
+ const sub = positional2[0];
666
+ const rootDir = resolve2(flags2.get("dir") ?? process.cwd());
667
+ const VALID_ROLES2 = /* @__PURE__ */ new Set(["owner", "editor", "viewer"]);
668
+ try {
669
+ if (sub === void 0 || sub === "list") {
670
+ const members = await list2({ rootDir });
671
+ if (json) {
672
+ printJson({ protocolVersion: PROTOCOL_VERSION, members });
673
+ return;
674
+ }
675
+ const C = kleur;
676
+ if (members.length === 0) {
677
+ console.log(C.dim("no members"));
678
+ return;
679
+ }
680
+ console.log(C.bold().cyan("tapd members"));
681
+ for (const m of members) {
682
+ const roleColor = m.role === "owner" ? C.yellow : m.role === "editor" ? C.green : C.dim;
683
+ const emailSuffix = m.email ? ` ${C.dim(m.email)}` : "";
684
+ console.log(
685
+ ` ${C.bold(m.userId)} ${roleColor(m.role.padEnd(7))}${emailSuffix} ${C.dim(`joined ${m.joinedAt}`)}`
686
+ );
687
+ }
688
+ return;
689
+ }
690
+ if (sub === "remove") {
691
+ const userId = positional2[1];
692
+ if (!userId) {
693
+ if (json) printJsonError("missing_argument", "usage: tapd members remove <userId>");
694
+ else console.error(kleur.red("error:"), "usage: tapd members remove <userId>");
695
+ process.exit(2);
696
+ }
697
+ const result = await remove({ rootDir, userId });
698
+ if (json) {
699
+ printJson({
700
+ protocolVersion: PROTOCOL_VERSION,
701
+ userId,
702
+ removed: result.removed,
703
+ tokensRevoked: result.tokensRevoked
704
+ });
705
+ return;
706
+ }
707
+ const C = kleur;
708
+ console.log(
709
+ `${C.green("\u2713")} removed ${C.bold(userId)} ${C.dim(`(${result.tokensRevoked} token${result.tokensRevoked === 1 ? "" : "s"} revoked)`)}`
710
+ );
711
+ return;
712
+ }
713
+ if (sub === "set-role") {
714
+ const userId = positional2[1];
715
+ const role = positional2[2];
716
+ if (!userId || !role) {
717
+ if (json) printJsonError("missing_argument", "usage: tapd members set-role <userId> <owner|editor|viewer>");
718
+ else console.error(kleur.red("error:"), "usage: tapd members set-role <userId> <owner|editor|viewer>");
719
+ process.exit(2);
720
+ }
721
+ if (!VALID_ROLES2.has(role)) {
722
+ if (json) printJsonError("invalid_role", `invalid role '${role}' (allowed: owner|editor|viewer)`);
723
+ else console.error(kleur.red("error:"), `invalid role '${role}' (allowed: owner|editor|viewer)`);
724
+ process.exit(2);
725
+ }
726
+ const result = await setRole({
727
+ rootDir,
728
+ userId,
729
+ role
730
+ });
731
+ if (json) {
732
+ printJson({
733
+ protocolVersion: PROTOCOL_VERSION,
734
+ userId,
735
+ role: result.role,
736
+ changed: result.changed
737
+ });
738
+ return;
739
+ }
740
+ const C = kleur;
741
+ console.log(`${C.green("\u2713")} ${C.bold(userId)} \u2192 ${C.bold(role)}`);
742
+ return;
743
+ }
744
+ if (json) printJsonError("unknown_subcommand", `unknown subcommand: tapd members ${sub}`);
745
+ else {
746
+ console.error(kleur.red("error:"), `unknown subcommand: tapd members ${sub}`);
747
+ console.error(" see `tapd help`");
748
+ }
749
+ process.exit(2);
750
+ } catch (err) {
751
+ if (err instanceof NotInitializedError) {
752
+ if (json) printJsonError("not_initialized", err.message);
753
+ else console.error(kleur.red("error:"), err.message);
754
+ process.exit(1);
755
+ }
756
+ throw err;
757
+ }
758
+ }
412
759
  async function runInvite(positional2, flags2) {
413
760
  const json = flags2.get("json") === true;
414
761
  const sub = positional2[0];
415
- const rootDir = resolve(flags2.get("dir") ?? process.cwd());
762
+ const rootDir = resolve2(flags2.get("dir") ?? process.cwd());
416
763
  try {
417
764
  if (sub === void 0 || sub === "create") {
418
765
  const ops = parseOps(flags2.get("ops"));
@@ -543,22 +890,32 @@ async function runJoin(positional2, flags2) {
543
890
  if (json) printJsonError("missing_argument", "missing invite URL");
544
891
  else {
545
892
  console.error(kleur.red("error:"), "missing invite URL");
546
- console.error(" usage: tapd join <invite-url> --user-id <u>");
893
+ console.error(" usage: tapd join <invite-url>");
547
894
  }
548
895
  process.exit(2);
549
896
  }
550
- const userId = flags2.get("user-id");
551
- if (!userId) {
552
- if (json) printJsonError("missing_flag", "--user-id is required (placeholder until Clerk)");
553
- else console.error(kleur.red("error:"), "--user-id is required (Phase 3 placeholder until Clerk)");
554
- process.exit(2);
897
+ const anonymous = flags2.get("anonymous") === true;
898
+ let accessToken = null;
899
+ if (!anonymous) {
900
+ try {
901
+ accessToken = readHubToken();
902
+ } catch (err) {
903
+ if (err instanceof HubTokenMissingError) {
904
+ if (json) printJsonError("not_logged_in", err.message);
905
+ else {
906
+ console.error(kleur.red("error:"), err.message);
907
+ console.error(kleur.dim(" pass --anonymous for a pure-capability invite, or run `rig login`."));
908
+ }
909
+ process.exit(1);
910
+ }
911
+ throw err;
912
+ }
555
913
  }
556
- const rootDir = resolve(flags2.get("dir") ?? process.cwd());
557
- const email = flags2.get("email");
914
+ const rootDir = resolve2(flags2.get("dir") ?? process.cwd());
558
915
  const deviceLabel = flags2.get("device-label");
559
916
  let result;
560
917
  try {
561
- result = await join({ rootDir, inviteUrl, userId, email, deviceLabel });
918
+ result = await join({ rootDir, inviteUrl, accessToken, deviceLabel });
562
919
  } catch (err) {
563
920
  if (err instanceof AlreadyJoinedError) {
564
921
  if (json) printJsonError("already_joined", err.message);
@@ -597,9 +954,132 @@ async function runJoin(positional2, flags2) {
597
954
  console.log("");
598
955
  console.log(C.dim(" Next: `tapd start` (initial apply pulls the remote manifest)."));
599
956
  }
957
+ async function runImport(positional2, flags2) {
958
+ const json = flags2.get("json") === true;
959
+ const rootDir = resolve2(flags2.get("dir") ?? process.cwd());
960
+ const sub = positional2[0];
961
+ if (sub === "remove") {
962
+ const name = positional2[1];
963
+ if (!name) {
964
+ if (json) printJsonError("missing_argument", "usage: tapd import remove <name>");
965
+ else console.error(kleur.red("error:"), "usage: tapd import remove <name>");
966
+ process.exit(2);
967
+ }
968
+ let accessToken2 = null;
969
+ try {
970
+ accessToken2 = readHubToken();
971
+ } catch {
972
+ accessToken2 = null;
973
+ }
974
+ const result2 = await removeImport({
975
+ rootDir,
976
+ name,
977
+ purge: flags2.get("purge") === true,
978
+ accessToken: accessToken2
979
+ });
980
+ if (json) {
981
+ printJson({ protocolVersion: PROTOCOL_VERSION, ...result2, name });
982
+ return;
983
+ }
984
+ const C2 = kleur;
985
+ if (!result2.removed) {
986
+ console.log(C2.dim(`no import named "${name}"`));
987
+ return;
988
+ }
989
+ console.log(`${C2.green("\u2713")} removed import ${C2.bold(name)}${result2.purged ? C2.dim(" (purged files)") : ""}`);
990
+ switch (result2.revoke) {
991
+ case "revoked":
992
+ console.log(C2.dim(" grant relinquished on the relay."));
993
+ break;
994
+ case "not-creator":
995
+ console.log(
996
+ C2.yellow(" note:") + " the grant belongs to another user and remains active \u2014 only its owner can revoke it."
997
+ );
998
+ break;
999
+ case "no-token":
1000
+ console.log(C2.yellow(" note:") + " not signed in \u2014 the grant was not revoked. Run `rig login`, then re-remove, to relinquish it.");
1001
+ break;
1002
+ case "offline":
1003
+ console.log(C2.yellow(" note:") + " relay unreachable \u2014 local mount removed, grant not yet revoked.");
1004
+ break;
1005
+ }
1006
+ console.log(C2.dim(" restart the daemon to apply: `tapd start`"));
1007
+ return;
1008
+ }
1009
+ const inviteUrl = sub;
1010
+ const mountPath = flags2.get("path");
1011
+ if (!inviteUrl || !mountPath) {
1012
+ const msg = "usage: tapd import <invite-url> --path <subdir> [--name <name>]";
1013
+ if (json) printJsonError("missing_argument", msg);
1014
+ else console.error(kleur.red("error:"), msg);
1015
+ process.exit(2);
1016
+ }
1017
+ const anonymous = flags2.get("anonymous") === true;
1018
+ let accessToken = null;
1019
+ if (!anonymous) {
1020
+ try {
1021
+ accessToken = readHubToken();
1022
+ } catch (err) {
1023
+ if (err instanceof HubTokenMissingError) {
1024
+ if (json) printJsonError("not_logged_in", err.message);
1025
+ else {
1026
+ console.error(kleur.red("error:"), err.message);
1027
+ console.error(kleur.dim(" pass --anonymous for a pure-capability invite, or run `rig login`."));
1028
+ }
1029
+ process.exit(1);
1030
+ }
1031
+ throw err;
1032
+ }
1033
+ }
1034
+ let result;
1035
+ try {
1036
+ result = await addImport({
1037
+ rootDir,
1038
+ inviteUrl,
1039
+ path: mountPath,
1040
+ name: flags2.get("name"),
1041
+ accessToken,
1042
+ deviceLabel: flags2.get("device-label")
1043
+ });
1044
+ } catch (err) {
1045
+ if (err instanceof ImportValidationFailed) {
1046
+ if (json) printJsonError("import_invalid", err.message);
1047
+ else console.error(kleur.red("error:"), err.message);
1048
+ process.exit(2);
1049
+ }
1050
+ if (err instanceof ImportTokenScopeError) {
1051
+ if (json) printJsonError("insufficient_ops", err.message);
1052
+ else console.error(kleur.red("error:"), err.message);
1053
+ process.exit(1);
1054
+ }
1055
+ if (err instanceof InvalidInviteUrlError) {
1056
+ if (json) printJsonError("invalid_invite_url", err.message);
1057
+ else console.error(kleur.red("error:"), err.message);
1058
+ process.exit(2);
1059
+ }
1060
+ throw err;
1061
+ }
1062
+ if (json) {
1063
+ printJson({ protocolVersion: PROTOCOL_VERSION, ...result });
1064
+ return;
1065
+ }
1066
+ const C = kleur;
1067
+ console.log(C.bold().cyan("tapd import"));
1068
+ console.log(` name: ${C.bold(result.name)}`);
1069
+ console.log(` mount: ${result.path} ${C.dim("(read-only mirror)")}`);
1070
+ console.log(` binding: ${result.bindingId}`);
1071
+ console.log(` ops: ${result.ops.join(", ")}`);
1072
+ console.log(C.dim(` Token stored in ${rootDir}/.rig/tap/imports/${result.name}.local.json (mode 0600).`));
1073
+ if (result.importerBindingVerified === false) {
1074
+ console.log(
1075
+ C.yellow(" note:") + " recorded as a personal import (you \u2192 source); the rig-level edge needs membership in this meta-rig."
1076
+ );
1077
+ }
1078
+ console.log(C.dim(" Next: (re)start the daemon to begin mirroring \u2014 `tapd start`."));
1079
+ }
600
1080
  async function runStatus(flags2) {
601
1081
  const json = flags2.get("json") === true;
602
- const rootDir = resolve(flags2.get("dir") ?? process.cwd());
1082
+ const rootDir = resolve2(flags2.get("dir") ?? process.cwd());
603
1083
  let report;
604
1084
  try {
605
1085
  report = await status({ rootDir });
@@ -619,37 +1099,51 @@ async function runStatus(flags2) {
619
1099
  }
620
1100
  const C = kleur;
621
1101
  console.log(C.bold().cyan("tapd status"));
622
- console.log(` binding: ${C.bold(report.config.bindingId)}`);
623
- console.log(` relay: ${report.config.relayUrl}`);
624
- console.log(` device: ${C.bold(report.config.deviceId)}`);
625
- console.log(` cursor: ${report.cursor}`);
626
- console.log(` tracked: ${report.trackedPaths} paths`);
627
- if (report.pendingPeek.length === 0) {
628
- console.log(` pending: ${C.green("up to date")}`);
629
- } else {
630
- console.log(` pending: ${C.yellow(`${report.pendingPeek.length}+ events behind`)}`);
631
- for (const ev of report.pendingPeek) {
632
- console.log(C.dim(` ${ev.op.padEnd(6)} ${ev.path} (${ev.id})`));
1102
+ if (report.config) {
1103
+ console.log(` binding: ${C.bold(report.config.bindingId)}`);
1104
+ console.log(` relay: ${report.config.relayUrl}`);
1105
+ console.log(` device: ${C.bold(report.config.deviceId)}`);
1106
+ console.log(` cursor: ${report.cursor}`);
1107
+ console.log(` tracked: ${report.trackedPaths} paths`);
1108
+ if (report.pendingPeek.length === 0) {
1109
+ console.log(` pending: ${C.green("up to date")}`);
1110
+ } else {
1111
+ console.log(` pending: ${C.yellow(`${report.pendingPeek.length}+ events behind`)}`);
1112
+ for (const ev of report.pendingPeek) {
1113
+ console.log(C.dim(` ${ev.op.padEnd(6)} ${ev.path} (${ev.id})`));
1114
+ }
1115
+ }
1116
+ if (report.conflictCount > 0) {
1117
+ console.log(
1118
+ ` conflicts: ${C.yellow(String(report.conflictCount))} ${C.dim("(sidecars at *.conflict-from.*)")}`
1119
+ );
1120
+ } else {
1121
+ console.log(` conflicts: ${C.dim("none")}`);
1122
+ }
1123
+ if (report.lastApplyError) {
1124
+ const e = report.lastApplyError;
1125
+ console.log(
1126
+ ` last err: ${C.red(e.reason)} ${C.dim(`(${e.op} ${e.path} @ ${e.eventId}${e.at ? `, ${e.at}` : ""})`)}`
1127
+ );
633
1128
  }
634
- }
635
- if (report.conflictCount > 0) {
636
- console.log(
637
- ` conflicts: ${C.yellow(String(report.conflictCount))} ${C.dim("(sidecars at *.conflict-from.*)")}`
638
- );
639
1129
  } else {
640
- console.log(` conflicts: ${C.dim("none")}`);
1130
+ console.log(C.dim(" (import-only meta-rig \u2014 no primary binding)"));
641
1131
  }
642
- if (report.lastApplyError) {
643
- const e = report.lastApplyError;
644
- console.log(
645
- ` last err: ${C.red(e.reason)} ${C.dim(`(${e.op} ${e.path} @ ${e.eventId}${e.at ? `, ${e.at}` : ""})`)}`
646
- );
1132
+ if (report.mounts.length > 0) {
1133
+ console.log(` imports:`);
1134
+ for (const m of report.mounts) {
1135
+ if (m.state === "ok") {
1136
+ console.log(` ${C.green("\u25CF")} ${C.bold(m.name)} \u2192 ${m.path}${m.cursor ? C.dim(` (${m.cursor})`) : ""}`);
1137
+ } else {
1138
+ console.log(` ${C.yellow("\u25CB")} ${C.bold(m.name)} ${C.yellow("misconfigured")} ${C.dim(`(${m.reason ?? "unknown"})`)}`);
1139
+ }
1140
+ }
647
1141
  }
648
1142
  }
649
1143
  async function runUninit(flags2) {
650
1144
  const json = flags2.get("json") === true;
651
1145
  const purge = flags2.get("purge") === true;
652
- const rootDir = resolve(flags2.get("dir") ?? process.cwd());
1146
+ const rootDir = resolve2(flags2.get("dir") ?? process.cwd());
653
1147
  let result;
654
1148
  try {
655
1149
  result = uninit({ rootDir, purge });
@@ -706,7 +1200,7 @@ async function runUninit(flags2) {
706
1200
  }
707
1201
  }
708
1202
  async function runStart(flags2) {
709
- const rootDir = resolve(flags2.get("dir") ?? process.cwd());
1203
+ const rootDir = resolve2(flags2.get("dir") ?? process.cwd());
710
1204
  const pollSeconds = flags2.has("poll-seconds") ? Number(flags2.get("poll-seconds")) : void 0;
711
1205
  const pidfilePath = flags2.get("no-pidfile") === true ? null : pathJoin(rootDir, DAEMON_PIDFILE_REL3);
712
1206
  let handle = null;
@@ -750,26 +1244,136 @@ async function runStart(flags2) {
750
1244
  if (pidfilePath) console.log(` pid: ${process.pid} ${kleur.dim(`(${pidfilePath})`)}`);
751
1245
  console.log(kleur.dim(" press ctrl+c to stop"));
752
1246
  }
1247
+ async function runHistory(flags2) {
1248
+ const json = flags2.get("json") === true;
1249
+ const rootDir = resolve2(flags2.get("dir") ?? process.cwd());
1250
+ const path = flags2.get("path");
1251
+ const asOf = flags2.get("as-of");
1252
+ if (!path) {
1253
+ if (json) printJsonError("missing_flag", "--path is required");
1254
+ else console.error(kleur.red("error:"), "--path is required (e.g. theses/foo.md)");
1255
+ process.exit(2);
1256
+ }
1257
+ let events;
1258
+ try {
1259
+ events = await history({
1260
+ rootDir,
1261
+ path,
1262
+ ...asOf ? { untilInclusive: asOf } : {}
1263
+ });
1264
+ } catch (err) {
1265
+ if (err instanceof NotInitializedError) {
1266
+ if (json) printJsonError("not_initialized", err.message);
1267
+ else console.error(kleur.red("error:"), err.message);
1268
+ process.exit(1);
1269
+ }
1270
+ if (json) printJsonError("history_failed", err.message);
1271
+ else console.error(kleur.red("error:"), err.message);
1272
+ process.exit(1);
1273
+ }
1274
+ if (json) {
1275
+ printJson({
1276
+ protocolVersion: PROTOCOL_VERSION,
1277
+ path,
1278
+ events
1279
+ });
1280
+ return;
1281
+ }
1282
+ const C = kleur;
1283
+ console.log(C.bold().cyan("tapd history") + " " + C.dim(path));
1284
+ if (events.length === 0) {
1285
+ console.log(C.dim(" (no events \u2014 never written through this binding)"));
1286
+ return;
1287
+ }
1288
+ for (const ev of events) {
1289
+ const dot = ev.op === "delete" ? C.red("\u2022") : ev.op === "write" ? C.green("\u2022") : C.dim("\u2022");
1290
+ const sizeStr = ev.size != null ? `${ev.size}B` : "";
1291
+ const actor = ev.actorUserId ? C.dim(`by ${ev.actorUserId}`) : "";
1292
+ console.log(` ${dot} ${C.bold(ev.id.padEnd(10))} ${ev.op.padEnd(6)} ${C.dim(ev.createdAt)} ${sizeStr.padEnd(8)} ${actor}`);
1293
+ }
1294
+ }
1295
+ async function runRestore(flags2) {
1296
+ const json = flags2.get("json") === true;
1297
+ const rootDir = resolve2(flags2.get("dir") ?? process.cwd());
1298
+ const path = flags2.get("path");
1299
+ const asOf = flags2.get("as-of");
1300
+ if (!path) {
1301
+ if (json) printJsonError("missing_flag", "--path is required");
1302
+ else console.error(kleur.red("error:"), "--path is required");
1303
+ process.exit(2);
1304
+ }
1305
+ let result;
1306
+ try {
1307
+ result = await restore({
1308
+ rootDir,
1309
+ path,
1310
+ ...asOf ? { asOf } : {}
1311
+ });
1312
+ } catch (err) {
1313
+ if (err instanceof NotInitializedError) {
1314
+ if (json) printJsonError("not_initialized", err.message);
1315
+ else console.error(kleur.red("error:"), err.message);
1316
+ process.exit(1);
1317
+ }
1318
+ if (err instanceof PathNeverExistedError) {
1319
+ if (json) printJsonError("path_never_existed", err.message);
1320
+ else console.error(kleur.red("error:"), err.message);
1321
+ process.exit(1);
1322
+ }
1323
+ if (err instanceof PathCurrentlyDeletedError) {
1324
+ if (json) printJsonError("path_currently_deleted", err.message);
1325
+ else console.error(kleur.red("error:"), err.message);
1326
+ process.exit(1);
1327
+ }
1328
+ if (err instanceof HashMismatchError) {
1329
+ if (json) printJsonError("hash_mismatch", err.message);
1330
+ else console.error(kleur.red("error:"), err.message);
1331
+ process.exit(1);
1332
+ }
1333
+ if (json) printJsonError("restore_failed", err.message);
1334
+ else console.error(kleur.red("error:"), err.message);
1335
+ process.exit(1);
1336
+ }
1337
+ if (json) {
1338
+ printJson({
1339
+ protocolVersion: PROTOCOL_VERSION,
1340
+ ...result
1341
+ });
1342
+ return;
1343
+ }
1344
+ const C = kleur;
1345
+ console.log(
1346
+ C.green("\u2713 ") + `restored ${C.bold(path)} from ${C.bold(result.restoredFromCursor)} ` + C.dim(`(${result.size}B, hash ${result.hash.slice(0, 16)}\u2026)`)
1347
+ );
1348
+ if (result.overwrote) {
1349
+ console.log(C.dim(" (overwrote local file; daemon will resync to other devices)"));
1350
+ }
1351
+ }
753
1352
  async function runInit(flags2) {
754
1353
  const json = flags2.get("json") === true;
755
1354
  const name = flags2.get("name");
756
- const ownerUserId = flags2.get("owner-user-id");
757
1355
  if (!name) {
758
1356
  if (json) printJsonError("missing_flag", "--name is required");
759
1357
  else console.error(kleur.red("error:"), "--name is required");
760
1358
  process.exit(2);
761
1359
  }
762
- if (!ownerUserId) {
763
- if (json) printJsonError("missing_flag", "--owner-user-id is required (placeholder until Clerk)");
764
- else console.error(kleur.red("error:"), "--owner-user-id is required (placeholder until Phase 3 / Clerk)");
765
- process.exit(2);
1360
+ let accessToken;
1361
+ try {
1362
+ accessToken = readHubToken();
1363
+ } catch (err) {
1364
+ if (err instanceof HubTokenMissingError) {
1365
+ if (json) printJsonError("not_logged_in", err.message);
1366
+ else console.error(kleur.red("error:"), err.message);
1367
+ process.exit(1);
1368
+ }
1369
+ throw err;
766
1370
  }
767
- const rootDir = resolve(flags2.get("dir") ?? process.cwd());
768
- const relayUrl = flags2.get("relay") ?? "http://127.0.0.1:4030";
1371
+ const relayUrl = flags2.get("relay") ?? process.env.RIG_RELAY_URL ?? DEFAULT_RELAY_URL;
1372
+ const rootDir = resolve2(flags2.get("dir") ?? process.cwd());
769
1373
  const deviceLabel = flags2.get("device-label");
770
1374
  let result;
771
1375
  try {
772
- result = await init({ rootDir, relayUrl, ownerUserId, bindingName: name, deviceLabel });
1376
+ result = await init({ rootDir, relayUrl, accessToken, bindingName: name, deviceLabel });
773
1377
  } catch (err) {
774
1378
  if (err instanceof AlreadyInitializedError) {
775
1379
  if (json) printJsonError("already_initialized", err.message);
@@ -858,15 +1462,27 @@ if (flags.get("version") === true || command === "-v") {
858
1462
  case "invite":
859
1463
  await runInvite(positional, flags);
860
1464
  return;
1465
+ case "members":
1466
+ await runMembers(positional, flags);
1467
+ return;
861
1468
  case "join":
862
1469
  await runJoin(positional, flags);
863
1470
  return;
1471
+ case "import":
1472
+ await runImport(positional, flags);
1473
+ return;
864
1474
  case "start":
865
1475
  await runStart(flags);
866
1476
  return;
867
1477
  case "status":
868
1478
  await runStatus(flags);
869
1479
  return;
1480
+ case "history":
1481
+ await runHistory(flags);
1482
+ return;
1483
+ case "restore":
1484
+ await runRestore(flags);
1485
+ return;
870
1486
  case "uninit":
871
1487
  await runUninit(flags);
872
1488
  return;