sfora-cli 0.10.0 → 0.12.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.
Files changed (72) hide show
  1. package/README.md +174 -0
  2. package/dist/SforaFs.js +8 -6
  3. package/dist/api-client.d.ts +344 -4
  4. package/dist/api-client.js +289 -21
  5. package/dist/block-commands.d.ts +84 -0
  6. package/dist/block-commands.js +155 -0
  7. package/dist/chat.d.ts +89 -0
  8. package/dist/chat.js +189 -0
  9. package/dist/cli-args.d.ts +32 -0
  10. package/dist/cli-args.js +88 -0
  11. package/dist/cli.js +530 -88
  12. package/dist/format/blockSplice.d.ts +135 -0
  13. package/dist/format/blockSplice.js +330 -0
  14. package/dist/format/blocks/dropClosure.d.ts +10 -1
  15. package/dist/format/blocks/dropClosure.js +11 -1
  16. package/dist/format/callout.d.ts +69 -7
  17. package/dist/format/callout.js +112 -15
  18. package/dist/format/checklist.js +11 -4
  19. package/dist/format/formatAxes.d.ts +228 -0
  20. package/dist/format/formatAxes.js +454 -0
  21. package/dist/format/index.d.ts +1 -0
  22. package/dist/format/index.js +4 -0
  23. package/dist/format/lineGeometry.d.ts +34 -4
  24. package/dist/format/lineGeometry.js +140 -40
  25. package/dist/format/lint/appliesTo.d.ts +92 -0
  26. package/dist/format/lint/appliesTo.js +369 -0
  27. package/dist/format/lint/config.d.ts +106 -0
  28. package/dist/format/lint/config.js +205 -0
  29. package/dist/format/lint/fixAll.d.ts +62 -0
  30. package/dist/format/lint/fixAll.js +107 -0
  31. package/dist/format/lint/frontmatterSchema.d.ts +181 -0
  32. package/dist/format/lint/frontmatterSchema.js +660 -0
  33. package/dist/format/lint/index.d.ts +34 -5
  34. package/dist/format/lint/index.js +34 -5
  35. package/dist/format/lint/lintSource.d.ts +27 -7
  36. package/dist/format/lint/lintSource.js +67 -33
  37. package/dist/format/lint/rules/frontmatter-schema.d.ts +2 -0
  38. package/dist/format/lint/rules/frontmatter-schema.js +92 -0
  39. package/dist/format/lint/rules/index.d.ts +2 -1
  40. package/dist/format/lint/rules/index.js +7 -1
  41. package/dist/format/lint/rules/malformed-callout.js +25 -16
  42. package/dist/format/lint/rules/malformed-checklist.js +8 -3
  43. package/dist/format/lint/rules/malformed-frontmatter.js +6 -1
  44. package/dist/format/lint/severity.d.ts +15 -0
  45. package/dist/format/lint/severity.js +50 -0
  46. package/dist/format/lint/textEdits.d.ts +86 -0
  47. package/dist/format/lint/textEdits.js +162 -0
  48. package/dist/format/lint/types.d.ts +44 -8
  49. package/dist/format/markdown/slug.d.ts +28 -0
  50. package/dist/format/markdown/slug.js +63 -0
  51. package/dist/format/plaintext.js +13 -3
  52. package/dist/format/sheetCellSpans.d.ts +95 -0
  53. package/dist/format/sheetCellSpans.js +223 -0
  54. package/dist/format/sheetSelection.d.ts +136 -0
  55. package/dist/format/sheetSelection.js +282 -0
  56. package/dist/format/textStats.d.ts +23 -0
  57. package/dist/format/textStats.js +80 -0
  58. package/dist/format/wikiLinks.d.ts +60 -1
  59. package/dist/format/wikiLinks.js +195 -9
  60. package/dist/index.d.ts +26 -1
  61. package/dist/index.js +20 -3
  62. package/dist/opener.d.ts +23 -0
  63. package/dist/opener.js +26 -0
  64. package/dist/render.d.ts +162 -0
  65. package/dist/render.js +280 -0
  66. package/dist/shell-commands.d.ts +34 -0
  67. package/dist/shell-commands.js +108 -0
  68. package/dist/watch.d.ts +79 -0
  69. package/dist/watch.js +113 -0
  70. package/dist/web-url.d.ts +39 -0
  71. package/dist/web-url.js +63 -0
  72. package/package.json +1 -1
@@ -8,41 +8,156 @@
8
8
  * JSON/markdown shapes, and it surfaces non-2xx responses as {@link SforaApiError}.
9
9
  * Path/virtual-filesystem semantics live in SforaFs.
10
10
  */
