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.
Files changed (108) hide show
  1. package/README.md +147 -6
  2. package/dist/SforaFs.js +278 -10
  3. package/dist/api-client.d.ts +290 -5
  4. package/dist/api-client.js +307 -22
  5. package/dist/block-commands.d.ts +84 -0
  6. package/dist/block-commands.js +155 -0
  7. package/dist/cli.js +323 -29
  8. package/dist/format/__tests__/byteStable.d.ts +5 -0
  9. package/dist/format/__tests__/byteStable.js +64 -0
  10. package/dist/format/blockSplice.d.ts +135 -0
  11. package/dist/format/blockSplice.js +330 -0
  12. package/dist/format/blocks/dropClosure.d.ts +81 -0
  13. package/dist/format/blocks/dropClosure.js +196 -0
  14. package/dist/format/blocks/markdown-block-catalog.d.ts +18 -0
  15. package/dist/format/blocks/markdown-block-catalog.js +162 -0
  16. package/dist/format/blocks/markdown-block-ids.d.mts +1 -0
  17. package/dist/format/blocks/markdown-block-ids.mjs +25 -0
  18. package/dist/format/blocks/parsers.d.ts +105 -0
  19. package/dist/format/blocks/parsers.js +442 -0
  20. package/dist/format/blocks/structured-block-schema.d.ts +8 -0
  21. package/dist/format/blocks/structured-block-schema.js +30 -0
  22. package/dist/format/callout.d.ts +128 -0
  23. package/dist/format/callout.js +227 -0
  24. package/dist/format/cardMarkdown.d.ts +2 -0
  25. package/dist/format/cardMarkdown.js +10 -0
  26. package/dist/format/checklist.d.ts +34 -0
  27. package/dist/format/checklist.js +158 -0
  28. package/dist/format/formatAxes.d.ts +228 -0
  29. package/dist/format/formatAxes.js +454 -0
  30. package/dist/format/index.d.ts +19 -4
  31. package/dist/format/index.js +28 -4
  32. package/dist/format/lineGeometry.d.ts +100 -0
  33. package/dist/format/lineGeometry.js +424 -0
  34. package/dist/format/lint/appliesTo.d.ts +92 -0
  35. package/dist/format/lint/appliesTo.js +369 -0
  36. package/dist/format/lint/config.d.ts +106 -0
  37. package/dist/format/lint/config.js +205 -0
  38. package/dist/format/lint/fixAll.d.ts +62 -0
  39. package/dist/format/lint/fixAll.js +107 -0
  40. package/dist/format/lint/frontmatterSchema.d.ts +181 -0
  41. package/dist/format/lint/frontmatterSchema.js +660 -0
  42. package/dist/format/lint/index.d.ts +49 -0
  43. package/dist/format/lint/index.js +51 -0
  44. package/dist/format/lint/lintSource.d.ts +56 -0
  45. package/dist/format/lint/lintSource.js +188 -0
  46. package/dist/format/lint/rules/broken-wiki-link.d.ts +2 -0
  47. package/dist/format/lint/rules/broken-wiki-link.js +45 -0
  48. package/dist/format/lint/rules/frontmatter-schema.d.ts +2 -0
  49. package/dist/format/lint/rules/frontmatter-schema.js +92 -0
  50. package/dist/format/lint/rules/index.d.ts +11 -0
  51. package/dist/format/lint/rules/index.js +32 -0
  52. package/dist/format/lint/rules/malformed-callout.d.ts +2 -0
  53. package/dist/format/lint/rules/malformed-callout.js +88 -0
  54. package/dist/format/lint/rules/malformed-checklist.d.ts +2 -0
  55. package/dist/format/lint/rules/malformed-checklist.js +65 -0
  56. package/dist/format/lint/rules/malformed-frontmatter.d.ts +2 -0
  57. package/dist/format/lint/rules/malformed-frontmatter.js +98 -0
  58. package/dist/format/lint/rules/malformed-structured-block.d.ts +2 -0
  59. package/dist/format/lint/rules/malformed-structured-block.js +134 -0
  60. package/dist/format/lint/rules/malformed-wiki-link.d.ts +2 -0
  61. package/dist/format/lint/rules/malformed-wiki-link.js +43 -0
  62. package/dist/format/lint/rules/orphan-reference.d.ts +2 -0
  63. package/dist/format/lint/rules/orphan-reference.js +87 -0
  64. package/dist/format/lint/severity.d.ts +15 -0
  65. package/dist/format/lint/severity.js +50 -0
  66. package/dist/format/lint/textEdits.d.ts +86 -0
  67. package/dist/format/lint/textEdits.js +162 -0
  68. package/dist/format/lint/types.d.ts +116 -0
  69. package/dist/format/lint/types.js +16 -0
  70. package/dist/format/markdown/dates.js +2 -0
  71. package/dist/format/markdown/document.js +2 -0
  72. package/dist/format/markdown/index.js +2 -0
  73. package/dist/format/markdown/mentions.js +2 -0
  74. package/dist/format/markdown/slug.d.ts +28 -0
  75. package/dist/format/markdown/slug.js +65 -0
  76. package/dist/format/markdown/yaml.js +2 -0
  77. package/dist/format/noteMarkdown.js +2 -0
  78. package/dist/format/parseWithFallback.d.ts +13 -0
  79. package/dist/format/parseWithFallback.js +98 -0
  80. package/dist/format/plaintext.d.ts +5 -0
  81. package/dist/format/plaintext.js +51 -0
  82. package/dist/format/postMarkdown.js +3 -1
  83. package/dist/format/sheetCellSpans.d.ts +95 -0
  84. package/dist/format/sheetCellSpans.js +223 -0
  85. package/dist/format/sheetSelection.d.ts +136 -0
  86. package/dist/format/sheetSelection.js +282 -0
  87. package/dist/format/taskUploadFilename.d.ts +6 -0
  88. package/dist/format/taskUploadFilename.js +13 -0
  89. package/dist/format/textStats.d.ts +23 -0
  90. package/dist/format/textStats.js +80 -0
  91. package/dist/format/wayfinder.d.ts +50 -0
  92. package/dist/format/wayfinder.js +203 -0
  93. package/dist/format/wikiLinks.d.ts +78 -0
  94. package/dist/format/wikiLinks.js +266 -0
  95. package/dist/index.d.ts +26 -1
  96. package/dist/index.js +20 -3
  97. package/dist/mcp-server.js +5 -2
  98. package/dist/opener.d.ts +23 -0
  99. package/dist/opener.js +26 -0
  100. package/dist/render.d.ts +132 -0
  101. package/dist/render.js +208 -0
  102. package/dist/shell-commands.d.ts +34 -0
  103. package/dist/shell-commands.js +108 -0
  104. package/dist/watch.d.ts +79 -0
  105. package/dist/watch.js +113 -0
  106. package/dist/web-url.d.ts +39 -0
  107. package/dist/web-url.js +63 -0
  108. 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 { createSforaShell, createLocalShell, SforaApiClient } from "./index.js";
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>/docs/ docs
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 cmd = process.platform === "darwin"
201
- ? "open"
202
- : process.platform === "win32"
203
- ? "start"
204
- : "xdg-open";
225
+ const { command, args } = openerCommand(process.platform, url);
205
226
  try {
206
- spawn(cmd, [url], { stdio: "ignore", detached: true }).unref();
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 name = base.endsWith(".md") ? base : `${base}.md`;
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
- ok(`${args.draft ? "Drafted" : "Posted"} to ${project} · ${name}`);
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}/docs/${name}`, md);
538
- ok(`Doc saved to ${project} · ${name}`);
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
- ok(`Task created in ${project} / ${col} · ${name}`);
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
- const { fs } = createSforaShell({
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({ baseUrl, apiKey, org, cwd: args.cwd });
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;