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.
- package/README.md +174 -0
- package/dist/SforaFs.js +8 -6
- package/dist/api-client.d.ts +344 -4
- package/dist/api-client.js +289 -21
- package/dist/block-commands.d.ts +84 -0
- package/dist/block-commands.js +155 -0
- package/dist/chat.d.ts +89 -0
- package/dist/chat.js +189 -0
- package/dist/cli-args.d.ts +32 -0
- package/dist/cli-args.js +88 -0
- package/dist/cli.js +530 -88
- package/dist/format/blockSplice.d.ts +135 -0
- package/dist/format/blockSplice.js +330 -0
- package/dist/format/blocks/dropClosure.d.ts +10 -1
- package/dist/format/blocks/dropClosure.js +11 -1
- package/dist/format/callout.d.ts +69 -7
- package/dist/format/callout.js +112 -15
- package/dist/format/checklist.js +11 -4
- package/dist/format/formatAxes.d.ts +228 -0
- package/dist/format/formatAxes.js +454 -0
- package/dist/format/index.d.ts +1 -0
- package/dist/format/index.js +4 -0
- package/dist/format/lineGeometry.d.ts +34 -4
- package/dist/format/lineGeometry.js +140 -40
- package/dist/format/lint/appliesTo.d.ts +92 -0
- package/dist/format/lint/appliesTo.js +369 -0
- package/dist/format/lint/config.d.ts +106 -0
- package/dist/format/lint/config.js +205 -0
- package/dist/format/lint/fixAll.d.ts +62 -0
- package/dist/format/lint/fixAll.js +107 -0
- package/dist/format/lint/frontmatterSchema.d.ts +181 -0
- package/dist/format/lint/frontmatterSchema.js +660 -0
- package/dist/format/lint/index.d.ts +34 -5
- package/dist/format/lint/index.js +34 -5
- package/dist/format/lint/lintSource.d.ts +27 -7
- package/dist/format/lint/lintSource.js +67 -33
- package/dist/format/lint/rules/frontmatter-schema.d.ts +2 -0
- package/dist/format/lint/rules/frontmatter-schema.js +92 -0
- package/dist/format/lint/rules/index.d.ts +2 -1
- package/dist/format/lint/rules/index.js +7 -1
- package/dist/format/lint/rules/malformed-callout.js +25 -16
- package/dist/format/lint/rules/malformed-checklist.js +8 -3
- package/dist/format/lint/rules/malformed-frontmatter.js +6 -1
- package/dist/format/lint/severity.d.ts +15 -0
- package/dist/format/lint/severity.js +50 -0
- package/dist/format/lint/textEdits.d.ts +86 -0
- package/dist/format/lint/textEdits.js +162 -0
- package/dist/format/lint/types.d.ts +44 -8
- package/dist/format/markdown/slug.d.ts +28 -0
- package/dist/format/markdown/slug.js +63 -0
- package/dist/format/plaintext.js +13 -3
- package/dist/format/sheetCellSpans.d.ts +95 -0
- package/dist/format/sheetCellSpans.js +223 -0
- package/dist/format/sheetSelection.d.ts +136 -0
- package/dist/format/sheetSelection.js +282 -0
- package/dist/format/textStats.d.ts +23 -0
- package/dist/format/textStats.js +80 -0
- package/dist/format/wikiLinks.d.ts +60 -1
- package/dist/format/wikiLinks.js +195 -9
- package/dist/index.d.ts +26 -1
- package/dist/index.js +20 -3
- package/dist/opener.d.ts +23 -0
- package/dist/opener.js +26 -0
- package/dist/render.d.ts +162 -0
- package/dist/render.js +280 -0
- package/dist/shell-commands.d.ts +34 -0
- package/dist/shell-commands.js +108 -0
- package/dist/watch.d.ts +79 -0
- package/dist/watch.js +113 -0
- package/dist/web-url.d.ts +39 -0
- package/dist/web-url.js +63 -0
- package/package.json +1 -1
package/dist/api-client.js
CHANGED
|
@@ -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
|
-
|
|
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"] =
|
|
152
|
+
headers["Content-Type"] = contentType;
|
|
43
153
|
let res;
|
|
44
154
|
try {
|
|
45
|
-
res = await fetch(`${this.#baseUrl}${path}`, {
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
152
|
-
|
|
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
|
|
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 {
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 =
|
|
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 =
|
|
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
|
+
}
|