sfora-cli 0.10.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.
- package/README.md +139 -0
- package/dist/SforaFs.js +8 -6
- package/dist/api-client.d.ts +243 -4
- package/dist/api-client.js +248 -20
- package/dist/block-commands.d.ts +84 -0
- package/dist/block-commands.js +155 -0
- package/dist/cli.js +317 -26
- 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 +132 -0
- package/dist/render.js +208 -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,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
|
-
|
|
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}`, {
|
|
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
|
-
|
|
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 (
|
|
230
|
+
return this.#jsonFrom(res);
|
|
96
231
|
}
|
|
97
232
|
/** `GET …/links.md` — the project's external links as markdown. */
|
|
98
233
|
async getProjectLinks(slug) {
|
|
@@ -143,13 +278,36 @@ export class SforaApiClient {
|
|
|
143
278
|
return this.#text(`/v1/fs/projects/${slug}/${base}/${file}`);
|
|
144
279
|
}
|
|
145
280
|
/** Create a post or upsert a mutable draft from a Markdown file. */
|
|
146
|
-
async writePost(projectSlug, kind, filename, markdown) {
|
|
281
|
+
async writePost(projectSlug, kind, filename, markdown, options) {
|
|
147
282
|
const base = routeBase(kind);
|
|
148
283
|
const slug = encodeURIComponent(projectSlug);
|
|
149
284
|
const file = encodeURIComponent(filename);
|
|
150
|
-
const res = await this.#request("PUT", `/v1/fs/projects/${slug}/${base}/${file}`, markdown);
|
|
151
|
-
|
|
152
|
-
|
|
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);
|
|
153
311
|
}
|
|
154
312
|
/** `DELETE …/(posts|drafts)/:filename.md` — soft-deletes the matched post. */
|
|
155
313
|
async deletePost(projectSlug, kind, filename) {
|
|
@@ -175,7 +333,7 @@ export class SforaApiClient {
|
|
|
175
333
|
const slug = encodeURIComponent(projectSlug);
|
|
176
334
|
const file = encodeURIComponent(filename);
|
|
177
335
|
const res = await this.#request("PUT", `/v1/fs/projects/${slug}/library/documents/${file}`, markdown);
|
|
178
|
-
return
|
|
336
|
+
return await this.#jsonFrom(res);
|
|
179
337
|
}
|
|
180
338
|
async deleteNote(projectSlug, filename) {
|
|
181
339
|
const slug = encodeURIComponent(projectSlug);
|
|
@@ -232,7 +390,14 @@ export class SforaApiClient {
|
|
|
232
390
|
async getBoardMeta(projectSlug) {
|
|
233
391
|
const slug = encodeURIComponent(projectSlug);
|
|
234
392
|
const data = await this.#json(`/v1/fs/projects/${slug}/board`);
|
|
235
|
-
return {
|
|
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
|
+
};
|
|
236
401
|
}
|
|
237
402
|
/** `GET …/board` — list the project's columns (auto-creates the board on first call). */
|
|
238
403
|
async listColumns(projectSlug) {
|
|
@@ -264,7 +429,7 @@ export class SforaApiClient {
|
|
|
264
429
|
const col = encodeURIComponent(columnDir);
|
|
265
430
|
const file = encodeURIComponent(filename);
|
|
266
431
|
const res = await this.#request("PUT", `/v1/fs/projects/${slug}/board/${col}/${file}`, markdown);
|
|
267
|
-
return
|
|
432
|
+
return await this.#jsonFrom(res);
|
|
268
433
|
}
|
|
269
434
|
/** `DELETE …/board/:column/:filename.md` — archives the card. */
|
|
270
435
|
async deleteCard(projectSlug, columnDir, filename) {
|
|
@@ -288,7 +453,7 @@ export class SforaApiClient {
|
|
|
288
453
|
});
|
|
289
454
|
if (!res.ok)
|
|
290
455
|
throw await this.#toError(res);
|
|
291
|
-
return
|
|
456
|
+
return await this.#jsonFrom(res);
|
|
292
457
|
}
|
|
293
458
|
/**
|
|
294
459
|
* `POST …/board/:column/_rename` with `{ name }` — renames the column,
|
|
@@ -307,7 +472,7 @@ export class SforaApiClient {
|
|
|
307
472
|
});
|
|
308
473
|
if (!res.ok)
|
|
309
474
|
throw await this.#toError(res);
|
|
310
|
-
return
|
|
475
|
+
return await this.#jsonFrom(res);
|
|
311
476
|
}
|
|
312
477
|
/** `DELETE …/board/:column` — column must be empty. */
|
|
313
478
|
async deleteColumn(projectSlug, columnDir) {
|
|
@@ -333,7 +498,7 @@ export class SforaApiClient {
|
|
|
333
498
|
});
|
|
334
499
|
if (!res.ok)
|
|
335
500
|
throw await this.#toError(res);
|
|
336
|
-
return
|
|
501
|
+
return await this.#jsonFrom(res);
|
|
337
502
|
}
|
|
338
503
|
// ─── Post actions (comment / react) ─────────────────────────────
|
|
339
504
|
/** `POST /v1/posts/:postId/comments` — add a comment to a post. */
|
|
@@ -348,7 +513,7 @@ export class SforaApiClient {
|
|
|
348
513
|
});
|
|
349
514
|
if (!res.ok)
|
|
350
515
|
throw await this.#toError(res);
|
|
351
|
-
const data =
|
|
516
|
+
const data = await this.#jsonFrom(res);
|
|
352
517
|
return { id: data.comment?._id ?? "" };
|
|
353
518
|
}
|
|
354
519
|
/**
|
|
@@ -366,9 +531,72 @@ export class SforaApiClient {
|
|
|
366
531
|
});
|
|
367
532
|
if (!res.ok)
|
|
368
533
|
throw await this.#toError(res);
|
|
369
|
-
const data =
|
|
534
|
+
const data = await this.#jsonFrom(res);
|
|
370
535
|
return data.reaction ?? { content: emoji, reacted: true };
|
|
371
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
|
+
}
|
|
372
600
|
/** `GET /v1/fs/inbox/mentions.md` — markdown summary of unread mentions. */
|
|
373
601
|
async readInbox() {
|
|
374
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>;
|
|
@@ -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
|
+
}
|