11
+ import { fsRequestPath, webUrlFromResponse } from "./web-url.js";
12
+ /** The effect report in a parsed response body, or null when there is none. */
13
+ export function writeEffectFrom(body) {
14
+ if (!body || typeof body !== "object")
15
+ return null;
16
+ const obj = body;
17
+ if (typeof obj.changed !== "boolean")
18
+ return null;
19
+ const counts = obj.blockIds;
20
+ const blockIds = counts &&
21
+ typeof counts.rebound === "number" &&
22
+ typeof counts.orphaned === "number" &&
23
+ typeof counts.total === "number"
24
+ ? { rebound: counts.rebound, orphaned: counts.orphaned, total: counts.total }
25
+ : undefined;
26
+ return blockIds ? { changed: obj.changed, blockIds } : { changed: obj.changed };
27
+ }
11
28
  /** Non-2xx response from the fs API. Carries the HTTP status + machine code so SforaFs can map it to an errno. */
12
29
  export class SforaApiError extends Error {
13
30
  status;
14
31
  code;
15
- constructor(status, code, message) {
32
+ /**
33
+ * The parsed error body, when there was one.
34
+ *
35
+ * Some refusals are USEFUL, not merely informative: a 409 from a `?block=`
36
+ * write carries the document's current blocks so the caller can re-aim
37
+ * without a second round trip. Throwing away everything but the message
38
+ * would make the CLI ask for that page again. Untyped here because the fs
39
+ * error envelope is `{ error, message }` plus whatever the specific refusal
40
+ * adds; {@link blockConflictFrom} is the typed reader for the one shape the
41
+ * CLI acts on.
42
+ */
43
+ data;
44
+ constructor(status, code, message, data) {
16
45
  super(message);
17
46
  this.name = "SforaApiError";
18
47
  this.status = status;
19
48
  this.code = code;
49
+ this.data = data;
20
50
  }
21
51
  }
52
+ /** The 409 recovery payload inside an error, or null for any other failure. */
53
+ export function blockConflictFrom(error) {
54
+ if (!(error instanceof SforaApiError) || error.status !== 409)
55
+ return null;
56
+ const data = error.data;
57
+ if (!data || !Array.isArray(data.blocks))
58
+ return null;
59
+ return {
60
+ message: typeof data.message === "string" ? data.message : error.message,
61
+ block: typeof data.block === "string" ? data.block : undefined,
62
+ // Narrowed row by row rather than cast wholesale. A cast is what let the
63
+ // renderer print `undefined` in a column the payload never had: the shape
64
+ // was ASSERTED to match `?view=blocks` and never checked against the wire.
65
+ // Anything that is not a summary is dropped, so a server that grows the
66
+ // payload cannot make the table lie.
67
+ blocks: data.blocks.filter(isBlockSummary),
68
+ };
69
+ }
70
+ function isBlockSummary(row) {
71
+ if (!row || typeof row !== "object")
72
+ return false;
73
+ const b = row;
74
+ return (typeof b.id === "string" &&
75
+ typeof b.line === "number" &&
76
+ typeof b.preview === "string");
77
+ }
22
78
  /** The route segment a `PostKind` maps to. `scheduled` reads/writes through the drafts route. */
23
79
  function routeBase(kind) {
24
80
  return kind === "posts" ? "posts" : "drafts";
25
81
  }
82
+ /** `?block=<id>` when one was asked for, and nothing at all when it wasn't. */
83
+ function blockQuery(options) {
84
+ return options?.blockId
85
+ ? `?block=${encodeURIComponent(options.blockId)}`
86
+ : "";
87
+ }
26
88
  export class SforaApiClient {
27
89
  #baseUrl;
28
90
  #apiKey;
29
91
  #actAs;
92
+ /**
93
+ * What the LAST response said about itself, beyond the data it carried.
94
+ *
95
+ * Two facts live here and both are for the human at the terminal: the page
96
+ * the response names (card #335) and the effect report a markdown write
97
+ * returns (card #333). Recorded rather than returned because the callers
98
+ * that print them — `sfora cat`, `sfora ls`, `sfora post/task/doc`, the
99
+ * shell — reach the API through `SforaFs`, which speaks `IFileSystem` and
100
+ * has nowhere in its signatures to put either. A second request to fetch
101
+ * them would double every read to print one dim line.
102
+ *
103
+ * {@link takeResponseInfo} CLEARS it, so a command can never print a link or
104
+ * an effect belonging to an earlier one.
105
+ */
106
+ #lastInfo = { url: null, effect: null, present: false };
30
107
  constructor(config) {
31
108
  this.#baseUrl = config.baseUrl.replace(/\/+$/, "");
32
109
  this.#apiKey = config.apiKey;
33
110
  this.#actAs = config.actAs;
34
111
  }
112
+ /** What the last response said about itself, consumed. */
113
+ takeResponseInfo() {
114
+ const info = this.#lastInfo;
115
+ this.#lastInfo = { url: null, effect: null, present: false };
116
+ return info;
117
+ }
118
+ /**
119
+ * The web page an fs path is read on, or null when it has none.
120
+ *
121
+ * One request, and the ONLY route knowledge involved is the server's: this
122
+ * reads the path the caller gave and reports the link the answer carried.
123
+ * A directory listing answers in JSON, a document in markdown with the link
124
+ * in a header, and {@link webUrlFromResponse} covers both.
125
+ */
126
+ async resolveWebUrl(fsPath) {
127
+ const res = await this.#request("GET", fsRequestPath(fsPath));
128
+ const fromHeader = webUrlFromResponse(res.headers);
129
+ if (fromHeader)
130
+ return fromHeader;
131
+ const type = res.headers.get("content-type") ?? "";
132
+ if (!type.includes("json"))
133
+ return null;
134
+ return webUrlFromResponse(res.headers, await res.text());
135
+ }
35
136
  // ─── HTTP plumbing ──────────────────────────────────────────────
36
- async #request(method, path, body) {
137
+ async #request(method, path, body,
138
+ /**
139
+ * Cancels the request in flight. Only the long-poll passes one: it is the
140
+ * single call that blocks for tens of seconds, so it is the single call
141
+ * where "stop" has to mean the socket and not just a flag the caller will
142
+ * read after it returns.
143
+ */
144
+ signal,
145
+ /** The fs surface speaks markdown; the /api chat endpoints speak JSON. */
146
+ contentType = "text/markdown") {
37
147
  const headers = Object.create(null);
38
148
  headers.Authorization = `Bearer ${this.#apiKey}`;
39
149
  if (this.#actAs)
40
150
  headers["X-Sfora-Act-As"] = this.#actAs;
41
151
  if (body !== undefined)
42
- headers["Content-Type"] = "text/markdown";
152
+ headers["Content-Type"] = contentType;
43
153
  let res;
44
154
  try {
45
- res = await fetch(`${this.#baseUrl}${path}`, { method, headers, body });
155
+ res = await fetch(`${this.#baseUrl}${path}`, {
156
+ method,
157
+ headers,
158
+ body,
159
+ signal,
160
+ });
46
161
  }
47
162
  catch (e) {
48
163
  // Network/DNS/connection failures — surface as a 0-status error so the
@@ -52,16 +167,39 @@ export class SforaApiClient {
52
167
  }
53
168
  if (!res.ok)
54
169
  throw await this.#toError(res);
170
+ // Markdown reads carry their page link in a header; JSON responses carry it
171
+ // in the body, and `#jsonFrom` picks it up there. Set on every response so
172
+ // one that names no entity CLEARS what an earlier one left behind.
173
+ this.#lastInfo = {
174
+ url: webUrlFromResponse(res.headers),
175
+ effect: null,
176
+ present: false,
177
+ };
55
178
  return res;
56
179
  }
180
+ /** Parse a JSON response, remembering the page and effect report it names. */
181
+ async #jsonFrom(res) {
182
+ const text = await res.text();
183
+ const parsed = JSON.parse(text);
184
+ this.#lastInfo = {
185
+ url: webUrlFromResponse(res.headers, text),
186
+ effect: writeEffectFrom(parsed),
187
+ present: !!parsed &&
188
+ typeof parsed === "object" &&
189
+ parsed.present === true,
190
+ };
191
+ return parsed;
192
+ }
57
193
  async #toError(res) {
