sfora-cli 0.9.0 → 0.11.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 (108) hide show
  1. package/README.md +147 -6
  2. package/dist/SforaFs.js +278 -10
  3. package/dist/api-client.d.ts +290 -5
  4. package/dist/api-client.js +307 -22
  5. package/dist/block-commands.d.ts +84 -0
  6. package/dist/block-commands.js +155 -0
  7. package/dist/cli.js +323 -29
  8. package/dist/format/__tests__/byteStable.d.ts +5 -0
  9. package/dist/format/__tests__/byteStable.js +64 -0
  10. package/dist/format/blockSplice.d.ts +135 -0
  11. package/dist/format/blockSplice.js +330 -0
  12. package/dist/format/blocks/dropClosure.d.ts +81 -0
  13. package/dist/format/blocks/dropClosure.js +196 -0
  14. package/dist/format/blocks/markdown-block-catalog.d.ts +18 -0
  15. package/dist/format/blocks/markdown-block-catalog.js +162 -0
  16. package/dist/format/blocks/markdown-block-ids.d.mts +1 -0
  17. package/dist/format/blocks/markdown-block-ids.mjs +25 -0
  18. package/dist/format/blocks/parsers.d.ts +105 -0
  19. package/dist/format/blocks/parsers.js +442 -0
  20. package/dist/format/blocks/structured-block-schema.d.ts +8 -0
  21. package/dist/format/blocks/structured-block-schema.js +30 -0
  22. package/dist/format/callout.d.ts +128 -0
  23. package/dist/format/callout.js +227 -0
  24. package/dist/format/cardMarkdown.d.ts +2 -0
  25. package/dist/format/cardMarkdown.js +10 -0
  26. package/dist/format/checklist.d.ts +34 -0
  27. package/dist/format/checklist.js +158 -0
  28. package/dist/format/formatAxes.d.ts +228 -0
  29. package/dist/format/formatAxes.js +454 -0
  30. package/dist/format/index.d.ts +19 -4
  31. package/dist/format/index.js +28 -4
  32. package/dist/format/lineGeometry.d.ts +100 -0
  33. package/dist/format/lineGeometry.js +424 -0
  34. package/dist/format/lint/appliesTo.d.ts +92 -0
  35. package/dist/format/lint/appliesTo.js +369 -0
  36. package/dist/format/lint/config.d.ts +106 -0
  37. package/dist/format/lint/config.js +205 -0
  38. package/dist/format/lint/fixAll.d.ts +62 -0
  39. package/dist/format/lint/fixAll.js +107 -0
  40. package/dist/format/lint/frontmatterSchema.d.ts +181 -0
  41. package/dist/format/lint/frontmatterSchema.js +660 -0
  42. package/dist/format/lint/index.d.ts +49 -0
  43. package/dist/format/lint/index.js +51 -0
  44. package/dist/format/lint/lintSource.d.ts +56 -0
  45. package/dist/format/lint/lintSource.js +188 -0
  46. package/dist/format/lint/rules/broken-wiki-link.d.ts +2 -0
  47. package/dist/format/lint/rules/broken-wiki-link.js +45 -0
  48. package/dist/format/lint/rules/frontmatter-schema.d.ts +2 -0
  49. package/dist/format/lint/rules/frontmatter-schema.js +92 -0
  50. package/dist/format/lint/rules/index.d.ts +11 -0
  51. package/dist/format/lint/rules/index.js +32 -0
  52. package/dist/format/lint/rules/malformed-callout.d.ts +2 -0
  53. package/dist/format/lint/rules/malformed-callout.js +88 -0
  54. package/dist/format/lint/rules/malformed-checklist.d.ts +2 -0
  55. package/dist/format/lint/rules/malformed-checklist.js +65 -0
  56. package/dist/format/lint/rules/malformed-frontmatter.d.ts +2 -0
  57. package/dist/format/lint/rules/malformed-frontmatter.js +98 -0
  58. package/dist/format/lint/rules/malformed-structured-block.d.ts +2 -0
  59. package/dist/format/lint/rules/malformed-structured-block.js +134 -0
  60. package/dist/format/lint/rules/malformed-wiki-link.d.ts +2 -0
  61. package/dist/format/lint/rules/malformed-wiki-link.js +43 -0
  62. package/dist/format/lint/rules/orphan-reference.d.ts +2 -0
  63. package/dist/format/lint/rules/orphan-reference.js +87 -0
  64. package/dist/format/lint/severity.d.ts +15 -0
  65. package/dist/format/lint/severity.js +50 -0
  66. package/dist/format/lint/textEdits.d.ts +86 -0
  67. package/dist/format/lint/textEdits.js +162 -0
  68. package/dist/format/lint/types.d.ts +116 -0
  69. package/dist/format/lint/types.js +16 -0
  70. package/dist/format/markdown/dates.js +2 -0
  71. package/dist/format/markdown/document.js +2 -0
  72. package/dist/format/markdown/index.js +2 -0
  73. package/dist/format/markdown/mentions.js +2 -0
  74. package/dist/format/markdown/slug.d.ts +28 -0
  75. package/dist/format/markdown/slug.js +65 -0
  76. package/dist/format/markdown/yaml.js +2 -0
  77. package/dist/format/noteMarkdown.js +2 -0
  78. package/dist/format/parseWithFallback.d.ts +13 -0
  79. package/dist/format/parseWithFallback.js +98 -0
  80. package/dist/format/plaintext.d.ts +5 -0
  81. package/dist/format/plaintext.js +51 -0
  82. package/dist/format/postMarkdown.js +3 -1
  83. package/dist/format/sheetCellSpans.d.ts +95 -0
  84. package/dist/format/sheetCellSpans.js +223 -0
  85. package/dist/format/sheetSelection.d.ts +136 -0
  86. package/dist/format/sheetSelection.js +282 -0
  87. package/dist/format/taskUploadFilename.d.ts +6 -0
  88. package/dist/format/taskUploadFilename.js +13 -0
  89. package/dist/format/textStats.d.ts +23 -0
  90. package/dist/format/textStats.js +80 -0
  91. package/dist/format/wayfinder.d.ts +50 -0
  92. package/dist/format/wayfinder.js +203 -0
  93. package/dist/format/wikiLinks.d.ts +78 -0
  94. package/dist/format/wikiLinks.js +266 -0
  95. package/dist/index.d.ts +26 -1
  96. package/dist/index.js +20 -3
  97. package/dist/mcp-server.js +5 -2
  98. package/dist/opener.d.ts +23 -0
  99. package/dist/opener.js +26 -0
  100. package/dist/render.d.ts +132 -0
  101. package/dist/render.js +208 -0
  102. package/dist/shell-commands.d.ts +34 -0
  103. package/dist/shell-commands.js +108 -0
  104. package/dist/watch.d.ts +79 -0
  105. package/dist/watch.js +113 -0
  106. package/dist/web-url.d.ts +39 -0
  107. package/dist/web-url.js +63 -0
  108. package/package.json +7 -6
