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
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `blocks`, `put` and `url` — one implementation, two front doors.
|
|
3
|
+
*
|
|
4
|
+
* Card #333. These three run identically as CLI verbs (`sfora put …`) and as
|
|
5
|
+
* commands inside the interactive shell (`put …`), and they do it by being
|
|
6
|
+
* written once here: each takes its arguments and returns
|
|
7
|
+
* `{ stdout, stderr, exitCode }`, which is exactly what just-bash's `Command`
|
|
8
|
+
* interface wants and exactly what the CLI writes to its own streams.
|
|
9
|
+
*
|
|
10
|
+
* WHY STREAMS AND AN EXIT CODE RATHER THAN PRINTING. A shell command that
|
|
11
|
+
* printed to `process.stdout` would escape the pipeline — `blocks x.md | grep
|
|
12
|
+
* heading` would print everything and pipe nothing. Returning the text makes
|
|
13
|
+
* both callers correct and makes the tests below assert on a value instead of
|
|
14
|
+
* spying on the process.
|
|
15
|
+
*
|
|
16
|
+
* The split against `render.ts` is the usual one: this module talks to the
|
|
17
|
+
* server and decides what to say; `render.ts` decides how it looks.
|
|
18
|
+
*/
|
|
19
|
+
import { blockConflictFrom, SforaApiError, } from "./api-client.js";
|
|
20
|
+
import { colors, renderBlockConflict, renderBlocks, renderWriteEffect, urlLine, PRESENCE_NOTE, } from "./render.js";
|
|
21
|
+
const ok = (stdout, stderr = "") => ({
|
|
22
|
+
stdout,
|
|
23
|
+
stderr,
|
|
24
|
+
exitCode: 0,
|
|
25
|
+
});
|
|
26
|
+
const fail = (stderr) => ({
|
|
27
|
+
stdout: "",
|
|
28
|
+
stderr: stderr.endsWith("\n") ? stderr : `${stderr}\n`,
|
|
29
|
+
exitCode: 1,
|
|
30
|
+
});
|
|
31
|
+
export function presenceNotice() {
|
|
32
|
+
let said = false;
|
|
33
|
+
return {
|
|
34
|
+
claim() {
|
|
35
|
+
if (said)
|
|
36
|
+
return false;
|
|
37
|
+
said = true;
|
|
38
|
+
return true;
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
/** An absolute fs path from a possibly-relative one plus the shell's cwd. */
|
|
43
|
+
export function resolveFsPath(cwd, path) {
|
|
44
|
+
if (path.startsWith("/"))
|
|
45
|
+
return path;
|
|
46
|
+
const parts = `${cwd}/${path}`.split("/");
|
|
47
|
+
const out = [];
|
|
48
|
+
for (const part of parts) {
|
|
49
|
+
if (!part || part === ".")
|
|
50
|
+
continue;
|
|
51
|
+
if (part === "..")
|
|
52
|
+
out.pop();
|
|
53
|
+
else
|
|
54
|
+
out.push(part);
|
|
55
|
+
}
|
|
56
|
+
return `/${out.join("/")}`;
|
|
57
|
+
}
|
|
58
|
+
/** Turn an API failure into the message a person should read. */
|
|
59
|
+
function apiMessage(error) {
|
|
60
|
+
if (error instanceof SforaApiError) {
|
|
61
|
+
return error.status === 0
|
|
62
|
+
? `could not reach the server — ${error.message}`
|
|
63
|
+
: error.message;
|
|
64
|
+
}
|
|
65
|
+
return error instanceof Error ? error.message : String(error);
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* `blocks <path>` — what a `?block=` write can aim at.
|
|
69
|
+
*
|
|
70
|
+
* This is the read that makes single-block writing usable: the ids are
|
|
71
|
+
* fingerprints over the document's bytes, so they are stable until somebody
|
|
72
|
+
* changes that block, and `writable` says which of them the write door will
|
|
73
|
+
* actually accept (a frontmatter fence and a title heading are served but not
|
|
74
|
+
* stored, so their ids address nothing).
|
|
75
|
+
*/
|
|
76
|
+
export async function blocksCommand(client, fsPath, options = {}) {
|
|
77
|
+
try {
|
|
78
|
+
const view = await client.readBlocks(fsPath);
|
|
79
|
+
// The view already carries `url` and `renderBlocks` prints it, so consume
|
|
80
|
+
// the recorded info here: an unconsumed link would be printed a second
|
|
81
|
+
// time by whatever ran this command.
|
|
82
|
+
client.takeResponseInfo();
|
|
83
|
+
return ok(options.json
|
|
84
|
+
? `${JSON.stringify(view, null, 2)}\n`
|
|
85
|
+
: `${renderBlocks(view)}\n`);
|
|
86
|
+
}
|
|
87
|
+
catch (error) {
|
|
88
|
+
return fail(`${colors.red}error:${colors.reset} ${apiMessage(error)}`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* `put <path>` — write a file, or with `--block <id>` exactly one block of it.
|
|
93
|
+
*
|
|
94
|
+
* Always reports the EFFECT, never a bare "saved". sfora's write door splices:
|
|
95
|
+
* a PUT of bytes that parse the same as the stored ones stores nothing, and
|
|
96
|
+
* `changed: false` is the honest and frequent answer to a `GET`-edit-`PUT`
|
|
97
|
+
* loop that reformatted more than it meant to.
|
|
98
|
+
*
|
|
99
|
+
* A 409 is the interesting failure and it is not really a failure: block ids
|
|
100
|
+
* are content-derived, so "this id resolves to nothing" means somebody changed
|
|
101
|
+
* that block since you read it. The server sends the document's current blocks
|
|
102
|
+
* with the refusal, so the recovery is printed as a table to re-aim from,
|
|
103
|
+
* rather than as an error to go re-investigate.
|
|
104
|
+
*/
|
|
105
|
+
export async function putCommand(client, fsPath, body, options = {}) {
|
|
106
|
+
try {
|
|
107
|
+
const result = await client.writePath(fsPath, body, {
|
|
108
|
+
blockId: options.blockId,
|
|
109
|
+
});
|
|
110
|
+
const info = client.takeResponseInfo();
|
|
111
|
+
if (options.json)
|
|
112
|
+
return ok(`${JSON.stringify(result, null, 2)}\n`);
|
|
113
|
+
// The confirmation is a REPORT, not data — so stderr, which keeps
|
|
114
|
+
// `put … | something` from feeding a downstream command an ANSI receipt,
|
|
115
|
+
// and keeps `put --json` the only thing that ever reaches stdout.
|
|
116
|
+
const lines = [
|
|
117
|
+
`${colors.green}✓${colors.reset} ${options.blockId
|
|
118
|
+
? `Wrote block ${options.blockId} of ${result.path ?? fsPath}`
|
|
119
|
+
: `Wrote ${result.path ?? fsPath}`}`,
|
|
120
|
+
];
|
|
121
|
+
const effect = renderWriteEffect(info.effect);
|
|
122
|
+
if (effect)
|
|
123
|
+
lines.push(` ${effect}`);
|
|
124
|
+
if (info.url)
|
|
125
|
+
lines.push(` ${urlLine(info.url)}`);
|
|
126
|
+
// Presence is a fact about being SEEN, so it is said once and only when the
|
|
127
|
+
// server reports it — document writes declare it, posts and cards do not.
|
|
128
|
+
// `claim()` is behind the `present` check so a write that reported no
|
|
129
|
+
// roster cannot spend the run's one announcement on nothing.
|
|
130
|
+
if (info.present && (options.presence?.claim() ?? true)) {
|
|
131
|
+
lines.push(` ${colors.dim}${PRESENCE_NOTE}${colors.reset}`);
|
|
132
|
+
}
|
|
133
|
+
return ok("", `${lines.join("\n")}\n`);
|
|
134
|
+
}
|
|
135
|
+
catch (error) {
|
|
136
|
+
const conflict = blockConflictFrom(error);
|
|
137
|
+
if (conflict)
|
|
138
|
+
return fail(renderBlockConflict(conflict, fsPath));
|
|
139
|
+
return fail(`${colors.red}error:${colors.reset} ${apiMessage(error)}`);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
/** `url <path>` — the page, printed. See `web-url.ts` for who knows the route. */
|
|
143
|
+
export async function urlCommand(client, fsPath, options = {}) {
|
|
144
|
+
try {
|
|
145
|
+
const url = await client.resolveWebUrl(fsPath);
|
|
146
|
+
client.takeResponseInfo(); // printed below; do not leave it for a caller
|
|
147
|
+
if (!url) {
|
|
148
|
+
return fail(`${colors.red}error:${colors.reset} ${fsPath} has no page on the web`);
|
|
149
|
+
}
|
|
150
|
+
return ok(options.json ? `${JSON.stringify({ path: fsPath, url })}\n` : `${url}\n`);
|
|
151
|
+
}
|
|
152
|
+
catch (error) {
|
|
153
|
+
return fail(`${colors.red}error:${colors.reset} ${apiMessage(error)}`);
|
|
154
|
+
}
|
|
155
|
+
}
|
package/dist/cli.js
CHANGED
|
@@ -10,22 +10,17 @@ import * as readline from "node:readline";
|
|
|
10
10
|
import { spawn } from "node:child_process";
|
|
11
11
|
import { readFile as readLocalFile } from "node:fs/promises";
|
|
12
12
|
import { basename } from "node:path";
|
|
13
|
-
import {
|
|
13
|
+
import { taskUploadFilename } from "./format/taskUploadFilename.js";
|
|
14
|
+
import { createSforaShell, createLocalShell, } from "./index.js";
|
|
15
|
+
import { openerCommand } from "./opener.js";
|
|
16
|
+
import { blocksCommand, presenceNotice, putCommand, } from "./block-commands.js";
|
|
17
|
+
import { colors, renderWriteEffect, urlLine, PRESENCE_NOTE } from "./render.js";
|
|
18
|
+
import { parseWatchTarget, watchLoop } from "./watch.js";
|
|
14
19
|
import { LocalWorkspace, initWorkspace, findWorkspace, migrateWorkspaceStages, } from "./local/workspace.js";
|
|
15
20
|
import { runMcpServer } from "./mcp-server.js";
|
|
16
21
|
import { readConfig, writeConfig, resolveSettings, upsertProfile, effectiveProfiles, DEFAULT_URL, } from "./config.js";
|
|
17
|
-
const colors = {
|
|
18
|
-
reset: "\x1b[0m",
|
|
19
|
-
bold: "\x1b[1m",
|
|
20
|
-
dim: "\x1b[2m",
|
|
21
|
-
cyan: "\x1b[36m",
|
|
22
|
-
green: "\x1b[32m",
|
|
23
|
-
yellow: "\x1b[33m",
|
|
24
|
-
blue: "\x1b[34m",
|
|
25
|
-
red: "\x1b[31m",
|
|
26
|
-
};
|
|
27
22
|
function parseArgs(argv) {
|
|
28
|
-
const args = { rest: [], cwd: "/", mcp: false, help: false, draft: false, json: false, local: false, cloud: false };
|
|
23
|
+
const args = { rest: [], cwd: "/", mcp: false, help: false, draft: false, json: false, local: false, cloud: false, self: false };
|
|
29
24
|
for (let i = 0; i < argv.length; i++) {
|
|
30
25
|
const a = argv[i];
|
|
31
26
|
if (a === "--mcp")
|
|
@@ -78,6 +73,16 @@ function parseArgs(argv) {
|
|
|
78
73
|
args.local = true;
|
|
79
74
|
else if (a === "--cloud")
|
|
80
75
|
args.cloud = true;
|
|
76
|
+
else if (a === "--block")
|
|
77
|
+
args.block = argv[++i];
|
|
78
|
+
else if (a.startsWith("--block="))
|
|
79
|
+
args.block = a.slice("--block=".length);
|
|
80
|
+
else if (a === "--self")
|
|
81
|
+
args.self = true;
|
|
82
|
+
else if (a === "--wait")
|
|
83
|
+
args.wait = Number.parseInt(argv[++i] ?? "", 10);
|
|
84
|
+
else if (a.startsWith("--wait="))
|
|
85
|
+
args.wait = Number.parseInt(a.slice("--wait=".length), 10);
|
|
81
86
|
else if (!a.startsWith("-")) {
|
|
82
87
|
if (!args.command)
|
|
83
88
|
args.command = a;
|
|
@@ -120,11 +125,23 @@ Browse & read:
|
|
|
120
125
|
sfora me Show who you're signed in as
|
|
121
126
|
sfora ls [path] List a path (default /projects)
|
|
122
127
|
sfora cat <path> Print a file's markdown
|
|
128
|
+
sfora url <path> Print the web URL for a path
|
|
129
|
+
sfora open <path> Open that URL in your browser
|
|
123
130
|
sfora Open the interactive shell
|
|
124
131
|
|
|
125
132
|
Add --json to any list command (projects/posts/tasks/ls/me) for
|
|
126
133
|
machine-readable output and stable scripting.
|
|
127
134
|
|
|
135
|
+
Write, and watch others write:
|
|
136
|
+
sfora blocks <path> List a document's addressable blocks
|
|
137
|
+
sfora put <path> <file.md> Write a file (add --block <id> for one block)
|
|
138
|
+
sfora put <path> --block <id> - …or pipe the block's markdown on stdin
|
|
139
|
+
sfora watch <path-or-project> Stream write pings (add --json for NDJSON)
|
|
140
|
+
|
|
141
|
+
Every write prints what it did — "changed" (with how many block ids
|
|
142
|
+
survived) or "no change". Watching and writing make you visible in the
|
|
143
|
+
document; watch --self also shows your own writes.
|
|
144
|
+
|
|
128
145
|
Agents & config:
|
|
129
146
|
sfora --mcp [--org <slug>] Run as an MCP server (for agents)
|
|
130
147
|
sfora mcp-config Print an MCP config snippet to paste
|
|
@@ -144,20 +161,28 @@ const SHELL_HELP = `${colors.bold}sfora shell${colors.reset} ${colors.dim}— yo
|
|
|
144
161
|
Standard tools work against your workspace:
|
|
145
162
|
ls cat grep find head tail wc sed awk echo cd pwd
|
|
146
163
|
|
|
164
|
+
${colors.dim}And three of sfora's own${colors.reset}
|
|
165
|
+
blocks <path> the blocks a --block write can aim at
|
|
166
|
+
put <path> [file|-] write a file (--block <id> writes one block)
|
|
167
|
+
url <path> where it lives on the web
|
|
168
|
+
|
|
147
169
|
${colors.dim}Where things live${colors.reset}
|
|
148
170
|
/projects your projects
|
|
149
171
|
/projects/<slug>/posts/<file>.md published posts
|
|
150
172
|
/projects/<slug>/drafts/ drafts
|
|
151
173
|
/projects/<slug>/board/<col>/ board tasks
|
|
152
|
-
/projects/<slug>/
|
|
174
|
+
/projects/<slug>/library/ documents, files, and repositories
|
|
153
175
|
/inbox/mentions.md your mentions
|
|
154
176
|
/me/api-key who you're signed in as
|
|
155
177
|
|
|
156
178
|
${colors.dim}Try${colors.reset}
|
|
157
179
|
ls /projects
|
|
158
180
|
cat /projects/<slug>/posts/<file>.md
|
|
181
|
+
find /projects/<slug>/library -type f
|
|
159
182
|
grep -ri todo /projects
|
|
160
183
|
echo "# Hello" > /projects/<slug>/posts/hello.md
|
|
184
|
+
blocks /projects/<slug>/docs/spec.md
|
|
185
|
+
echo "A rewritten paragraph." | put /projects/<slug>/docs/spec.md --block <id>
|
|
161
186
|
|
|
162
187
|
${colors.dim}Outside the shell${colors.reset}, sfora has verbs: post · task · doc · new · projects (run ${colors.cyan}sfora --help${colors.reset})
|
|
163
188
|
Type ${colors.cyan}exit${colors.reset} to quit.
|
|
@@ -197,13 +222,9 @@ async function runInit(args) {
|
|
|
197
222
|
}
|
|
198
223
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
199
224
|
function openBrowser(url) {
|
|
200
|
-
const
|
|
201
|
-
? "open"
|
|
202
|
-
: process.platform === "win32"
|
|
203
|
-
? "start"
|
|
204
|
-
: "xdg-open";
|
|
225
|
+
const { command, args } = openerCommand(process.platform, url);
|
|
205
226
|
try {
|
|
206
|
-
spawn(
|
|
227
|
+
spawn(command, args, { stdio: "ignore", detached: true }).unref();
|
|
207
228
|
}
|
|
208
229
|
catch {
|
|
209
230
|
/* best effort — the URL is printed for manual opening */
|
|
@@ -382,6 +403,11 @@ const VERBS = new Set([
|
|
|
382
403
|
"new",
|
|
383
404
|
"ls",
|
|
384
405
|
"cat",
|
|
406
|
+
"url",
|
|
407
|
+
"open",
|
|
408
|
+
"blocks",
|
|
409
|
+
"put",
|
|
410
|
+
"watch",
|
|
385
411
|
"post",
|
|
386
412
|
"task",
|
|
387
413
|
"doc",
|
|
@@ -396,8 +422,29 @@ const VERBS = new Set([
|
|
|
396
422
|
]);
|
|
397
423
|
async function runVerb(args, fs, client) {
|
|
398
424
|
const ok = (msg) => console.log(`${colors.green}✓${colors.reset} ${msg}`);
|
|
425
|
+
// The prose confirmation, suppressed under --json: a script parsing stdout
|
|
426
|
+
// must find JSON there and nothing else.
|
|
427
|
+
const said = (msg) => {
|
|
428
|
+
if (!args.json)
|
|
429
|
+
ok(msg);
|
|
430
|
+
};
|
|
399
431
|
const emitJson = (v) => console.log(JSON.stringify(v, null, 2));
|
|
400
432
|
const plural = (n, w) => `${n} ${w}${n === 1 ? "" : "s"}`;
|
|
433
|
+
/**
|
|
434
|
+
* The link the LAST request reported, as one dim line — on stderr.
|
|
435
|
+
*
|
|
436
|
+
* stderr and not stdout, deliberately: `sfora cat x.md > x.md` and
|
|
437
|
+
* `sfora ls | wc -l` are the reason those verbs exist, and a link mixed into
|
|
438
|
+
* stdout would corrupt the first and miscount the second. A human sees both
|
|
439
|
+
* streams; a pipe sees only the bytes it asked for. Suppressed under --json
|
|
440
|
+
* for the same reason.
|
|
441
|
+
*/
|
|
442
|
+
const showUrl = () => {
|
|
443
|
+
const { url } = client.takeResponseInfo();
|
|
444
|
+
if (url && !args.json)
|
|
445
|
+
process.stderr.write(`${urlLine(url)}\n`);
|
|
446
|
+
return url;
|
|
447
|
+
};
|
|
401
448
|
if (args.command === "projects") {
|
|
402
449
|
const projects = await client.listProjects();
|
|
403
450
|
if (args.json)
|
|
@@ -425,14 +472,62 @@ async function runVerb(args, fs, client) {
|
|
|
425
472
|
if (args.json)
|
|
426
473
|
return emitJson(entries);
|
|
427
474
|
console.log(entries.join("\n"));
|
|
475
|
+
showUrl();
|
|
428
476
|
return;
|
|
429
477
|
}
|
|
430
478
|
if (args.command === "cat") {
|
|
431
479
|
if (!args.rest[0])
|
|
432
480
|
throw new Error("usage: sfora cat <path>");
|
|
433
481
|
process.stdout.write(await fs.readFile(args.rest[0], "utf8"));
|
|
482
|
+
showUrl();
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
// `url` and `open` are the same question — where does this live on the web —
|
|
486
|
+
// asked once and answered by the server. The CLI holds no route table.
|
|
487
|
+
if (args.command === "url" || args.command === "open") {
|
|
488
|
+
const path = args.rest[0];
|
|
489
|
+
if (!path)
|
|
490
|
+
throw new Error(`usage: sfora ${args.command} <path>`);
|
|
491
|
+
const url = await client.resolveWebUrl(path);
|
|
492
|
+
if (!url) {
|
|
493
|
+
throw new Error(`${path} has no page on the web — files, repositories and the derived ` +
|
|
494
|
+
"views (README, mentions) are read through the fs only");
|
|
495
|
+
}
|
|
496
|
+
if (args.json)
|
|
497
|
+
return emitJson({ path, url });
|
|
498
|
+
console.log(url);
|
|
499
|
+
if (args.command === "open")
|
|
500
|
+
openBrowser(url);
|
|
434
501
|
return;
|
|
435
502
|
}
|
|
503
|
+
// `blocks` and `put` are the shell's own commands, run without the shell.
|
|
504
|
+
// One implementation (block-commands.ts) so the two doors cannot drift.
|
|
505
|
+
if (args.command === "blocks") {
|
|
506
|
+
if (!args.rest[0])
|
|
507
|
+
throw new Error("usage: sfora blocks <path> [--json]");
|
|
508
|
+
return writeCommandOutput(await blocksCommand(client, args.rest[0], { json: args.json }));
|
|
509
|
+
}
|
|
510
|
+
if (args.command === "put") {
|
|
511
|
+
const path = args.rest[0];
|
|
512
|
+
if (!path) {
|
|
513
|
+
throw new Error("usage: sfora put <path> [<file.md>|-] [--block <id>] [--json]");
|
|
514
|
+
}
|
|
515
|
+
const source = args.rest[1];
|
|
516
|
+
const body = source && source !== "-"
|
|
517
|
+
? await readLocalFile(source, "utf8")
|
|
518
|
+
: await readStdin();
|
|
519
|
+
return writeCommandOutput(await putCommand(client, path, body, {
|
|
520
|
+
blockId: args.block,
|
|
521
|
+
json: args.json,
|
|
522
|
+
presence: runPresence,
|
|
523
|
+
}));
|
|
524
|
+
}
|
|
525
|
+
if (args.command === "watch") {
|
|
526
|
+
if (!args.rest[0]) {
|
|
527
|
+
throw new Error("usage: sfora watch <path-or-project> [--json] [--self] [--wait <secs>]");
|
|
528
|
+
}
|
|
529
|
+
return runWatch(args, client, args.rest[0]);
|
|
530
|
+
}
|
|
436
531
|
if (args.command === "me" || args.command === "whoami") {
|
|
437
532
|
const text = (await client.readMe()).trim();
|
|
438
533
|
if (args.json) {
|
|
@@ -526,16 +621,19 @@ async function runVerb(args, fs, client) {
|
|
|
526
621
|
const md = await readLocalFile(file, "utf8");
|
|
527
622
|
const project = await resolveProject(fs, args.project, md);
|
|
528
623
|
const base = basename(file);
|
|
529
|
-
const
|
|
624
|
+
const sourceName = base.endsWith(".md") ? base : `${base}.md`;
|
|
625
|
+
const name = args.command === "task" ? taskUploadFilename(sourceName) : sourceName;
|
|
530
626
|
if (args.command === "post") {
|
|
531
627
|
const dir = args.draft ? "drafts" : "posts";
|
|
532
628
|
await fs.writeFile(`/projects/${project}/${dir}/${name}`, md);
|
|
533
|
-
|
|
629
|
+
said(`${args.draft ? "Drafted" : "Posted"} to ${project} · ${name}`);
|
|
630
|
+
reportWrite(client, args.json);
|
|
534
631
|
return;
|
|
535
632
|
}
|
|
536
633
|
if (args.command === "doc") {
|
|
537
|
-
await fs.writeFile(`/projects/${project}/
|
|
538
|
-
|
|
634
|
+
await fs.writeFile(`/projects/${project}/library/documents/${name}`, md);
|
|
635
|
+
said(`Doc saved to ${project} · ${name}`);
|
|
636
|
+
reportWrite(client, args.json);
|
|
539
637
|
return;
|
|
540
638
|
}
|
|
541
639
|
if (args.command === "task") {
|
|
@@ -554,10 +652,190 @@ async function runVerb(args, fs, client) {
|
|
|
554
652
|
col = cols.find((c) => c.replace(/^\d+-/, "") === want) ?? col;
|
|
555
653
|
}
|
|
556
654
|
await fs.writeFile(`/projects/${project}/board/${col}/${name}`, md);
|
|
557
|
-
|
|
655
|
+
said(`Task created in ${project} / ${col} · ${name}`);
|
|
656
|
+
reportWrite(client, args.json);
|
|
558
657
|
return;
|
|
559
658
|
}
|
|
560
659
|
}
|
|
660
|
+
/**
|
|
661
|
+
* What a write DID — printed after every one of them (card #333).
|
|
662
|
+
*
|
|
663
|
+
* Three lines at most, all on stderr and all dim, in the order a reader needs
|
|
664
|
+
* them: the effect (did the bytes move, and did the block ids survive), the
|
|
665
|
+
* page, and — once per process — the fact that writing put you on the
|
|
666
|
+
* document's roster. `announcedPresence` is module-level rather than per-call
|
|
667
|
+
* because "once" means once per run, not once per document.
|
|
668
|
+
*/
|
|
669
|
+
/**
|
|
670
|
+
* `sfora watch <target>` — the long-poll, wired to the real process.
|
|
671
|
+
*
|
|
672
|
+
* Everything decision-shaped lives in `watch.ts`; this resolves what is being
|
|
673
|
+
* watched, opens the roster, and arranges for ^C to close it. The two are
|
|
674
|
+
* separate so the loop can be tested without a signal handler or a socket.
|
|
675
|
+
*/
|
|
676
|
+
async function runWatch(args, client, target) {
|
|
677
|
+
const parsed = parseWatchTarget(target);
|
|
678
|
+
const wait = Number.isFinite(args.wait) ? args.wait : undefined;
|
|
679
|
+
// A document watch needs the entity id `/v1/events?doc=` takes, and the
|
|
680
|
+
// server states it in `?view=blocks` — the CLI does not read it out of the
|
|
681
|
+
// file. A project watch needs only the slug it was given.
|
|
682
|
+
let docId;
|
|
683
|
+
if (parsed.kind === "path") {
|
|
684
|
+
const resolved = await client.resolveDocId(parsed.path);
|
|
685
|
+
if (!resolved) {
|
|
686
|
+
throw new Error(`${parsed.path} has no document behind it to watch — point watch at a ` +
|
|
687
|
+
"post, draft, document or board card, or at a project");
|
|
688
|
+
}
|
|
689
|
+
docId = resolved;
|
|
690
|
+
}
|
|
691
|
+
// Presence, if this path has a roster. Asked rather than inferred: a `422`
|
|
692
|
+
// is the server saying "no roster here", and posts and cards get one.
|
|
693
|
+
let canBeat = parsed.kind === "path";
|
|
694
|
+
const beat = async ({ leave }) => {
|
|
695
|
+
if (!canBeat || parsed.kind !== "path")
|
|
696
|
+
return;
|
|
697
|
+
const roster = await client.declarePresence(parsed.path, {
|
|
698
|
+
kind: "viewing",
|
|
699
|
+
leave,
|
|
700
|
+
});
|
|
701
|
+
if (roster === null)
|
|
702
|
+
canBeat = false;
|
|
703
|
+
};
|
|
704
|
+
// Start at NOW, not at the retained backlog: `watch` is a live channel, and
|
|
705
|
+
// opening one should not replay a day of history nobody was waiting for.
|
|
706
|
+
const since = Date.now();
|
|
707
|
+
let stop = false;
|
|
708
|
+
// ^C HAS TO REACH THE SOCKET. Registering a handler suppresses Node's own
|
|
709
|
+
// SIGINT exit, so a handler that only sets a flag turns ^C into "stop after
|
|
710
|
+
// the current long-poll finishes" — up to `--wait` seconds of a terminal
|
|
711
|
+
// that says "^C to stop" and does not, with further ^Cs inert because the
|
|
712
|
+
// default handler is gone. The user's remaining exit is ^\, which kills the
|
|
713
|
+
// process outright, skips the `finally`, and leaves exactly the roster ghost
|
|
714
|
+
// the watch exists to avoid. Aborting the in-flight request is what makes
|
|
715
|
+
// the first ^C land: the poll rejects at once, the loop sees `stopped()`,
|
|
716
|
+
// and the `finally` retracts presence on the way out.
|
|
717
|
+
const inFlight = new AbortController();
|
|
718
|
+
let signalled = false;
|
|
719
|
+
const onSignal = (signal) => {
|
|
720
|
+
stop = true;
|
|
721
|
+
inFlight.abort();
|
|
722
|
+
// A second one is the impatient case — the leave request is hanging too,
|
|
723
|
+
// or the network is gone. Stand down and let the default behaviour end the
|
|
724
|
+
// process; the ghost expires on its own in ~90 seconds.
|
|
725
|
+
if (signalled) {
|
|
726
|
+
process.off("SIGINT", onSignal);
|
|
727
|
+
process.off("SIGTERM", onSignal);
|
|
728
|
+
process.kill(process.pid, signal);
|
|
729
|
+
return;
|
|
730
|
+
}
|
|
731
|
+
signalled = true;
|
|
732
|
+
};
|
|
733
|
+
process.on("SIGINT", onSignal);
|
|
734
|
+
process.on("SIGTERM", onSignal);
|
|
735
|
+
if (!args.json) {
|
|
736
|
+
process.stderr.write(`${colors.dim}watching ${parsed.kind === "project" ? `project ${parsed.slug}` : parsed.path}${args.self ? "" : " (not your own writes)"} — ^C to stop${colors.reset}\n`);
|
|
737
|
+
}
|
|
738
|
+
try {
|
|
739
|
+
await watchLoop({
|
|
740
|
+
poll: (cursor) => client.pollEvents({
|
|
741
|
+
since: cursor,
|
|
742
|
+
wait,
|
|
743
|
+
doc: docId,
|
|
744
|
+
project: parsed.kind === "project" ? parsed.slug : undefined,
|
|
745
|
+
includeSelf: args.self,
|
|
746
|
+
signal: inFlight.signal,
|
|
747
|
+
}),
|
|
748
|
+
beat: parsed.kind === "path" ? beat : undefined,
|
|
749
|
+
write: (text) => process.stdout.write(text),
|
|
750
|
+
// Warnings on stderr so `--json` stdout stays parseable NDJSON.
|
|
751
|
+
warn: (text) => process.stderr.write(`${colors.dim}${text}${colors.reset}\n`),
|
|
752
|
+
sleep,
|
|
753
|
+
}, { json: args.json, since, stopped: () => stop });
|
|
754
|
+
}
|
|
755
|
+
finally {
|
|
756
|
+
process.off("SIGINT", onSignal);
|
|
757
|
+
process.off("SIGTERM", onSignal);
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
/**
|
|
761
|
+
* A shared command's result, on the real streams.
|
|
762
|
+
*
|
|
763
|
+
* The exit code is honoured rather than thrown: a 409 re-aim table is a
|
|
764
|
+
* complete, useful answer that happens to mean "try again", and turning it back
|
|
765
|
+
* into an exception would put the CLI's generic `error:` prefix in front of a
|
|
766
|
+
* table that already explains itself.
|
|
767
|
+
*/
|
|
768
|
+
function writeCommandOutput(out) {
|
|
769
|
+
if (out.stdout)
|
|
770
|
+
process.stdout.write(out.stdout);
|
|
771
|
+
if (out.stderr)
|
|
772
|
+
process.stderr.write(out.stderr);
|
|
773
|
+
if (out.exitCode !== 0)
|
|
774
|
+
process.exitCode = out.exitCode;
|
|
775
|
+
}
|
|
776
|
+
/** Everything piped in, as text. Empty when nothing was. */
|
|
777
|
+
async function readStdin() {
|
|
778
|
+
if (process.stdin.isTTY)
|
|
779
|
+
return "";
|
|
780
|
+
const chunks = [];
|
|
781
|
+
for await (const chunk of process.stdin) {
|
|
782
|
+
chunks.push(Buffer.from(chunk));
|
|
783
|
+
}
|
|
784
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
785
|
+
}
|
|
786
|
+
/**
|
|
787
|
+
* The trailing dim lines after a shell line: what a write did, and where it
|
|
788
|
+
* lives.
|
|
789
|
+
*
|
|
790
|
+
* Gated on the command, not on "did the last request name anything", and that
|
|
791
|
+
* is deliberate. A `grep -ri todo /projects` reads dozens of files, and the
|
|
792
|
+
* link of whichever happened to be read LAST would be a link to a document the
|
|
793
|
+
* user never asked about. So the rule is narrow and stated: a write always
|
|
794
|
+
* reports its effect (that is card #333's whole point — a splice can store
|
|
795
|
+
* nothing, and silence would read as success), and a link is offered only
|
|
796
|
+
* after the two commands whose subject is one path.
|
|
797
|
+
*
|
|
798
|
+
* `put`, `blocks` and `url` consume the info themselves, so nothing here
|
|
799
|
+
* double-prints them.
|
|
800
|
+
*/
|
|
801
|
+
function reportShellLine(line, client) {
|
|
802
|
+
const { url, effect, present } = client.takeResponseInfo();
|
|
803
|
+
const verb = line.trim().split(/\s+/)[0];
|
|
804
|
+
const effectLine = renderWriteEffect(effect);
|
|
805
|
+
if (effectLine)
|
|
806
|
+
process.stderr.write(` ${effectLine}\n`);
|
|
807
|
+
if (url && (effect || verb === "cat" || verb === "ls")) {
|
|
808
|
+
process.stderr.write(` ${urlLine(url)}\n`);
|
|
809
|
+
}
|
|
810
|
+
if (present && runPresence.claim()) {
|
|
811
|
+
process.stderr.write(` ${colors.dim}${PRESENCE_NOTE}${colors.reset}\n`);
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
/**
|
|
815
|
+
* The run's one "you are visible" announcement.
|
|
816
|
+
*
|
|
817
|
+
* Module-level because a run IS this process, and shared with the shell's own
|
|
818
|
+
* `put` (it is handed to `createSforaShell`) because otherwise the interactive
|
|
819
|
+
* session has two latches — the shell command's and this one — and says the
|
|
820
|
+
* line twice for what is one fact.
|
|
821
|
+
*/
|
|
822
|
+
const runPresence = presenceNotice();
|
|
823
|
+
function reportWrite(client, json) {
|
|
824
|
+
const { url, effect, present } = client.takeResponseInfo();
|
|
825
|
+
if (json) {
|
|
826
|
+
process.stdout.write(`${JSON.stringify({ url, present, ...(effect ?? {}) })}\n`);
|
|
827
|
+
return;
|
|
828
|
+
}
|
|
829
|
+
const line = renderWriteEffect(effect);
|
|
830
|
+
if (line)
|
|
831
|
+
process.stderr.write(` ${line}\n`);
|
|
832
|
+
if (url)
|
|
833
|
+
process.stderr.write(` ${urlLine(url)}\n`);
|
|
834
|
+
// Only a DOCUMENT write puts you on a roster, and the server is what says so.
|
|
835
|
+
if (present && runPresence.claim()) {
|
|
836
|
+
process.stderr.write(` ${colors.dim}${PRESENCE_NOTE}${colors.reset}\n`);
|
|
837
|
+
}
|
|
838
|
+
}
|
|
561
839
|
// ─── Local mode (a .sfora/ directory — no server, no account) ─────
|
|
562
840
|
const LOCAL_ONLY_HINT = "cloud command — run it with --cloud (after `sfora login`), or outside the .sfora/ repo";
|
|
563
841
|
async function runLocalVerb(args, root) {
|
|
@@ -746,13 +1024,17 @@ async function main() {
|
|
|
746
1024
|
}
|
|
747
1025
|
// Structured verbs — the workspace as a markdown filesystem.
|
|
748
1026
|
if (args.command && VERBS.has(args.command)) {
|
|
749
|
-
|
|
1027
|
+
// ONE client for the verb: `SforaFs` reads and writes through it, and the
|
|
1028
|
+
// verbs read back off it what the last response reported (the page, the
|
|
1029
|
+
// effect report). A second client would be watching a different
|
|
1030
|
+
// conversation and would always answer "nothing".
|
|
1031
|
+
const { fs, client } = createSforaShell({
|
|
750
1032
|
baseUrl,
|
|
751
1033
|
apiKey,
|
|
752
1034
|
org: settings.org ?? "",
|
|
753
1035
|
actAs: args.as,
|
|
1036
|
+
presence: runPresence,
|
|
754
1037
|
});
|
|
755
|
-
const client = new SforaApiClient({ baseUrl, apiKey, actAs: args.as });
|
|
756
1038
|
try {
|
|
757
1039
|
await runVerb(args, fs, client);
|
|
758
1040
|
}
|
|
@@ -780,7 +1062,13 @@ async function main() {
|
|
|
780
1062
|
return;
|
|
781
1063
|
}
|
|
782
1064
|
const org = settings.org;
|
|
783
|
-
const { bash, fs } = createSforaShell({
|
|
1065
|
+
const { bash, fs, client } = createSforaShell({
|
|
1066
|
+
baseUrl,
|
|
1067
|
+
apiKey,
|
|
1068
|
+
org,
|
|
1069
|
+
cwd: args.cwd,
|
|
1070
|
+
presence: runPresence,
|
|
1071
|
+
});
|
|
784
1072
|
// Pre-flight: confirm auth + connectivity and greet with the resolved identity.
|
|
785
1073
|
let identity = "";
|
|
786
1074
|
try {
|
|
@@ -801,9 +1089,11 @@ async function main() {
|
|
|
801
1089
|
console.log(`${colors.yellow}warning:${colors.reset} could not reach ${baseUrl} or authenticate — commands may fail.`);
|
|
802
1090
|
}
|
|
803
1091
|
console.log(`${colors.dim}Try: ls /projects · cat /inbox/mentions.md · ${colors.reset}${colors.cyan}help${colors.reset}${colors.dim} for commands · ${colors.reset}${colors.cyan}exit${colors.reset}${colors.dim} to quit${colors.reset}\n`);
|
|
804
|
-
await runShell(bash, fs, SHELL_HELP, args.cwd);
|
|
1092
|
+
await runShell(bash, fs, SHELL_HELP, args.cwd, client);
|
|
805
1093
|
}
|
|
806
|
-
async function runShell(bash, fs, helpText, initialCwd
|
|
1094
|
+
async function runShell(bash, fs, helpText, initialCwd,
|
|
1095
|
+
/** Absent in local mode — there is no server, so nothing reports a link. */
|
|
1096
|
+
client) {
|
|
807
1097
|
// Shell state threaded across exec() calls — just-bash does not persist cwd/env
|
|
808
1098
|
// between separate exec()s, so we carry them forward ourselves.
|
|
809
1099
|
let cwd = initialCwd;
|
|
@@ -822,6 +1112,8 @@ async function runShell(bash, fs, helpText, initialCwd) {
|
|
|
822
1112
|
"ls", "cat", "cd", "pwd", "grep", "find", "echo", "head", "tail", "wc",
|
|
823
1113
|
"sed", "awk", "sort", "uniq", "mkdir", "rm", "mv", "cp", "touch", "help",
|
|
824
1114
|
"exit",
|
|
1115
|
+
// sfora's own, registered on the cloud shell (see `sforaShellCommands`).
|
|
1116
|
+
"blocks", "put", "url",
|
|
825
1117
|
];
|
|
826
1118
|
async function completePath(token) {
|
|
827
1119
|
const slash = token.lastIndexOf("/");
|
|
@@ -885,6 +1177,8 @@ async function runShell(bash, fs, helpText, initialCwd) {
|
|
|
885
1177
|
// reads as native — the user never typed `bash`.
|
|
886
1178
|
if (res.stderr)
|
|
887
1179
|
process.stderr.write(res.stderr.replace(/^bash:/gm, "sfora:"));
|
|
1180
|
+
if (client)
|
|
1181
|
+
reportShellLine(line, client);
|
|
888
1182
|
env = res.env;
|
|
889
1183
|
cwd = res.env?.PWD ?? cwd;
|
|
890
1184
|
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export type RoundTrip = (source: string) => string;
|
|
2
|
+
export declare const documentRoundTrip: RoundTrip;
|
|
3
|
+
export declare function assertByteStable(source: string, roundTrip?: RoundTrip): void;
|
|
4
|
+
export declare function assertRoundTripIdentity(source: string, roundTrip?: RoundTrip): void;
|
|
5
|
+
export declare function isByteStable(source: string, roundTrip?: RoundTrip): boolean;
|