sfora-cli 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,762 @@
1
+ /**
2
+ * A {@link IFileSystem} backed by sfora's `/v1/fs` HTTP API. Posts appear as a
3
+ * Unix tree:
4
+ *
5
+ * /
6
+ * ├── projects/<slug>/posts/<YYYY-MM-DD-title>.md (lazy → GET …/posts/…)
7
+ * │ /drafts/<…>.md (lazy → GET …/drafts/…)
8
+ * ├── inbox/mentions.md (lazy → GET …/inbox)
9
+ * └── me/api-key (lazy → GET …/me/api-key)
10
+ *
11
+ * Reads are lazy: directory listings hit the API (cached ~5s so back-to-back
12
+ * `ls && cat` doesn't double-fetch), and file bodies are only fetched when read.
13
+ * Writes `PUT` markdown; `rm` of a post file soft-deletes it. Everything else
14
+ * (symlinks, cross-file cp/mv, mkdir of new projects) is denied to match the
15
+ * read/append/create surface the backend actually supports.
16
+ *
17
+ * Error messages follow the `ERRNO: text, op 'path'` convention just-bash maps
18
+ * back onto shell-visible errors.
19
+ */
20
+ import { SforaApiError, } from "./api-client.js";
21
+ const DIR_MODE = 0o755;
22
+ const FILE_MODE = 0o644;
23
+ const LIST_TTL_MS = 5_000;
24
+ // ─── Path helpers (pure, no I/O) ───────────────────────────────────
25
+ function normalize(path) {
26
+ if (!path || path === "/")
27
+ return "/";
28
+ let p = path.endsWith("/") && path !== "/" ? path.slice(0, -1) : path;
29
+ if (!p.startsWith("/"))
30
+ p = `/${p}`;
31
+ const out = [];
32
+ for (const part of p.split("/")) {
33
+ if (!part || part === ".")
34
+ continue;
35
+ if (part === "..")
36
+ out.pop();
37
+ else
38
+ out.push(part);
39
+ }
40
+ return `/${out.join("/")}`;
41
+ }
42
+ function segments(path) {
43
+ return normalize(path)
44
+ .split("/")
45
+ .filter((s) => s.length > 0);
46
+ }
47
+ /** Strip `.md` and an optional `YYYY-MM-DD-` date prefix to get the slug part. */
48
+ function fileSlug(name) {
49
+ const base = name.endsWith(".md") ? name.slice(0, -3) : name;
50
+ return base.replace(/^\d{4}-\d{2}-\d{2}-/, "");
51
+ }
52
+ function classify(path) {
53
+ const seg = segments(path);
54
+ if (seg.length === 0)
55
+ return { kind: "root" };
56
+ if (seg.length === 1) {
57
+ if (seg[0] === "projects")
58
+ return { kind: "projectsDir" };
59
+ if (seg[0] === "inbox")
60
+ return { kind: "inboxDir" };
61
+ if (seg[0] === "me")
62
+ return { kind: "meDir" };
63
+ return { kind: "unknown", path: normalize(path) };
64
+ }
65
+ if (seg[0] === "inbox" && seg[1] === "mentions.md" && seg.length === 2) {
66
+ return { kind: "inboxFile" };
67
+ }
68
+ if (seg[0] === "me" && seg[1] === "api-key" && seg.length === 2) {
69
+ return { kind: "meFile" };
70
+ }
71
+ if (seg[0] === "projects") {
72
+ const slug = seg[1];
73
+ if (seg.length === 2)
74
+ return { kind: "projectDir", slug };
75
+ const dir = seg[2];
76
+ if (dir === "posts" || dir === "drafts") {
77
+ if (seg.length === 3)
78
+ return { kind: "postsDir", slug, dir };
79
+ if (seg.length === 4) {
80
+ return { kind: "postFile", slug, dir, filename: seg[3] };
81
+ }
82
+ }
83
+ if (dir === "board") {
84
+ if (seg.length === 3)
85
+ return { kind: "boardDir", slug };
86
+ if (seg.length === 4) {
87
+ return { kind: "boardColumnDir", slug, column: seg[3] };
88
+ }
89
+ if (seg.length === 5) {
90
+ return {
91
+ kind: "boardCardFile",
92
+ slug,
93
+ column: seg[3],
94
+ filename: seg[4],
95
+ };
96
+ }
97
+ }
98
+ }
99
+ return { kind: "unknown", path: normalize(path) };
100
+ }
101
+ // ─── errno-flavoured errors ────────────────────────────────────────
102
+ const err = (code, text, op, path) => new Error(`${code}: ${text}, ${op} '${path}'`);
103
+ const enoent = (op, p) => err("ENOENT", "no such file or directory", op, p);
104
+ const enotdir = (op, p) => err("ENOTDIR", "not a directory", op, p);
105
+ const eisdir = (op, p) => err("EISDIR", "illegal operation on a directory", op, p);
106
+ const eacces = (op, p) => err("EACCES", "permission denied", op, p);
107
+ const eperm = (op, p) => err("EPERM", "operation not permitted", op, p);
108
+ /** Map an API failure onto a filesystem-shaped error for `op` at `path`. */
109
+ function fromApi(e, op, path) {
110
+ if (e instanceof SforaApiError) {
111
+ if (e.status === 404)
112
+ return enoent(op, path);
113
+ if (e.status === 401 || e.status === 403)
114
+ return eacces(op, path);
115
+ if (e.status === 0)
116
+ return new Error(`${op} '${path}': ${e.message}`);
117
+ return new Error(`${op} '${path}': ${e.message}`);
118
+ }
119
+ return e instanceof Error ? e : new Error(String(e));
120
+ }
121
+ function toText(content) {
122
+ return typeof content === "string" ? content : new TextDecoder().decode(content);
123
+ }
124
+ function toNodeEncoding(enc) {
125
+ if (enc === "utf-8")
126
+ return "utf8";
127
+ if (enc === "binary")
128
+ return "latin1";
129
+ return enc;
130
+ }
131
+ function readEncoding(options) {
132
+ if (typeof options === "string")
133
+ return options;
134
+ return options?.encoding ?? "utf8";
135
+ }
136
+ export class SforaFs {
137
+ #client;
138
+ #cache = new Map();
139
+ // Sync snapshots for getAllPaths() (glob support) — updated as lists resolve.
140
+ #projectSlugs = [];
141
+ #entrySnapshots = new Map();
142
+ constructor(client) {
143
+ this.#client = client;
144
+ }
145
+ // ─── caching ─────────────────────────────────────────────────────
146
+ #cached(key, loader) {
147
+ const now = Date.now();
148
+ const hit = this.#cache.get(key);
149
+ if (hit && now - hit.at < LIST_TTL_MS)
150
+ return hit.value;
151
+ const value = loader();
152
+ this.#cache.set(key, { at: now, value });
153
+ // Drop failed loads so the next access retries instead of caching the error.
154
+ value.catch(() => {
155
+ if (this.#cache.get(key)?.value === value)
156
+ this.#cache.delete(key);
157
+ });
158
+ return value;
159
+ }
160
+ #listProjects() {
161
+ return this.#cached("projects", async () => {
162
+ const projects = await this.#client.listProjects();
163
+ this.#projectSlugs = projects.map((p) => p.slug);
164
+ return projects;
165
+ });
166
+ }
167
+ #listEntries(slug, dir) {
168
+ const key = `${dir}:${slug}`;
169
+ return this.#cached(key, async () => {
170
+ const entries = await this.#client.listPosts(slug, dir);
171
+ this.#entrySnapshots.set(`/projects/${slug}/${dir}`, entries.map((e) => e.filename));
172
+ return entries;
173
+ });
174
+ }
175
+ #invalidate(slug, dir) {
176
+ this.#cache.delete(`${dir}:${slug}`);
177
+ this.#cache.delete("projects"); // postCount changes
178
+ }
179
+ // ─── board caches ────────────────────────────────────────────────
180
+ #listColumns(slug) {
181
+ const key = `columns:${slug}`;
182
+ return this.#cached(key, async () => {
183
+ const columns = await this.#client.listColumns(slug);
184
+ this.#entrySnapshots.set(`/projects/${slug}/board`, columns.map((c) => c.dirname));
185
+ return columns;
186
+ });
187
+ }
188
+ #listCards(slug, column) {
189
+ const key = `cards:${slug}/${column}`;
190
+ return this.#cached(key, async () => {
191
+ const cards = await this.#client.listCards(slug, column);
192
+ this.#entrySnapshots.set(`/projects/${slug}/board/${column}`, cards.map((c) => c.filename));
193
+ return cards;
194
+ });
195
+ }
196
+ #invalidateBoard(slug, column) {
197
+ this.#cache.delete(`columns:${slug}`);
198
+ if (column)
199
+ this.#cache.delete(`cards:${slug}/${column}`);
200
+ else {
201
+ // Nuke every cards:<slug>/* entry — a column rename/delete affects many.
202
+ for (const k of this.#cache.keys()) {
203
+ if (k.startsWith(`cards:${slug}/`))
204
+ this.#cache.delete(k);
205
+ }
206
+ }
207
+ }
208
+ async #columnExists(slug, column) {
209
+ const cols = await this.#listColumns(slug);
210
+ return cols.some((c) => c.dirname === column);
211
+ }
212
+ async #matchCard(slug, column, filename) {
213
+ const cards = await this.#listCards(slug, column);
214
+ // Try exact filename first; fall back to matching by card number prefix
215
+ // (`42-anything.md` → card #42), then by slug (`three.md` → `NNNN-three.md`).
216
+ // Mirrors matchCardByFilename in convex/httpHelpers.ts so an agent typing
217
+ // `mv three.md …` works even though listings return `0035-three.md`.
218
+ const exact = cards.find((c) => c.filename === filename);
219
+ if (exact)
220
+ return exact;
221
+ const numMatch = /^(\d+)(?:-|\.)/.exec(filename);
222
+ if (numMatch) {
223
+ const n = Number.parseInt(numMatch[1], 10);
224
+ const byNum = cards.find((c) => c.number === n);
225
+ if (byNum)
226
+ return byNum;
227
+ }
228
+ const wantSlug = filename
229
+ .replace(/\.md$/i, "")
230
+ .replace(/^\d+-/, "")
231
+ .toLowerCase();
232
+ return cards.find((c) => c.filename
233
+ .replace(/\.md$/i, "")
234
+ .replace(/^\d+-/, "")
235
+ .toLowerCase() === wantSlug);
236
+ }
237
+ async #projectExists(slug) {
238
+ const projects = await this.#listProjects();
239
+ return projects.some((p) => p.slug === slug);
240
+ }
241
+ async #matchEntry(slug, dir, filename) {
242
+ const entries = await this.#listEntries(slug, dir);
243
+ const want = fileSlug(filename);
244
+ return (entries.find((e) => e.filename === filename) ??
245
+ entries.find((e) => fileSlug(e.filename) === want));
246
+ }
247
+ // ─── reads ───────────────────────────────────────────────────────
248
+ async #readText(path) {
249
+ const loc = classify(path);
250
+ switch (loc.kind) {
251
+ case "postFile":
252
+ try {
253
+ return await this.#client.readPost(loc.slug, loc.filename, loc.dir);
254
+ }
255
+ catch (e) {
256
+ throw fromApi(e, "open", normalize(path));
257
+ }
258
+ case "boardCardFile":
259
+ try {
260
+ return await this.#client.readCard(loc.slug, loc.column, loc.filename);
261
+ }
262
+ catch (e) {
263
+ throw fromApi(e, "open", normalize(path));
264
+ }
265
+ case "inboxFile":
266
+ try {
267
+ return await this.#client.readInbox();
268
+ }
269
+ catch (e) {
270
+ throw fromApi(e, "open", normalize(path));
271
+ }
272
+ case "meFile":
273
+ try {
274
+ return await this.#client.readMe();
275
+ }
276
+ catch (e) {
277
+ throw fromApi(e, "open", normalize(path));
278
+ }
279
+ case "root":
280
+ case "projectsDir":
281
+ case "inboxDir":
282
+ case "meDir":
283
+ case "projectDir":
284
+ case "postsDir":
285
+ case "boardDir":
286
+ case "boardColumnDir":
287
+ throw eisdir("read", normalize(path));
288
+ default:
289
+ throw enoent("open", normalize(path));
290
+ }
291
+ }
292
+ async readFile(path, options) {
293
+ const text = await this.#readText(path);
294
+ const enc = readEncoding(options);
295
+ if (enc === "utf8" || enc === "utf-8")
296
+ return text;
297
+ return Buffer.from(text, "utf8").toString(toNodeEncoding(enc));
298
+ }
299
+ async readFileBuffer(path) {
300
+ const text = await this.#readText(path);
301
+ return new TextEncoder().encode(text);
302
+ }
303
+ async writeFile(path, content, _options) {
304
+ const loc = classify(path);
305
+ const norm = normalize(path);
306
+ if (loc.kind === "inboxFile" || loc.kind === "meFile") {
307
+ throw eacces("open", norm);
308
+ }
309
+ if (loc.kind === "root" ||
310
+ loc.kind === "projectsDir" ||
311
+ loc.kind === "inboxDir" ||
312
+ loc.kind === "meDir" ||
313
+ loc.kind === "projectDir" ||
314
+ loc.kind === "postsDir" ||
315
+ loc.kind === "boardDir" ||
316
+ loc.kind === "boardColumnDir") {
317
+ throw eisdir("open", norm);
318
+ }
319
+ if (loc.kind === "postFile") {
320
+ try {
321
+ await this.#client.writePost(loc.slug, loc.dir, loc.filename, toText(content));
322
+ }
323
+ catch (e) {
324
+ throw fromApi(e, "open", norm);
325
+ }
326
+ this.#invalidate(loc.slug, loc.dir);
327
+ return;
328
+ }
329
+ if (loc.kind === "boardCardFile") {
330
+ try {
331
+ const res = await this.#client.writeCard(loc.slug, loc.column, loc.filename, toText(content));
332
+ // If the server relocated the card (frontmatter `column:` changed),
333
+ // invalidate both columns.
334
+ this.#invalidateBoard(loc.slug, loc.column);
335
+ if (res.movedTo && res.movedTo !== loc.column) {
336
+ this.#invalidateBoard(loc.slug, res.movedTo);
337
+ }
338
+ }
339
+ catch (e) {
340
+ throw fromApi(e, "open", norm);
341
+ }
342
+ return;
343
+ }
344
+ // Writes only land under projects/<slug>/(posts|drafts|board/<col>).
345
+ throw eacces("open", norm);
346
+ }
347
+ async appendFile(path, content, options) {
348
+ let existing = "";
349
+ if (await this.exists(path)) {
350
+ existing = await this.#readText(path);
351
+ }
352
+ await this.writeFile(path, existing + toText(content), options);
353
+ }
354
+ async exists(path) {
355
+ const loc = classify(path);
356
+ try {
357
+ switch (loc.kind) {
358
+ case "root":
359
+ case "projectsDir":
360
+ case "inboxDir":
361
+ case "meDir":
362
+ case "inboxFile":
363
+ case "meFile":
364
+ return true;
365
+ case "projectDir":
366
+ case "postsDir":
367
+ case "boardDir":
368
+ return await this.#projectExists(loc.slug);
369
+ case "boardColumnDir":
370
+ return ((await this.#projectExists(loc.slug)) &&
371
+ (await this.#columnExists(loc.slug, loc.column)));
372
+ case "postFile":
373
+ return (await this.#matchEntry(loc.slug, loc.dir, loc.filename)) !==
374
+ undefined;
375
+ case "boardCardFile":
376
+ return ((await this.#matchCard(loc.slug, loc.column, loc.filename)) !==
377
+ undefined);
378
+ default:
379
+ return false;
380
+ }
381
+ }
382
+ catch {
383
+ return false;
384
+ }
385
+ }
386
+ #dirStat() {
387
+ return {
388
+ isFile: false,
389
+ isDirectory: true,
390
+ isSymbolicLink: false,
391
+ mode: DIR_MODE,
392
+ size: 0,
393
+ mtime: new Date(),
394
+ };
395
+ }
396
+ #fileStat(mtime, size = 0) {
397
+ return {
398
+ isFile: true,
399
+ isDirectory: false,
400
+ isSymbolicLink: false,
401
+ mode: FILE_MODE,
402
+ size,
403
+ mtime,
404
+ };
405
+ }
406
+ async stat(path) {
407
+ const loc = classify(path);
408
+ const norm = normalize(path);
409
+ try {
410
+ switch (loc.kind) {
411
+ case "root":
412
+ case "projectsDir":
413
+ case "inboxDir":
414
+ case "meDir":
415
+ return this.#dirStat();
416
+ case "inboxFile":
417
+ case "meFile":
418
+ return this.#fileStat(new Date());
419
+ case "projectDir":
420
+ case "postsDir":
421
+ case "boardDir":
422
+ if (!(await this.#projectExists(loc.slug)))
423
+ throw enoent("stat", norm);
424
+ return this.#dirStat();
425
+ case "boardColumnDir":
426
+ if (!(await this.#projectExists(loc.slug)))
427
+ throw enoent("stat", norm);
428
+ if (!(await this.#columnExists(loc.slug, loc.column))) {
429
+ throw enoent("stat", norm);
430
+ }
431
+ return this.#dirStat();
432
+ case "postFile": {
433
+ const entry = await this.#matchEntry(loc.slug, loc.dir, loc.filename);
434
+ if (!entry)
435
+ throw enoent("stat", norm);
436
+ return this.#fileStat(new Date(entry.publishedAt || Date.now()));
437
+ }
438
+ case "boardCardFile": {
439
+ const card = await this.#matchCard(loc.slug, loc.column, loc.filename);
440
+ if (!card)
441
+ throw enoent("stat", norm);
442
+ return this.#fileStat(new Date(card.lastActivityAt || Date.now()));
443
+ }
444
+ default:
445
+ throw enoent("stat", norm);
446
+ }
447
+ }
448
+ catch (e) {
449
+ throw fromApi(e, "stat", norm);
450
+ }
451
+ }
452
+ lstat(path) {
453
+ // No symlinks in this filesystem, so lstat == stat.
454
+ return this.stat(path);
455
+ }
456
+ // ─── directories ─────────────────────────────────────────────────
457
+ async readdirWithFileTypes(path) {
458
+ const loc = classify(path);
459
+ const norm = normalize(path);
460
+ const dirent = (name, isDir) => ({
461
+ name,
462
+ isFile: !isDir,
463
+ isDirectory: isDir,
464
+ isSymbolicLink: false,
465
+ });
466
+ try {
467
+ switch (loc.kind) {
468
+ case "root":
469
+ return [
470
+ dirent("inbox", true),
471
+ dirent("me", true),
472
+ dirent("projects", true),
473
+ ];
474
+ case "projectsDir": {
475
+ const projects = await this.#listProjects();
476
+ return projects
477
+ .map((p) => p.slug)
478
+ .sort()
479
+ .map((s) => dirent(s, true));
480
+ }
481
+ case "inboxDir":
482
+ return [dirent("mentions.md", false)];
483
+ case "meDir":
484
+ return [dirent("api-key", false)];
485
+ case "projectDir":
486
+ if (!(await this.#projectExists(loc.slug))) {
487
+ throw enoent("scandir", norm);
488
+ }
489
+ return [
490
+ dirent("board", true),
491
+ dirent("drafts", true),
492
+ dirent("posts", true),
493
+ ];
494
+ case "postsDir": {
495
+ const entries = await this.#listEntries(loc.slug, loc.dir);
496
+ return entries.map((e) => dirent(e.filename, false));
497
+ }
498
+ case "boardDir": {
499
+ if (!(await this.#projectExists(loc.slug))) {
500
+ throw enoent("scandir", norm);
501
+ }
502
+ const cols = await this.#listColumns(loc.slug);
503
+ return cols
504
+ .slice()
505
+ .sort((a, b) => a.position - b.position)
506
+ .map((c) => dirent(c.dirname, true));
507
+ }
508
+ case "boardColumnDir": {
509
+ if (!(await this.#columnExists(loc.slug, loc.column))) {
510
+ throw enoent("scandir", norm);
511
+ }
512
+ const cards = await this.#listCards(loc.slug, loc.column);
513
+ return cards.map((c) => dirent(c.filename, false));
514
+ }
515
+ case "inboxFile":
516
+ case "meFile":
517
+ case "postFile":
518
+ case "boardCardFile":
519
+ throw enotdir("scandir", norm);
520
+ default:
521
+ throw enoent("scandir", norm);
522
+ }
523
+ }
524
+ catch (e) {
525
+ throw fromApi(e, "scandir", norm);
526
+ }
527
+ }
528
+ async readdir(path) {
529
+ const entries = await this.readdirWithFileTypes(path);
530
+ return entries.map((e) => e.name);
531
+ }
532
+ async mkdir(path, _options) {
533
+ const loc = classify(path);
534
+ const norm = normalize(path);
535
+ switch (loc.kind) {
536
+ // Known structural directories already exist — mkdir is a no-op.
537
+ case "root":
538
+ case "projectsDir":
539
+ case "inboxDir":
540
+ case "meDir":
541
+ return;
542
+ case "projectDir":
543
+ case "postsDir":
544
+ case "boardDir":
545
+ if (await this.#projectExists(loc.slug))
546
+ return;
547
+ throw eacces("mkdir", norm);
548
+ case "boardColumnDir": {
549
+ if (!(await this.#projectExists(loc.slug)))
550
+ throw eacces("mkdir", norm);
551
+ // Already exists — no-op.
552
+ if (await this.#columnExists(loc.slug, loc.column))
553
+ return;
554
+ try {
555
+ // Strip the NN-prefix to derive the human-readable name; the server
556
+ // re-slugifies and assigns the position.
557
+ const name = loc.column.replace(/^\d+-/, "").replace(/-/g, " ");
558
+ await this.#client.createColumn(loc.slug, name || loc.column);
559
+ }
560
+ catch (e) {
561
+ throw fromApi(e, "mkdir", norm);
562
+ }
563
+ this.#invalidateBoard(loc.slug);
564
+ return;
565
+ }
566
+ default:
567
+ // Can't create new projects / arbitrary dirs through the fs.
568
+ throw eacces("mkdir", norm);
569
+ }
570
+ }
571
+ // ─── removal (rm = unlink → soft delete) ───────────────────────────
572
+ async rm(path, options) {
573
+ const loc = classify(path);
574
+ const norm = normalize(path);
575
+ switch (loc.kind) {
576
+ case "postFile": {
577
+ const exists = (await this.#matchEntry(loc.slug, loc.dir, loc.filename)) !==
578
+ undefined;
579
+ if (!exists) {
580
+ if (options?.force)
581
+ return;
582
+ throw enoent("unlink", norm);
583
+ }
584
+ try {
585
+ await this.#client.deletePost(loc.slug, loc.dir, loc.filename);
586
+ }
587
+ catch (e) {
588
+ throw fromApi(e, "unlink", norm);
589
+ }
590
+ this.#invalidate(loc.slug, loc.dir);
591
+ return;
592
+ }
593
+ case "boardCardFile": {
594
+ const card = await this.#matchCard(loc.slug, loc.column, loc.filename);
595
+ if (!card) {
596
+ if (options?.force)
597
+ return;
598
+ throw enoent("unlink", norm);
599
+ }
600
+ try {
601
+ await this.#client.deleteCard(loc.slug, loc.column, card.filename);
602
+ }
603
+ catch (e) {
604
+ throw fromApi(e, "unlink", norm);
605
+ }
606
+ this.#invalidateBoard(loc.slug, loc.column);
607
+ return;
608
+ }
609
+ case "boardColumnDir": {
610
+ // `rmdir`/`rm -r` on a column → delete the column. Server enforces
611
+ // empty unless we'd want to add a force-cascade, which we don't.
612
+ if (!(await this.#columnExists(loc.slug, loc.column))) {
613
+ if (options?.force)
614
+ return;
615
+ throw enoent("unlink", norm);
616
+ }
617
+ try {
618
+ await this.#client.deleteColumn(loc.slug, loc.column);
619
+ }
620
+ catch (e) {
621
+ throw fromApi(e, "unlink", norm);
622
+ }
623
+ this.#invalidateBoard(loc.slug);
624
+ return;
625
+ }
626
+ case "inboxFile":
627
+ case "meFile":
628
+ throw eacces("unlink", norm);
629
+ case "root":
630
+ case "projectsDir":
631
+ case "inboxDir":
632
+ case "meDir":
633
+ case "projectDir":
634
+ case "postsDir":
635
+ case "boardDir":
636
+ // Top-level structural dirs can't be removed.
637
+ if (options?.recursive)
638
+ throw eperm("rm", norm);
639
+ throw eisdir("unlink", norm);
640
+ default:
641
+ if (options?.force)
642
+ return;
643
+ throw enoent("unlink", norm);
644
+ }
645
+ }
646
+ // ─── unsupported mutations ─────────────────────────────────────────
647
+ cp(src, _dest, _options) {
648
+ return Promise.reject(eperm("cp", normalize(src)));
649
+ }
650
+ async mv(src, dest) {
651
+ const srcLoc = classify(src);
652
+ const destLoc = classify(dest);
653
+ const srcNorm = normalize(src);
654
+ const destNorm = normalize(dest);
655
+ // Card moves: /board/A/x.md → /board/B/x.md (or /board/B/, which keeps the
656
+ // filename). Body and metadata stay; only the column changes.
657
+ if (srcLoc.kind === "boardCardFile") {
658
+ let targetColumn;
659
+ if (destLoc.kind === "boardCardFile") {
660
+ if (destLoc.slug !== srcLoc.slug) {
661
+ throw eperm("rename", srcNorm);
662
+ }
663
+ targetColumn = destLoc.column;
664
+ }
665
+ else if (destLoc.kind === "boardColumnDir") {
666
+ if (destLoc.slug !== srcLoc.slug) {
667
+ throw eperm("rename", srcNorm);
668
+ }
669
+ targetColumn = destLoc.column;
670
+ }
671
+ else {
672
+ throw eperm("rename", srcNorm);
673
+ }
674
+ const card = await this.#matchCard(srcLoc.slug, srcLoc.column, srcLoc.filename);
675
+ if (!card)
676
+ throw enoent("rename", srcNorm);
677
+ // Same column → no-op (avoid a roundtrip when an agent re-`mv`s in place).
678
+ if (targetColumn === srcLoc.column)
679
+ return;
680
+ if (!(await this.#columnExists(srcLoc.slug, targetColumn))) {
681
+ throw enoent("rename", destNorm);
682
+ }
683
+ try {
684
+ await this.#client.moveCard(srcLoc.slug, srcLoc.column, card.filename, targetColumn);
685
+ }
686
+ catch (e) {
687
+ throw fromApi(e, "rename", srcNorm);
688
+ }
689
+ this.#invalidateBoard(srcLoc.slug, srcLoc.column);
690
+ this.#invalidateBoard(srcLoc.slug, targetColumn);
691
+ return;
692
+ }
693
+ // Column renames: /board/old → /board/new (same project). Strips the NN-
694
+ // prefix to derive the new human-readable name; server picks a fresh prefix
695
+ // to preserve position.
696
+ if (srcLoc.kind === "boardColumnDir" && destLoc.kind === "boardColumnDir") {
697
+ if (srcLoc.slug !== destLoc.slug)
698
+ throw eperm("rename", srcNorm);
699
+ if (!(await this.#columnExists(srcLoc.slug, srcLoc.column))) {
700
+ throw enoent("rename", srcNorm);
701
+ }
702
+ const name = destLoc.column.replace(/^\d+-/, "").replace(/-/g, " ");
703
+ try {
704
+ await this.#client.renameColumn(srcLoc.slug, srcLoc.column, name);
705
+ }
706
+ catch (e) {
707
+ throw fromApi(e, "rename", srcNorm);
708
+ }
709
+ this.#invalidateBoard(srcLoc.slug);
710
+ return;
711
+ }
712
+ throw eperm("rename", srcNorm);
713
+ }
714
+ symlink(_target, linkPath) {
715
+ return Promise.reject(eperm("symlink", normalize(linkPath)));
716
+ }
717
+ link(_existingPath, newPath) {
718
+ return Promise.reject(eperm("link", normalize(newPath)));
719
+ }
720
+ readlink(path) {
721
+ return Promise.reject(err("EINVAL", "invalid argument", "readlink", normalize(path)));
722
+ }
723
+ // ─── trivial / pure ────────────────────────────────────────────────
724
+ resolvePath(base, path) {
725
+ if (path.startsWith("/"))
726
+ return normalize(path);
727
+ return normalize(base === "/" ? `/${path}` : `${base}/${path}`);
728
+ }
729
+ realpath(path) {
730
+ // No symlinks — the canonical path is just the normalized one.
731
+ return Promise.resolve(normalize(path));
732
+ }
733
+ getAllPaths() {
734
+ const paths = new Set([
735
+ "/",
736
+ "/projects",
737
+ "/inbox",
738
+ "/me",
739
+ "/inbox/mentions.md",
740
+ "/me/api-key",
741
+ ]);
742
+ for (const slug of this.#projectSlugs) {
743
+ paths.add(`/projects/${slug}`);
744
+ paths.add(`/projects/${slug}/posts`);
745
+ paths.add(`/projects/${slug}/drafts`);
746
+ paths.add(`/projects/${slug}/board`);
747
+ }
748
+ for (const [dirPath, filenames] of this.#entrySnapshots) {
749
+ for (const name of filenames)
750
+ paths.add(`${dirPath}/${name}`);
751
+ }
752
+ return [...paths];
753
+ }
754
+ chmod(_path, _mode) {
755
+ // Permissions are virtual; accept and ignore.
756
+ return Promise.resolve();
757
+ }
758
+ utimes(_path, _atime, _mtime) {
759
+ // mtime is server-owned; accept and ignore.
760
+ return Promise.resolve();
761
+ }
762
+ }