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/render.d.ts
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the CLI prints — as pure functions, so it can be tested.
|
|
3
|
+
*
|
|
4
|
+
* Everything here takes data and returns a string. No I/O, no process, no
|
|
5
|
+
* colours decided by a TTY check: the caller writes the result. That is what
|
|
6
|
+
* lets `render.test.ts` assert on a 409 re-aim table or a blocks listing
|
|
7
|
+
* without a server, a terminal, or a snapshot of the whole command.
|
|
8
|
+
*
|
|
9
|
+
* The rule these follow, from the mission's binding: THE CLI PRINTS WHAT THE
|
|
10
|
+
* SERVER SENDS. Nothing here computes a URL, a route, or a block id — each
|
|
11
|
+
* comes off a response and is formatted.
|
|
12
|
+
*/
|
|
13
|
+
import type { BlockConflict, BlockView, BlocksView, PresenceView, WriteEffect } from "./api-client.js";
|
|
14
|
+
export declare const colors: {
|
|
15
|
+
reset: string;
|
|
16
|
+
bold: string;
|
|
17
|
+
dim: string;
|
|
18
|
+
cyan: string;
|
|
19
|
+
green: string;
|
|
20
|
+
yellow: string;
|
|
21
|
+
blue: string;
|
|
22
|
+
red: string;
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* A link, as one quiet line.
|
|
26
|
+
*
|
|
27
|
+
* Dim and unadorned because it is an offer, not an instruction: the line a
|
|
28
|
+
* reader's eye skips until the moment they want it, and a terminal that
|
|
29
|
+
* hyperlinks URLs makes it clickable without any markup from us.
|
|
30
|
+
*/
|
|
31
|
+
export declare function urlLine(url: string): string;
|
|
32
|
+
/** The first line of a block's text, collapsed and clipped for one row. */
|
|
33
|
+
export declare function blockSummary(block: BlockView, width?: number): string;
|
|
34
|
+
/**
|
|
35
|
+
* `sfora blocks <path>` — the addressable view, one row per block.
|
|
36
|
+
*
|
|
37
|
+
* The id comes FIRST because it is the only column anyone retypes: the next
|
|
38
|
+
* command is `sfora put <path> --block <id>`, and a listing that buries the id
|
|
39
|
+
* makes the reader hunt for it.
|
|
40
|
+
*
|
|
41
|
+
* `writable: false` is stated, not omitted, and it is stated as "read-only"
|
|
42
|
+
* rather than by leaving the row out. Those blocks are real — the frontmatter
|
|
43
|
+
* fence, the title heading — they occupy lines the reader can see in the file,
|
|
44
|
+
* and hiding them would make the listing disagree with `cat`.
|
|
45
|
+
*/
|
|
46
|
+
export declare function renderBlocks(view: BlocksView): string;
|
|
47
|
+
/**
|
|
48
|
+
* The 409 a `?block=` write gets: the id named nothing, here is what the
|
|
49
|
+
* document has now.
|
|
50
|
+
*
|
|
51
|
+
* A table and not a message, because the recovery IS the payload: the server
|
|
52
|
+
* already sent the current blocks, so the fix is one glance and one retry
|
|
53
|
+
* rather than a second `sfora blocks` round trip. The retry line is spelled
|
|
54
|
+
* out with the real path for the same reason.
|
|
55
|
+
*
|
|
56
|
+
* THE COLUMNS ARE THE PAYLOAD'S — id, line, preview — and not `sfora blocks`'s.
|
|
57
|
+
* The two lists describe different bytes (see {@link BlockSummary}): every id
|
|
58
|
+
* here already resolves at the write door, so there is no writable/read-only
|
|
59
|
+
* distinction to draw and no node type in the payload to draw one with. An
|
|
60
|
+
* earlier version of this function reached for both anyway and printed
|
|
61
|
+
* `undefined read-only` on every row, which inverted the one fact the table
|
|
62
|
+
* exists to convey. `line` earns the column it took: it is where in the file
|
|
63
|
+
* the caller is holding to look, and it is the field that tells two blocks with
|
|
64
|
+
* the same opening words apart.
|
|
65
|
+
*/
|
|
66
|
+
export declare function renderBlockConflict(conflict: BlockConflict, fsPath: string): string;
|
|
67
|
+
/**
|
|
68
|
+
* The effect report, as one line — printed after EVERY write.
|
|
69
|
+
*
|
|
70
|
+
* A bare "saved" is a lie the CLI used to tell: sfora's write door splices, so
|
|
71
|
+
* a PUT of bytes that parse the same as the stored ones stores nothing and
|
|
72
|
+
* `changed: false` is a normal answer. Saying so is the difference between an
|
|
73
|
+
* agent that knows its edit landed and one that assumes it did.
|
|
74
|
+
*
|
|
75
|
+
* The rebind counts describe the blocks the document had BEFORE this write:
|
|
76
|
+
* `total` of them, of which `rebound` are the same block under a new id and
|
|
77
|
+
* `orphaned` are blocks nothing in the new document can be shown to be. The
|
|
78
|
+
* rest kept the id they had, which is the number worth leading with — it is
|
|
79
|
+
* what tells a reader whether their block addresses still work.
|
|
80
|
+
*/
|
|
81
|
+
export declare function renderWriteEffect(effect: WriteEffect | null): string | null;
|
|
82
|
+
/**
|
|
83
|
+
* The one-time "you are visible" note.
|
|
84
|
+
*
|
|
85
|
+
* Writing DECLARES PRESENCE server-side — the document's roster shows the
|
|
86
|
+
* writer, and a human with it open sees them. That is a fact about the user's
|
|
87
|
+
* visibility to other people, so it is said out loud rather than left to be
|
|
88
|
+
* discovered in the UI; once per run, because said on every write it would be
|
|
89
|
+
* noise and the second one teaches nothing.
|
|
90
|
+
*/
|
|
91
|
+
export declare const PRESENCE_NOTE = "you are visible as editing this document";
|
|
92
|
+
/**
|
|
93
|
+
* `sfora where` — one sentence per person, and a link they can be met at.
|
|
94
|
+
*
|
|
95
|
+
* A SENTENCE, not a table. The answer to "where is Thijs" is read once and
|
|
96
|
+
* acted on immediately ("open that document"), so it is written the way it
|
|
97
|
+
* would be said: *Thijs is editing test-document.md — <url>*. A columnar
|
|
98
|
+
* listing would be denser and would make the reader assemble the sentence
|
|
99
|
+
* themselves.
|
|
100
|
+
*
|
|
101
|
+
* The block id rides in parentheses only when somebody claimed one. Humans
|
|
102
|
+
* are present at document level today (the editor has no cheap source-offset
|
|
103
|
+
* mapping), so an always-present column would be empty on most rows and read
|
|
104
|
+
* as a fault rather than as an absence of claim.
|
|
105
|
+
*
|
|
106
|
+
* Grouped runs, no headers: two people in one document print as two adjacent
|
|
107
|
+
* lines naming the same file. The repetition is the grouping, and it costs
|
|
108
|
+
* nothing to scan — where a header would cost a line per document and break
|
|
109
|
+
* `sfora where | grep` into a two-line lookup.
|
|
110
|
+
*/
|
|
111
|
+
export declare function renderPresence(view: PresenceView): string;
|
|
112
|
+
/**
|
|
113
|
+
* `sfora where --json` — one flat record per person-in-a-document, per line.
|
|
114
|
+
*
|
|
115
|
+
* FLAT, where the wire shape is grouped. NDJSON's contract is that a line is a
|
|
116
|
+
* record, and the record a consumer of this wants is "who is where": grouping
|
|
117
|
+
* would make a line's shape depend on how many people happened to be in one
|
|
118
|
+
* file, and `jq -r .url` would stop working. The document's fields are copied
|
|
119
|
+
* onto each line rather than referenced, so no line needs another to be read.
|
|
120
|
+
*/
|
|
121
|
+
export declare function presenceRecords(view: PresenceView): unknown[];
|
|
122
|
+
/** One `doc.write` / `doc.delete` ping from `/v1/events`, as one line. */
|
|
123
|
+
export interface DocPing {
|
|
124
|
+
type: "doc.write" | "doc.delete";
|
|
125
|
+
ts: number;
|
|
126
|
+
docType?: string;
|
|
127
|
+
docId?: string;
|
|
128
|
+
title?: string;
|
|
129
|
+
path?: string;
|
|
130
|
+
url?: string;
|
|
131
|
+
author?: string;
|
|
132
|
+
authorType?: "human" | "agent";
|
|
133
|
+
changed?: boolean;
|
|
134
|
+
deleted?: boolean;
|
|
135
|
+
restricted?: boolean;
|
|
136
|
+
blockIds?: {
|
|
137
|
+
rebound: number;
|
|
138
|
+
orphaned: number;
|
|
139
|
+
total: number;
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
/** `HH:MM:SS` in the reader's own timezone — a ping is read as it lands. */
|
|
143
|
+
export declare function pingTime(ts: number): string;
|
|
144
|
+
/**
|
|
145
|
+
* `time · author · doc · what changed · url` — one ping, one line.
|
|
146
|
+
*
|
|
147
|
+
* A RESTRICTED ping keeps its shape and loses its pointer: the server
|
|
148
|
+
* delivered the fact (a document you are watching moved, by whom) and withheld
|
|
149
|
+
* the title and the link because they belong to somebody's draft. Printing
|
|
150
|
+
* "(restricted)" rather than dropping the line is the honest half — a watch
|
|
151
|
+
* that went silent would look broken.
|
|
152
|
+
*/
|
|
153
|
+
export declare function renderPing(ping: DocPing): string;
|
|
154
|
+
/**
|
|
155
|
+
* The NDJSON line for one ping — the machine half of `sfora watch --json`.
|
|
156
|
+
*
|
|
157
|
+
* The SERVER'S event object, verbatim, plus nothing. A CLI that reshaped it
|
|
158
|
+
* would become a second schema to keep in step with `/v1/events`, and the one
|
|
159
|
+
* consumer that matters here (an agent piping this into a program) is better
|
|
160
|
+
* served by the wire shape it can also get from the HTTP door directly.
|
|
161
|
+
*/
|
|
162
|
+
export declare function ndjson(event: unknown): string;
|
package/dist/render.js
ADDED
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the CLI prints — as pure functions, so it can be tested.
|
|
3
|
+
*
|
|
4
|
+
* Everything here takes data and returns a string. No I/O, no process, no
|
|
5
|
+
* colours decided by a TTY check: the caller writes the result. That is what
|
|
6
|
+
* lets `render.test.ts` assert on a 409 re-aim table or a blocks listing
|
|
7
|
+
* without a server, a terminal, or a snapshot of the whole command.
|
|
8
|
+
*
|
|
9
|
+
* The rule these follow, from the mission's binding: THE CLI PRINTS WHAT THE
|
|
10
|
+
* SERVER SENDS. Nothing here computes a URL, a route, or a block id — each
|
|
11
|
+
* comes off a response and is formatted.
|
|
12
|
+
*/
|
|
13
|
+
export const colors = {
|
|
14
|
+
reset: "\x1b[0m",
|
|
15
|
+
bold: "\x1b[1m",
|
|
16
|
+
dim: "\x1b[2m",
|
|
17
|
+
cyan: "\x1b[36m",
|
|
18
|
+
green: "\x1b[32m",
|
|
19
|
+
yellow: "\x1b[33m",
|
|
20
|
+
blue: "\x1b[34m",
|
|
21
|
+
red: "\x1b[31m",
|
|
22
|
+
};
|
|
23
|
+
const dim = (text) => `${colors.dim}${text}${colors.reset}`;
|
|
24
|
+
/**
|
|
25
|
+
* A link, as one quiet line.
|
|
26
|
+
*
|
|
27
|
+
* Dim and unadorned because it is an offer, not an instruction: the line a
|
|
28
|
+
* reader's eye skips until the moment they want it, and a terminal that
|
|
29
|
+
* hyperlinks URLs makes it clickable without any markup from us.
|
|
30
|
+
*/
|
|
31
|
+
export function urlLine(url) {
|
|
32
|
+
return dim(url);
|
|
33
|
+
}
|
|
34
|
+
/** One line of text, collapsed and clipped to a column. */
|
|
35
|
+
function oneLine(text, width) {
|
|
36
|
+
const firstLine = text.split("\n").find((l) => l.trim()) ?? "";
|
|
37
|
+
const flat = firstLine.replace(/\s+/g, " ").trim();
|
|
38
|
+
return flat.length > width ? `${flat.slice(0, width - 1)}…` : flat;
|
|
39
|
+
}
|
|
40
|
+
/** The first line of a block's text, collapsed and clipped for one row. */
|
|
41
|
+
export function blockSummary(block, width = 56) {
|
|
42
|
+
return oneLine(block.text ?? "", width);
|
|
43
|
+
}
|
|
44
|
+
function pad(text, width) {
|
|
45
|
+
return text.length >= width ? text : text + " ".repeat(width - text.length);
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* `sfora blocks <path>` — the addressable view, one row per block.
|
|
49
|
+
*
|
|
50
|
+
* The id comes FIRST because it is the only column anyone retypes: the next
|
|
51
|
+
* command is `sfora put <path> --block <id>`, and a listing that buries the id
|
|
52
|
+
* makes the reader hunt for it.
|
|
53
|
+
*
|
|
54
|
+
* `writable: false` is stated, not omitted, and it is stated as "read-only"
|
|
55
|
+
* rather than by leaving the row out. Those blocks are real — the frontmatter
|
|
56
|
+
* fence, the title heading — they occupy lines the reader can see in the file,
|
|
57
|
+
* and hiding them would make the listing disagree with `cat`.
|
|
58
|
+
*/
|
|
59
|
+
export function renderBlocks(view) {
|
|
60
|
+
const lines = [];
|
|
61
|
+
if (view.unaddressable) {
|
|
62
|
+
return [
|
|
63
|
+
`${colors.yellow}no addressable blocks${colors.reset}`,
|
|
64
|
+
dim(view.unaddressable),
|
|
65
|
+
...(view.url ? [urlLine(view.url)] : []),
|
|
66
|
+
].join("\n");
|
|
67
|
+
}
|
|
68
|
+
if (view.blocks.length === 0) {
|
|
69
|
+
lines.push(dim("(no blocks)"));
|
|
70
|
+
}
|
|
71
|
+
else {
|
|
72
|
+
const idWidth = Math.max(...view.blocks.map((b) => b.id.length));
|
|
73
|
+
const typeWidth = Math.max(...view.blocks.map((b) => b.type.length));
|
|
74
|
+
for (const block of view.blocks) {
|
|
75
|
+
const row = `${pad(block.id, idWidth)} ${dim(pad(block.type, typeWidth))} ${blockSummary(block)}`;
|
|
76
|
+
lines.push(block.writable ? row : `${dim(row)} ${dim("read-only")}`);
|
|
77
|
+
}
|
|
78
|
+
const writable = view.blocks.filter((b) => b.writable).length;
|
|
79
|
+
lines.push(dim(`${view.blocks.length} block${view.blocks.length === 1 ? "" : "s"} · ${writable} writable`));
|
|
80
|
+
}
|
|
81
|
+
if (view.url)
|
|
82
|
+
lines.push(urlLine(view.url));
|
|
83
|
+
return lines.join("\n");
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* The 409 a `?block=` write gets: the id named nothing, here is what the
|
|
87
|
+
* document has now.
|
|
88
|
+
*
|
|
89
|
+
* A table and not a message, because the recovery IS the payload: the server
|
|
90
|
+
* already sent the current blocks, so the fix is one glance and one retry
|
|
91
|
+
* rather than a second `sfora blocks` round trip. The retry line is spelled
|
|
92
|
+
* out with the real path for the same reason.
|
|
93
|
+
*
|
|
94
|
+
* THE COLUMNS ARE THE PAYLOAD'S — id, line, preview — and not `sfora blocks`'s.
|
|
95
|
+
* The two lists describe different bytes (see {@link BlockSummary}): every id
|
|
96
|
+
* here already resolves at the write door, so there is no writable/read-only
|
|
97
|
+
* distinction to draw and no node type in the payload to draw one with. An
|
|
98
|
+
* earlier version of this function reached for both anyway and printed
|
|
99
|
+
* `undefined read-only` on every row, which inverted the one fact the table
|
|
100
|
+
* exists to convey. `line` earns the column it took: it is where in the file
|
|
101
|
+
* the caller is holding to look, and it is the field that tells two blocks with
|
|
102
|
+
* the same opening words apart.
|
|
103
|
+
*/
|
|
104
|
+
export function renderBlockConflict(conflict, fsPath) {
|
|
105
|
+
const lines = [
|
|
106
|
+
`${colors.yellow}that block is gone${colors.reset} ${dim("— somebody changed it since you read it")}`,
|
|
107
|
+
dim(conflict.message),
|
|
108
|
+
"",
|
|
109
|
+
];
|
|
110
|
+
if (conflict.blocks.length === 0) {
|
|
111
|
+
lines.push(dim("(the document has no addressable blocks now)"));
|
|
112
|
+
}
|
|
113
|
+
else {
|
|
114
|
+
lines.push(dim("the document has these blocks now:"));
|
|
115
|
+
const idWidth = Math.max(...conflict.blocks.map((b) => b.id.length));
|
|
116
|
+
const lineWidth = Math.max(...conflict.blocks.map((b) => `${b.line}`.length));
|
|
117
|
+
for (const block of conflict.blocks) {
|
|
118
|
+
const at = `${" ".repeat(lineWidth - `${block.line}`.length)}${block.line}`;
|
|
119
|
+
lines.push(` ${pad(block.id, idWidth)} ${dim(`line ${at}`)} ${oneLine(block.preview, 48)}`);
|
|
120
|
+
}
|
|
121
|
+
lines.push("", dim(`re-aim with: sfora put ${fsPath} --block <id>`));
|
|
122
|
+
}
|
|
123
|
+
return lines.join("\n");
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* The effect report, as one line — printed after EVERY write.
|
|
127
|
+
*
|
|
128
|
+
* A bare "saved" is a lie the CLI used to tell: sfora's write door splices, so
|
|
129
|
+
* a PUT of bytes that parse the same as the stored ones stores nothing and
|
|
130
|
+
* `changed: false` is a normal answer. Saying so is the difference between an
|
|
131
|
+
* agent that knows its edit landed and one that assumes it did.
|
|
132
|
+
*
|
|
133
|
+
* The rebind counts describe the blocks the document had BEFORE this write:
|
|
134
|
+
* `total` of them, of which `rebound` are the same block under a new id and
|
|
135
|
+
* `orphaned` are blocks nothing in the new document can be shown to be. The
|
|
136
|
+
* rest kept the id they had, which is the number worth leading with — it is
|
|
137
|
+
* what tells a reader whether their block addresses still work.
|
|
138
|
+
*/
|
|
139
|
+
export function renderWriteEffect(effect) {
|
|
140
|
+
if (!effect)
|
|
141
|
+
return null;
|
|
142
|
+
if (!effect.changed) {
|
|
143
|
+
return dim("no change — the stored bytes already matched");
|
|
144
|
+
}
|
|
145
|
+
const counts = effect.blockIds;
|
|
146
|
+
if (!counts || counts.total === 0)
|
|
147
|
+
return dim("changed");
|
|
148
|
+
const kept = counts.total - counts.rebound - counts.orphaned;
|
|
149
|
+
const parts = [`${kept} of ${counts.total} block ids kept`];
|
|
150
|
+
if (counts.rebound)
|
|
151
|
+
parts.push(`${counts.rebound} moved`);
|
|
152
|
+
if (counts.orphaned)
|
|
153
|
+
parts.push(`${counts.orphaned} orphaned`);
|
|
154
|
+
return dim(`changed · ${parts.join(" · ")}`);
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* The one-time "you are visible" note.
|
|
158
|
+
*
|
|
159
|
+
* Writing DECLARES PRESENCE server-side — the document's roster shows the
|
|
160
|
+
* writer, and a human with it open sees them. That is a fact about the user's
|
|
161
|
+
* visibility to other people, so it is said out loud rather than left to be
|
|
162
|
+
* discovered in the UI; once per run, because said on every write it would be
|
|
163
|
+
* noise and the second one teaches nothing.
|
|
164
|
+
*/
|
|
165
|
+
export const PRESENCE_NOTE = "you are visible as editing this document";
|
|
166
|
+
/**
|
|
167
|
+
* `sfora where` — one sentence per person, and a link they can be met at.
|
|
168
|
+
*
|
|
169
|
+
* A SENTENCE, not a table. The answer to "where is Thijs" is read once and
|
|
170
|
+
* acted on immediately ("open that document"), so it is written the way it
|
|
171
|
+
* would be said: *Thijs is editing test-document.md — <url>*. A columnar
|
|
172
|
+
* listing would be denser and would make the reader assemble the sentence
|
|
173
|
+
* themselves.
|
|
174
|
+
*
|
|
175
|
+
* The block id rides in parentheses only when somebody claimed one. Humans
|
|
176
|
+
* are present at document level today (the editor has no cheap source-offset
|
|
177
|
+
* mapping), so an always-present column would be empty on most rows and read
|
|
178
|
+
* as a fault rather than as an absence of claim.
|
|
179
|
+
*
|
|
180
|
+
* Grouped runs, no headers: two people in one document print as two adjacent
|
|
181
|
+
* lines naming the same file. The repetition is the grouping, and it costs
|
|
182
|
+
* nothing to scan — where a header would cost a line per document and break
|
|
183
|
+
* `sfora where | grep` into a two-line lookup.
|
|
184
|
+
*/
|
|
185
|
+
export function renderPresence(view) {
|
|
186
|
+
const lines = [];
|
|
187
|
+
const totalHere = view.documents.reduce((n, d) => n + d.here.length, 0);
|
|
188
|
+
if (totalHere === 0) {
|
|
189
|
+
return dim(view.member
|
|
190
|
+
? `${view.member.name} isn't in a document right now`
|
|
191
|
+
: "nobody is in a document right now");
|
|
192
|
+
}
|
|
193
|
+
for (const doc of view.documents) {
|
|
194
|
+
for (const who of doc.here) {
|
|
195
|
+
lines.push(presenceLine(doc, who));
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return lines.join("\n");
|
|
199
|
+
}
|
|
200
|
+
function presenceLine(doc, who) {
|
|
201
|
+
const where = who.block
|
|
202
|
+
? `${doc.filename} ${dim(`(block ${who.block})`)}`
|
|
203
|
+
: doc.filename;
|
|
204
|
+
return `${who.name} is ${who.kind} ${where} ${dim("—")} ${urlLine(doc.url)}`;
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* `sfora where --json` — one flat record per person-in-a-document, per line.
|
|
208
|
+
*
|
|
209
|
+
* FLAT, where the wire shape is grouped. NDJSON's contract is that a line is a
|
|
210
|
+
* record, and the record a consumer of this wants is "who is where": grouping
|
|
211
|
+
* would make a line's shape depend on how many people happened to be in one
|
|
212
|
+
* file, and `jq -r .url` would stop working. The document's fields are copied
|
|
213
|
+
* onto each line rather than referenced, so no line needs another to be read.
|
|
214
|
+
*/
|
|
215
|
+
export function presenceRecords(view) {
|
|
216
|
+
const records = [];
|
|
217
|
+
for (const doc of view.documents) {
|
|
218
|
+
for (const who of doc.here) {
|
|
219
|
+
records.push({
|
|
220
|
+
memberId: who.memberId,
|
|
221
|
+
name: who.name,
|
|
222
|
+
type: who.type,
|
|
223
|
+
kind: who.kind,
|
|
224
|
+
block: who.block,
|
|
225
|
+
lastSeenAt: who.lastSeenAt,
|
|
226
|
+
ttlSeconds: view.ttlSeconds,
|
|
227
|
+
docId: doc.docId,
|
|
228
|
+
title: doc.title,
|
|
229
|
+
filename: doc.filename,
|
|
230
|
+
path: doc.path,
|
|
231
|
+
project: doc.project.slug,
|
|
232
|
+
url: doc.url,
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
return records;
|
|
237
|
+
}
|
|
238
|
+
/** `HH:MM:SS` in the reader's own timezone — a ping is read as it lands. */
|
|
239
|
+
export function pingTime(ts) {
|
|
240
|
+
return new Date(ts).toTimeString().slice(0, 8);
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* `time · author · doc · what changed · url` — one ping, one line.
|
|
244
|
+
*
|
|
245
|
+
* A RESTRICTED ping keeps its shape and loses its pointer: the server
|
|
246
|
+
* delivered the fact (a document you are watching moved, by whom) and withheld
|
|
247
|
+
* the title and the link because they belong to somebody's draft. Printing
|
|
248
|
+
* "(restricted)" rather than dropping the line is the honest half — a watch
|
|
249
|
+
* that went silent would look broken.
|
|
250
|
+
*/
|
|
251
|
+
export function renderPing(ping) {
|
|
252
|
+
const who = ping.author ?? "someone";
|
|
253
|
+
const what = ping.restricted
|
|
254
|
+
? dim("(a document you can't open)")
|
|
255
|
+
: (ping.title ?? ping.path ?? ping.docId ?? "a document");
|
|
256
|
+
const parts = [dim(pingTime(ping.ts)), who, what];
|
|
257
|
+
if (ping.type === "doc.delete") {
|
|
258
|
+
parts.push(`${colors.yellow}deleted${colors.reset}`);
|
|
259
|
+
}
|
|
260
|
+
else {
|
|
261
|
+
const counts = ping.blockIds;
|
|
262
|
+
parts.push(dim(counts && counts.total
|
|
263
|
+
? `${counts.total - counts.rebound - counts.orphaned} of ${counts.total} block ids kept`
|
|
264
|
+
: "edited"));
|
|
265
|
+
}
|
|
266
|
+
if (ping.url)
|
|
267
|
+
parts.push(urlLine(ping.url));
|
|
268
|
+
return parts.join(dim(" · "));
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* The NDJSON line for one ping — the machine half of `sfora watch --json`.
|
|
272
|
+
*
|
|
273
|
+
* The SERVER'S event object, verbatim, plus nothing. A CLI that reshaped it
|
|
274
|
+
* would become a second schema to keep in step with `/v1/events`, and the one
|
|
275
|
+
* consumer that matters here (an agent piping this into a program) is better
|
|
276
|
+
* served by the wire shape it can also get from the HTTP door directly.
|
|
277
|
+
*/
|
|
278
|
+
export function ndjson(event) {
|
|
279
|
+
return JSON.stringify(event);
|
|
280
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The sfora-native commands inside the shell: `blocks`, `put`, `url`.
|
|
3
|
+
*
|
|
4
|
+
* Card #333's "and the fs shell equivalent". The shell already writes whole
|
|
5
|
+
* files (`echo '# Title' > /projects/x/docs/y.md` is a PUT), but it had no way
|
|
6
|
+
* to aim at ONE block, and no way to see what a write did. These three close
|
|
7
|
+
* that gap without teaching the shell anything new about routes: they are the
|
|
8
|
+
* same functions the CLI verbs call.
|
|
9
|
+
*
|
|
10
|
+
* `put` reads its body from a file argument or from STDIN, which is what makes
|
|
11
|
+
* it shell-shaped — `sed 's/foo/bar/' <(…) | put /projects/x/docs/y.md --block
|
|
12
|
+
* k7f3a2cx` is the loop this exists for.
|
|
13
|
+
*/
|
|
14
|
+
import type { Command } from "just-bash";
|
|
15
|
+
import type { SforaApiClient } from "./api-client.js";
|
|
16
|
+
import { type PresenceNotice } from "./block-commands.js";
|
|
17
|
+
/** Flags these commands understand, split from the positional arguments. */
|
|
18
|
+
interface ParsedArgs {
|
|
19
|
+
positional: string[];
|
|
20
|
+
blockId?: string;
|
|
21
|
+
json: boolean;
|
|
22
|
+
}
|
|
23
|
+
export declare function parseShellArgs(args: string[]): ParsedArgs;
|
|
24
|
+
/**
|
|
25
|
+
* The three commands, bound to the client the shell's fs already uses.
|
|
26
|
+
*
|
|
27
|
+
* `presence` is the RUN's latch, not the shell's: an interactive session
|
|
28
|
+
* prints the presence note from two places (a `put` command, and the trailing
|
|
29
|
+
* report after any line that wrote), and both must count as the same "once".
|
|
30
|
+
* A caller that passes none gets a fresh one, so a standalone shell still says
|
|
31
|
+
* it exactly once.
|
|
32
|
+
*/
|
|
33
|
+
export declare function sforaShellCommands(client: SforaApiClient, presence?: PresenceNotice): Command[];
|
|
34
|
+
export {};
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The sfora-native commands inside the shell: `blocks`, `put`, `url`.
|
|
3
|
+
*
|
|
4
|
+
* Card #333's "and the fs shell equivalent". The shell already writes whole
|
|
5
|
+
* files (`echo '# Title' > /projects/x/docs/y.md` is a PUT), but it had no way
|
|
6
|
+
* to aim at ONE block, and no way to see what a write did. These three close
|
|
7
|
+
* that gap without teaching the shell anything new about routes: they are the
|
|
8
|
+
* same functions the CLI verbs call.
|
|
9
|
+
*
|
|
10
|
+
* `put` reads its body from a file argument or from STDIN, which is what makes
|
|
11
|
+
* it shell-shaped — `sed 's/foo/bar/' <(…) | put /projects/x/docs/y.md --block
|
|
12
|
+
* k7f3a2cx` is the loop this exists for.
|
|
13
|
+
*/
|
|
14
|
+
import { decodeBytesToUtf8 } from "just-bash";
|
|
15
|
+
import { blocksCommand, presenceNotice, putCommand, resolveFsPath, urlCommand, } from "./block-commands.js";
|
|
16
|
+
export function parseShellArgs(args) {
|
|
17
|
+
const parsed = { positional: [], json: false };
|
|
18
|
+
for (let i = 0; i < args.length; i++) {
|
|
19
|
+
const a = args[i];
|
|
20
|
+
if (a === "--json")
|
|
21
|
+
parsed.json = true;
|
|
22
|
+
else if (a === "--block")
|
|
23
|
+
parsed.blockId = args[++i];
|
|
24
|
+
else if (a.startsWith("--block=")) {
|
|
25
|
+
parsed.blockId = a.slice("--block=".length);
|
|
26
|
+
}
|
|
27
|
+
else
|
|
28
|
+
parsed.positional.push(a);
|
|
29
|
+
}
|
|
30
|
+
return parsed;
|
|
31
|
+
}
|
|
32
|
+
const toExec = (out) => ({
|
|
33
|
+
stdout: out.stdout,
|
|
34
|
+
stderr: out.stderr,
|
|
35
|
+
exitCode: out.exitCode,
|
|
36
|
+
});
|
|
37
|
+
const usage = (text) => ({
|
|
38
|
+
stdout: "",
|
|
39
|
+
stderr: `${text}\n`,
|
|
40
|
+
exitCode: 2,
|
|
41
|
+
});
|
|
42
|
+
/**
|
|
43
|
+
* The body of a `put`: a file argument read through the shell's own fs, or
|
|
44
|
+
* whatever arrived on stdin.
|
|
45
|
+
*
|
|
46
|
+
* `-` is spelled out as "stdin" because a pipeline that produced nothing and a
|
|
47
|
+
* caller that forgot the body are the same empty string, and the write door
|
|
48
|
+
* refuses an empty `--block` body with a message about truncated pipes. Being
|
|
49
|
+
* explicit at the call site is how the shell keeps that refusal meaningful.
|
|
50
|
+
*/
|
|
51
|
+
async function putBody(ctx, fileArg, stdin) {
|
|
52
|
+
if (fileArg && fileArg !== "-") {
|
|
53
|
+
const path = resolveFsPath(ctx.cwd, fileArg);
|
|
54
|
+
const content = await ctx.fs.readFile(path, "utf8");
|
|
55
|
+
return typeof content === "string"
|
|
56
|
+
? content
|
|
57
|
+
: new TextDecoder().decode(content);
|
|
58
|
+
}
|
|
59
|
+
return stdin;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* The three commands, bound to the client the shell's fs already uses.
|
|
63
|
+
*
|
|
64
|
+
* `presence` is the RUN's latch, not the shell's: an interactive session
|
|
65
|
+
* prints the presence note from two places (a `put` command, and the trailing
|
|
66
|
+
* report after any line that wrote), and both must count as the same "once".
|
|
67
|
+
* A caller that passes none gets a fresh one, so a standalone shell still says
|
|
68
|
+
* it exactly once.
|
|
69
|
+
*/
|
|
70
|
+
export function sforaShellCommands(client, presence = presenceNotice()) {
|
|
71
|
+
return [
|
|
72
|
+
{
|
|
73
|
+
name: "blocks",
|
|
74
|
+
async execute(args, ctx) {
|
|
75
|
+
const { positional, json } = parseShellArgs(args);
|
|
76
|
+
if (!positional[0])
|
|
77
|
+
return usage("usage: blocks <path> [--json]");
|
|
78
|
+
return toExec(await blocksCommand(client, resolveFsPath(ctx.cwd, positional[0]), {
|
|
79
|
+
json,
|
|
80
|
+
}));
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
name: "put",
|
|
85
|
+
async execute(args, ctx) {
|
|
86
|
+
const { positional, blockId, json } = parseShellArgs(args);
|
|
87
|
+
if (!positional[0]) {
|
|
88
|
+
return usage("usage: put <path> [<file>|-] [--block <id>] [--json] (body from stdin when no file)");
|
|
89
|
+
}
|
|
90
|
+
// `ctx.stdin` is just-bash's opaque byte string, not a buffer: the
|
|
91
|
+
// engine's own decoder is the only correct way to read it as text.
|
|
92
|
+
const body = await putBody(ctx, positional[1], decodeBytesToUtf8(ctx.stdin));
|
|
93
|
+
return toExec(await putCommand(client, resolveFsPath(ctx.cwd, positional[0]), body, { blockId, json, presence }));
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
name: "url",
|
|
98
|
+
async execute(args, ctx) {
|
|
99
|
+
const { positional, json } = parseShellArgs(args);
|
|
100
|
+
if (!positional[0])
|
|
101
|
+
return usage("usage: url <path> [--json]");
|
|
102
|
+
return toExec(await urlCommand(client, resolveFsPath(ctx.cwd, positional[0]), {
|
|
103
|
+
json,
|
|
104
|
+
}));
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
];
|
|
108
|
+
}
|
package/dist/watch.d.ts
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
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 type { AgentEventsPage } from "./api-client.js";
|
|
32
|
+
/** Everything the loop needs from the outside world. */
|
|
33
|
+
export interface WatchDeps {
|
|
34
|
+
/** One long-poll. Rejects on a dropped connection — the loop backs off. */
|
|
35
|
+
poll(since: number): Promise<AgentEventsPage>;
|
|
36
|
+
/** Say "viewing" (or retract). Absent when the target has no roster. */
|
|
37
|
+
beat?(options: {
|
|
38
|
+
leave?: boolean;
|
|
39
|
+
}): Promise<void>;
|
|
40
|
+
/** Where a printed line goes. */
|
|
41
|
+
write(text: string): void;
|
|
42
|
+
/** Where a warning goes — never mixed into `--json` output. */
|
|
43
|
+
warn(text: string): void;
|
|
44
|
+
sleep(ms: number): Promise<void>;
|
|
45
|
+
}
|
|
46
|
+
export interface WatchOptions {
|
|
47
|
+
/** NDJSON instead of prose: one event object per line, the server's shape. */
|
|
48
|
+
json?: boolean;
|
|
49
|
+
/** Where to start. 0 replays the retained backlog; the CLI starts at "now". */
|
|
50
|
+
since?: number;
|
|
51
|
+
/** Stop when this returns true — a signal, or a counter in a test. */
|
|
52
|
+
stopped?: () => boolean;
|
|
53
|
+
/** First backoff step, doubling to {@link MAX_BACKOFF_MS}. */
|
|
54
|
+
backoffMs?: number;
|
|
55
|
+
}
|
|
56
|
+
/** A watcher that has been unreachable this long is not polling any faster. */
|
|
57
|
+
export declare const MAX_BACKOFF_MS = 30000;
|
|
58
|
+
/**
|
|
59
|
+
* Poll until stopped, printing what arrives.
|
|
60
|
+
*
|
|
61
|
+
* Returns the cursor it reached, which is what makes the loop testable: a test
|
|
62
|
+
* asserts that a reconnect resumed from it rather than from zero.
|
|
63
|
+
*/
|
|
64
|
+
export declare function watchLoop(deps: WatchDeps, options?: WatchOptions): Promise<number>;
|
|
65
|
+
/**
|
|
66
|
+
* What `sfora watch <target>` is watching: one document, or a project.
|
|
67
|
+
*
|
|
68
|
+
* A path names a document (`/projects/acme/docs/x.md`); a bare word names a
|
|
69
|
+
* project, and so does the project's own directory, since `/projects/acme` is
|
|
70
|
+
* how the fs spells it.
|
|
71
|
+
*/
|
|
72
|
+
export type WatchTarget = {
|
|
73
|
+
kind: "project";
|
|
74
|
+
slug: string;
|
|
75
|
+
} | {
|
|
76
|
+
kind: "path";
|
|
77
|
+
path: string;
|
|
78
|
+
};
|
|
79
|
+
export declare function parseWatchTarget(target: string): WatchTarget;
|