@@ -8,32 +8,140 @@
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) {
37
145
  const headers = Object.create(null);
38
146
  headers.Authorization = `Bearer ${this.#apiKey}`;
39
147
  if (this.#actAs)
@@ -42,7 +150,12 @@ export class SforaApiClient {
42
150
  headers["Content-Type"] = "text/markdown";
43
151
  let res;
44
152
  try {
45
- res = await fetch(`${this.#baseUrl}${path}`, { method, headers, body });
153
+ res = await fetch(`${this.#baseUrl}${path}`, {
154
+ method,
155
+ headers,
156
+ body,
157
+ signal,
158
+ });
46
159
  }
47
160
  catch (e) {
48
161
  // Network/DNS/connection failures — surface as a 0-status error so the
@@ -52,16 +165,39 @@ export class SforaApiClient {
52
165
  }
53
166
  if (!res.ok)
54
167
  throw await this.#toError(res);
168
+ // Markdown reads carry their page link in a header; JSON responses carry it
169
+ // in the body, and `#jsonFrom` picks it up there. Set on every response so
170
+ // one that names no entity CLEARS what an earlier one left behind.
171
+ this.#lastInfo = {
172
+ url: webUrlFromResponse(res.headers),
173
+ effect: null,
174
+ present: false,
175
+ };
55
176
  return res;
56
177
  }
178
+ /** Parse a JSON response, remembering the page and effect report it names. */
179
+ async #jsonFrom(res) {
180
+ const text = await res.text();
181
+ const parsed = JSON.parse(text);
182
+ this.#lastInfo = {
183
+ url: webUrlFromResponse(res.headers, text),
184
+ effect: writeEffectFrom(parsed),
185
+ present: !!parsed &&
186
+ typeof parsed === "object" &&
187
+ parsed.present === true,
188
+ };
189
+ return parsed;
190
+ }
57
191
  async #toError(res) {
