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/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
|
|
@@ -21,7 +22,9 @@ shell interpreter backs the interactive mode).
|
|
|
21
22
|
├── projects/<slug>/posts/<YYYY-MM-DD-title>.md # published posts (GET·PUT·DELETE)
|
|
22
23
|
│ /drafts/<…>.md # your drafts (GET·PUT·DELETE)
|
|
23
24
|
│ /board/<NN-col>/<NNNN-card>.md # tasks by column (GET·PUT·DELETE)
|
|
24
|
-
│ /
|
|
25
|
+
│ /library/documents/<…>.md # docs / notes (GET·PUT·DELETE)
|
|
26
|
+
│ /files/<…> # uploaded files (GET)
|
|
27
|
+
│ /repositories/<repo>/… # source trees (GET)
|
|
25
28
|
├── inbox/mentions.md # unread mentions (GET)
|
|
26
29
|
└── me/api-key # your identity (GET)
|
|
27
30
|
```
|
|
@@ -154,11 +157,11 @@ sfora:/$ cd /projects/general/posts # cwd persists across commands
|
|
|
154
157
|
sfora:/projects/general/posts$ exit
|
|
155
158
|
```
|
|
156
159
|
|
|
157
|
-
Writing a file
|
|
158
|
-
|
|
159
|
-
is
|
|
160
|
-
|
|
161
|
-
schedules auto-publish.
|
|
160
|
+
Writing a post file publishes a new immutable record. Edit mutable work under
|
|
161
|
+
`…/drafts/` and publish only when it is ready; overwriting an existing published
|
|
162
|
+
filename is rejected. A bare `@Display Name` that matches an active member is
|
|
163
|
+
rehydrated to a real mention server-side. `rm` soft-deletes (author or org
|
|
164
|
+
admin/owner). A `scheduledFor:` in draft frontmatter schedules auto-publish.
|
|
162
165
|
|
|
163
166
|
## MCP server (Claude Desktop / Cursor)
|
|
164
167
|
|
|
@@ -209,6 +212,144 @@ backend) are the public exports, alongside the lower-level `SforaApiClient`.
|
|
|
209
212
|
|
|
210
213
|
## Supported operations
|
|
211
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
|
+
## Links — `sfora url` · `sfora open`
|
|
332
|
+
|
|
333
|
+
Every workspace thing has a page. The server says where; the CLI prints it.
|
|
334
|
+
|
|
335
|
+
```bash
|
|
336
|
+
sfora url /projects/acme/library/documents/kickoff.md
|
|
337
|
+
# https://www.sfora.ai/org/acme/notes/k17e8c0...
|
|
338
|
+
|
|
339
|
+
sfora open /projects/acme/board/02-todo/0042-fix-login.md # …and opens it
|
|
340
|
+
sfora url /projects/acme/board --json # { "path": …, "url": … }
|
|
341
|
+
```
|
|
342
|
+
|
|
343
|
+
The CLI holds **no route table and no hostname**. It asks for the path you gave
|
|
344
|
+
and reads the link off the answer — a markdown read carries it in the
|
|
345
|
+
`X-Sfora-Url` header, a JSON one in a `url` field — so links stay right when the
|
|
346
|
+
app's routes move, and a dev deployment hands out dev links.
|
|
347
|
+
|
|
348
|
+
`ls`, `cat` and the write verbs print the same link as one dim line **on
|
|
349
|
+
stderr**, so `sfora cat x.md > x.md` and `sfora ls | wc -l` stay byte-clean.
|
|
350
|
+
Paths with no page of their own (uploaded files, repository trees, your inbox)
|
|
351
|
+
print nothing, and `sfora url` on one says so.
|
|
352
|
+
|
|
212
353
|
| op | behaviour |
|
|
213
354
|
|----|-----------|
|
|
214
355
|
| `ls`, `readdir` | directory listings via `/v1/fs/projects/…` (cached ~5s) |
|
package/dist/SforaFs.js
CHANGED
|
@@ -79,6 +79,15 @@ function classify(path) {
|
|
|
79
79
|
if (dir === "links.md" && seg.length === 3) {
|
|
80
80
|
return { kind: "linksFile", slug };
|
|
81
81
|
}
|
|
82
|
+
if (dir === "plan.md" && seg.length === 3) {
|
|
83
|
+
return { kind: "planFile", slug };
|
|
84
|
+
}
|
|
85
|
+
if (dir === "map.md" && seg.length === 3) {
|
|
86
|
+
return { kind: "mapFile", slug };
|
|
87
|
+
}
|
|
88
|
+
if (dir === "asks.md" && seg.length === 3) {
|
|
89
|
+
return { kind: "asksFile", slug };
|
|
90
|
+
}
|
|
82
91
|
if (dir === "posts" || dir === "drafts") {
|
|
83
92
|
if (seg.length === 3)
|
|
84
93
|
return { kind: "postsDir", slug, dir };
|
|
@@ -92,6 +101,38 @@ function classify(path) {
|
|
|
92
101
|
if (seg.length === 4)
|
|
93
102
|
return { kind: "docFile", slug, filename: seg[3] };
|
|
94
103
|
}
|
|
104
|
+
if (dir === "artifacts") {
|
|
105
|
+
if (seg.length === 3)
|
|
106
|
+
return { kind: "filesDir", slug };
|
|
107
|
+
if (seg.length === 4)
|
|
108
|
+
return { kind: "artifactFile", slug, filename: seg[3] };
|
|
109
|
+
}
|
|
110
|
+
if (dir === "library") {
|
|
111
|
+
if (seg.length === 3)
|
|
112
|
+
return { kind: "libraryDir", slug };
|
|
113
|
+
if (seg[3] === "documents") {
|
|
114
|
+
if (seg.length === 4)
|
|
115
|
+
return { kind: "docsDir", slug };
|
|
116
|
+
if (seg.length === 5)
|
|
117
|
+
return { kind: "docFile", slug, filename: seg[4] };
|
|
118
|
+
}
|
|
119
|
+
if (seg[3] === "files") {
|
|
120
|
+
if (seg.length === 4)
|
|
121
|
+
return { kind: "filesDir", slug };
|
|
122
|
+
if (seg.length === 5)
|
|
123
|
+
return { kind: "artifactFile", slug, filename: seg[4] };
|
|
124
|
+
}
|
|
125
|
+
if (seg[3] === "repositories") {
|
|
126
|
+
if (seg.length === 4)
|
|
127
|
+
return { kind: "repositoriesDir", slug };
|
|
128
|
+
return {
|
|
129
|
+
kind: "repositoryPath",
|
|
130
|
+
slug,
|
|
131
|
+
repository: seg[4],
|
|
132
|
+
path: seg.slice(5).join("/"),
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
}
|
|
95
136
|
if (dir === "pulls") {
|
|
96
137
|
if (seg.length === 3)
|
|
97
138
|
return { kind: "pullsDir", slug };
|
|
@@ -212,9 +253,45 @@ export class SforaFs {
|
|
|
212
253
|
return this.#cached(key, async () => {
|
|
213
254
|
const notes = await this.#client.listNotes(slug);
|
|
214
255
|
this.#entrySnapshots.set(`/projects/${slug}/docs`, notes.map((n) => n.filename));
|
|
256
|
+
this.#entrySnapshots.set(`/projects/${slug}/library/documents`, notes.map((n) => n.filename));
|
|
215
257
|
return notes;
|
|
216
258
|
});
|
|
217
259
|
}
|
|
260
|
+
#invalidateNotes(slug) {
|
|
261
|
+
this.#cache.delete(`docs:${slug}`);
|
|
262
|
+
}
|
|
263
|
+
#listArtifacts(slug) {
|
|
264
|
+
return this.#cached(`artifacts:${slug}`, async () => {
|
|
265
|
+
const artifacts = await this.#client.listArtifacts(slug);
|
|
266
|
+
const names = artifacts.map((artifact) => artifact.name);
|
|
267
|
+
this.#entrySnapshots.set(`/projects/${slug}/artifacts`, names);
|
|
268
|
+
this.#entrySnapshots.set(`/projects/${slug}/library/files`, names);
|
|
269
|
+
return artifacts;
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
#listRepositories(slug) {
|
|
273
|
+
return this.#cached(`repositories:${slug}`, async () => {
|
|
274
|
+
const repositories = await this.#client.listRepositories(slug);
|
|
275
|
+
this.#entrySnapshots.set(`/projects/${slug}/library/repositories`, repositories.map((repository) => repository.dirname));
|
|
276
|
+
return repositories;
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
#repositoryTree(slug, repository) {
|
|
280
|
+
return this.#cached(`repository-tree:${slug}/${repository}`, async () => {
|
|
281
|
+
const tree = await this.#client.getRepositoryTree(slug, repository);
|
|
282
|
+
this.#entrySnapshots.set(`/projects/${slug}/library/repositories/${repository}`, tree.entries.map((entry) => entry.path));
|
|
283
|
+
return tree;
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
async #repositoryEntry(slug, repository, path) {
|
|
287
|
+
if (!path) {
|
|
288
|
+
const repositories = await this.#listRepositories(slug);
|
|
289
|
+
return repositories.some((candidate) => candidate.dirname === repository)
|
|
290
|
+
? { kind: "directory" }
|
|
291
|
+
: undefined;
|
|
292
|
+
}
|
|
293
|
+
return (await this.#repositoryTree(slug, repository)).entries.find((entry) => entry.path === path);
|
|
294
|
+
}
|
|
218
295
|
// ─── pull requests cache (read-only) ─────────────────────────────
|
|
219
296
|
#listPulls(slug) {
|
|
220
297
|
const key = `pulls:${slug}`;
|
|
@@ -336,12 +413,14 @@ export class SforaFs {
|
|
|
336
413
|
const columns = meta.columns
|
|
337
414
|
.slice()
|
|
338
415
|
.sort((a, b) => a.position - b.position);
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
];
|
|
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}`, "");
|
|
345
424
|
for (const col of columns) {
|
|
346
425
|
out.push(`## ${col.name}`, "");
|
|
347
426
|
const cards = await this.#listCards(slug, col.dirname);
|
|
@@ -381,6 +460,31 @@ export class SforaFs {
|
|
|
381
460
|
catch (e) {
|
|
382
461
|
throw fromApi(e, "open", normalize(path));
|
|
383
462
|
}
|
|
463
|
+
case "artifactFile":
|
|
464
|
+
try {
|
|
465
|
+
return await this.#client.readArtifact(loc.slug, loc.filename);
|
|
466
|
+
}
|
|
467
|
+
catch (e) {
|
|
468
|
+
throw fromApi(e, "open", normalize(path));
|
|
469
|
+
}
|
|
470
|
+
case "repositoryPath": {
|
|
471
|
+
if (!loc.path)
|
|
472
|
+
throw eisdir("read", normalize(path));
|
|
473
|
+
const entry = await this.#repositoryEntry(loc.slug, loc.repository, loc.path);
|
|
474
|
+
if (!entry)
|
|
475
|
+
throw enoent("open", normalize(path));
|
|
476
|
+
if (entry.kind === "directory")
|
|
477
|
+
throw eisdir("read", normalize(path));
|
|
478
|
+
if (entry.kind === "submodule") {
|
|
479
|
+
return `Submodule ${loc.path}\nRepository: ${loc.repository}\n`;
|
|
480
|
+
}
|
|
481
|
+
try {
|
|
482
|
+
return await this.#client.readRepositoryFile(loc.slug, loc.repository, loc.path);
|
|
483
|
+
}
|
|
484
|
+
catch (e) {
|
|
485
|
+
throw fromApi(e, "open", normalize(path));
|
|
486
|
+
}
|
|
487
|
+
}
|
|
384
488
|
case "pullFile":
|
|
385
489
|
try {
|
|
386
490
|
return await this.#client.readPull(loc.slug, loc.number);
|
|
@@ -416,6 +520,27 @@ export class SforaFs {
|
|
|
416
520
|
catch (e) {
|
|
417
521
|
throw fromApi(e, "open", normalize(path));
|
|
418
522
|
}
|
|
523
|
+
case "planFile":
|
|
524
|
+
try {
|
|
525
|
+
return await this.#client.readPlan(loc.slug);
|
|
526
|
+
}
|
|
527
|
+
catch (e) {
|
|
528
|
+
throw fromApi(e, "open", normalize(path));
|
|
529
|
+
}
|
|
530
|
+
case "mapFile":
|
|
531
|
+
try {
|
|
532
|
+
return await this.#client.readMap(loc.slug);
|
|
533
|
+
}
|
|
534
|
+
catch (e) {
|
|
535
|
+
throw fromApi(e, "open", normalize(path));
|
|
536
|
+
}
|
|
537
|
+
case "asksFile":
|
|
538
|
+
try {
|
|
539
|
+
return await this.#client.readAsks(loc.slug);
|
|
540
|
+
}
|
|
541
|
+
catch (e) {
|
|
542
|
+
throw fromApi(e, "open", normalize(path));
|
|
543
|
+
}
|
|
419
544
|
case "root":
|
|
420
545
|
case "projectsDir":
|
|
421
546
|
case "inboxDir":
|
|
@@ -423,6 +548,9 @@ export class SforaFs {
|
|
|
423
548
|
case "projectDir":
|
|
424
549
|
case "postsDir":
|
|
425
550
|
case "docsDir":
|
|
551
|
+
case "libraryDir":
|
|
552
|
+
case "filesDir":
|
|
553
|
+
case "repositoriesDir":
|
|
426
554
|
case "pullsDir":
|
|
427
555
|
case "boardDir":
|
|
428
556
|
case "boardColumnDir":
|
|
@@ -449,6 +577,10 @@ export class SforaFs {
|
|
|
449
577
|
if (loc.kind === "inboxFile" || loc.kind === "meFile") {
|
|
450
578
|
throw eacces("open", norm);
|
|
451
579
|
}
|
|
580
|
+
// asks.md is a read-only projection — claims go through the asks API.
|
|
581
|
+
if (loc.kind === "asksFile") {
|
|
582
|
+
throw eacces("open", norm);
|
|
583
|
+
}
|
|
452
584
|
if (loc.kind === "linksFile") {
|
|
453
585
|
try {
|
|
454
586
|
await this.#client.setProjectLinks(loc.slug, toText(content));
|
|
@@ -458,6 +590,17 @@ export class SforaFs {
|
|
|
458
590
|
}
|
|
459
591
|
return;
|
|
460
592
|
}
|
|
593
|
+
// plan.md: the PUT edits `## the goal` only; the server ignores (and
|
|
594
|
+
// names) every generated section, so a read-modify-write is always safe.
|
|
595
|
+
if (loc.kind === "planFile") {
|
|
596
|
+
try {
|
|
597
|
+
await this.#client.writePlan(loc.slug, toText(content));
|
|
598
|
+
}
|
|
599
|
+
catch (e) {
|
|
600
|
+
throw fromApi(e, "open", norm);
|
|
601
|
+
}
|
|
602
|
+
return;
|
|
603
|
+
}
|
|
461
604
|
if (loc.kind === "root" ||
|
|
462
605
|
loc.kind === "projectsDir" ||
|
|
463
606
|
loc.kind === "inboxDir" ||
|
|
@@ -465,12 +608,21 @@ export class SforaFs {
|
|
|
465
608
|
loc.kind === "projectDir" ||
|
|
466
609
|
loc.kind === "postsDir" ||
|
|
467
610
|
loc.kind === "docsDir" ||
|
|
611
|
+
loc.kind === "libraryDir" ||
|
|
612
|
+
loc.kind === "filesDir" ||
|
|
613
|
+
loc.kind === "repositoriesDir" ||
|
|
468
614
|
loc.kind === "pullsDir" ||
|
|
469
615
|
loc.kind === "boardDir" ||
|
|
470
616
|
loc.kind === "boardColumnDir" ||
|
|
471
617
|
loc.kind === "publicDir") {
|
|
472
618
|
throw eisdir("open", norm);
|
|
473
619
|
}
|
|
620
|
+
// map.md is derived from the board and refuses writes server-side (405);
|
|
621
|
+
// fail locally with the same message so an agent learns the write doors
|
|
622
|
+
// without a round trip.
|
|
623
|
+
if (loc.kind === "mapFile") {
|
|
624
|
+
throw eacces("open", norm);
|
|
625
|
+
}
|
|
474
626
|
if (loc.kind === "postFile") {
|
|
475
627
|
try {
|
|
476
628
|
await this.#client.writePost(loc.slug, loc.dir, loc.filename, toText(content));
|
|
@@ -481,6 +633,16 @@ export class SforaFs {
|
|
|
481
633
|
this.#invalidate(loc.slug, loc.dir);
|
|
482
634
|
return;
|
|
483
635
|
}
|
|
636
|
+
if (loc.kind === "docFile") {
|
|
637
|
+
try {
|
|
638
|
+
await this.#client.writeNote(loc.slug, loc.filename, toText(content));
|
|
639
|
+
}
|
|
640
|
+
catch (e) {
|
|
641
|
+
throw fromApi(e, "open", norm);
|
|
642
|
+
}
|
|
643
|
+
this.#invalidateNotes(loc.slug);
|
|
644
|
+
return;
|
|
645
|
+
}
|
|
484
646
|
if (loc.kind === "boardCardFile") {
|
|
485
647
|
try {
|
|
486
648
|
const res = await this.#client.writeCard(loc.slug, loc.column, loc.filename, toText(content));
|
|
@@ -520,8 +682,14 @@ export class SforaFs {
|
|
|
520
682
|
case "projectDir":
|
|
521
683
|
case "postsDir":
|
|
522
684
|
case "docsDir":
|
|
685
|
+
case "libraryDir":
|
|
686
|
+
case "filesDir":
|
|
687
|
+
case "repositoriesDir":
|
|
523
688
|
case "pullsDir":
|
|
524
689
|
case "boardDir":
|
|
690
|
+
case "planFile":
|
|
691
|
+
case "mapFile":
|
|
692
|
+
case "asksFile":
|
|
525
693
|
return await this.#projectExists(loc.slug);
|
|
526
694
|
case "publicDir":
|
|
527
695
|
case "roadmapFile":
|
|
@@ -536,6 +704,10 @@ export class SforaFs {
|
|
|
536
704
|
undefined;
|
|
537
705
|
case "docFile":
|
|
538
706
|
return (await this.#matchNote(loc.slug, loc.filename)) !== undefined;
|
|
707
|
+
case "artifactFile":
|
|
708
|
+
return (await this.#listArtifacts(loc.slug)).some((artifact) => artifact.name === loc.filename);
|
|
709
|
+
case "repositoryPath":
|
|
710
|
+
return (await this.#repositoryEntry(loc.slug, loc.repository, loc.path)) !== undefined;
|
|
539
711
|
case "pullFile":
|
|
540
712
|
return (await this.#matchPull(loc.slug, loc.number)) !== undefined;
|
|
541
713
|
case "boardCardFile":
|
|
@@ -583,12 +755,18 @@ export class SforaFs {
|
|
|
583
755
|
case "meFile":
|
|
584
756
|
return this.#fileStat(new Date());
|
|
585
757
|
case "linksFile":
|
|
758
|
+
case "planFile":
|
|
759
|
+
case "mapFile":
|
|
760
|
+
case "asksFile":
|
|
586
761
|
if (!(await this.#projectExists(loc.slug)))
|
|
587
762
|
throw enoent("stat", norm);
|
|
588
763
|
return this.#fileStat(new Date());
|
|
589
764
|
case "projectDir":
|
|
590
765
|
case "postsDir":
|
|
591
766
|
case "docsDir":
|
|
767
|
+
case "libraryDir":
|
|
768
|
+
case "filesDir":
|
|
769
|
+
case "repositoriesDir":
|
|
592
770
|
case "pullsDir":
|
|
593
771
|
case "boardDir":
|
|
594
772
|
if (!(await this.#projectExists(loc.slug)))
|
|
@@ -625,6 +803,20 @@ export class SforaFs {
|
|
|
625
803
|
throw enoent("stat", norm);
|
|
626
804
|
return this.#fileStat(new Date(note.lastEditedAt || Date.now()));
|
|
627
805
|
}
|
|
806
|
+
case "artifactFile": {
|
|
807
|
+
const artifact = (await this.#listArtifacts(loc.slug)).find((candidate) => candidate.name === loc.filename);
|
|
808
|
+
if (!artifact)
|
|
809
|
+
throw enoent("stat", norm);
|
|
810
|
+
return this.#fileStat(new Date(artifact.uploadedAt || Date.now()), artifact.size);
|
|
811
|
+
}
|
|
812
|
+
case "repositoryPath": {
|
|
813
|
+
const entry = await this.#repositoryEntry(loc.slug, loc.repository, loc.path);
|
|
814
|
+
if (!entry)
|
|
815
|
+
throw enoent("stat", norm);
|
|
816
|
+
return entry.kind === "directory"
|
|
817
|
+
? this.#dirStat()
|
|
818
|
+
: this.#fileStat(new Date(), "size" in entry ? entry.size ?? 0 : 0);
|
|
819
|
+
}
|
|
628
820
|
case "pullFile": {
|
|
629
821
|
const pull = await this.#matchPull(loc.slug, loc.number);
|
|
630
822
|
if (!pull)
|
|
@@ -683,10 +875,13 @@ export class SforaFs {
|
|
|
683
875
|
throw enoent("scandir", norm);
|
|
684
876
|
}
|
|
685
877
|
const entries = [
|
|
878
|
+
dirent("asks.md", false),
|
|
686
879
|
dirent("board", true),
|
|
687
|
-
dirent("docs", true),
|
|
688
880
|
dirent("drafts", true),
|
|
881
|
+
dirent("library", true),
|
|
689
882
|
dirent("links.md", false),
|
|
883
|
+
dirent("plan.md", false),
|
|
884
|
+
dirent("map.md", false),
|
|
690
885
|
dirent("posts", true),
|
|
691
886
|
dirent("pulls", true),
|
|
692
887
|
];
|
|
@@ -696,6 +891,14 @@ export class SforaFs {
|
|
|
696
891
|
}
|
|
697
892
|
return entries;
|
|
698
893
|
}
|
|
894
|
+
case "libraryDir":
|
|
895
|
+
if (!(await this.#projectExists(loc.slug)))
|
|
896
|
+
throw enoent("scandir", norm);
|
|
897
|
+
return [
|
|
898
|
+
dirent("documents", true),
|
|
899
|
+
dirent("files", true),
|
|
900
|
+
dirent("repositories", true),
|
|
901
|
+
];
|
|
699
902
|
case "postsDir": {
|
|
700
903
|
const entries = await this.#listEntries(loc.slug, loc.dir);
|
|
701
904
|
return entries.map((e) => dirent(e.filename, false));
|
|
@@ -707,6 +910,35 @@ export class SforaFs {
|
|
|
707
910
|
const notes = await this.#listNotes(loc.slug);
|
|
708
911
|
return notes.map((n) => dirent(n.filename, false));
|
|
709
912
|
}
|
|
913
|
+
case "filesDir": {
|
|
914
|
+
if (!(await this.#projectExists(loc.slug)))
|
|
915
|
+
throw enoent("scandir", norm);
|
|
916
|
+
return (await this.#listArtifacts(loc.slug)).map((artifact) => dirent(artifact.name, false));
|
|
917
|
+
}
|
|
918
|
+
case "repositoriesDir": {
|
|
919
|
+
if (!(await this.#projectExists(loc.slug)))
|
|
920
|
+
throw enoent("scandir", norm);
|
|
921
|
+
return (await this.#listRepositories(loc.slug)).map((repository) => dirent(repository.dirname, true));
|
|
922
|
+
}
|
|
923
|
+
case "repositoryPath": {
|
|
924
|
+
const current = await this.#repositoryEntry(loc.slug, loc.repository, loc.path);
|
|
925
|
+
if (!current)
|
|
926
|
+
throw enoent("scandir", norm);
|
|
927
|
+
if (current.kind !== "directory")
|
|
928
|
+
throw enotdir("scandir", norm);
|
|
929
|
+
const prefix = loc.path ? `${loc.path}/` : "";
|
|
930
|
+
const children = new Map();
|
|
931
|
+
for (const entry of (await this.#repositoryTree(loc.slug, loc.repository)).entries) {
|
|
932
|
+
if (!entry.path.startsWith(prefix) || entry.path === loc.path)
|
|
933
|
+
continue;
|
|
934
|
+
const rest = entry.path.slice(prefix.length);
|
|
935
|
+
const [name, ...tail] = rest.split("/");
|
|
936
|
+
if (!name)
|
|
937
|
+
continue;
|
|
938
|
+
children.set(name, tail.length > 0 || entry.kind === "directory");
|
|
939
|
+
}
|
|
940
|
+
return [...children].map(([name, isDir]) => dirent(name, isDir));
|
|
941
|
+
}
|
|
710
942
|
case "pullsDir": {
|
|
711
943
|
if (!(await this.#projectExists(loc.slug))) {
|
|
712
944
|
throw enoent("scandir", norm);
|
|
@@ -743,9 +975,12 @@ export class SforaFs {
|
|
|
743
975
|
case "meFile":
|
|
744
976
|
case "postFile":
|
|
745
977
|
case "docFile":
|
|
978
|
+
case "artifactFile":
|
|
746
979
|
case "pullFile":
|
|
747
980
|
case "boardCardFile":
|
|
748
981
|
case "roadmapFile":
|
|
982
|
+
case "planFile":
|
|
983
|
+
case "asksFile":
|
|
749
984
|
throw enotdir("scandir", norm);
|
|
750
985
|
default:
|
|
751
986
|
throw enoent("scandir", norm);
|
|
@@ -772,6 +1007,9 @@ export class SforaFs {
|
|
|
772
1007
|
case "projectDir":
|
|
773
1008
|
case "postsDir":
|
|
774
1009
|
case "docsDir":
|
|
1010
|
+
case "libraryDir":
|
|
1011
|
+
case "filesDir":
|
|
1012
|
+
case "repositoriesDir":
|
|
775
1013
|
case "pullsDir":
|
|
776
1014
|
case "boardDir":
|
|
777
1015
|
case "publicDir":
|
|
@@ -858,12 +1096,32 @@ export class SforaFs {
|
|
|
858
1096
|
}
|
|
859
1097
|
case "inboxFile":
|
|
860
1098
|
case "meFile":
|
|
861
|
-
case "
|
|
1099
|
+
case "artifactFile":
|
|
1100
|
+
case "repositoryPath":
|
|
862
1101
|
case "pullFile":
|
|
863
1102
|
case "roadmapFile":
|
|
864
|
-
|
|
865
|
-
|
|
1103
|
+
case "planFile":
|
|
1104
|
+
case "asksFile":
|
|
1105
|
+
// Uploaded files and repositories are read-only projections; the
|
|
1106
|
+
// roadmap and pulls are owned by their source systems; the plan and
|
|
1107
|
+
// asks are generated views that can't be unlinked.
|
|
866
1108
|
throw eacces("unlink", norm);
|
|
1109
|
+
case "docFile": {
|
|
1110
|
+
const note = await this.#matchNote(loc.slug, loc.filename);
|
|
1111
|
+
if (!note) {
|
|
1112
|
+
if (options?.force)
|
|
1113
|
+
return;
|
|
1114
|
+
throw enoent("unlink", norm);
|
|
1115
|
+
}
|
|
1116
|
+
try {
|
|
1117
|
+
await this.#client.deleteNote(loc.slug, loc.filename);
|
|
1118
|
+
}
|
|
1119
|
+
catch (e) {
|
|
1120
|
+
throw fromApi(e, "unlink", norm);
|
|
1121
|
+
}
|
|
1122
|
+
this.#invalidateNotes(loc.slug);
|
|
1123
|
+
return;
|
|
1124
|
+
}
|
|
867
1125
|
case "root":
|
|
868
1126
|
case "projectsDir":
|
|
869
1127
|
case "inboxDir":
|
|
@@ -871,6 +1129,9 @@ export class SforaFs {
|
|
|
871
1129
|
case "projectDir":
|
|
872
1130
|
case "postsDir":
|
|
873
1131
|
case "docsDir":
|
|
1132
|
+
case "libraryDir":
|
|
1133
|
+
case "filesDir":
|
|
1134
|
+
case "repositoriesDir":
|
|
874
1135
|
case "pullsDir":
|
|
875
1136
|
case "boardDir":
|
|
876
1137
|
case "publicDir":
|
|
@@ -982,9 +1243,16 @@ export class SforaFs {
|
|
|
982
1243
|
]);
|
|
983
1244
|
for (const slug of this.#projectSlugs) {
|
|
984
1245
|
paths.add(`/projects/${slug}`);
|
|
1246
|
+
paths.add(`/projects/${slug}/plan.md`);
|
|
1247
|
+
paths.add(`/projects/${slug}/asks.md`);
|
|
985
1248
|
paths.add(`/projects/${slug}/posts`);
|
|
986
1249
|
paths.add(`/projects/${slug}/drafts`);
|
|
987
1250
|
paths.add(`/projects/${slug}/docs`);
|
|
1251
|
+
paths.add(`/projects/${slug}/artifacts`);
|
|
1252
|
+
paths.add(`/projects/${slug}/library`);
|
|
1253
|
+
paths.add(`/projects/${slug}/library/documents`);
|
|
1254
|
+
paths.add(`/projects/${slug}/library/files`);
|
|
1255
|
+
paths.add(`/projects/${slug}/library/repositories`);
|
|
988
1256
|
paths.add(`/projects/${slug}/pulls`);
|
|
989
1257
|
paths.add(`/projects/${slug}/board`);
|
|
990
1258
|
// `public/` is only real for boards known to be shared.
|