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.
- package/README.md +147 -6
- package/dist/SforaFs.js +278 -10
- package/dist/api-client.d.ts +290 -5
- package/dist/api-client.js +307 -22
- package/dist/block-commands.d.ts +84 -0
- package/dist/block-commands.js +155 -0
- package/dist/cli.js +323 -29
- package/dist/format/__tests__/byteStable.d.ts +5 -0
- package/dist/format/__tests__/byteStable.js +64 -0
- package/dist/format/blockSplice.d.ts +135 -0
- package/dist/format/blockSplice.js +330 -0
- package/dist/format/blocks/dropClosure.d.ts +81 -0
- package/dist/format/blocks/dropClosure.js +196 -0
- package/dist/format/blocks/markdown-block-catalog.d.ts +18 -0
- package/dist/format/blocks/markdown-block-catalog.js +162 -0
- package/dist/format/blocks/markdown-block-ids.d.mts +1 -0
- package/dist/format/blocks/markdown-block-ids.mjs +25 -0
- package/dist/format/blocks/parsers.d.ts +105 -0
- package/dist/format/blocks/parsers.js +442 -0
- package/dist/format/blocks/structured-block-schema.d.ts +8 -0
- package/dist/format/blocks/structured-block-schema.js +30 -0
- package/dist/format/callout.d.ts +128 -0
- package/dist/format/callout.js +227 -0
- package/dist/format/cardMarkdown.d.ts +2 -0
- package/dist/format/cardMarkdown.js +10 -0
- package/dist/format/checklist.d.ts +34 -0
- package/dist/format/checklist.js +158 -0
- package/dist/format/formatAxes.d.ts +228 -0
- package/dist/format/formatAxes.js +454 -0
- package/dist/format/index.d.ts +19 -4
- package/dist/format/index.js +28 -4
- package/dist/format/lineGeometry.d.ts +100 -0
- package/dist/format/lineGeometry.js +424 -0
- 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 +49 -0
- package/dist/format/lint/index.js +51 -0
- package/dist/format/lint/lintSource.d.ts +56 -0
- package/dist/format/lint/lintSource.js +188 -0
- package/dist/format/lint/rules/broken-wiki-link.d.ts +2 -0
- package/dist/format/lint/rules/broken-wiki-link.js +45 -0
- 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 +11 -0
- package/dist/format/lint/rules/index.js +32 -0
- package/dist/format/lint/rules/malformed-callout.d.ts +2 -0
- package/dist/format/lint/rules/malformed-callout.js +88 -0
- package/dist/format/lint/rules/malformed-checklist.d.ts +2 -0
- package/dist/format/lint/rules/malformed-checklist.js +65 -0
- package/dist/format/lint/rules/malformed-frontmatter.d.ts +2 -0
- package/dist/format/lint/rules/malformed-frontmatter.js +98 -0
- package/dist/format/lint/rules/malformed-structured-block.d.ts +2 -0
- package/dist/format/lint/rules/malformed-structured-block.js +134 -0
- package/dist/format/lint/rules/malformed-wiki-link.d.ts +2 -0
- package/dist/format/lint/rules/malformed-wiki-link.js +43 -0
- package/dist/format/lint/rules/orphan-reference.d.ts +2 -0
- package/dist/format/lint/rules/orphan-reference.js +87 -0
- 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 +116 -0
- package/dist/format/lint/types.js +16 -0
- package/dist/format/markdown/dates.js +2 -0
- package/dist/format/markdown/document.js +2 -0
- package/dist/format/markdown/index.js +2 -0
- package/dist/format/markdown/mentions.js +2 -0
- package/dist/format/markdown/slug.d.ts +28 -0
- package/dist/format/markdown/slug.js +65 -0
- package/dist/format/markdown/yaml.js +2 -0
- package/dist/format/noteMarkdown.js +2 -0
- package/dist/format/parseWithFallback.d.ts +13 -0
- package/dist/format/parseWithFallback.js +98 -0
- package/dist/format/plaintext.d.ts +5 -0
- package/dist/format/plaintext.js +51 -0
- package/dist/format/postMarkdown.js +3 -1
- 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/taskUploadFilename.d.ts +6 -0
- package/dist/format/taskUploadFilename.js +13 -0
- package/dist/format/textStats.d.ts +23 -0
- package/dist/format/textStats.js +80 -0
- package/dist/format/wayfinder.d.ts +50 -0
- package/dist/format/wayfinder.js +203 -0
- package/dist/format/wikiLinks.d.ts +78 -0
- package/dist/format/wikiLinks.js +266 -0
- package/dist/index.d.ts +26 -1
- package/dist/index.js +20 -3
- package/dist/mcp-server.js +5 -2
- 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 +7 -6
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) {
|
|
@@ -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
|
-
/**
|
|
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
|
-
|
|
133
|
-
|
|
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}/
|
|
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}/
|
|
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 {
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 =
|
|
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 =
|
|
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>;
|