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/README.md
CHANGED
|
@@ -11,6 +11,7 @@ sfora new "Acme" # create a project
|
|
|
11
11
|
sfora post plan.md --project acme # push a markdown plan as a post
|
|
12
12
|
sfora task spec.md --project acme # …or a task on the board
|
|
13
13
|
sfora cat /projects/acme/board/01-todo/0042-fix-login.md
|
|
14
|
+
sfora url /projects/acme/docs/kickoff.md # where it lives on the web
|
|
14
15
|
```
|
|
15
16
|
|
|
16
17
|
Under the hood it maps a Unix view onto sfora's `/v1/fs` HTTP API (a sandboxed
|
|
@@ -211,6 +212,179 @@ backend) are the public exports, alongside the lower-level `SforaApiClient`.
|
|
|
211
212
|
|
|
212
213
|
## Supported operations
|
|
213
214
|
|
|
215
|
+
## Blocks — write one paragraph, not the file
|
|
216
|
+
|
|
217
|
+
A markdown body is addressable: every top-level block has an id that is a
|
|
218
|
+
fingerprint over its bytes, so `sfora blocks` lists them and
|
|
219
|
+
`sfora put --block <id>` replaces exactly one and leaves every other byte alone.
|
|
220
|
+
|
|
221
|
+
```bash
|
|
222
|
+
$ sfora blocks /projects/acme/docs/kickoff.md
|
|
223
|
+
kfrontmat yaml id: k17e8c0... read-only
|
|
224
|
+
k7f3a2cx heading # Kickoff read-only
|
|
225
|
+
kq8w1zzp paragraph The hill chart is the one view that a…
|
|
226
|
+
3 blocks · 1 writable
|
|
227
|
+
https://www.sfora.ai/org/acme/notes/k17e8c0...
|
|
228
|
+
|
|
229
|
+
$ echo "A rewritten paragraph." | sfora put /projects/acme/docs/kickoff.md --block kq8w1zzp
|
|
230
|
+
✓ Wrote block kq8w1zzp of /v1/fs/projects/acme/library/documents/kickoff.md
|
|
231
|
+
changed · 3 of 4 block ids kept · 1 moved
|
|
232
|
+
https://www.sfora.ai/org/acme/notes/k17e8c0...
|
|
233
|
+
you are visible as editing this document
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
`read-only` blocks are real lines you can see in the file — the frontmatter
|
|
237
|
+
fence, the title heading — that the write door does not store, so their ids
|
|
238
|
+
address nothing. Aim at a writable one.
|
|
239
|
+
|
|
240
|
+
**Every write prints what it did.** sfora's write door splices: a PUT of bytes
|
|
241
|
+
that parse the same as the stored ones stores nothing, so `no change — the
|
|
242
|
+
stored bytes already matched` is a real and frequent answer to a
|
|
243
|
+
read-edit-write loop that reformatted more than it meant to. When bytes did
|
|
244
|
+
move, the line says how many block ids survived it.
|
|
245
|
+
|
|
246
|
+
**A stale id is not an error, it is a re-aim.** Block ids are derived from
|
|
247
|
+
content, so "this id resolves to nothing" means somebody changed that block
|
|
248
|
+
since you read it. The server sends the document's current blocks with the
|
|
249
|
+
refusal, and the CLI prints them:
|
|
250
|
+
|
|
251
|
+
```
|
|
252
|
+
that block is gone — somebody changed it since you read it
|
|
253
|
+
Block kq8w1zzp is not in this document any more.
|
|
254
|
+
|
|
255
|
+
the document has these blocks now:
|
|
256
|
+
k7f3a2cx line 1 ## Agenda
|
|
257
|
+
kt2p9lmx line 3 A rewritten paragraph.
|
|
258
|
+
kb91xz4q line 5 ```json
|
|
259
|
+
|
|
260
|
+
re-aim with: sfora put /projects/acme/docs/kickoff.md --block <id>
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
The columns differ from `sfora blocks` on purpose. That listing describes what
|
|
264
|
+
a READ serves — frontmatter, the title heading, and `writable` to say which of
|
|
265
|
+
those the write door can reach. This one is what the write door FOUND, which is
|
|
266
|
+
the document's stored body: every id here already resolves, so there is no
|
|
267
|
+
read-only row to mark, and `line` is where in the file to look.
|
|
268
|
+
|
|
269
|
+
`blocks`, `put` and `url` are also commands **inside the shell**, so the body
|
|
270
|
+
can come from a pipeline:
|
|
271
|
+
|
|
272
|
+
```text
|
|
273
|
+
sfora:/$ blocks /projects/acme/docs/kickoff.md | grep paragraph
|
|
274
|
+
sfora:/$ sed 's/hill chart/hill/' draft.md | put /projects/acme/docs/kickoff.md --block kq8w1zzp
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
Writing a document also makes you **visible in it** — the app's avatar stack
|
|
278
|
+
shows you editing, with the block you aimed at. The CLI says so once per run.
|
|
279
|
+
|
|
280
|
+
## Watch — a document as a channel
|
|
281
|
+
|
|
282
|
+
`sfora watch` long-polls the workspace and prints every write as it lands. Point
|
|
283
|
+
it at one document, or at a whole project.
|
|
284
|
+
|
|
285
|
+
```bash
|
|
286
|
+
$ sfora watch /projects/acme/docs/kickoff.md
|
|
287
|
+
watching /projects/acme/docs/kickoff.md (not your own writes) — ^C to stop
|
|
288
|
+
14:22:07 · Ada Lovelace · Kickoff · 4 of 5 block ids kept · https://www.sfora.ai/org/acme/notes/k17e8c0...
|
|
289
|
+
14:24:19 · Dogfood Bot · Kickoff · edited · https://www.sfora.ai/org/acme/notes/k17e8c0...
|
|
290
|
+
|
|
291
|
+
$ sfora watch acme --json | jq -r 'select(.docType == "note") | .title'
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
* `--json` writes **NDJSON** — one event per line (schema below).
|
|
295
|
+
* Your own writes are excluded by default; `--self` includes them.
|
|
296
|
+
* `--wait <secs>` sets the per-request long-poll budget (0–50, default 25).
|
|
297
|
+
* Watching a document makes you **visible in it** as a viewer; ^C aborts the
|
|
298
|
+
open long-poll and retracts you on the way out, rather than leaving a ghost
|
|
299
|
+
in the avatar stack for 90 seconds. It lands immediately — it does not wait
|
|
300
|
+
out the `--wait` budget. A second ^C gives up on the retraction and exits
|
|
301
|
+
now.
|
|
302
|
+
* A dropped connection reconnects with exponential backoff to 30s and resumes
|
|
303
|
+
from the cursor already reached — no replayed pings, no lost ones.
|
|
304
|
+
|
|
305
|
+
### The NDJSON schema
|
|
306
|
+
|
|
307
|
+
Each line is the server's `/v1/events` object, **verbatim** — the CLI reshapes
|
|
308
|
+
nothing, so one parser reads the CLI's stream and the HTTP door's pages alike.
|
|
309
|
+
|
|
310
|
+
| field | |
|
|
311
|
+
|---|---|
|
|
312
|
+
| `type` | `"doc.write"` or `"doc.delete"` |
|
|
313
|
+
| `ts` | epoch ms — also the cursor value |
|
|
314
|
+
| `docType` | `"note"` · `"post"` · `"card"` |
|
|
315
|
+
| `docId` | the entity id |
|
|
316
|
+
| `projectId` | the project it lives in |
|
|
317
|
+
| `title`, `path` | the document's title and fs path |
|
|
318
|
+
| `url` | the page it can be read on |
|
|
319
|
+
| `author`, `authorId`, `authorType` | who wrote |
|
|
320
|
+
| `changed` | always `true` — a write that changed nothing never pings |
|
|
321
|
+
| `blockIds` | `{ rebound, orphaned, total }` for the version before this write |
|
|
322
|
+
| `deleted` | `doc.delete` only; `blockIds` is then absent |
|
|
323
|
+
| `restricted` | the ping is real, its pointer was withheld — see below |
|
|
324
|
+
|
|
325
|
+
A **restricted** ping has no `title`, `path` or `url`: the document is somebody's
|
|
326
|
+
unpublished draft. The event is still delivered, because a watch that went
|
|
327
|
+
silent would look connected and be deaf.
|
|
328
|
+
|
|
329
|
+
Warnings (reconnects) go to stderr, so `--json` stdout stays parseable.
|
|
330
|
+
|
|
331
|
+
## Where — who's in which document
|
|
332
|
+
|
|
333
|
+
`sfora watch` tells you when a document moves. `sfora where` tells you where
|
|
334
|
+
people *are* — the reverse of the presence roster, and the answer to "the doc
|
|
335
|
+
I'm looking at" when nobody sent a link.
|
|
336
|
+
|
|
337
|
+
```bash
|
|
338
|
+
$ sfora where
|
|
339
|
+
Thijs is editing test-document.md — https://www.sfora.ai/org/acme/notes/k17e8c0...
|
|
340
|
+
Dogfood Bot is editing test-document.md (block k7f3a2cx) — https://www.sfora.ai/org/acme/notes/k17e8c0...
|
|
341
|
+
|
|
342
|
+
$ sfora where Thijs
|
|
343
|
+
Thijs is editing test-document.md — https://www.sfora.ai/org/acme/notes/k17e8c0...
|
|
344
|
+
|
|
345
|
+
$ sfora where --json | jq -r 'select(.type == "human") | .path'
|
|
346
|
+
```
|
|
347
|
+
|
|
348
|
+
* The name is optional and takes a member name, an id, or `self`. A name nobody
|
|
349
|
+
in the workspace answers to is an error, not an empty list — "who?" and
|
|
350
|
+
"nowhere" are different answers.
|
|
351
|
+
* The block id appears only when somebody claimed one. Agents address blocks
|
|
352
|
+
natively; humans are present at document level today.
|
|
353
|
+
* **Asking declares nothing.** Unlike `watch` and every write, `where` is a
|
|
354
|
+
plain `GET` — running it never puts you in a document.
|
|
355
|
+
* You see what your key can open, and nothing else: presence carries a title
|
|
356
|
+
and a link, so a document you're not on the project for never appears.
|
|
357
|
+
* `--as <agent>` composes — the answer is then that agent's view.
|
|
358
|
+
* `--json` writes **NDJSON**, one record per person-in-a-document (flat, so
|
|
359
|
+
each line stands alone): `memberId`, `name`, `type`, `kind`, `block`,
|
|
360
|
+
`lastSeenAt`, `ttlSeconds`, `docId`, `title`, `filename`, `path`, `project`,
|
|
361
|
+
`url`.
|
|
362
|
+
|
|
363
|
+
An entry is live while `lastSeenAt` is inside `ttlSeconds` (90) — the server
|
|
364
|
+
filters on it before answering, so nothing stale comes back.
|
|
365
|
+
|
|
366
|
+
## Links — `sfora url` · `sfora open`
|
|
367
|
+
|
|
368
|
+
Every workspace thing has a page. The server says where; the CLI prints it.
|
|
369
|
+
|
|
370
|
+
```bash
|
|
371
|
+
sfora url /projects/acme/library/documents/kickoff.md
|
|
372
|
+
# https://www.sfora.ai/org/acme/notes/k17e8c0...
|
|
373
|
+
|
|
374
|
+
sfora open /projects/acme/board/02-todo/0042-fix-login.md # …and opens it
|
|
375
|
+
sfora url /projects/acme/board --json # { "path": …, "url": … }
|
|
376
|
+
```
|
|
377
|
+
|
|
378
|
+
The CLI holds **no route table and no hostname**. It asks for the path you gave
|
|
379
|
+
and reads the link off the answer — a markdown read carries it in the
|
|
380
|
+
`X-Sfora-Url` header, a JSON one in a `url` field — so links stay right when the
|
|
381
|
+
app's routes move, and a dev deployment hands out dev links.
|
|
382
|
+
|
|
383
|
+
`ls`, `cat` and the write verbs print the same link as one dim line **on
|
|
384
|
+
stderr**, so `sfora cat x.md > x.md` and `sfora ls | wc -l` stay byte-clean.
|
|
385
|
+
Paths with no page of their own (uploaded files, repository trees, your inbox)
|
|
386
|
+
print nothing, and `sfora url` on one says so.
|
|
387
|
+
|
|
214
388
|
| op | behaviour |
|
|
215
389
|
|----|-----------|
|
|
216
390
|
| `ls`, `readdir` | directory listings via `/v1/fs/projects/…` (cached ~5s) |
|
package/dist/SforaFs.js
CHANGED
|
@@ -413,12 +413,14 @@ export class SforaFs {
|
|
|
413
413
|
const columns = meta.columns
|
|
414
414
|
.slice()
|
|
415
415
|
.sort((a, b) => a.position - b.position);
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
];
|
|
416
|
+
// The share link is the SERVER'S (`publicUrl` off `GET …/board`), not a
|
|
417
|
+
// hostname glued onto the slug here: which origin a deployment serves its
|
|
418
|
+
// pages from is the deployment's business, and a CLI pointed at a laptop or
|
|
419
|
+
// a preview would otherwise quote the production one. An older server that
|
|
420
|
+
// does not send it leaves the heading and the columns, minus the line.
|
|
421
|
+
const out = [`# ${project.name} roadmap`, ""];
|
|
422
|
+
if (meta.publicUrl)
|
|
423
|
+
out.push(`> ${meta.publicUrl}`, "");
|
|
422
424
|
for (const col of columns) {
|
|
423
425
|
out.push(`## ${col.name}`, "");
|
|
424
426
|
const cards = await this.#listCards(slug, col.dirname);
|
package/dist/api-client.d.ts
CHANGED
|
@@ -24,10 +24,56 @@ export interface Entry {
|
|
|
24
24
|
scheduledFor?: number;
|
|
25
25
|
isDraft: boolean;
|
|
26
26
|
}
|
|
27
|
+
/**
|
|
28
|
+
* How many block ids survived a write, from the server's rebinding ledger.
|
|
29
|
+
*
|
|
30
|
+
* Absent rather than zeroed when the door computed no correspondence — a
|
|
31
|
+
* create has no previous version to rebind from.
|
|
32
|
+
*/
|
|
33
|
+
export interface RebindCounts {
|
|
34
|
+
rebound: number;
|
|
35
|
+
orphaned: number;
|
|
36
|
+
total: number;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* The effect report every markdown write returns: did the stored bytes move,
|
|
40
|
+
* and what happened to the block ids if so.
|
|
41
|
+
*
|
|
42
|
+
* The reason a write reports this at all is that sfora's write door SPLICES —
|
|
43
|
+
* a PUT of bytes that parse the same as the stored ones stores nothing, so
|
|
44
|
+
* `changed: false` is a real and common answer, and a bare `201` would read as
|
|
45
|
+
* "saved" for a write that did nothing.
|
|
46
|
+
*/
|
|
47
|
+
export interface WriteEffect {
|
|
48
|
+
changed: boolean;
|
|
49
|
+
blockIds?: RebindCounts;
|
|
50
|
+
}
|
|
51
|
+
/** What the last response said about itself. See `takeResponseInfo`. */
|
|
52
|
+
export interface ResponseInfo {
|
|
53
|
+
/** The absolute web page the response named, if any (card #335). */
|
|
54
|
+
url: string | null;
|
|
55
|
+
/** The effect report, when the response was a markdown write (card #333). */
|
|
56
|
+
effect: WriteEffect | null;
|
|
57
|
+
/**
|
|
58
|
+
* The write put the caller on the document's roster.
|
|
59
|
+
*
|
|
60
|
+
* Only document writes declare presence server-side — posts and cards have
|
|
61
|
+
* markdown bodies but nobody has one open in a document editor — so this
|
|
62
|
+
* comes off the response rather than being inferred from the path shape.
|
|
63
|
+
*/
|
|
64
|
+
present: boolean;
|
|
65
|
+
}
|
|
66
|
+
/** The effect report in a parsed response body, or null when there is none. */
|
|
67
|
+
export declare function writeEffectFrom(body: unknown): WriteEffect | null;
|
|
27
68
|
/** Result of a successful `PUT` (create/update). */
|
|
28
|
-
export interface WriteResult {
|
|
69
|
+
export interface WriteResult extends Partial<WriteEffect> {
|
|
29
70
|
filename: string;
|
|
30
71
|
id: string;
|
|
72
|
+
/** The fs path the write landed at — canonical, which may differ from the URL. */
|
|
73
|
+
path?: string;
|
|
74
|
+
/** The web page it can be read on. */
|
|
75
|
+
url?: string;
|
|
76
|
+
created?: boolean;
|
|
31
77
|
}
|
|
32
78
|
/**
|
|
33
79
|
* A note (doc) file entry. Mirrors a row of `GET …/docs`. Notes have stable
|
|
@@ -100,6 +146,14 @@ export interface BoardColumn {
|
|
|
100
146
|
export interface BoardMeta {
|
|
101
147
|
publicSlug: string | null;
|
|
102
148
|
columns: BoardColumn[];
|
|
149
|
+
/**
|
|
150
|
+
* The shared roadmap's public page, or `null` when the board isn't shared.
|
|
151
|
+
*
|
|
152
|
+
* The slug next to it is the identifier; this is the LINK, and it comes from
|
|
153
|
+
* the server for the same reason every other one does — the hostname is the
|
|
154
|
+
* deployment's, not the CLI's.
|
|
155
|
+
*/
|
|
156
|
+
publicUrl?: string | null;
|
|
103
157
|
}
|
|
104
158
|
/** A card file under `/projects/<slug>/board/<col>/`. `filename` is `NNNN-<slug>.md`. */
|
|
105
159
|
export interface BoardCardEntry {
|
|
@@ -110,12 +164,126 @@ export interface BoardCardEntry {
|
|
|
110
164
|
lastActivityAt: number;
|
|
111
165
|
}
|
|
112
166
|
/** Result of `PUT …/board/<col>/<filename>.md`. */
|
|
113
|
-
export interface CardWriteResult {
|
|
167
|
+
export interface CardWriteResult extends Partial<WriteEffect> {
|
|
114
168
|
filename: string;
|
|
115
169
|
id: string;
|
|
116
170
|
number: number;
|
|
117
171
|
/** New column dirname — set when frontmatter `column:` or `status:` moved the card. */
|
|
118
172
|
movedTo?: string;
|
|
173
|
+
path?: string;
|
|
174
|
+
/** The web page it can be read on. */
|
|
175
|
+
url?: string;
|
|
176
|
+
created?: boolean;
|
|
177
|
+
}
|
|
178
|
+
/** One member in a document right now. Mirrors a row of `here`. */
|
|
179
|
+
export interface DocPresenceMember {
|
|
180
|
+
memberId: string;
|
|
181
|
+
name: string;
|
|
182
|
+
type: "human" | "agent";
|
|
183
|
+
kind: "viewing" | "editing";
|
|
184
|
+
blockId?: string;
|
|
185
|
+
lastSeenAt: number;
|
|
186
|
+
}
|
|
187
|
+
/** `POST <doc>/_presence` — the roster, after your own declaration landed. */
|
|
188
|
+
export interface DocPresence {
|
|
189
|
+
document: string;
|
|
190
|
+
url?: string;
|
|
191
|
+
present: boolean;
|
|
192
|
+
block: string | null;
|
|
193
|
+
blockResolved: boolean | null;
|
|
194
|
+
here: DocPresenceMember[];
|
|
195
|
+
}
|
|
196
|
+
/** One occupant of a document, as `GET /v1/presence` reports them. */
|
|
197
|
+
export interface PresenceOccupant {
|
|
198
|
+
memberId: string;
|
|
199
|
+
name: string;
|
|
200
|
+
type: "human" | "agent";
|
|
201
|
+
kind: "viewing" | "editing";
|
|
202
|
+
/** The block they have claimed, or `null` for the document at large. */
|
|
203
|
+
block: string | null;
|
|
204
|
+
lastSeenAt: number;
|
|
205
|
+
}
|
|
206
|
+
/** One document somebody is in, with everyone who is in it. */
|
|
207
|
+
export interface PresenceDocument {
|
|
208
|
+
docId: string;
|
|
209
|
+
title: string;
|
|
210
|
+
filename: string;
|
|
211
|
+
/** The fs path — what `sfora cat` and `sfora put` take. */
|
|
212
|
+
path: string;
|
|
213
|
+
project: {
|
|
214
|
+
slug: string;
|
|
215
|
+
name: string;
|
|
216
|
+
};
|
|
217
|
+
url: string;
|
|
218
|
+
here: PresenceOccupant[];
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* `GET /v1/presence` — where everybody is, grouped by document.
|
|
222
|
+
*
|
|
223
|
+
* `ttlSeconds` is the server's own definition of "still here": an entry is
|
|
224
|
+
* live while `Date.now() - lastSeenAt` is inside it. Stated rather than
|
|
225
|
+
* assumed, so a consumer never has to hardcode the heartbeat window.
|
|
226
|
+
*/
|
|
227
|
+
export interface PresenceView {
|
|
228
|
+
ttlSeconds: number;
|
|
229
|
+
/** The member asked about, when one was named; `null` for everybody. */
|
|
230
|
+
member: {
|
|
231
|
+
memberId: string;
|
|
232
|
+
name: string;
|
|
233
|
+
type: "human" | "agent";
|
|
234
|
+
} | null;
|
|
235
|
+
documents: PresenceDocument[];
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* One room, as `GET /api/rooms` lists it. `joined: false` rows appear only
|
|
239
|
+
* with `?all=1` — open rooms the caller can discover and self-join.
|
|
240
|
+
*/
|
|
241
|
+
export interface Room {
|
|
242
|
+
_id: string;
|
|
243
|
+
name: string;
|
|
244
|
+
type: string;
|
|
245
|
+
description?: string | null;
|
|
246
|
+
/** The owning project's id (not slug), or null for a free-standing room. */
|
|
247
|
+
projectId: string | null;
|
|
248
|
+
joined: boolean;
|
|
249
|
+
}
|
|
250
|
+
/** Who wrote a chat message. Humans and agents are the same member model. */
|
|
251
|
+
export interface ChatAuthor {
|
|
252
|
+
_id: string;
|
|
253
|
+
name: string;
|
|
254
|
+
type: "human" | "agent";
|
|
255
|
+
}
|
|
256
|
+
/** One message, as a row of `GET /api/rooms/:id/messages`. Body is markdown. */
|
|
257
|
+
export interface ChatMessage {
|
|
258
|
+
_id: string;
|
|
259
|
+
body: string;
|
|
260
|
+
_creationTime: number;
|
|
261
|
+
/** Null when the author's member row is gone. */
|
|
262
|
+
author: ChatAuthor | null;
|
|
263
|
+
}
|
|
264
|
+
/** A page of messages, NEWEST FIRST — the server paginates backwards. */
|
|
265
|
+
export interface MessagesPage {
|
|
266
|
+
page: ChatMessage[];
|
|
267
|
+
continueCursor: string;
|
|
268
|
+
isDone: boolean;
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* One event off `/v1/events`.
|
|
272
|
+
*
|
|
273
|
+
* Deliberately loose beyond the fields the CLI prints: the feed carries room
|
|
274
|
+
* messages and ask state-changes as well as document writes, and a client that
|
|
275
|
+
* enumerated every field would have to be edited each time the server learns a
|
|
276
|
+
* new kind. `--json` forwards the object verbatim for exactly this reason.
|
|
277
|
+
*/
|
|
278
|
+
export interface AgentEvent {
|
|
279
|
+
type: string;
|
|
280
|
+
ts: number;
|
|
281
|
+
[key: string]: unknown;
|
|
282
|
+
}
|
|
283
|
+
/** A page of the long-poll: what happened, and where to poll from next. */
|
|
284
|
+
export interface AgentEventsPage {
|
|
285
|
+
events: AgentEvent[];
|
|
286
|
+
cursor: number;
|
|
119
287
|
}
|
|
120
288
|
export interface SforaApiConfig {
|
|
121
289
|
/** e.g. `https://your-sfora.com` or `http://localhost:2222`. Trailing slashes are trimmed. */
|
|
@@ -134,11 +302,101 @@ export interface SforaApiConfig {
|
|
|
134
302
|
export declare class SforaApiError extends Error {
|
|
135
303
|
readonly status: number;
|
|
136
304
|
readonly code: string;
|
|
137
|
-
|
|
305
|
+
/**
|
|
306
|
+
* The parsed error body, when there was one.
|
|
307
|
+
*
|
|
308
|
+
* Some refusals are USEFUL, not merely informative: a 409 from a `?block=`
|
|
309
|
+
* write carries the document's current blocks so the caller can re-aim
|
|
310
|
+
* without a second round trip. Throwing away everything but the message
|
|
311
|
+
* would make the CLI ask for that page again. Untyped here because the fs
|
|
312
|
+
* error envelope is `{ error, message }` plus whatever the specific refusal
|
|
313
|
+
* adds; {@link blockConflictFrom} is the typed reader for the one shape the
|
|
314
|
+
* CLI acts on.
|
|
315
|
+
*/
|
|
316
|
+
readonly data?: unknown;
|
|
317
|
+
constructor(status: number, code: string, message: string, data?: unknown);
|
|
318
|
+
}
|
|
319
|
+
/** One block, as `?view=blocks` describes it. */
|
|
320
|
+
export interface BlockView {
|
|
321
|
+
id: string;
|
|
322
|
+
writable: boolean;
|
|
323
|
+
type: string;
|
|
324
|
+
lines: [number, number];
|
|
325
|
+
text: string;
|
|
326
|
+
depth?: number;
|
|
327
|
+
lang?: string;
|
|
328
|
+
}
|
|
329
|
+
/** `GET …?view=blocks` — the addressable view of a document. */
|
|
330
|
+
export interface BlocksView {
|
|
331
|
+
/** The fs path these blocks are OF — what to GET for the document itself. */
|
|
332
|
+
canonical: string;
|
|
333
|
+
note: string;
|
|
334
|
+
document?: {
|
|
335
|
+
id?: string;
|
|
336
|
+
title?: string;
|
|
337
|
+
lastEditedAt?: string;
|
|
338
|
+
};
|
|
339
|
+
blocks: BlockView[];
|
|
340
|
+
/** Present when block extents could not be determined; `blocks` is then empty. */
|
|
341
|
+
unaddressable?: string;
|
|
342
|
+
/** The web page the document is read on (card #335). */
|
|
343
|
+
url?: string;
|
|
344
|
+
}
|
|
345
|
+
/**
|
|
346
|
+
* One block, as a 409 lists it.
|
|
347
|
+
*
|
|
348
|
+
* A DIFFERENT SHAPE from {@link BlockView}, and the difference is not an
|
|
349
|
+
* oversight on either side. `?view=blocks` projects what the read serves — the
|
|
350
|
+
* frontmatter envelope, the title heading, the mdast type of each node, and
|
|
351
|
+
* `writable` to say which of those the write door can actually resolve. The
|
|
352
|
+
* 409 lists what the write door FOUND, which is the document's stored body:
|
|
353
|
+
* every id in it resolves by construction, so there is no `writable` to report
|
|
354
|
+
* and no envelope block to report it about. Three fields, one line each in the
|
|
355
|
+
* re-aim table: which id, where it is, what it says.
|
|
356
|
+
*/
|
|
357
|
+
export interface BlockSummary {
|
|
358
|
+
id: string;
|
|
359
|
+
/** 1-based line the block starts on, in the stored body. */
|
|
360
|
+
line: number;
|
|
361
|
+
/** The block's first non-empty line, clipped by the server. */
|
|
362
|
+
preview: string;
|
|
363
|
+
}
|
|
364
|
+
/**
|
|
365
|
+
* The recovery payload on a 409 from a `?block=` write: the id no longer
|
|
366
|
+
* resolves, and here is what the document has NOW so the caller can re-aim.
|
|
367
|
+
*/
|
|
368
|
+
export interface BlockConflict {
|
|
369
|
+
message: string;
|
|
370
|
+
/** The id that was aimed at. */
|
|
371
|
+
block?: string;
|
|
372
|
+
blocks: BlockSummary[];
|
|
373
|
+
}
|
|
374
|
+
/** The 409 recovery payload inside an error, or null for any other failure. */
|
|
375
|
+
export declare function blockConflictFrom(error: unknown): BlockConflict | null;
|
|
376
|
+
/** Options every markdown write door takes. */
|
|
377
|
+
export interface WriteOptions {
|
|
378
|
+
/**
|
|
379
|
+
* Write ONE block instead of the whole file — the id from `?view=blocks`.
|
|
380
|
+
*
|
|
381
|
+
* The body is then that block's markdown, with no frontmatter and no title:
|
|
382
|
+
* a block write edits prose and never a document's state.
|
|
383
|
+
*/
|
|
384
|
+
blockId?: string;
|
|
138
385
|
}
|
|
139
386
|
export declare class SforaApiClient {
|
|
140
387
|
#private;
|
|
141
388
|
constructor(config: SforaApiConfig);
|
|
389
|
+
/** What the last response said about itself, consumed. */
|
|
390
|
+
takeResponseInfo(): ResponseInfo;
|
|
391
|
+
/**
|
|
392
|
+
* The web page an fs path is read on, or null when it has none.
|
|
393
|
+
*
|
|
394
|
+
* One request, and the ONLY route knowledge involved is the server's: this
|
|
395
|
+
* reads the path the caller gave and reports the link the answer carried.
|
|
396
|
+
* A directory listing answers in JSON, a document in markdown with the link
|
|
397
|
+
* in a header, and {@link webUrlFromResponse} covers both.
|
|
398
|
+
*/
|
|
399
|
+
resolveWebUrl(fsPath: string): Promise<string | null>;
|
|
142
400
|
/** `GET /v1/fs/projects` */
|
|
143
401
|
listProjects(): Promise<Project[]>;
|
|
144
402
|
/** `POST /v1/fs/projects` — create a project; the caller becomes its lead. */
|
|
@@ -168,7 +426,23 @@ export declare class SforaApiClient {
|
|
|
168
426
|
/** `GET …/posts/:filename.md` (or `…/drafts/…`). Returns the raw markdown body. */
|
|
169
427
|
readPost(projectSlug: string, filename: string, kind?: PostKind): Promise<string>;
|
|
170
428
|
/** Create a post or upsert a mutable draft from a Markdown file. */
|
|
171
|
-
writePost(projectSlug: string, kind: PostKind, filename: string, markdown: string): Promise<WriteResult>;
|
|
429
|
+
writePost(projectSlug: string, kind: PostKind, filename: string, markdown: string, options?: WriteOptions): Promise<WriteResult>;
|
|
430
|
+
/**
|
|
431
|
+
* `GET …?view=blocks` on any fs path with a stored markdown body — the
|
|
432
|
+
* addressable view a `?block=` write aims into.
|
|
433
|
+
*
|
|
434
|
+
* The path is the caller's fs path (`/projects/acme/docs/x.md`), not a
|
|
435
|
+
* `/v1/fs` URL: the CLI does not build routes.
|
|
436
|
+
*/
|
|
437
|
+
readBlocks(fsPath: string): Promise<BlocksView>;
|
|
438
|
+
/**
|
|
439
|
+
* `PUT <fs path>` — the generic write door, so the CLI can put to any path
|
|
440
|
+
* (and to a single block of it) without a per-entity method.
|
|
441
|
+
*
|
|
442
|
+
* A 409 from a `?block=` write throws a {@link SforaApiError} carrying the
|
|
443
|
+
* server's recovery payload; read it with {@link blockConflictFrom}.
|
|
444
|
+
*/
|
|
445
|
+
writePath(fsPath: string, markdown: string, options?: WriteOptions): Promise<WriteResult & Partial<CardWriteResult>>;
|
|
172
446
|
/** `DELETE …/(posts|drafts)/:filename.md` — soft-deletes the matched post. */
|
|
173
447
|
deletePost(projectSlug: string, kind: PostKind, filename: string): Promise<void>;
|
|
174
448
|
/** `GET …/docs` — the project's notes, most-recently-edited first. */
|
|
@@ -236,6 +510,72 @@ export declare class SforaApiClient {
|
|
|
236
510
|
content: string;
|
|
237
511
|
reacted: boolean;
|
|
238
512
|
}>;
|
|
513
|
+
/**
|
|
514
|
+
* `POST <path>/_presence` — say you are in a document.
|
|
515
|
+
*
|
|
516
|
+
* Returns `null` when the path has no roster. The CLI does not decide which
|
|
517
|
+
* paths those are: it asks, and a `422` is the server's named refusal
|
|
518
|
+
* ("posts and board cards have markdown bodies but nobody has one open in a
|
|
519
|
+
* document editor"). Every other failure is thrown as usual.
|
|
520
|
+
*/
|
|
521
|
+
declarePresence(fsPath: string, options?: {
|
|
522
|
+
kind?: "viewing" | "editing";
|
|
523
|
+
block?: string;
|
|
524
|
+
leave?: boolean;
|
|
525
|
+
}): Promise<DocPresence | null>;
|
|
526
|
+
/**
|
|
527
|
+
* `GET /v1/presence` — where everybody is right now.
|
|
528
|
+
*
|
|
529
|
+
* The reverse of {@link declarePresence}, and a pure read: asking never
|
|
530
|
+
* enrols the asker in anything. `member` takes an id, a name, or `self`.
|
|
531
|
+
*
|
|
532
|
+
* Documents the key cannot open are absent from the answer — the server
|
|
533
|
+
* filters, the CLI prints. That is why this returns everything it is given
|
|
534
|
+
* rather than filtering again here.
|
|
535
|
+
*/
|
|
536
|
+
listPresence(member?: string): Promise<PresenceView>;
|
|
537
|
+
/**
|
|
538
|
+
* `GET /v1/events` — the long-poll. Blocks server-side until something
|
|
539
|
+
* happens or the wait budget elapses, then answers with a cursor to poll
|
|
540
|
+
* from next.
|
|
541
|
+
*
|
|
542
|
+
* `signal` is not optional in spirit: this is the ONE request in the client
|
|
543
|
+
* that is designed to hang, so a caller that cannot cancel it cannot stop.
|
|
544
|
+
* `sfora watch` aborts it from its ^C handler, which is the difference
|
|
545
|
+
* between a terminal that says "^C to stop" and one that does.
|
|
546
|
+
*/
|
|
547
|
+
pollEvents(params: {
|
|
548
|
+
since: number;
|
|
549
|
+
wait?: number;
|
|
550
|
+
doc?: string;
|
|
551
|
+
project?: string;
|
|
552
|
+
includeSelf?: boolean;
|
|
553
|
+
signal?: AbortSignal;
|
|
554
|
+
}): Promise<AgentEventsPage>;
|
|
555
|
+
/**
|
|
556
|
+
* The entity id behind an fs path — what `/v1/events?doc=` wants.
|
|
557
|
+
*
|
|
558
|
+
* Read off `?view=blocks`, which states the document's identity in its
|
|
559
|
+
* `document` field, rather than parsed out of the file's frontmatter: the
|
|
560
|
+
* projection is the server saying which row these bytes are.
|
|
561
|
+
*/
|
|
562
|
+
resolveDocId(fsPath: string): Promise<string | null>;
|
|
563
|
+
/**
|
|
564
|
+
* `GET /api/rooms` — the caller's rooms. `all` adds open-but-unjoined rooms
|
|
565
|
+
* (`joined: false`), so the room can be found before it is joined.
|
|
566
|
+
*/
|
|
567
|
+
listRooms(all?: boolean): Promise<Room[]>;
|
|
568
|
+
/** `POST /api/rooms/:id/join` — self-join an open room. Idempotent. */
|
|
569
|
+
joinRoom(roomId: string): Promise<{
|
|
570
|
+
joined: boolean;
|
|
571
|
+
already: boolean;
|
|
572
|
+
}>;
|
|
573
|
+
/** `GET /api/rooms/:id/messages` — history, newest first (server cap: 100). */
|
|
574
|
+
listRoomMessages(roomId: string, limit?: number): Promise<MessagesPage>;
|
|
575
|
+
/** `POST /api/rooms/:id/messages` with `{ body }` — send markdown. */
|
|
576
|
+
sendRoomMessage(roomId: string, body: string): Promise<{
|
|
577
|
+
messageId: string;
|
|
578
|
+
}>;
|
|
239
579
|
/** `GET /v1/fs/inbox/mentions.md` — markdown summary of unread mentions. */
|
|
240
580
|
readInbox(): Promise<string>;
|
|
241
581
|
/** `GET /v1/fs/me/api-key` — text identity (no key material). */
|