58
192
  const text = await res.text().catch(() => "");
59
193
  let code = "bad_request";
60
194
  let message = text || res.statusText;
195
+ let data;
61
196
  if (text) {
62
197
  try {
63
198
  const parsed = JSON.parse(text);
64
199
  if (parsed && typeof parsed === "object") {
200
+ data = parsed;
65
201
  const obj = parsed;
66
202
  if (typeof obj.error === "string")
67
203
  code = obj.error;
@@ -73,11 +209,10 @@ export class SforaApiClient {
73
209
  // Non-JSON error body — keep the raw text as the message.
74
210
  }
75
211
  }
76
- return new SforaApiError(res.status, code, message);
212
+ return new SforaApiError(res.status, code, message, data);
77
213
  }
78
- async #json(path) {
79
- const res = await this.#request("GET", path);
80
- return (await res.json());
214
+ async #json(path, signal) {
215
+ return this.#jsonFrom(await this.#request("GET", path, undefined, signal));
81
216
  }
82
217
  async #text(path) {
83
218
  const res = await this.#request("GET", path);
@@ -92,7 +227,7 @@ export class SforaApiClient {
92
227
  /** `POST /v1/fs/projects` — create a project; the caller becomes its lead. */
93
228
  async createProject(name) {
94
229
  const res = await this.#request("POST", "/v1/fs/projects", JSON.stringify({ name }));
95
- return (await res.json());
230
+ return this.#jsonFrom(res);
96
231
  }
97
232
  /** `GET …/links.md` — the project's external links as markdown. */
98
233
  async getProjectLinks(slug) {
@@ -102,6 +237,25 @@ export class SforaApiClient {
102
237
  async setProjectLinks(slug, markdown) {
103
238
  await this.#request("PUT", `/v1/fs/projects/${encodeURIComponent(slug)}/links.md`, markdown);
104
239
  }
240
+ /** `GET …/plan.md` — the project's plan (goal + question buckets). */
241
+ // The map as a file: derived, read-only (a PUT is refused server-side).
242
+ async readMap(slug) {
243
+ return this.#text(`/v1/fs/projects/${encodeURIComponent(slug)}/map.md`);
244
+ }
245
+ async readPlan(slug) {
246
+ return this.#text(`/v1/fs/projects/${encodeURIComponent(slug)}/plan.md`);
247
+ }
248
+ /**
249
+ * `PUT …/plan.md` — set the goal. Only the `## the goal` section is
250
+ * honored; the server names everything it ignored in `ignoredSections`.
251
+ */
252
+ async writePlan(slug, markdown) {
253
+ await this.#request("PUT", `/v1/fs/projects/${encodeURIComponent(slug)}/plan.md`, markdown);
254
+ }
255
+ /** `GET …/asks.md` — coordination asks (read-only projection). */
256
+ async readAsks(slug) {
257
+ return this.#text(`/v1/fs/projects/${encodeURIComponent(slug)}/asks.md`);
258
+ }
105
259
  /**
106
260
  * `GET …/posts` or `…/drafts`. `scheduled` lists drafts that have a
107
261
  * (future) `scheduledFor` set.
@@ -123,14 +277,37 @@ export class SforaApiClient {
123
277
  const file = encodeURIComponent(filename);
124
278
  return this.#text(`/v1/fs/projects/${slug}/${base}/${file}`);
125
279
  }
126
- /** `PUT …/(posts|drafts)/:filename.md` — create or update from a markdown file. */
127
- async writePost(projectSlug, kind, filename, markdown) {
280
+ /** Create a post or upsert a mutable draft from a Markdown file. */
281
+ async writePost(projectSlug, kind, filename, markdown, options) {
128
282
  const base = routeBase(kind);
129
283
  const slug = encodeURIComponent(projectSlug);
130
284
  const file = encodeURIComponent(filename);
131
- const res = await this.#request("PUT", `/v1/fs/projects/${slug}/${base}/${file}`, markdown);
132
- const data = (await res.json());
133
- return { filename: data.filename, id: data.id };
285
+ const res = await this.#request("PUT", `/v1/fs/projects/${slug}/${base}/${file}${blockQuery(options)}`, markdown);
286
+ // The WHOLE report, not just `{ filename, id }`: `changed` and the rebind
287
+ // counts are what card #333 prints after every write, and narrowing here
288
+ // would throw them away one layer below the caller that needs them.
289
+ return this.#jsonFrom(res);
290
+ }
291
+ /**
292
+ * `GET …?view=blocks` on any fs path with a stored markdown body — the
293
+ * addressable view a `?block=` write aims into.
294
+ *
295
+ * The path is the caller's fs path (`/projects/acme/docs/x.md`), not a
296
+ * `/v1/fs` URL: the CLI does not build routes.
297
+ */
298
+ async readBlocks(fsPath) {
299
+ return this.#json(`${fsRequestPath(fsPath)}?view=blocks`);
300
+ }
301
+ /**
302
+ * `PUT <fs path>` — the generic write door, so the CLI can put to any path
303
+ * (and to a single block of it) without a per-entity method.
304
+ *
305
+ * A 409 from a `?block=` write throws a {@link SforaApiError} carrying the
306
+ * server's recovery payload; read it with {@link blockConflictFrom}.
307
+ */
308
+ async writePath(fsPath, markdown, options) {
309
+ const res = await this.#request("PUT", `${fsRequestPath(fsPath)}${blockQuery(options)}`, markdown);
310
+ return this.#jsonFrom(res);
134
311
  }
135
312
  /** `DELETE …/(posts|drafts)/:filename.md` — soft-deletes the matched post. */
136
313
  async deletePost(projectSlug, kind, filename) {
@@ -143,14 +320,52 @@ export class SforaApiClient {
143
320
  /** `GET …/docs` — the project's notes, most-recently-edited first. */
144
321
  async listNotes(projectSlug) {
145
322
  const slug = encodeURIComponent(projectSlug);
146
- const data = await this.#json(`/v1/fs/projects/${slug}/docs`);
323
+ const data = await this.#json(`/v1/fs/projects/${slug}/library/documents`);
147
324
  return data.docs ?? [];
148
325
  }
149
326
  /** `GET …/docs/:filename.md`. Returns the raw markdown body. */
150
327
  async readNote(projectSlug, filename) {
151
328
  const slug = encodeURIComponent(projectSlug);
152
329
  const file = encodeURIComponent(filename);
153
- return this.#text(`/v1/fs/projects/${slug}/docs/${file}`);
330
+ return this.#text(`/v1/fs/projects/${slug}/library/documents/${file}`);
331
+ }
332
+ async writeNote(projectSlug, filename, markdown) {
333
+ const slug = encodeURIComponent(projectSlug);
334
+ const file = encodeURIComponent(filename);
335
+ const res = await this.#request("PUT", `/v1/fs/projects/${slug}/library/documents/${file}`, markdown);
336
+ return await this.#jsonFrom(res);
337
+ }
338
+ async deleteNote(projectSlug, filename) {
339
+ const slug = encodeURIComponent(projectSlug);
340
+ const file = encodeURIComponent(filename);
341
+ await this.#request("DELETE", `/v1/fs/projects/${slug}/library/documents/${file}`);
342
+ }
343
+ // ─── Unified project Library ────────────────────────────────────
344
+ async listArtifacts(projectSlug) {
345
+ const slug = encodeURIComponent(projectSlug);
346
+ const data = await this.#json(`/v1/fs/projects/${slug}/library/files`);
347
+ return data.artifacts ?? [];
348
+ }
349
+ async readArtifact(projectSlug, filename) {
350
+ const slug = encodeURIComponent(projectSlug);
351
+ const file = encodeURIComponent(filename);
352
+ return this.#text(`/v1/fs/projects/${slug}/library/files/${file}`);
353
+ }
354
+ async listRepositories(projectSlug) {
355
+ const slug = encodeURIComponent(projectSlug);
356
+ const data = await this.#json(`/v1/fs/projects/${slug}/library/repositories`);
357
+ return data.repositories ?? [];
358
+ }
359
+ async getRepositoryTree(projectSlug, dirname) {
360
+ const slug = encodeURIComponent(projectSlug);
361
+ const repo = encodeURIComponent(dirname);
362
+ return this.#json(`/v1/fs/projects/${slug}/library/repositories/${repo}`);
363
+ }
364
+ async readRepositoryFile(projectSlug, dirname, path) {
365
+ const slug = encodeURIComponent(projectSlug);
366
+ const repo = encodeURIComponent(dirname);
367
+ const file = path.split("/").map(encodeURIComponent).join("/");
368
+ return this.#text(`/v1/fs/projects/${slug}/library/repositories/${repo}/${file}`);
154
369
  }
155
370
  // ─── Pull requests (read-only) ───────────────────────────────────
156
371
  /** `GET …/pulls` — the project's synced pull requests, open first. */
@@ -175,7 +390,14 @@ export class SforaApiClient {
175
390
  async getBoardMeta(projectSlug) {
176
391
  const slug = encodeURIComponent(projectSlug);
177
392
  const data = await this.#json(`/v1/fs/projects/${slug}/board`);
178
- return { publicSlug: data.publicSlug ?? null, columns: data.columns };
393
+ return {
394
+ publicSlug: data.publicSlug ?? null,
395
+ columns: data.columns,
396
+ // Kept, not derived. The roadmap projection quotes this verbatim, and
397
+ // rebuilding it here from `publicSlug` would put the hostname back in
398
+ // the CLI — which is the whole thing the server sending it prevents.
399
+ publicUrl: data.publicUrl ?? null,
400
+ };
179
401
  }
180
402
  /** `GET …/board` — list the project's columns (auto-creates the board on first call). */
181
403
  async listColumns(projectSlug) {
@@ -207,7 +429,7 @@ export class SforaApiClient {
207
429
  const col = encodeURIComponent(columnDir);
208
430
  const file = encodeURIComponent(filename);
209
431
  const res = await this.#request("PUT", `/v1/fs/projects/${slug}/board/${col}/${file}`, markdown);
210
- return (await res.json());
432
+ return await this.#jsonFrom(res);
211
433
  }
212
434
  /** `DELETE …/board/:column/:filename.md` — archives the card. */
213
435
  async deleteCard(projectSlug, columnDir, filename) {
@@ -231,7 +453,7 @@ export class SforaApiClient {
231
453
  });
232
454
  if (!res.ok)
233
455
  throw await this.#toError(res);
234
- return (await res.json());
456
+ return await this.#jsonFrom(res);
235
457
  }
236
458
  /**
237
459
  * `POST …/board/:column/_rename` with `{ name }` — renames the column,
@@ -250,7 +472,7 @@ export class SforaApiClient {
250
472
  });
251
473
  if (!res.ok)
252
474
  throw await this.#toError(res);
253
- return (await res.json());
475
+ return await this.#jsonFrom(res);
254
476
  }
255
477
  /** `DELETE …/board/:column` — column must be empty. */
256
478
  async deleteColumn(projectSlug, columnDir) {
@@ -276,7 +498,7 @@ export class SforaApiClient {
276
498
  });
277
499
  if (!res.ok)
278
500
  throw await this.#toError(res);
279
- return (await res.json());
501
+ return await this.#jsonFrom(res);
280
502
  }
281
503
  // ─── Post actions (comment / react) ─────────────────────────────
282
504
  /** `POST /v1/posts/:postId/comments` — add a comment to a post. */
@@ -291,7 +513,7 @@ export class SforaApiClient {
291
513
  });
292
514
  if (!res.ok)
293
515
  throw await this.#toError(res);
294
- const data = (await res.json());
516
+ const data = await this.#jsonFrom(res);
295
517
  return { id: data.comment?._id ?? "" };
296
518
  }
297
519
  /**
@@ -309,9 +531,72 @@ export class SforaApiClient {
309
531
  });
310
532
  if (!res.ok)
311
533
  throw await this.#toError(res);
312
- const data = (await res.json());
534
+ const data = await this.#jsonFrom(res);
313
535
  return data.reaction ?? { content: emoji, reacted: true };
314
536
  }
537
+ // ─── Live docs: the roster and the ping stream ───────────────────
538
+ /**
539
+ * `POST <path>/_presence` — say you are in a document.
540
+ *
541
+ * Returns `null` when the path has no roster. The CLI does not decide which
542
+ * paths those are: it asks, and a `422` is the server's named refusal
543
+ * ("posts and board cards have markdown bodies but nobody has one open in a
544
+ * document editor"). Every other failure is thrown as usual.
545
+ */
546
+ async declarePresence(fsPath, options = {}) {
547
+ const params = new URLSearchParams();
548
+ if (options.kind)
549
+ params.set("kind", options.kind);
550
+ if (options.block)
551
+ params.set("block", options.block);
552
+ if (options.leave)
553
+ params.set("leave", "");
554
+ const query = params.toString();
555
+ try {
556
+ const res = await this.#request("POST", `${fsRequestPath(fsPath)}/_presence${query ? `?${query}` : ""}`);
557
+ return this.#jsonFrom(res);
558
+ }
559
+ catch (error) {
560
+ if (error instanceof SforaApiError && error.status === 422)
561
+ return null;
562
+ throw error;
563
+ }
564
+ }
565
+ /**
566
+ * `GET /v1/events` — the long-poll. Blocks server-side until something
567
+ * happens or the wait budget elapses, then answers with a cursor to poll
568
+ * from next.
569
+ *
570
+ * `signal` is not optional in spirit: this is the ONE request in the client
571
+ * that is designed to hang, so a caller that cannot cancel it cannot stop.
572
+ * `sfora watch` aborts it from its ^C handler, which is the difference
573
+ * between a terminal that says "^C to stop" and one that does.
574
+ */
575
+ async pollEvents(params) {
576
+ const query = new URLSearchParams({ since: String(params.since) });
577
+ if (params.wait !== undefined)
578
+ query.set("wait", String(params.wait));
579
+ if (params.doc)
580
+ query.set("doc", params.doc);
581
+ if (params.project)
582
+ query.set("project", params.project);
583
+ // `?self=include` is the server's spelling; the default is to exclude your
584
+ // own writes, matching the message branch's "don't wake on your own".
585
+ if (params.includeSelf)
586
+ query.set("self", "include");
587
+ return this.#json(`/v1/events?${query.toString()}`, params.signal);
588
+ }
589
+ /**
590
+ * The entity id behind an fs path — what `/v1/events?doc=` wants.
591
+ *
592
+ * Read off `?view=blocks`, which states the document's identity in its
593
+ * `document` field, rather than parsed out of the file's frontmatter: the
594
+ * projection is the server saying which row these bytes are.
595
+ */
596
+ async resolveDocId(fsPath) {
597
+ const view = await this.readBlocks(fsPath);
598
+ return view.document?.id ?? null;
599
+ }
315
600
  /** `GET /v1/fs/inbox/mentions.md` — markdown summary of unread mentions. */
316
601
  async readInbox() {
317
602
  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>;