sfora-cli 0.10.0 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +174 -0
- package/dist/SforaFs.js +8 -6
- package/dist/api-client.d.ts +344 -4
- package/dist/api-client.js +289 -21
- package/dist/block-commands.d.ts +84 -0
- package/dist/block-commands.js +155 -0
- package/dist/chat.d.ts +89 -0
- package/dist/chat.js +189 -0
- package/dist/cli-args.d.ts +32 -0
- package/dist/cli-args.js +88 -0
- package/dist/cli.js +530 -88
- package/dist/format/blockSplice.d.ts +135 -0
- package/dist/format/blockSplice.js +330 -0
- package/dist/format/blocks/dropClosure.d.ts +10 -1
- package/dist/format/blocks/dropClosure.js +11 -1
- package/dist/format/callout.d.ts +69 -7
- package/dist/format/callout.js +112 -15
- package/dist/format/checklist.js +11 -4
- package/dist/format/formatAxes.d.ts +228 -0
- package/dist/format/formatAxes.js +454 -0
- package/dist/format/index.d.ts +1 -0
- package/dist/format/index.js +4 -0
- package/dist/format/lineGeometry.d.ts +34 -4
- package/dist/format/lineGeometry.js +140 -40
- package/dist/format/lint/appliesTo.d.ts +92 -0
- package/dist/format/lint/appliesTo.js +369 -0
- package/dist/format/lint/config.d.ts +106 -0
- package/dist/format/lint/config.js +205 -0
- package/dist/format/lint/fixAll.d.ts +62 -0
- package/dist/format/lint/fixAll.js +107 -0
- package/dist/format/lint/frontmatterSchema.d.ts +181 -0
- package/dist/format/lint/frontmatterSchema.js +660 -0
- package/dist/format/lint/index.d.ts +34 -5
- package/dist/format/lint/index.js +34 -5
- package/dist/format/lint/lintSource.d.ts +27 -7
- package/dist/format/lint/lintSource.js +67 -33
- package/dist/format/lint/rules/frontmatter-schema.d.ts +2 -0
- package/dist/format/lint/rules/frontmatter-schema.js +92 -0
- package/dist/format/lint/rules/index.d.ts +2 -1
- package/dist/format/lint/rules/index.js +7 -1
- package/dist/format/lint/rules/malformed-callout.js +25 -16
- package/dist/format/lint/rules/malformed-checklist.js +8 -3
- package/dist/format/lint/rules/malformed-frontmatter.js +6 -1
- package/dist/format/lint/severity.d.ts +15 -0
- package/dist/format/lint/severity.js +50 -0
- package/dist/format/lint/textEdits.d.ts +86 -0
- package/dist/format/lint/textEdits.js +162 -0
- package/dist/format/lint/types.d.ts +44 -8
- package/dist/format/markdown/slug.d.ts +28 -0
- package/dist/format/markdown/slug.js +63 -0
- package/dist/format/plaintext.js +13 -3
- package/dist/format/sheetCellSpans.d.ts +95 -0
- package/dist/format/sheetCellSpans.js +223 -0
- package/dist/format/sheetSelection.d.ts +136 -0
- package/dist/format/sheetSelection.js +282 -0
- package/dist/format/textStats.d.ts +23 -0
- package/dist/format/textStats.js +80 -0
- package/dist/format/wikiLinks.d.ts +60 -1
- package/dist/format/wikiLinks.js +195 -9
- package/dist/index.d.ts +26 -1
- package/dist/index.js +20 -3
- package/dist/opener.d.ts +23 -0
- package/dist/opener.js +26 -0
- package/dist/render.d.ts +162 -0
- package/dist/render.js +280 -0
- package/dist/shell-commands.d.ts +34 -0
- package/dist/shell-commands.js +108 -0
- package/dist/watch.d.ts +79 -0
- package/dist/watch.js +113 -0
- package/dist/web-url.d.ts +39 -0
- package/dist/web-url.js +63 -0
- package/package.json +1 -1
package/dist/watch.js
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `sfora watch` — a document as a channel.
|
|
3
|
+
*
|
|
4
|
+
* Card #332. `/v1/events` has been the agent wake-up transport since 2026-07;
|
|
5
|
+
* card #328 taught it `doc.write`, and this is the consumer that makes a write
|
|
6
|
+
* into a ping somebody actually sees. Point it at a document and every edit
|
|
7
|
+
* anybody makes to it arrives as a line; point it at a project and every
|
|
8
|
+
* document in the project does.
|
|
9
|
+
*
|
|
10
|
+
* FOUR THINGS THIS OWNS, and each is here rather than in `cli.ts` because each
|
|
11
|
+
* is a rule with a reason:
|
|
12
|
+
*
|
|
13
|
+
* 1. **The cursor survives reconnects.** A dropped connection is normal on a
|
|
14
|
+
* long-poll; losing the cursor with it would replay pings already printed,
|
|
15
|
+
* or (worse, with `since=0`) dump a day of backlog. It is a variable in
|
|
16
|
+
* this loop and it only ever moves forward.
|
|
17
|
+
* 2. **Backoff on drops, reset on success.** A server that is down must not be
|
|
18
|
+
* hammered by a watcher that reconnects instantly, and a watcher that
|
|
19
|
+
* backed off must not stay slow once the server is back.
|
|
20
|
+
* 3. **Presence while watching.** Reading a document IS being in it, so the
|
|
21
|
+
* watch declares `viewing` and re-declares on each poll — the roster row
|
|
22
|
+
* expires ~90s after the last beat and the poll cadence is well inside
|
|
23
|
+
* that. Paths with no roster (a post, a card, a project) simply get no
|
|
24
|
+
* declaration; the server says which those are, not the CLI.
|
|
25
|
+
* 4. **Leaving on exit.** A watch that ended and left a row behind would show
|
|
26
|
+
* a ghost in somebody's avatar stack for a minute and a half.
|
|
27
|
+
*
|
|
28
|
+
* The loop takes its I/O as arguments so a test can drive it with canned pages
|
|
29
|
+
* and a fake clock. Nothing here touches `process`.
|
|
30
|
+
*/
|
|
31
|
+
import { ndjson, renderPing } from "./render.js";
|
|
32
|
+
/** A watcher that has been unreachable this long is not polling any faster. */
|
|
33
|
+
export const MAX_BACKOFF_MS = 30_000;
|
|
34
|
+
const DEFAULT_BACKOFF_MS = 1_000;
|
|
35
|
+
/**
|
|
36
|
+
* The kinds this prints as pings.
|
|
37
|
+
*
|
|
38
|
+
* A `?doc=` poll returns only these; a `?project=` poll also carries room
|
|
39
|
+
* messages and ask changes, which are somebody else's stream. Filtering here
|
|
40
|
+
* rather than asking the server for less keeps one code path for both scopes.
|
|
41
|
+
*/
|
|
42
|
+
function isDocPing(event) {
|
|
43
|
+
return event.type === "doc.write" || event.type === "doc.delete";
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Poll until stopped, printing what arrives.
|
|
47
|
+
*
|
|
48
|
+
* Returns the cursor it reached, which is what makes the loop testable: a test
|
|
49
|
+
* asserts that a reconnect resumed from it rather than from zero.
|
|
50
|
+
*/
|
|
51
|
+
export async function watchLoop(deps, options = {}) {
|
|
52
|
+
const stopped = options.stopped ?? (() => false);
|
|
53
|
+
const firstBackoff = options.backoffMs ?? DEFAULT_BACKOFF_MS;
|
|
54
|
+
let cursor = options.since ?? 0;
|
|
55
|
+
let backoff = firstBackoff;
|
|
56
|
+
let declared = false;
|
|
57
|
+
try {
|
|
58
|
+
while (!stopped()) {
|
|
59
|
+
// Beat before the poll, not after: the poll blocks for tens of seconds,
|
|
60
|
+
// and a row that expires mid-poll would drop the watcher off the roster
|
|
61
|
+
// for exactly as long as nothing is happening in the document.
|
|
62
|
+
if (deps.beat) {
|
|
63
|
+
try {
|
|
64
|
+
await deps.beat({});
|
|
65
|
+
declared = true;
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
// A roster we could not join is not a reason to stop watching.
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
let page;
|
|
72
|
+
try {
|
|
73
|
+
page = await deps.poll(cursor);
|
|
74
|
+
}
|
|
75
|
+
catch (error) {
|
|
76
|
+
if (stopped())
|
|
77
|
+
break;
|
|
78
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
79
|
+
deps.warn(`reconnecting in ${Math.round(backoff / 1000)}s — ${message}`);
|
|
80
|
+
await deps.sleep(backoff);
|
|
81
|
+
backoff = Math.min(backoff * 2, MAX_BACKOFF_MS);
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
// A poll that answered is a poll that works: any earlier trouble is over.
|
|
85
|
+
backoff = firstBackoff;
|
|
86
|
+
for (const event of page.events) {
|
|
87
|
+
if (!isDocPing(event))
|
|
88
|
+
continue;
|
|
89
|
+
deps.write(options.json ? `${ndjson(event)}\n` : `${renderPing(event)}\n`);
|
|
90
|
+
}
|
|
91
|
+
// Never backwards: an empty page holds the cursor where it was.
|
|
92
|
+
cursor = Math.max(cursor, page.cursor);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
finally {
|
|
96
|
+
// Leave even when the loop is being torn down by an exception — a ghost in
|
|
97
|
+
// somebody's avatar stack is the one failure a watcher can leave behind.
|
|
98
|
+
if (declared && deps.beat) {
|
|
99
|
+
await deps.beat({ leave: true }).catch(() => { });
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return cursor;
|
|
103
|
+
}
|
|
104
|
+
export function parseWatchTarget(target) {
|
|
105
|
+
const trimmed = target.trim().replace(/\/+$/, "");
|
|
106
|
+
const segments = trimmed.split("/").filter(Boolean);
|
|
107
|
+
if (!trimmed.includes("/"))
|
|
108
|
+
return { kind: "project", slug: trimmed };
|
|
109
|
+
if (segments.length === 2 && segments[0] === "projects") {
|
|
110
|
+
return { kind: "project", slug: segments[1] };
|
|
111
|
+
}
|
|
112
|
+
return { kind: "path", path: trimmed.startsWith("/") ? trimmed : `/${trimmed}` };
|
|
113
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where a thing lives on the web — read off the server's answer, never built.
|
|
3
|
+
*
|
|
4
|
+
* Card #335. The CLI holds NO route knowledge: it does not know that a document
|
|
5
|
+
* lives at `/org/<slug>/notes/<id>` or that a card opens as `?detail=card:<id>`,
|
|
6
|
+
* and it does not know the deployment's web origin. It asks for a path over
|
|
7
|
+
* `/v1/fs` and prints the link the server put on the answer. That is the whole
|
|
8
|
+
* contract, and it is why `sfora url` keeps working when the app's routes move.
|
|
9
|
+
*
|
|
10
|
+
* The server states it in one of two places, because fs responses come in two
|
|
11
|
+
* shapes:
|
|
12
|
+
*
|
|
13
|
+
* • a **markdown read** answers with the document's bytes, so the link rides
|
|
14
|
+
* in the `X-Sfora-Url` response header — putting it in the file would
|
|
15
|
+
* change the canonical bytes every block id is computed over;
|
|
16
|
+
* • a **JSON response** (a listing, a `?view=`, a write result) carries a
|
|
17
|
+
* top-level `url` field.
|
|
18
|
+
*
|
|
19
|
+
* Both are read here so no caller has to care which door it opened.
|
|
20
|
+
*/
|
|
21
|
+
/** The response header a markdown read carries its page link in. */
|
|
22
|
+
export declare const WEB_URL_HEADER = "x-sfora-url";
|
|
23
|
+
/**
|
|
24
|
+
* The web URL a response names, or null when it names none.
|
|
25
|
+
*
|
|
26
|
+
* `body` is the response text when the caller already has it; without it only
|
|
27
|
+
* the header is consulted. Passing a body that is not JSON is fine — a
|
|
28
|
+
* markdown read hands its own bytes in and the parse simply fails.
|
|
29
|
+
*/
|
|
30
|
+
export declare function webUrlFromResponse(headers: {
|
|
31
|
+
get(name: string): string | null;
|
|
32
|
+
}, body?: string): string | null;
|
|
33
|
+
/**
|
|
34
|
+
* An fs path (`/projects/acme/posts/x.md`) as a `/v1/fs` request path.
|
|
35
|
+
*
|
|
36
|
+
* Each segment is encoded on its own so a filename with a space or a `#`
|
|
37
|
+
* survives, and the separators do not.
|
|
38
|
+
*/
|
|
39
|
+
export declare function fsRequestPath(path: string): string;
|
package/dist/web-url.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where a thing lives on the web — read off the server's answer, never built.
|
|
3
|
+
*
|
|
4
|
+
* Card #335. The CLI holds NO route knowledge: it does not know that a document
|
|
5
|
+
* lives at `/org/<slug>/notes/<id>` or that a card opens as `?detail=card:<id>`,
|
|
6
|
+
* and it does not know the deployment's web origin. It asks for a path over
|
|
7
|
+
* `/v1/fs` and prints the link the server put on the answer. That is the whole
|
|
8
|
+
* contract, and it is why `sfora url` keeps working when the app's routes move.
|
|
9
|
+
*
|
|
10
|
+
* The server states it in one of two places, because fs responses come in two
|
|
11
|
+
* shapes:
|
|
12
|
+
*
|
|
13
|
+
* • a **markdown read** answers with the document's bytes, so the link rides
|
|
14
|
+
* in the `X-Sfora-Url` response header — putting it in the file would
|
|
15
|
+
* change the canonical bytes every block id is computed over;
|
|
16
|
+
* • a **JSON response** (a listing, a `?view=`, a write result) carries a
|
|
17
|
+
* top-level `url` field.
|
|
18
|
+
*
|
|
19
|
+
* Both are read here so no caller has to care which door it opened.
|
|
20
|
+
*/
|
|
21
|
+
/** The response header a markdown read carries its page link in. */
|
|
22
|
+
export const WEB_URL_HEADER = "x-sfora-url";
|
|
23
|
+
/** An absolute `http(s)` URL, or null. Anything else is not a link we print. */
|
|
24
|
+
function absolute(value) {
|
|
25
|
+
return typeof value === "string" && /^https?:\/\//.test(value) ? value : null;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* The web URL a response names, or null when it names none.
|
|
29
|
+
*
|
|
30
|
+
* `body` is the response text when the caller already has it; without it only
|
|
31
|
+
* the header is consulted. Passing a body that is not JSON is fine — a
|
|
32
|
+
* markdown read hands its own bytes in and the parse simply fails.
|
|
33
|
+
*/
|
|
34
|
+
export function webUrlFromResponse(headers, body) {
|
|
35
|
+
const fromHeader = absolute(headers.get(WEB_URL_HEADER));
|
|
36
|
+
if (fromHeader)
|
|
37
|
+
return fromHeader;
|
|
38
|
+
if (!body)
|
|
39
|
+
return null;
|
|
40
|
+
try {
|
|
41
|
+
const parsed = JSON.parse(body);
|
|
42
|
+
if (parsed && typeof parsed === "object") {
|
|
43
|
+
return absolute(parsed.url);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
// Not JSON — a markdown body, which says nothing about its page.
|
|
48
|
+
}
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* An fs path (`/projects/acme/posts/x.md`) as a `/v1/fs` request path.
|
|
53
|
+
*
|
|
54
|
+
* Each segment is encoded on its own so a filename with a space or a `#`
|
|
55
|
+
* survives, and the separators do not.
|
|
56
|
+
*/
|
|
57
|
+
export function fsRequestPath(path) {
|
|
58
|
+
const segments = path
|
|
59
|
+
.split("/")
|
|
60
|
+
.filter((s) => s.length > 0)
|
|
61
|
+
.map(encodeURIComponent);
|
|
62
|
+
return `/v1/fs${segments.length ? `/${segments.join("/")}` : ""}`;
|
|
63
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sfora-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Your sfora workspace as a markdown filesystem — a CLI + MCP server. Post/task/doc, ls/cat/grep, and a shell so agents operate sfora natively.",
|
|
6
6
|
"keywords": [
|