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/cli.js
CHANGED
|
@@ -11,83 +11,16 @@ import { spawn } from "node:child_process";
|
|
|
11
11
|
import { readFile as readLocalFile } from "node:fs/promises";
|
|
12
12
|
import { basename } from "node:path";
|
|
13
13
|
import { taskUploadFilename } from "./format/taskUploadFilename.js";
|
|
14
|
-
import { createSforaShell, createLocalShell,
|
|
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, ndjson, presenceRecords, renderPresence, renderWriteEffect, urlLine, PRESENCE_NOTE, } from "./render.js";
|
|
18
|
+
import { parseWatchTarget, watchLoop } from "./watch.js";
|
|
15
19
|
import { LocalWorkspace, initWorkspace, findWorkspace, migrateWorkspaceStages, } from "./local/workspace.js";
|
|
16
20
|
import { runMcpServer } from "./mcp-server.js";
|
|
17
21
|
import { readConfig, writeConfig, resolveSettings, upsertProfile, effectiveProfiles, DEFAULT_URL, } from "./config.js";
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
bold: "\x1b[1m",
|
|
21
|
-
dim: "\x1b[2m",
|
|
22
|
-
cyan: "\x1b[36m",
|
|
23
|
-
green: "\x1b[32m",
|
|
24
|
-
yellow: "\x1b[33m",
|
|
25
|
-
blue: "\x1b[34m",
|
|
26
|
-
red: "\x1b[31m",
|
|
27
|
-
};
|
|
28
|
-
function parseArgs(argv) {
|
|
29
|
-
const args = { rest: [], cwd: "/", mcp: false, help: false, draft: false, json: false, local: false, cloud: false };
|
|
30
|
-
for (let i = 0; i < argv.length; i++) {
|
|
31
|
-
const a = argv[i];
|
|
32
|
-
if (a === "--mcp")
|
|
33
|
-
args.mcp = true;
|
|
34
|
-
else if (a === "--help" || a === "-h")
|
|
35
|
-
args.help = true;
|
|
36
|
-
else if (a === "--org")
|
|
37
|
-
args.org = argv[++i];
|
|
38
|
-
else if (a.startsWith("--org="))
|
|
39
|
-
args.org = a.slice("--org=".length);
|
|
40
|
-
else if (a === "--cwd")
|
|
41
|
-
args.cwd = argv[++i] ?? "/";
|
|
42
|
-
else if (a.startsWith("--cwd="))
|
|
43
|
-
args.cwd = a.slice("--cwd=".length);
|
|
44
|
-
else if (a === "--url")
|
|
45
|
-
args.url = argv[++i];
|
|
46
|
-
else if (a.startsWith("--url="))
|
|
47
|
-
args.url = a.slice("--url=".length);
|
|
48
|
-
else if (a === "--key")
|
|
49
|
-
args.key = argv[++i];
|
|
50
|
-
else if (a.startsWith("--key="))
|
|
51
|
-
args.key = a.slice("--key=".length);
|
|
52
|
-
else if (a === "--bot" || a === "--agent")
|
|
53
|
-
args.bot = argv[++i];
|
|
54
|
-
else if (a.startsWith("--bot="))
|
|
55
|
-
args.bot = a.slice("--bot=".length);
|
|
56
|
-
else if (a.startsWith("--agent="))
|
|
57
|
-
args.bot = a.slice("--agent=".length);
|
|
58
|
-
else if (a === "--as")
|
|
59
|
-
args.as = argv[++i];
|
|
60
|
-
else if (a.startsWith("--as="))
|
|
61
|
-
args.as = a.slice("--as=".length);
|
|
62
|
-
else if (a === "--web")
|
|
63
|
-
args.web = argv[++i];
|
|
64
|
-
else if (a.startsWith("--web="))
|
|
65
|
-
args.web = a.slice("--web=".length);
|
|
66
|
-
else if (a === "--project")
|
|
67
|
-
args.project = argv[++i];
|
|
68
|
-
else if (a.startsWith("--project="))
|
|
69
|
-
args.project = a.slice("--project=".length);
|
|
70
|
-
else if (a === "--column")
|
|
71
|
-
args.column = argv[++i];
|
|
72
|
-
else if (a.startsWith("--column="))
|
|
73
|
-
args.column = a.slice("--column=".length);
|
|
74
|
-
else if (a === "--draft")
|
|
75
|
-
args.draft = true;
|
|
76
|
-
else if (a === "--json")
|
|
77
|
-
args.json = true;
|
|
78
|
-
else if (a === "--local")
|
|
79
|
-
args.local = true;
|
|
80
|
-
else if (a === "--cloud")
|
|
81
|
-
args.cloud = true;
|
|
82
|
-
else if (!a.startsWith("-")) {
|
|
83
|
-
if (!args.command)
|
|
84
|
-
args.command = a;
|
|
85
|
-
else
|
|
86
|
-
args.rest.push(a);
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
return args;
|
|
90
|
-
}
|
|
22
|
+
import { parseArgs } from "./cli-args.js";
|
|
23
|
+
import { chatTailLoop, renderChatMessage, renderRoomList, resolveRoomRef, roomSlug, } from "./chat.js";
|
|
91
24
|
const HELP = `sfora — the CLI for your sfora workspace
|
|
92
25
|
|
|
93
26
|
Get started (no account needed):
|
|
@@ -121,11 +54,33 @@ Browse & read:
|
|
|
121
54
|
sfora me Show who you're signed in as
|
|
122
55
|
sfora ls [path] List a path (default /projects)
|
|
123
56
|
sfora cat <path> Print a file's markdown
|
|
57
|
+
sfora url <path> Print the web URL for a path
|
|
58
|
+
sfora open <path> Open that URL in your browser
|
|
124
59
|
sfora Open the interactive shell
|
|
125
60
|
|
|
126
61
|
Add --json to any list command (projects/posts/tasks/ls/me) for
|
|
127
62
|
machine-readable output and stable scripting.
|
|
128
63
|
|
|
64
|
+
Chat:
|
|
65
|
+
sfora rooms List rooms (● joined · ○ open to join)
|
|
66
|
+
sfora join <room> Join an open room
|
|
67
|
+
sfora chat <room> [-n <count>] Read the room, then type to talk —
|
|
68
|
+
new messages stream in live
|
|
69
|
+
sfora chat <room> -m "text" Send one message and exit (for scripts
|
|
70
|
+
and agents)
|
|
71
|
+
|
|
72
|
+
Write, and watch others write:
|
|
73
|
+
sfora blocks <path> List a document's addressable blocks
|
|
74
|
+
sfora put <path> <file.md> Write a file (add --block <id> for one block)
|
|
75
|
+
sfora put <path> --block <id> - …or pipe the block's markdown on stdin
|
|
76
|
+
sfora watch <path-or-project> Stream write pings (add --json for NDJSON)
|
|
77
|
+
sfora where [name] Who's in which document right now
|
|
78
|
+
|
|
79
|
+
Every write prints what it did — "changed" (with how many block ids
|
|
80
|
+
survived) or "no change". Watching and writing make you visible in the
|
|
81
|
+
document; watch --self also shows your own writes. "where" is a read only —
|
|
82
|
+
asking never puts you in a document.
|
|
83
|
+
|
|
129
84
|
Agents & config:
|
|
130
85
|
sfora --mcp [--org <slug>] Run as an MCP server (for agents)
|
|
131
86
|
sfora mcp-config Print an MCP config snippet to paste
|
|
@@ -145,6 +100,11 @@ const SHELL_HELP = `${colors.bold}sfora shell${colors.reset} ${colors.dim}— yo
|
|
|
145
100
|
Standard tools work against your workspace:
|
|
146
101
|
ls cat grep find head tail wc sed awk echo cd pwd
|
|
147
102
|
|
|
103
|
+
${colors.dim}And three of sfora's own${colors.reset}
|
|
104
|
+
blocks <path> the blocks a --block write can aim at
|
|
105
|
+
put <path> [file|-] write a file (--block <id> writes one block)
|
|
106
|
+
url <path> where it lives on the web
|
|
107
|
+
|
|
148
108
|
${colors.dim}Where things live${colors.reset}
|
|
149
109
|
/projects your projects
|
|
150
110
|
/projects/<slug>/posts/<file>.md published posts
|
|
@@ -160,6 +120,8 @@ ${colors.dim}Try${colors.reset}
|
|
|
160
120
|
find /projects/<slug>/library -type f
|
|
161
121
|
grep -ri todo /projects
|
|
162
122
|
echo "# Hello" > /projects/<slug>/posts/hello.md
|
|
123
|
+
blocks /projects/<slug>/docs/spec.md
|
|
124
|
+
echo "A rewritten paragraph." | put /projects/<slug>/docs/spec.md --block <id>
|
|
163
125
|
|
|
164
126
|
${colors.dim}Outside the shell${colors.reset}, sfora has verbs: post · task · doc · new · projects (run ${colors.cyan}sfora --help${colors.reset})
|
|
165
127
|
Type ${colors.cyan}exit${colors.reset} to quit.
|
|
@@ -199,13 +161,9 @@ async function runInit(args) {
|
|
|
199
161
|
}
|
|
200
162
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
201
163
|
function openBrowser(url) {
|
|
202
|
-
const
|
|
203
|
-
? "open"
|
|
204
|
-
: process.platform === "win32"
|
|
205
|
-
? "start"
|
|
206
|
-
: "xdg-open";
|
|
164
|
+
const { command, args } = openerCommand(process.platform, url);
|
|
207
165
|
try {
|
|
208
|
-
spawn(
|
|
166
|
+
spawn(command, args, { stdio: "ignore", detached: true }).unref();
|
|
209
167
|
}
|
|
210
168
|
catch {
|
|
211
169
|
/* best effort — the URL is printed for manual opening */
|
|
@@ -384,6 +342,12 @@ const VERBS = new Set([
|
|
|
384
342
|
"new",
|
|
385
343
|
"ls",
|
|
386
344
|
"cat",
|
|
345
|
+
"url",
|
|
346
|
+
"open",
|
|
347
|
+
"blocks",
|
|
348
|
+
"put",
|
|
349
|
+
"watch",
|
|
350
|
+
"where",
|
|
387
351
|
"post",
|
|
388
352
|
"task",
|
|
389
353
|
"doc",
|
|
@@ -395,11 +359,35 @@ const VERBS = new Set([
|
|
|
395
359
|
"whoami",
|
|
396
360
|
"comment",
|
|
397
361
|
"react",
|
|
362
|
+
"rooms",
|
|
363
|
+
"join",
|
|
364
|
+
"chat",
|
|
398
365
|
]);
|
|
399
366
|
async function runVerb(args, fs, client) {
|
|
400
367
|
const ok = (msg) => console.log(`${colors.green}✓${colors.reset} ${msg}`);
|
|
368
|
+
// The prose confirmation, suppressed under --json: a script parsing stdout
|
|
369
|
+
// must find JSON there and nothing else.
|
|
370
|
+
const said = (msg) => {
|
|
371
|
+
if (!args.json)
|
|
372
|
+
ok(msg);
|
|
373
|
+
};
|
|
401
374
|
const emitJson = (v) => console.log(JSON.stringify(v, null, 2));
|
|
402
375
|
const plural = (n, w) => `${n} ${w}${n === 1 ? "" : "s"}`;
|
|
376
|
+
/**
|
|
377
|
+
* The link the LAST request reported, as one dim line — on stderr.
|
|
378
|
+
*
|
|
379
|
+
* stderr and not stdout, deliberately: `sfora cat x.md > x.md` and
|
|
380
|
+
* `sfora ls | wc -l` are the reason those verbs exist, and a link mixed into
|
|
381
|
+
* stdout would corrupt the first and miscount the second. A human sees both
|
|
382
|
+
* streams; a pipe sees only the bytes it asked for. Suppressed under --json
|
|
383
|
+
* for the same reason.
|
|
384
|
+
*/
|
|
385
|
+
const showUrl = () => {
|
|
386
|
+
const { url } = client.takeResponseInfo();
|
|
387
|
+
if (url && !args.json)
|
|
388
|
+
process.stderr.write(`${urlLine(url)}\n`);
|
|
389
|
+
return url;
|
|
390
|
+
};
|
|
403
391
|
if (args.command === "projects") {
|
|
404
392
|
const projects = await client.listProjects();
|
|
405
393
|
if (args.json)
|
|
@@ -427,14 +415,115 @@ async function runVerb(args, fs, client) {
|
|
|
427
415
|
if (args.json)
|
|
428
416
|
return emitJson(entries);
|
|
429
417
|
console.log(entries.join("\n"));
|
|
418
|
+
showUrl();
|
|
430
419
|
return;
|
|
431
420
|
}
|
|
432
421
|
if (args.command === "cat") {
|
|
433
422
|
if (!args.rest[0])
|
|
434
423
|
throw new Error("usage: sfora cat <path>");
|
|
435
424
|
process.stdout.write(await fs.readFile(args.rest[0], "utf8"));
|
|
425
|
+
showUrl();
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
// `url` and `open` are the same question — where does this live on the web —
|
|
429
|
+
// asked once and answered by the server. The CLI holds no route table.
|
|
430
|
+
if (args.command === "url" || args.command === "open") {
|
|
431
|
+
const path = args.rest[0];
|
|
432
|
+
if (!path)
|
|
433
|
+
throw new Error(`usage: sfora ${args.command} <path>`);
|
|
434
|
+
const url = await client.resolveWebUrl(path);
|
|
435
|
+
if (!url) {
|
|
436
|
+
throw new Error(`${path} has no page on the web — files, repositories and the derived ` +
|
|
437
|
+
"views (README, mentions) are read through the fs only");
|
|
438
|
+
}
|
|
439
|
+
if (args.json)
|
|
440
|
+
return emitJson({ path, url });
|
|
441
|
+
console.log(url);
|
|
442
|
+
if (args.command === "open")
|
|
443
|
+
openBrowser(url);
|
|
436
444
|
return;
|
|
437
445
|
}
|
|
446
|
+
// `blocks` and `put` are the shell's own commands, run without the shell.
|
|
447
|
+
// One implementation (block-commands.ts) so the two doors cannot drift.
|
|
448
|
+
if (args.command === "blocks") {
|
|
449
|
+
if (!args.rest[0])
|
|
450
|
+
throw new Error("usage: sfora blocks <path> [--json]");
|
|
451
|
+
return writeCommandOutput(await blocksCommand(client, args.rest[0], { json: args.json }));
|
|
452
|
+
}
|
|
453
|
+
if (args.command === "put") {
|
|
454
|
+
const path = args.rest[0];
|
|
455
|
+
if (!path) {
|
|
456
|
+
throw new Error("usage: sfora put <path> [<file.md>|-] [--block <id>] [--json]");
|
|
457
|
+
}
|
|
458
|
+
const source = args.rest[1];
|
|
459
|
+
const body = source && source !== "-"
|
|
460
|
+
? await readLocalFile(source, "utf8")
|
|
461
|
+
: await readStdin();
|
|
462
|
+
return writeCommandOutput(await putCommand(client, path, body, {
|
|
463
|
+
blockId: args.block,
|
|
464
|
+
json: args.json,
|
|
465
|
+
presence: runPresence,
|
|
466
|
+
}));
|
|
467
|
+
}
|
|
468
|
+
if (args.command === "watch") {
|
|
469
|
+
if (!args.rest[0]) {
|
|
470
|
+
throw new Error("usage: sfora watch <path-or-project> [--json] [--self] [--wait <secs>]");
|
|
471
|
+
}
|
|
472
|
+
return runWatch(args, client, args.rest[0]);
|
|
473
|
+
}
|
|
474
|
+
// `where` — the reverse of presence. Card #345.
|
|
475
|
+
//
|
|
476
|
+
// Nothing is declared by running it: the server answers a query and the CLI
|
|
477
|
+
// prints it. That matters enough to be a property of the verb rather than a
|
|
478
|
+
// note about it — `sfora where` is the command an agent runs to find out
|
|
479
|
+
// where a human is working, and a version of it that joined the roster would
|
|
480
|
+
// make an agent appear in a document it had only asked about.
|
|
481
|
+
//
|
|
482
|
+
// The name is the whole argument list. `--as` composes for free (it is a
|
|
483
|
+
// header on the client, so `sfora where --as bot` asks as the bot and gets
|
|
484
|
+
// the bot's visibility), and no `--project` filter exists because the answer
|
|
485
|
+
// is already scoped to what this key can open.
|
|
486
|
+
if (args.command === "where") {
|
|
487
|
+
const view = await client.listPresence(args.rest[0]);
|
|
488
|
+
if (args.json) {
|
|
489
|
+
// NDJSON, matching `watch --json`: one record per line, no wrapper. A
|
|
490
|
+
// pretty-printed object would break `sfora where --json | jq -r .url`
|
|
491
|
+
// for the same reason it would there.
|
|
492
|
+
for (const record of presenceRecords(view)) {
|
|
493
|
+
process.stdout.write(`${ndjson(record)}\n`);
|
|
494
|
+
}
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
497
|
+
console.log(renderPresence(view));
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
// Chat — the same rooms the app shows, from the terminal. Humans and agents
|
|
501
|
+
// are the same member model, so this is how either sits in the conversation.
|
|
502
|
+
if (args.command === "rooms") {
|
|
503
|
+
const rooms = await client.listRooms(true);
|
|
504
|
+
if (args.json)
|
|
505
|
+
return emitJson(rooms);
|
|
506
|
+
console.log(renderRoomList(rooms));
|
|
507
|
+
return;
|
|
508
|
+
}
|
|
509
|
+
if (args.command === "join") {
|
|
510
|
+
if (!args.rest[0])
|
|
511
|
+
throw new Error("usage: sfora join <room>");
|
|
512
|
+
const room = await resolveRoomOrThrow(client, args.rest[0]);
|
|
513
|
+
const result = await client.joinRoom(room._id);
|
|
514
|
+
if (args.json)
|
|
515
|
+
return emitJson({ ...result, roomId: room._id, name: room.name });
|
|
516
|
+
ok(result.already
|
|
517
|
+
? `Already in #${roomSlug(room.name)}`
|
|
518
|
+
: `Joined #${roomSlug(room.name)}`);
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
if (args.command === "chat") {
|
|
522
|
+
if (!args.rest[0]) {
|
|
523
|
+
throw new Error('usage: sfora chat <room> [-n <count>] [-m "text"]');
|
|
524
|
+
}
|
|
525
|
+
return runChat(args, client);
|
|
526
|
+
}
|
|
438
527
|
if (args.command === "me" || args.command === "whoami") {
|
|
439
528
|
const text = (await client.readMe()).trim();
|
|
440
529
|
if (args.json) {
|
|
@@ -533,12 +622,14 @@ async function runVerb(args, fs, client) {
|
|
|
533
622
|
if (args.command === "post") {
|
|
534
623
|
const dir = args.draft ? "drafts" : "posts";
|
|
535
624
|
await fs.writeFile(`/projects/${project}/${dir}/${name}`, md);
|
|
536
|
-
|
|
625
|
+
said(`${args.draft ? "Drafted" : "Posted"} to ${project} · ${name}`);
|
|
626
|
+
reportWrite(client, args.json);
|
|
537
627
|
return;
|
|
538
628
|
}
|
|
539
629
|
if (args.command === "doc") {
|
|
540
630
|
await fs.writeFile(`/projects/${project}/library/documents/${name}`, md);
|
|
541
|
-
|
|
631
|
+
said(`Doc saved to ${project} · ${name}`);
|
|
632
|
+
reportWrite(client, args.json);
|
|
542
633
|
return;
|
|
543
634
|
}
|
|
544
635
|
if (args.command === "task") {
|
|
@@ -557,9 +648,344 @@ async function runVerb(args, fs, client) {
|
|
|
557
648
|
col = cols.find((c) => c.replace(/^\d+-/, "") === want) ?? col;
|
|
558
649
|
}
|
|
559
650
|
await fs.writeFile(`/projects/${project}/board/${col}/${name}`, md);
|
|
560
|
-
|
|
651
|
+
said(`Task created in ${project} / ${col} · ${name}`);
|
|
652
|
+
reportWrite(client, args.json);
|
|
653
|
+
return;
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
/**
|
|
657
|
+
* What a write DID — printed after every one of them (card #333).
|
|
658
|
+
*
|
|
659
|
+
* Three lines at most, all on stderr and all dim, in the order a reader needs
|
|
660
|
+
* them: the effect (did the bytes move, and did the block ids survive), the
|
|
661
|
+
* page, and — once per process — the fact that writing put you on the
|
|
662
|
+
* document's roster. `announcedPresence` is module-level rather than per-call
|
|
663
|
+
* because "once" means once per run, not once per document.
|
|
664
|
+
*/
|
|
665
|
+
/**
|
|
666
|
+
* `sfora watch <target>` — the long-poll, wired to the real process.
|
|
667
|
+
*
|
|
668
|
+
* Everything decision-shaped lives in `watch.ts`; this resolves what is being
|
|
669
|
+
* watched, opens the roster, and arranges for ^C to close it. The two are
|
|
670
|
+
* separate so the loop can be tested without a signal handler or a socket.
|
|
671
|
+
*/
|
|
672
|
+
async function runWatch(args, client, target) {
|
|
673
|
+
const parsed = parseWatchTarget(target);
|
|
674
|
+
const wait = Number.isFinite(args.wait) ? args.wait : undefined;
|
|
675
|
+
// A document watch needs the entity id `/v1/events?doc=` takes, and the
|
|
676
|
+
// server states it in `?view=blocks` — the CLI does not read it out of the
|
|
677
|
+
// file. A project watch needs only the slug it was given.
|
|
678
|
+
let docId;
|
|
679
|
+
if (parsed.kind === "path") {
|
|
680
|
+
const resolved = await client.resolveDocId(parsed.path);
|
|
681
|
+
if (!resolved) {
|
|
682
|
+
throw new Error(`${parsed.path} has no document behind it to watch — point watch at a ` +
|
|
683
|
+
"post, draft, document or board card, or at a project");
|
|
684
|
+
}
|
|
685
|
+
docId = resolved;
|
|
686
|
+
}
|
|
687
|
+
// Presence, if this path has a roster. Asked rather than inferred: a `422`
|
|
688
|
+
// is the server saying "no roster here", and posts and cards get one.
|
|
689
|
+
let canBeat = parsed.kind === "path";
|
|
690
|
+
const beat = async ({ leave }) => {
|
|
691
|
+
if (!canBeat || parsed.kind !== "path")
|
|
692
|
+
return;
|
|
693
|
+
const roster = await client.declarePresence(parsed.path, {
|
|
694
|
+
kind: "viewing",
|
|
695
|
+
leave,
|
|
696
|
+
});
|
|
697
|
+
if (roster === null)
|
|
698
|
+
canBeat = false;
|
|
699
|
+
};
|
|
700
|
+
// Start at NOW, not at the retained backlog: `watch` is a live channel, and
|
|
701
|
+
// opening one should not replay a day of history nobody was waiting for.
|
|
702
|
+
const since = Date.now();
|
|
703
|
+
let stop = false;
|
|
704
|
+
// ^C HAS TO REACH THE SOCKET. Registering a handler suppresses Node's own
|
|
705
|
+
// SIGINT exit, so a handler that only sets a flag turns ^C into "stop after
|
|
706
|
+
// the current long-poll finishes" — up to `--wait` seconds of a terminal
|
|
707
|
+
// that says "^C to stop" and does not, with further ^Cs inert because the
|
|
708
|
+
// default handler is gone. The user's remaining exit is ^\, which kills the
|
|
709
|
+
// process outright, skips the `finally`, and leaves exactly the roster ghost
|
|
710
|
+
// the watch exists to avoid. Aborting the in-flight request is what makes
|
|
711
|
+
// the first ^C land: the poll rejects at once, the loop sees `stopped()`,
|
|
712
|
+
// and the `finally` retracts presence on the way out.
|
|
713
|
+
const inFlight = new AbortController();
|
|
714
|
+
let signalled = false;
|
|
715
|
+
const onSignal = (signal) => {
|
|
716
|
+
stop = true;
|
|
717
|
+
inFlight.abort();
|
|
718
|
+
// A second one is the impatient case — the leave request is hanging too,
|
|
719
|
+
// or the network is gone. Stand down and let the default behaviour end the
|
|
720
|
+
// process; the ghost expires on its own in ~90 seconds.
|
|
721
|
+
if (signalled) {
|
|
722
|
+
process.off("SIGINT", onSignal);
|
|
723
|
+
process.off("SIGTERM", onSignal);
|
|
724
|
+
process.kill(process.pid, signal);
|
|
725
|
+
return;
|
|
726
|
+
}
|
|
727
|
+
signalled = true;
|
|
728
|
+
};
|
|
729
|
+
process.on("SIGINT", onSignal);
|
|
730
|
+
process.on("SIGTERM", onSignal);
|
|
731
|
+
if (!args.json) {
|
|
732
|
+
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`);
|
|
733
|
+
}
|
|
734
|
+
try {
|
|
735
|
+
await watchLoop({
|
|
736
|
+
poll: (cursor) => client.pollEvents({
|
|
737
|
+
since: cursor,
|
|
738
|
+
wait,
|
|
739
|
+
doc: docId,
|
|
740
|
+
project: parsed.kind === "project" ? parsed.slug : undefined,
|
|
741
|
+
includeSelf: args.self,
|
|
742
|
+
signal: inFlight.signal,
|
|
743
|
+
}),
|
|
744
|
+
beat: parsed.kind === "path" ? beat : undefined,
|
|
745
|
+
write: (text) => process.stdout.write(text),
|
|
746
|
+
// Warnings on stderr so `--json` stdout stays parseable NDJSON.
|
|
747
|
+
warn: (text) => process.stderr.write(`${colors.dim}${text}${colors.reset}\n`),
|
|
748
|
+
sleep,
|
|
749
|
+
}, { json: args.json, since, stopped: () => stop });
|
|
750
|
+
}
|
|
751
|
+
finally {
|
|
752
|
+
process.off("SIGINT", onSignal);
|
|
753
|
+
process.off("SIGTERM", onSignal);
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
/**
|
|
757
|
+
* The room a reference names, or a thrown explanation.
|
|
758
|
+
*
|
|
759
|
+
* Resolution reads `?all=1` so an unjoined room can still be found — `sfora
|
|
760
|
+
* join dogfood` has to work before the caller is in it. Ambiguity throws with
|
|
761
|
+
* the candidates rather than guessing: sending into the wrong room is the
|
|
762
|
+
* failure worth a retype.
|
|
763
|
+
*/
|
|
764
|
+
async function resolveRoomOrThrow(client, ref) {
|
|
765
|
+
const rooms = await client.listRooms(true);
|
|
766
|
+
const match = resolveRoomRef(rooms, ref);
|
|
767
|
+
if (match.kind === "match")
|
|
768
|
+
return match.room;
|
|
769
|
+
if (match.kind === "ambiguous") {
|
|
770
|
+
throw new Error(`'${ref}' matches several rooms — say which:\n` +
|
|
771
|
+
match.candidates.map((r) => ` ${roomSlug(r.name)}`).join("\n"));
|
|
772
|
+
}
|
|
773
|
+
throw new Error(`no room matches '${ref}' — run \`sfora rooms\` to see them`);
|
|
774
|
+
}
|
|
775
|
+
/**
|
|
776
|
+
* `sfora chat <room>` — the room in the terminal, wired to the real process.
|
|
777
|
+
*
|
|
778
|
+
* Everything decision-shaped (name resolution, formatting, the tail loop)
|
|
779
|
+
* lives in `chat.ts` where it is tested; this owns readline, ^C, and the two
|
|
780
|
+
* modes: `-m` sends one message and exits (what scripts and agents use), bare
|
|
781
|
+
* `chat` prints history and opens a prompt with a live tail above it.
|
|
782
|
+
*/
|
|
783
|
+
async function runChat(args, client) {
|
|
784
|
+
const room = await resolveRoomOrThrow(client, args.rest[0]);
|
|
785
|
+
const tag = `#${roomSlug(room.name)}`;
|
|
786
|
+
const isTty = Boolean(process.stdin.isTTY);
|
|
787
|
+
if (!room.joined) {
|
|
788
|
+
// Offered inline on a terminal; stated as the fix everywhere else — a
|
|
789
|
+
// script cannot answer a question, so it gets the command instead.
|
|
790
|
+
if (args.message !== undefined || !isTty) {
|
|
791
|
+
throw new Error(`you have not joined ${tag} — run \`sfora join ${roomSlug(room.name)}\` first`);
|
|
792
|
+
}
|
|
793
|
+
const ask = readline.createInterface({
|
|
794
|
+
input: process.stdin,
|
|
795
|
+
output: process.stdout,
|
|
796
|
+
});
|
|
797
|
+
const answer = await new Promise((res) => ask.question(`You have not joined ${tag} — join? (y/n) `, res));
|
|
798
|
+
ask.close();
|
|
799
|
+
if (!/^y(es)?$/i.test(answer.trim()))
|
|
800
|
+
return;
|
|
801
|
+
await client.joinRoom(room._id);
|
|
802
|
+
console.log(`${colors.green}✓${colors.reset} Joined ${tag}`);
|
|
803
|
+
}
|
|
804
|
+
// One-shot send.
|
|
805
|
+
if (args.message !== undefined) {
|
|
806
|
+
const text = args.message.trim();
|
|
807
|
+
if (!text)
|
|
808
|
+
throw new Error('usage: sfora chat <room> -m "text"');
|
|
809
|
+
const { messageId } = await client.sendRoomMessage(room._id, text);
|
|
810
|
+
if (args.json) {
|
|
811
|
+
console.log(JSON.stringify({ messageId, roomId: room._id }, null, 2));
|
|
812
|
+
return;
|
|
813
|
+
}
|
|
814
|
+
console.log(`${colors.green}✓${colors.reset} Sent to ${tag}`);
|
|
561
815
|
return;
|
|
562
816
|
}
|
|
817
|
+
// History, oldest first — the page arrives newest-first.
|
|
818
|
+
const limit = Number.isFinite(args.limit) && args.limit > 0
|
|
819
|
+
? Math.min(args.limit, 100)
|
|
820
|
+
: 30;
|
|
821
|
+
// Seed the seen set from as many messages as the tail's refetch pulls (20):
|
|
822
|
+
// with -n below that, the doorbell would otherwise "discover" older history
|
|
823
|
+
// and flood it as if it had just arrived. Print only the asked-for n.
|
|
824
|
+
const history = await client.listRoomMessages(room._id, Math.max(limit, 20));
|
|
825
|
+
const seeded = history.page.slice().reverse();
|
|
826
|
+
const seen = new Set(seeded.map((m) => m._id));
|
|
827
|
+
const messages = seeded.slice(-limit);
|
|
828
|
+
if (messages.length === 0) {
|
|
829
|
+
console.log(`${colors.dim}(no messages yet)${colors.reset}`);
|
|
830
|
+
}
|
|
831
|
+
for (const msg of messages)
|
|
832
|
+
console.log(renderChatMessage(msg, Date.now()));
|
|
833
|
+
process.stderr.write(`${colors.dim}${tag} — type to send · /quit (or ^C) to leave${colors.reset}\n`);
|
|
834
|
+
// The prompt and the tail share one screen: an arriving message clears the
|
|
835
|
+
// prompt line, prints, and readline redraws the prompt with whatever was
|
|
836
|
+
// being typed. `readline.clearLine` over anything fancier — robust beats
|
|
837
|
+
// pretty at 80 columns.
|
|
838
|
+
const rl = readline.createInterface({
|
|
839
|
+
input: process.stdin,
|
|
840
|
+
output: process.stdout,
|
|
841
|
+
terminal: isTty,
|
|
842
|
+
});
|
|
843
|
+
rl.setPrompt(`${colors.cyan}${tag}${colors.reset} `);
|
|
844
|
+
const printAbove = (text) => {
|
|
845
|
+
if (isTty) {
|
|
846
|
+
readline.clearLine(process.stdout, 0);
|
|
847
|
+
readline.cursorTo(process.stdout, 0);
|
|
848
|
+
}
|
|
849
|
+
process.stdout.write(`${text}\n`);
|
|
850
|
+
if (isTty)
|
|
851
|
+
rl.prompt(true);
|
|
852
|
+
};
|
|
853
|
+
// ^C has to reach the socket, same as `watch`: the long-poll hangs for tens
|
|
854
|
+
// of seconds, and a flag alone would leave a terminal that says it stopped
|
|
855
|
+
// and hasn't. Closing readline ends the line iterator; aborting the request
|
|
856
|
+
// ends the poll; the loop sees `stopped()` and returns.
|
|
857
|
+
let stop = false;
|
|
858
|
+
const inFlight = new AbortController();
|
|
859
|
+
rl.on("SIGINT", () => rl.close());
|
|
860
|
+
rl.on("close", () => {
|
|
861
|
+
stop = true;
|
|
862
|
+
inFlight.abort();
|
|
863
|
+
});
|
|
864
|
+
const wait = Number.isFinite(args.wait) ? args.wait : undefined;
|
|
865
|
+
const tail = chatTailLoop({
|
|
866
|
+
poll: (since) => client.pollEvents({ since, wait, signal: inFlight.signal }),
|
|
867
|
+
fetchRecent: async () => (await client.listRoomMessages(room._id, 20)).page,
|
|
868
|
+
print: (msg) => printAbove(renderChatMessage(msg, Date.now())),
|
|
869
|
+
warn: (text) => printAbove(`${colors.dim}${text}${colors.reset}`),
|
|
870
|
+
sleep,
|
|
871
|
+
}, {
|
|
872
|
+
roomId: room._id,
|
|
873
|
+
seen,
|
|
874
|
+
// Cursor on SERVER time: the newest history message's _creationTime.
|
|
875
|
+
// A client clock running fast would otherwise open a blind window at
|
|
876
|
+
// startup; any overlap re-delivery is absorbed by the seen set.
|
|
877
|
+
since: history.page[0]?._creationTime ?? Date.now(),
|
|
878
|
+
stopped: () => stop,
|
|
879
|
+
});
|
|
880
|
+
if (isTty)
|
|
881
|
+
rl.prompt();
|
|
882
|
+
for await (const input of rl) {
|
|
883
|
+
const line = input.trim();
|
|
884
|
+
if (!line) {
|
|
885
|
+
if (isTty)
|
|
886
|
+
rl.prompt();
|
|
887
|
+
continue;
|
|
888
|
+
}
|
|
889
|
+
if (line === "/quit" || line === "/exit" || line === "/q")
|
|
890
|
+
break;
|
|
891
|
+
try {
|
|
892
|
+
// The send's own echo is the typed line still on screen; recording the
|
|
893
|
+
// id keeps the tail's refetch from printing it a second time.
|
|
894
|
+
const { messageId } = await client.sendRoomMessage(room._id, line);
|
|
895
|
+
seen.add(messageId);
|
|
896
|
+
}
|
|
897
|
+
catch (e) {
|
|
898
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
899
|
+
process.stderr.write(`${colors.red}error:${colors.reset} ${msg}\n`);
|
|
900
|
+
}
|
|
901
|
+
if (isTty)
|
|
902
|
+
rl.prompt();
|
|
903
|
+
}
|
|
904
|
+
stop = true;
|
|
905
|
+
inFlight.abort();
|
|
906
|
+
rl.close();
|
|
907
|
+
await tail.catch(() => { });
|
|
908
|
+
if (isTty)
|
|
909
|
+
console.log("");
|
|
910
|
+
}
|
|
911
|
+
/**
|
|
912
|
+
* A shared command's result, on the real streams.
|
|
913
|
+
*
|
|
914
|
+
* The exit code is honoured rather than thrown: a 409 re-aim table is a
|
|
915
|
+
* complete, useful answer that happens to mean "try again", and turning it back
|
|
916
|
+
* into an exception would put the CLI's generic `error:` prefix in front of a
|
|
917
|
+
* table that already explains itself.
|
|
918
|
+
*/
|
|
919
|
+
function writeCommandOutput(out) {
|
|
920
|
+
if (out.stdout)
|
|
921
|
+
process.stdout.write(out.stdout);
|
|
922
|
+
if (out.stderr)
|
|
923
|
+
process.stderr.write(out.stderr);
|
|
924
|
+
if (out.exitCode !== 0)
|
|
925
|
+
process.exitCode = out.exitCode;
|
|
926
|
+
}
|
|
927
|
+
/** Everything piped in, as text. Empty when nothing was. */
|
|
928
|
+
async function readStdin() {
|
|
929
|
+
if (process.stdin.isTTY)
|
|
930
|
+
return "";
|
|
931
|
+
const chunks = [];
|
|
932
|
+
for await (const chunk of process.stdin) {
|
|
933
|
+
chunks.push(Buffer.from(chunk));
|
|
934
|
+
}
|
|
935
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
936
|
+
}
|
|
937
|
+
/**
|
|
938
|
+
* The trailing dim lines after a shell line: what a write did, and where it
|
|
939
|
+
* lives.
|
|
940
|
+
*
|
|
941
|
+
* Gated on the command, not on "did the last request name anything", and that
|
|
942
|
+
* is deliberate. A `grep -ri todo /projects` reads dozens of files, and the
|
|
943
|
+
* link of whichever happened to be read LAST would be a link to a document the
|
|
944
|
+
* user never asked about. So the rule is narrow and stated: a write always
|
|
945
|
+
* reports its effect (that is card #333's whole point — a splice can store
|
|
946
|
+
* nothing, and silence would read as success), and a link is offered only
|
|
947
|
+
* after the two commands whose subject is one path.
|
|
948
|
+
*
|
|
949
|
+
* `put`, `blocks` and `url` consume the info themselves, so nothing here
|
|
950
|
+
* double-prints them.
|
|
951
|
+
*/
|
|
952
|
+
function reportShellLine(line, client) {
|
|
953
|
+
const { url, effect, present } = client.takeResponseInfo();
|
|
954
|
+
const verb = line.trim().split(/\s+/)[0];
|
|
955
|
+
const effectLine = renderWriteEffect(effect);
|
|
956
|
+
if (effectLine)
|
|
957
|
+
process.stderr.write(` ${effectLine}\n`);
|
|
958
|
+
if (url && (effect || verb === "cat" || verb === "ls")) {
|
|
959
|
+
process.stderr.write(` ${urlLine(url)}\n`);
|
|
960
|
+
}
|
|
961
|
+
if (present && runPresence.claim()) {
|
|
962
|
+
process.stderr.write(` ${colors.dim}${PRESENCE_NOTE}${colors.reset}\n`);
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
/**
|
|
966
|
+
* The run's one "you are visible" announcement.
|
|
967
|
+
*
|
|
968
|
+
* Module-level because a run IS this process, and shared with the shell's own
|
|
969
|
+
* `put` (it is handed to `createSforaShell`) because otherwise the interactive
|
|
970
|
+
* session has two latches — the shell command's and this one — and says the
|
|
971
|
+
* line twice for what is one fact.
|
|
972
|
+
*/
|
|
973
|
+
const runPresence = presenceNotice();
|
|
974
|
+
function reportWrite(client, json) {
|
|
975
|
+
const { url, effect, present } = client.takeResponseInfo();
|
|
976
|
+
if (json) {
|
|
977
|
+
process.stdout.write(`${JSON.stringify({ url, present, ...(effect ?? {}) })}\n`);
|
|
978
|
+
return;
|
|
979
|
+
}
|
|
980
|
+
const line = renderWriteEffect(effect);
|
|
981
|
+
if (line)
|
|
982
|
+
process.stderr.write(` ${line}\n`);
|
|
983
|
+
if (url)
|
|
984
|
+
process.stderr.write(` ${urlLine(url)}\n`);
|
|
985
|
+
// Only a DOCUMENT write puts you on a roster, and the server is what says so.
|
|
986
|
+
if (present && runPresence.claim()) {
|
|
987
|
+
process.stderr.write(` ${colors.dim}${PRESENCE_NOTE}${colors.reset}\n`);
|
|
988
|
+
}
|
|
563
989
|
}
|
|
564
990
|
// ─── Local mode (a .sfora/ directory — no server, no account) ─────
|
|
565
991
|
const LOCAL_ONLY_HINT = "cloud command — run it with --cloud (after `sfora login`), or outside the .sfora/ repo";
|
|
@@ -749,13 +1175,17 @@ async function main() {
|
|
|
749
1175
|
}
|
|
750
1176
|
// Structured verbs — the workspace as a markdown filesystem.
|
|
751
1177
|
if (args.command && VERBS.has(args.command)) {
|
|
752
|
-
|
|
1178
|
+
// ONE client for the verb: `SforaFs` reads and writes through it, and the
|
|
1179
|
+
// verbs read back off it what the last response reported (the page, the
|
|
1180
|
+
// effect report). A second client would be watching a different
|
|
1181
|
+
// conversation and would always answer "nothing".
|
|
1182
|
+
const { fs, client } = createSforaShell({
|
|
753
1183
|
baseUrl,
|
|
754
1184
|
apiKey,
|
|
755
1185
|
org: settings.org ?? "",
|
|
756
1186
|
actAs: args.as,
|
|
1187
|
+
presence: runPresence,
|
|
757
1188
|
});
|
|
758
|
-
const client = new SforaApiClient({ baseUrl, apiKey, actAs: args.as });
|
|
759
1189
|
try {
|
|
760
1190
|
await runVerb(args, fs, client);
|
|
761
1191
|
}
|
|
@@ -783,7 +1213,13 @@ async function main() {
|
|
|
783
1213
|
return;
|
|
784
1214
|
}
|
|
785
1215
|
const org = settings.org;
|
|
786
|
-
const { bash, fs } = createSforaShell({
|
|
1216
|
+
const { bash, fs, client } = createSforaShell({
|
|
1217
|
+
baseUrl,
|
|
1218
|
+
apiKey,
|
|
1219
|
+
org,
|
|
1220
|
+
cwd: args.cwd,
|
|
1221
|
+
presence: runPresence,
|
|
1222
|
+
});
|
|
787
1223
|
// Pre-flight: confirm auth + connectivity and greet with the resolved identity.
|
|
788
1224
|
let identity = "";
|
|
789
1225
|
try {
|
|
@@ -804,9 +1240,11 @@ async function main() {
|
|
|
804
1240
|
console.log(`${colors.yellow}warning:${colors.reset} could not reach ${baseUrl} or authenticate — commands may fail.`);
|
|
805
1241
|
}
|
|
806
1242
|
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`);
|
|
807
|
-
await runShell(bash, fs, SHELL_HELP, args.cwd);
|
|
1243
|
+
await runShell(bash, fs, SHELL_HELP, args.cwd, client);
|
|
808
1244
|
}
|
|
809
|
-
async function runShell(bash, fs, helpText, initialCwd
|
|
1245
|
+
async function runShell(bash, fs, helpText, initialCwd,
|
|
1246
|
+
/** Absent in local mode — there is no server, so nothing reports a link. */
|
|
1247
|
+
client) {
|
|
810
1248
|
// Shell state threaded across exec() calls — just-bash does not persist cwd/env
|
|
811
1249
|
// between separate exec()s, so we carry them forward ourselves.
|
|
812
1250
|
let cwd = initialCwd;
|
|
@@ -825,6 +1263,8 @@ async function runShell(bash, fs, helpText, initialCwd) {
|
|
|
825
1263
|
"ls", "cat", "cd", "pwd", "grep", "find", "echo", "head", "tail", "wc",
|
|
826
1264
|
"sed", "awk", "sort", "uniq", "mkdir", "rm", "mv", "cp", "touch", "help",
|
|
827
1265
|
"exit",
|
|
1266
|
+
// sfora's own, registered on the cloud shell (see `sforaShellCommands`).
|
|
1267
|
+
"blocks", "put", "url",
|
|
828
1268
|
];
|
|
829
1269
|
async function completePath(token) {
|
|
830
1270
|
const slash = token.lastIndexOf("/");
|
|
@@ -888,6 +1328,8 @@ async function runShell(bash, fs, helpText, initialCwd) {
|
|
|
888
1328
|
// reads as native — the user never typed `bash`.
|
|
889
1329
|
if (res.stderr)
|
|
890
1330
|
process.stderr.write(res.stderr.replace(/^bash:/gm, "sfora:"));
|
|
1331
|
+
if (client)
|
|
1332
|
+
reportShellLine(line, client);
|
|
891
1333
|
env = res.env;
|
|
892
1334
|
cwd = res.env?.PWD ?? cwd;
|
|
893
1335
|
}
|