58
194
  const text = await res.text().catch(() => "");
59
195
  let code = "bad_request";
60
196
  let message = text || res.statusText;
197
+ let data;
61
198
  if (text) {
62
199
  try {
63
200
  const parsed = JSON.parse(text);
64
201
  if (parsed && typeof parsed === "object") {
202
+ data = parsed;
65
203
  const obj = parsed;
66
204
  if (typeof obj.error === "string")
67
205
  code = obj.error;
@@ -73,11 +211,10 @@ export class SforaApiClient {
73
211
  // Non-JSON error body — keep the raw text as the message.
74
212
  }
75
213
  }
76
- return new SforaApiError(res.status, code, message);
214
+ return new SforaApiError(res.status, code, message, data);
77
215
  }
78
- async #json(path) {
79
- const res = await this.#request("GET", path);
80
- return (await res.json());
216
+ async #json(path, signal) {
217
+ return this.#jsonFrom(await this.#request("GET", path, undefined, signal));
81
218
  }
82
219
  async #text(path) {
83
220
  const res = await this.#request("GET", path);
@@ -92,7 +229,7 @@ export class SforaApiClient {
92
229
  /** `POST /v1/fs/projects` — create a project; the caller becomes its lead. */
93
230
  async createProject(name) {
94
231
  const res = await this.#request("POST", "/v1/fs/projects", JSON.stringify({ name }));
95
- return (await res.json());
232
+ return this.#jsonFrom(res);
96
233
  }
97
234
  /** `GET …/links.md` — the project's external links as markdown. */
98
235
  async getProjectLinks(slug) {
@@ -143,13 +280,36 @@ export class SforaApiClient {
143
280
  return this.#text(`/v1/fs/projects/${slug}/${base}/${file}`);
144
281
  }
145
282
  /** Create a post or upsert a mutable draft from a Markdown file. */
146
- async writePost(projectSlug, kind, filename, markdown) {
283
+ async writePost(projectSlug, kind, filename, markdown, options) {
147
284
  const base = routeBase(kind);
148
285
  const slug = encodeURIComponent(projectSlug);
149
286
  const file = encodeURIComponent(filename);
150
- const res = await this.#request("PUT", `/v1/fs/projects/${slug}/${base}/${file}`, markdown);
151
- const data = (await res.json());
152
- return { filename: data.filename, id: data.id };
287
+ const res = await this.#request("PUT", `/v1/fs/projects/${slug}/${base}/${file}${blockQuery(options)}`, markdown);
288
+ // The WHOLE report, not just `{ filename, id }`: `changed` and the rebind
289
+ // counts are what card #333 prints after every write, and narrowing here
290
+ // would throw them away one layer below the caller that needs them.
291
+ return this.#jsonFrom(res);
292
+ }
293
+ /**
294
+ * `GET …?view=blocks` on any fs path with a stored markdown body — the
295
+ * addressable view a `?block=` write aims into.
296
+ *
297
+ * The path is the caller's fs path (`/projects/acme/docs/x.md`), not a
298
+ * `/v1/fs` URL: the CLI does not build routes.
299
+ */
300
+ async readBlocks(fsPath) {
301
+ return this.#json(`${fsRequestPath(fsPath)}?view=blocks`);
302
+ }
303
+ /**
304
+ * `PUT <fs path>` — the generic write door, so the CLI can put to any path
305
+ * (and to a single block of it) without a per-entity method.
306
+ *
307
+ * A 409 from a `?block=` write throws a {@link SforaApiError} carrying the
308
+ * server's recovery payload; read it with {@link blockConflictFrom}.
309
+ */
310
+ async writePath(fsPath, markdown, options) {
311
+ const res = await this.#request("PUT", `${fsRequestPath(fsPath)}${blockQuery(options)}`, markdown);
312
+ return this.#jsonFrom(res);
153
313
  }
154
314
  /** `DELETE …/(posts|drafts)/:filename.md` — soft-deletes the matched post. */
155
315
  async deletePost(projectSlug, kind, filename) {
@@ -175,7 +335,7 @@ export class SforaApiClient {
175
335
  const slug = encodeURIComponent(projectSlug);
176
336
  const file = encodeURIComponent(filename);
177
337
  const res = await this.#request("PUT", `/v1/fs/projects/${slug}/library/documents/${file}`, markdown);
178
- return (await res.json());
338
+ return await this.#jsonFrom(res);
179
339
  }
180
340
  async deleteNote(projectSlug, filename) {
181
341
  const slug = encodeURIComponent(projectSlug);
@@ -232,7 +392,14 @@ export class SforaApiClient {
232
392
  async getBoardMeta(projectSlug) {
233
393
  const slug = encodeURIComponent(projectSlug);
234
394
  const data = await this.#json(`/v1/fs/projects/${slug}/board`);
235
- return { publicSlug: data.publicSlug ?? null, columns: data.columns };
395
+ return {
396
+ publicSlug: data.publicSlug ?? null,
397
+ columns: data.columns,
398
+ // Kept, not derived. The roadmap projection quotes this verbatim, and
399
+ // rebuilding it here from `publicSlug` would put the hostname back in
400
+ // the CLI — which is the whole thing the server sending it prevents.
401
+ publicUrl: data.publicUrl ?? null,
402
+ };
236
403
  }
237
404
  /** `GET …/board` — list the project's columns (auto-creates the board on first call). */
238
405
  async listColumns(projectSlug) {
@@ -264,7 +431,7 @@ export class SforaApiClient {
264
431
  const col = encodeURIComponent(columnDir);
265
432
  const file = encodeURIComponent(filename);
266
433
  const res = await this.#request("PUT", `/v1/fs/projects/${slug}/board/${col}/${file}`, markdown);
267
- return (await res.json());
434
+ return await this.#jsonFrom(res);
268
435
  }
269
436
  /** `DELETE …/board/:column/:filename.md` — archives the card. */
270
437
  async deleteCard(projectSlug, columnDir, filename) {
@@ -288,7 +455,7 @@ export class SforaApiClient {
288
455
  });
289
456
  if (!res.ok)
290
457
  throw await this.#toError(res);
291
- return (await res.json());
458
+ return await this.#jsonFrom(res);
292
459
  }
293
460
  /**
294
461
  * `POST …/board/:column/_rename` with `{ name }` — renames the column,
@@ -307,7 +474,7 @@ export class SforaApiClient {
307
474
  });
308
475
  if (!res.ok)
309
476
  throw await this.#toError(res);
310
- return (await res.json());
477
+ return await this.#jsonFrom(res);
311
478
  }
312
479
  /** `DELETE …/board/:column` — column must be empty. */
313
480
  async deleteColumn(projectSlug, columnDir) {
@@ -333,7 +500,7 @@ export class SforaApiClient {
333
500
  });
334
501
  if (!res.ok)
335
502
  throw await this.#toError(res);
336
- return (await res.json());
503
+ return await this.#jsonFrom(res);
337
504
  }
338
505
  // ─── Post actions (comment / react) ─────────────────────────────
339
506
  /** `POST /v1/posts/:postId/comments` — add a comment to a post. */
@@ -348,7 +515,7 @@ export class SforaApiClient {
348
515
  });
349
516
  if (!res.ok)
350
517
  throw await this.#toError(res);
351
- const data = (await res.json());
518
+ const data = await this.#jsonFrom(res);
352
519
  return { id: data.comment?._id ?? "" };
353
520
  }
354
521
  /**
@@ -366,9 +533,110 @@ export class SforaApiClient {
366
533
  });
367
534
  if (!res.ok)
368
535
  throw await this.#toError(res);
369
- const data = (await res.json());
536
+ const data = await this.#jsonFrom(res);
370
537
  return data.reaction ?? { content: emoji, reacted: true };
371
538
  }
539
+ // ─── Live docs: the roster and the ping stream ───────────────────
540
+ /**
541
+ * `POST <path>/_presence` — say you are in a document.
542
+ *
543
+ * Returns `null` when the path has no roster. The CLI does not decide which
544
+ * paths those are: it asks, and a `422` is the server's named refusal
545
+ * ("posts and board cards have markdown bodies but nobody has one open in a
546
+ * document editor"). Every other failure is thrown as usual.
547
+ */
548
+ async declarePresence(fsPath, options = {}) {
549
+ const params = new URLSearchParams();
550
+ if (options.kind)
551
+ params.set("kind", options.kind);
552
+ if (options.block)
553
+ params.set("block", options.block);
554
+ if (options.leave)
555
+ params.set("leave", "");
556
+ const query = params.toString();
557
+ try {
558
+ const res = await this.#request("POST", `${fsRequestPath(fsPath)}/_presence${query ? `?${query}` : ""}`);
559
+ return this.#jsonFrom(res);
560
+ }
561
+ catch (error) {
562
+ if (error instanceof SforaApiError && error.status === 422)
563
+ return null;
564
+ throw error;
565
+ }
566
+ }
567
+ /**
568
+ * `GET /v1/presence` — where everybody is right now.
569
+ *
570
+ * The reverse of {@link declarePresence}, and a pure read: asking never
571
+ * enrols the asker in anything. `member` takes an id, a name, or `self`.
572
+ *
573
+ * Documents the key cannot open are absent from the answer — the server
574
+ * filters, the CLI prints. That is why this returns everything it is given
575
+ * rather than filtering again here.
576
+ */
577
+ async listPresence(member) {
578
+ const query = member ? `?member=${encodeURIComponent(member)}` : "";
579
+ return this.#json(`/v1/presence${query}`);
580
+ }
581
+ /**
582
+ * `GET /v1/events` — the long-poll. Blocks server-side until something
583
+ * happens or the wait budget elapses, then answers with a cursor to poll
584
+ * from next.
585
+ *
586
+ * `signal` is not optional in spirit: this is the ONE request in the client
587
+ * that is designed to hang, so a caller that cannot cancel it cannot stop.
588
+ * `sfora watch` aborts it from its ^C handler, which is the difference
589
+ * between a terminal that says "^C to stop" and one that does.
590
+ */
591
+ async pollEvents(params) {
592
+ const query = new URLSearchParams({ since: String(params.since) });
593
+ if (params.wait !== undefined)
594
+ query.set("wait", String(params.wait));
595
+ if (params.doc)
596
+ query.set("doc", params.doc);
597
+ if (params.project)
598
+ query.set("project", params.project);
599
+ // `?self=include` is the server's spelling; the default is to exclude your
600
+ // own writes, matching the message branch's "don't wake on your own".
601
+ if (params.includeSelf)
602
+ query.set("self", "include");
603
+ return this.#json(`/v1/events?${query.toString()}`, params.signal);
604
+ }
605
+ /**
606
+ * The entity id behind an fs path — what `/v1/events?doc=` wants.
607
+ *
608
+ * Read off `?view=blocks`, which states the document's identity in its
609
+ * `document` field, rather than parsed out of the file's frontmatter: the
610
+ * projection is the server saying which row these bytes are.
611
+ */
612
+ async resolveDocId(fsPath) {
613
+ const view = await this.readBlocks(fsPath);
614
+ return view.document?.id ?? null;
615
+ }
616
+ // ─── Chat (rooms + messages) ─────────────────────────────────────
617
+ /**
618
+ * `GET /api/rooms` — the caller's rooms. `all` adds open-but-unjoined rooms
619
+ * (`joined: false`), so the room can be found before it is joined.
620
+ */
621
+ async listRooms(all = false) {
622
+ return this.#json(`/api/rooms${all ? "?all=1" : ""}`);
623
+ }
624
+ /** `POST /api/rooms/:id/join` — self-join an open room. Idempotent. */
625
+ async joinRoom(roomId) {
626
+ const res = await this.#request("POST", `/api/rooms/${encodeURIComponent(roomId)}/join`);
627
+ return this.#jsonFrom(res);
628
+ }
629
+ /** `GET /api/rooms/:id/messages` — history, newest first (server cap: 100). */
630
+ async listRoomMessages(roomId, limit = 30) {
631
+ return this.#json(`/api/rooms/${encodeURIComponent(roomId)}/messages?limit=${limit}`);
632
+ }
633
+ /** `POST /api/rooms/:id/messages` with `{ body }` — send markdown. */
634
+ async sendRoomMessage(roomId, body) {
635
+ // Through #request like every other call, so network failures surface as
636
+ // SforaApiError(0, "network_error") and `--as` rides the shared headers.
637
+ const res = await this.#request("POST", `/api/rooms/${encodeURIComponent(roomId)}/messages`, JSON.stringify({ body }), undefined, "application/json");
638
+ return this.#jsonFrom(res);
639
+ }
372
640
  /** `GET /v1/fs/inbox/mentions.md` — markdown summary of unread mentions. */
373
641
  async readInbox() {
374
642
  return this.#text("/v1/fs/inbox/mentions.md");
@@ -0,0 +1,84 @@
1
+ /**
2
+ * `blocks`, `put` and `url` — one implementation, two front doors.
3
+ *
4
+ * Card #333. These three run identically as CLI verbs (`sfora put …`) and as
5
+ * commands inside the interactive shell (`put …`), and they do it by being
6
+ * written once here: each takes its arguments and returns
7
+ * `{ stdout, stderr, exitCode }`, which is exactly what just-bash's `Command`
8
+ * interface wants and exactly what the CLI writes to its own streams.
9
+ *
10
+ * WHY STREAMS AND AN EXIT CODE RATHER THAN PRINTING. A shell command that
11
+ * printed to `process.stdout` would escape the pipeline — `blocks x.md | grep
12
+ * heading` would print everything and pipe nothing. Returning the text makes
13
+ * both callers correct and makes the tests below assert on a value instead of
14
+ * spying on the process.
15
+ *
16
+ * The split against `render.ts` is the usual one: this module talks to the
17
+ * server and decides what to say; `render.ts` decides how it looks.
18
+ */
19
+ import { type SforaApiClient } from "./api-client.js";
20
+ export interface CommandOutput {
21
+ stdout: string;
22
+ stderr: string;
23
+ exitCode: number;
24
+ }
25
+ /**
26
+ * "Once per run" as a value rather than a module-level flag.
27
+ *
28
+ * The presence note ("you are visible as editing this document") is a fact
29
+ * about the user's visibility to other people, so it is said out loud — but
30
+ * said on every write it is noise, and the second one teaches nothing. "Once"
31
+ * therefore has to be remembered somewhere, and the somewhere cannot be a
32
+ * `let` inside `putCommand` (per call is not once) nor a module-level flag
33
+ * (per PROCESS, which makes the second test in a file depend on the first, and
34
+ * which the interactive shell and the `put` verb would each keep their own of
35
+ * — announcing twice).
36
+ *
37
+ * A run owns one of these and hands it to everything that might say the line.
38
+ */
39
+ export interface PresenceNotice {
40
+ /** True the first time the note is worth printing, false forever after. */
41
+ claim(): boolean;
42
+ }
43
+ export declare function presenceNotice(): PresenceNotice;
44
+ /** An absolute fs path from a possibly-relative one plus the shell's cwd. */
45
+ export declare function resolveFsPath(cwd: string, path: string): string;
46
+ /**
47
+ * `blocks <path>` — what a `?block=` write can aim at.
48
+ *
49
+ * This is the read that makes single-block writing usable: the ids are
50
+ * fingerprints over the document's bytes, so they are stable until somebody
51
+ * changes that block, and `writable` says which of them the write door will
52
+ * actually accept (a frontmatter fence and a title heading are served but not
53
+ * stored, so their ids address nothing).
54
+ */
55
+ export declare function blocksCommand(client: SforaApiClient, fsPath: string, options?: {
56
+ json?: boolean;
57
+ }): Promise<CommandOutput>;
58
+ /**
59
+ * `put <path>` — write a file, or with `--block <id>` exactly one block of it.
60
+ *
61
+ * Always reports the EFFECT, never a bare "saved". sfora's write door splices:
62
+ * a PUT of bytes that parse the same as the stored ones stores nothing, and
63
+ * `changed: false` is the honest and frequent answer to a `GET`-edit-`PUT`
64
+ * loop that reformatted more than it meant to.
65
+ *
66
+ * A 409 is the interesting failure and it is not really a failure: block ids
67
+ * are content-derived, so "this id resolves to nothing" means somebody changed
68
+ * that block since you read it. The server sends the document's current blocks
69
+ * with the refusal, so the recovery is printed as a table to re-aim from,
70
+ * rather than as an error to go re-investigate.
71
+ */
72
+ export declare function putCommand(client: SforaApiClient, fsPath: string, body: string, options?: {
73
+ blockId?: string;
74
+ json?: boolean;
75
+ /**
76
+ * The run's presence latch. Omitted means "this call is the run" — a
77
+ * single `sfora put` announces, which is exactly once.
78
+ */
79
+ presence?: PresenceNotice;
80
+ }): Promise<CommandOutput>;
81
+ /** `url <path>` — the page, printed. See `web-url.ts` for who knows the route. */
82
+ export declare function urlCommand(client: SforaApiClient, fsPath: string, options?: {
83
+ json?: boolean;
84
+ }): Promise<CommandOutput>;
@@ -0,0 +1,155 @@
1
+ /**
2
+ * `blocks`, `put` and `url` — one implementation, two front doors.
3
+ *
4
+ * Card #333. These three run identically as CLI verbs (`sfora put …`) and as
5
+ * commands inside the interactive shell (`put …`), and they do it by being
6
+ * written once here: each takes its arguments and returns
7
+ * `{ stdout, stderr, exitCode }`, which is exactly what just-bash's `Command`
8
+ * interface wants and exactly what the CLI writes to its own streams.
9
+ *
10
+ * WHY STREAMS AND AN EXIT CODE RATHER THAN PRINTING. A shell command that
11
+ * printed to `process.stdout` would escape the pipeline — `blocks x.md | grep
12
+ * heading` would print everything and pipe nothing. Returning the text makes
13
+ * both callers correct and makes the tests below assert on a value instead of
14
+ * spying on the process.
15
+ *
16
+ * The split against `render.ts` is the usual one: this module talks to the
17
+ * server and decides what to say; `render.ts` decides how it looks.
18
+ */
19
+ import { blockConflictFrom, SforaApiError, } from "./api-client.js";
20
+ import { colors, renderBlockConflict, renderBlocks, renderWriteEffect, urlLine, PRESENCE_NOTE, } from "./render.js";
21
+ const ok = (stdout, stderr = "") => ({
22
+ stdout,
23
+ stderr,
24
+ exitCode: 0,
25
+ });
26
+ const fail = (stderr) => ({
27
+ stdout: "",
28
+ stderr: stderr.endsWith("\n") ? stderr : `${stderr}\n`,
29
+ exitCode: 1,
30
+ });
31
+ export function presenceNotice() {
32
+ let said = false;
33
+ return {
34
+ claim() {
35
+ if (said)
36
+ return false;
37
+ said = true;
38
+ return true;
39
+ },
40
+ };
41
+ }
42
+ /** An absolute fs path from a possibly-relative one plus the shell's cwd. */
43
+ export function resolveFsPath(cwd, path) {
44
+ if (path.startsWith("/"))
45
+ return path;
46
+ const parts = `${cwd}/${path}`.split("/");
47
+ const out = [];
48
+ for (const part of parts) {
49
+ if (!part || part === ".")
50
+ continue;
51
+ if (part === "..")
52
+ out.pop();
53
+ else
54
+ out.push(part);
55
+ }
56
+ return `/${out.join("/")}`;
57
+ }
58
+ /** Turn an API failure into the message a person should read. */
59
+ function apiMessage(error) {
60
+ if (error instanceof SforaApiError) {
61
+ return error.status === 0
62
+ ? `could not reach the server — ${error.message}`
63
+ : error.message;
64
+ }
65
+ return error instanceof Error ? error.message : String(error);
66
+ }
67
+ /**
68
+ * `blocks <path>` — what a `?block=` write can aim at.
69
+ *
70
+ * This is the read that makes single-block writing usable: the ids are
71
+ * fingerprints over the document's bytes, so they are stable until somebody
72
+ * changes that block, and `writable` says which of them the write door will
73
+ * actually accept (a frontmatter fence and a title heading are served but not
74
+ * stored, so their ids address nothing).
75
+ */
76
+ export async function blocksCommand(client, fsPath, options = {}) {
77
+ try {
78
+ const view = await client.readBlocks(fsPath);
79
+ // The view already carries `url` and `renderBlocks` prints it, so consume
80
+ // the recorded info here: an unconsumed link would be printed a second
81
+ // time by whatever ran this command.
82
+ client.takeResponseInfo();
83
+ return ok(options.json
84
+ ? `${JSON.stringify(view, null, 2)}\n`
85
+ : `${renderBlocks(view)}\n`);
86
+ }
87
+ catch (error) {
88
+ return fail(`${colors.red}error:${colors.reset} ${apiMessage(error)}`);
89
+ }
90
+ }
91
+ /**
92
+ * `put <path>` — write a file, or with `--block <id>` exactly one block of it.
93
+ *
94
+ * Always reports the EFFECT, never a bare "saved". sfora's write door splices:
95
+ * a PUT of bytes that parse the same as the stored ones stores nothing, and
96
+ * `changed: false` is the honest and frequent answer to a `GET`-edit-`PUT`
97
+ * loop that reformatted more than it meant to.
98
+ *
99
+ * A 409 is the interesting failure and it is not really a failure: block ids
100
+ * are content-derived, so "this id resolves to nothing" means somebody changed
101
+ * that block since you read it. The server sends the document's current blocks
102
+ * with the refusal, so the recovery is printed as a table to re-aim from,
103
+ * rather than as an error to go re-investigate.
104
+ */
105
+ export async function putCommand(client, fsPath, body, options = {}) {
106
+ try {
107
+ const result = await client.writePath(fsPath, body, {
108
+ blockId: options.blockId,
109
+ });
110
+ const info = client.takeResponseInfo();
111
+ if (options.json)
112
+ return ok(`${JSON.stringify(result, null, 2)}\n`);
113
+ // The confirmation is a REPORT, not data — so stderr, which keeps
114
+ // `put … | something` from feeding a downstream command an ANSI receipt,
115
+ // and keeps `put --json` the only thing that ever reaches stdout.
116
+ const lines = [
117
+ `${colors.green}✓${colors.reset} ${options.blockId
118
+ ? `Wrote block ${options.blockId} of ${result.path ?? fsPath}`
119
+ : `Wrote ${result.path ?? fsPath}`}`,
120
+ ];
121
+ const effect = renderWriteEffect(info.effect);
122
+ if (effect)
123
+ lines.push(` ${effect}`);
124
+ if (info.url)
125
+ lines.push(` ${urlLine(info.url)}`);
126
+ // Presence is a fact about being SEEN, so it is said once and only when the
127
+ // server reports it — document writes declare it, posts and cards do not.
128
+ // `claim()` is behind the `present` check so a write that reported no
129
+ // roster cannot spend the run's one announcement on nothing.
130
+ if (info.present && (options.presence?.claim() ?? true)) {
131
+ lines.push(` ${colors.dim}${PRESENCE_NOTE}${colors.reset}`);
132
+ }
133
+ return ok("", `${lines.join("\n")}\n`);
134
+ }
135
+ catch (error) {
136
+ const conflict = blockConflictFrom(error);
137
+ if (conflict)
138
+ return fail(renderBlockConflict(conflict, fsPath));
139
+ return fail(`${colors.red}error:${colors.reset} ${apiMessage(error)}`);
140
+ }
141
+ }
142
+ /** `url <path>` — the page, printed. See `web-url.ts` for who knows the route. */
143
+ export async function urlCommand(client, fsPath, options = {}) {
144
+ try {
145
+ const url = await client.resolveWebUrl(fsPath);
146
+ client.takeResponseInfo(); // printed below; do not leave it for a caller
147
+ if (!url) {
148
+ return fail(`${colors.red}error:${colors.reset} ${fsPath} has no page on the web`);
149
+ }
150
+ return ok(options.json ? `${JSON.stringify({ path: fsPath, url })}\n` : `${url}\n`);
151
+ }
152
+ catch (error) {
153
+ return fail(`${colors.red}error:${colors.reset} ${apiMessage(error)}`);
154
+ }
155
+ }