sfora-cli 0.7.0 → 0.8.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 +26 -0
- package/dist/SforaFs.d.ts +3 -0
- package/dist/SforaFs.js +231 -7
- package/dist/api-client.d.ts +58 -0
- package/dist/api-client.js +42 -3
- package/dist/cli.js +241 -22
- package/dist/config.d.ts +18 -0
- package/dist/config.js +97 -5
- package/dist/config.test.d.ts +1 -0
- package/dist/config.test.js +94 -0
- package/dist/format/cardMarkdown.d.ts +36 -0
- package/dist/format/cardMarkdown.js +83 -0
- package/dist/format/index.d.ts +13 -0
- package/dist/format/index.js +13 -0
- package/dist/format/markdown/dates.d.ts +3 -0
- package/dist/format/markdown/dates.js +26 -0
- package/dist/format/markdown/document.d.ts +7 -0
- package/dist/format/markdown/document.js +45 -0
- package/dist/format/markdown/index.d.ts +5 -0
- package/dist/format/markdown/index.js +9 -0
- package/dist/format/markdown/mentions.d.ts +8 -0
- package/dist/format/markdown/mentions.js +36 -0
- package/dist/format/markdown/slug.d.ts +2 -0
- package/dist/format/markdown/slug.js +19 -0
- package/dist/format/markdown/yaml.d.ts +4 -0
- package/dist/format/markdown/yaml.js +72 -0
- package/dist/format/noteMarkdown.d.ts +28 -0
- package/dist/format/noteMarkdown.js +66 -0
- package/dist/format/postMarkdown.d.ts +32 -0
- package/dist/format/postMarkdown.js +53 -0
- package/dist/index.d.ts +16 -1
- package/dist/index.js +15 -1
- package/dist/local/workspace.d.ts +68 -0
- package/dist/local/workspace.js +250 -0
- package/dist/mcp-server.d.ts +2 -0
- package/dist/mcp-server.js +19 -8
- package/package.json +2 -2
package/dist/cli.js
CHANGED
|
@@ -10,9 +10,10 @@ 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, SforaApiClient } from "./index.js";
|
|
13
|
+
import { createSforaShell, createLocalShell, SforaApiClient } from "./index.js";
|
|
14
|
+
import { LocalWorkspace, initWorkspace, findWorkspace, } from "./local/workspace.js";
|
|
14
15
|
import { runMcpServer } from "./mcp-server.js";
|
|
15
|
-
import { readConfig, writeConfig, resolveSettings, DEFAULT_URL, } from "./config.js";
|
|
16
|
+
import { readConfig, writeConfig, resolveSettings, upsertProfile, effectiveProfiles, DEFAULT_URL, } from "./config.js";
|
|
16
17
|
const colors = {
|
|
17
18
|
reset: "\x1b[0m",
|
|
18
19
|
bold: "\x1b[1m",
|
|
@@ -24,7 +25,7 @@ const colors = {
|
|
|
24
25
|
red: "\x1b[31m",
|
|
25
26
|
};
|
|
26
27
|
function parseArgs(argv) {
|
|
27
|
-
const args = { rest: [], cwd: "/", mcp: false, help: false, draft: false, json: false };
|
|
28
|
+
const args = { rest: [], cwd: "/", mcp: false, help: false, draft: false, json: false, local: false, cloud: false };
|
|
28
29
|
for (let i = 0; i < argv.length; i++) {
|
|
29
30
|
const a = argv[i];
|
|
30
31
|
if (a === "--mcp")
|
|
@@ -53,6 +54,10 @@ function parseArgs(argv) {
|
|
|
53
54
|
args.bot = a.slice("--bot=".length);
|
|
54
55
|
else if (a.startsWith("--agent="))
|
|
55
56
|
args.bot = a.slice("--agent=".length);
|
|
57
|
+
else if (a === "--as")
|
|
58
|
+
args.as = argv[++i];
|
|
59
|
+
else if (a.startsWith("--as="))
|
|
60
|
+
args.as = a.slice("--as=".length);
|
|
56
61
|
else if (a === "--web")
|
|
57
62
|
args.web = argv[++i];
|
|
58
63
|
else if (a.startsWith("--web="))
|
|
@@ -69,6 +74,10 @@ function parseArgs(argv) {
|
|
|
69
74
|
args.draft = true;
|
|
70
75
|
else if (a === "--json")
|
|
71
76
|
args.json = true;
|
|
77
|
+
else if (a === "--local")
|
|
78
|
+
args.local = true;
|
|
79
|
+
else if (a === "--cloud")
|
|
80
|
+
args.cloud = true;
|
|
72
81
|
else if (!a.startsWith("-")) {
|
|
73
82
|
if (!args.command)
|
|
74
83
|
args.command = a;
|
|
@@ -80,11 +89,15 @@ function parseArgs(argv) {
|
|
|
80
89
|
}
|
|
81
90
|
const HELP = `sfora — the CLI for your sfora workspace
|
|
82
91
|
|
|
83
|
-
Get started:
|
|
84
|
-
sfora
|
|
92
|
+
Get started (no account needed):
|
|
93
|
+
sfora init --local Create a .sfora/ workspace in this repo —
|
|
94
|
+
tasks/posts/docs as plain markdown files,
|
|
95
|
+
versioned with your code
|
|
96
|
+
sfora login Authorize with sfora cloud (saves your key)
|
|
85
97
|
sfora login --bot <name> Authorize as a named bot (its own key)
|
|
86
98
|
|
|
87
|
-
|
|
99
|
+
Inside a .sfora/ repo the CLI works on the local files; pass --cloud (or
|
|
100
|
+
--org/--url) to target sfora cloud instead. Run as a bot: add --bot <name>.
|
|
88
101
|
|
|
89
102
|
Push markdown straight to sfora:
|
|
90
103
|
sfora post <file.md> [--project <slug>] [--draft] Publish (or draft) a post
|
|
@@ -115,6 +128,7 @@ Browse & read:
|
|
|
115
128
|
Agents & config:
|
|
116
129
|
sfora --mcp [--org <slug>] Run as an MCP server (for agents)
|
|
117
130
|
sfora mcp-config Print an MCP config snippet to paste
|
|
131
|
+
sfora contexts List saved deployment+org keys (● = active)
|
|
118
132
|
sfora init Save URL + API key + org manually
|
|
119
133
|
|
|
120
134
|
Config resolution: flags > env (SFORA_API_KEY / SFORA_URL / SFORA_ORG) >
|
|
@@ -173,8 +187,13 @@ async function runInit(args) {
|
|
|
173
187
|
process.exitCode = 1;
|
|
174
188
|
return;
|
|
175
189
|
}
|
|
176
|
-
const
|
|
177
|
-
|
|
190
|
+
const { cfg: next, key: profile } = upsertProfile(cfg, {
|
|
191
|
+
url,
|
|
192
|
+
org: org || cfg.org,
|
|
193
|
+
apiKey: key,
|
|
194
|
+
});
|
|
195
|
+
const path = await writeConfig(next);
|
|
196
|
+
console.log(`${colors.green}✓${colors.reset} Saved ${path} ${colors.dim}(${profile})${colors.reset}`);
|
|
178
197
|
}
|
|
179
198
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
180
199
|
function openBrowser(url) {
|
|
@@ -237,17 +256,27 @@ async function runLogin(args, baseUrl) {
|
|
|
237
256
|
}
|
|
238
257
|
if (data.status === "approved" && data.apiKey) {
|
|
239
258
|
const cfg = await readConfig();
|
|
240
|
-
|
|
259
|
+
let next;
|
|
241
260
|
if (args.bot) {
|
|
242
261
|
// Save under the bot's slot; keep the user's personal key intact.
|
|
243
|
-
next
|
|
244
|
-
...cfg
|
|
245
|
-
|
|
262
|
+
next = {
|
|
263
|
+
...cfg,
|
|
264
|
+
url: base,
|
|
265
|
+
bots: {
|
|
266
|
+
...cfg.bots,
|
|
267
|
+
[args.bot]: { apiKey: data.apiKey, org: data.orgSlug ?? undefined },
|
|
268
|
+
},
|
|
246
269
|
};
|
|
247
270
|
}
|
|
248
271
|
else {
|
|
249
|
-
|
|
250
|
-
|
|
272
|
+
// Add/update this deployment+org as its own profile instead of
|
|
273
|
+
// overwriting one shared key — so logging in here never invalidates the
|
|
274
|
+
// key you hold for another deployment.
|
|
275
|
+
next = upsertProfile(cfg, {
|
|
276
|
+
url: base,
|
|
277
|
+
org: data.orgSlug ?? undefined,
|
|
278
|
+
apiKey: data.apiKey,
|
|
279
|
+
}).cfg;
|
|
251
280
|
}
|
|
252
281
|
const path = await writeConfig(next);
|
|
253
282
|
console.log(`\n${colors.green}✓${colors.reset} Logged in as ${data.name ?? "you"}${data.orgSlug ? ` ${colors.dim}(${data.orgSlug})${colors.reset}` : ""}${args.bot ? ` ${colors.dim}[bot: ${args.bot}]${colors.reset}` : ""} — saved ${path}`);
|
|
@@ -279,6 +308,37 @@ function printMcpConfig(s) {
|
|
|
279
308
|
};
|
|
280
309
|
process.stdout.write(`${JSON.stringify(snippet, null, 2)}\n`);
|
|
281
310
|
}
|
|
311
|
+
// `sfora contexts` — list the deployment+org identities saved in the config and
|
|
312
|
+
// mark the one the current flags/env resolve to. Local-only (no network), so
|
|
313
|
+
// you can always see which key you're about to use.
|
|
314
|
+
function printContexts(cfg, resolved) {
|
|
315
|
+
const mask = (k) => k ? `${k.slice(0, 12)}…` : `${colors.dim}(no key)${colors.reset}`;
|
|
316
|
+
const entries = Object.entries(effectiveProfiles(cfg));
|
|
317
|
+
if (entries.length === 0) {
|
|
318
|
+
console.log(`${colors.dim}No saved contexts. Run \`sfora login\`.${colors.reset}`);
|
|
319
|
+
}
|
|
320
|
+
else {
|
|
321
|
+
console.log(`${colors.bold}Contexts${colors.reset} ${colors.dim}(~/.sfora/config.json)${colors.reset}`);
|
|
322
|
+
for (const [key, p] of entries) {
|
|
323
|
+
const isResolved = resolved.apiKey != null && p.apiKey === resolved.apiKey;
|
|
324
|
+
const isActive = key === cfg.activeProfile;
|
|
325
|
+
const marker = isResolved
|
|
326
|
+
? `${colors.green}●${colors.reset}`
|
|
327
|
+
: isActive
|
|
328
|
+
? `${colors.cyan}○${colors.reset}`
|
|
329
|
+
: " ";
|
|
330
|
+
console.log(` ${marker} ${key.padEnd(28)} ${colors.dim}${p.url}${p.org ? ` · ${p.org}` : ""} · ${mask(p.apiKey)}${colors.reset}`);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
const bots = Object.entries(cfg.bots ?? {});
|
|
334
|
+
if (bots.length) {
|
|
335
|
+
console.log(`${colors.bold}Bots${colors.reset}`);
|
|
336
|
+
for (const [name, b] of bots) {
|
|
337
|
+
console.log(` ${colors.dim}${name.padEnd(28)} ${b.org ?? ""} · ${mask(b.apiKey)}${colors.reset}`);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
console.log(`\n${colors.dim}Resolved now → ${resolved.url}${resolved.org ? ` (org: ${resolved.org})` : ""} ${colors.green}●${colors.reset}${colors.dim} = active key${colors.reset}`);
|
|
341
|
+
}
|
|
282
342
|
// ─── Structured verbs (post / task / doc / ls / cat / projects) ──
|
|
283
343
|
// These talk to the workspace over the same /v1/fs the app + agents use, so the
|
|
284
344
|
// CLI is a real product interface — not a generic shell.
|
|
@@ -497,6 +557,106 @@ async function runVerb(args, fs, client) {
|
|
|
497
557
|
return;
|
|
498
558
|
}
|
|
499
559
|
}
|
|
560
|
+
// ─── Local mode (a .sfora/ directory — no server, no account) ─────
|
|
561
|
+
const LOCAL_ONLY_HINT = "cloud command — run it with --cloud (after `sfora login`), or outside the .sfora/ repo";
|
|
562
|
+
async function runLocalVerb(args, root) {
|
|
563
|
+
const ws = new LocalWorkspace(root);
|
|
564
|
+
const { fs } = createLocalShell(root);
|
|
565
|
+
const ok = (msg) => console.log(`${colors.green}✓${colors.reset} ${msg}`);
|
|
566
|
+
const emitJson = (v) => console.log(JSON.stringify(v, null, 2));
|
|
567
|
+
switch (args.command) {
|
|
568
|
+
case "ls": {
|
|
569
|
+
const entries = await fs.readdir(args.rest[0] ?? "/");
|
|
570
|
+
if (args.json)
|
|
571
|
+
return emitJson(entries);
|
|
572
|
+
console.log(entries.join("\n"));
|
|
573
|
+
return;
|
|
574
|
+
}
|
|
575
|
+
case "cat": {
|
|
576
|
+
if (!args.rest[0])
|
|
577
|
+
throw new Error("usage: sfora cat <path>");
|
|
578
|
+
process.stdout.write(await fs.readFile(args.rest[0], "utf8"));
|
|
579
|
+
return;
|
|
580
|
+
}
|
|
581
|
+
case "tasks": {
|
|
582
|
+
const tasks = await ws.listTasks(args.column);
|
|
583
|
+
if (args.json)
|
|
584
|
+
return emitJson(tasks);
|
|
585
|
+
const columns = await ws.listColumns();
|
|
586
|
+
for (const col of args.column ? [] : columns) {
|
|
587
|
+
const inCol = tasks.filter((t) => t.column === col);
|
|
588
|
+
console.log(`${colors.bold}${col.replace(/^\d+-/, "")}${colors.reset} ${colors.dim}(${inCol.length})${colors.reset}`);
|
|
589
|
+
for (const t of inCol) {
|
|
590
|
+
const dot = t.status === "closed" ? colors.green : colors.cyan;
|
|
591
|
+
console.log(` ${dot}●${colors.reset} ${colors.dim}#${t.number ?? "?"}${colors.reset} ${t.title}`);
|
|
592
|
+
}
|
|
593
|
+
if (inCol.length === 0)
|
|
594
|
+
console.log(` ${colors.dim}(empty)${colors.reset}`);
|
|
595
|
+
}
|
|
596
|
+
if (args.column) {
|
|
597
|
+
for (const t of tasks)
|
|
598
|
+
console.log(`${colors.dim}#${t.number ?? "?"}${colors.reset} ${t.title}`);
|
|
599
|
+
}
|
|
600
|
+
return;
|
|
601
|
+
}
|
|
602
|
+
case "posts": {
|
|
603
|
+
const files = await ws.listPosts(args.draft ? "drafts" : "posts");
|
|
604
|
+
if (args.json)
|
|
605
|
+
return emitJson(files);
|
|
606
|
+
console.log(files.length ? files.join("\n") : `${colors.dim}(no posts)${colors.reset}`);
|
|
607
|
+
return;
|
|
608
|
+
}
|
|
609
|
+
case "me":
|
|
610
|
+
case "whoami": {
|
|
611
|
+
if (args.json)
|
|
612
|
+
return emitJson({ mode: "local", workspace: root });
|
|
613
|
+
console.log(`local workspace ${colors.dim}· ${root}${colors.reset}`);
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
case "task":
|
|
617
|
+
case "post":
|
|
618
|
+
case "doc": {
|
|
619
|
+
const file = args.rest[0];
|
|
620
|
+
if (!file)
|
|
621
|
+
throw new Error(`usage: sfora ${args.command} <file.md>`);
|
|
622
|
+
const md = await readLocalFile(file, "utf8");
|
|
623
|
+
if (args.command === "task") {
|
|
624
|
+
const r = await ws.writeTask(md, { column: args.column });
|
|
625
|
+
ok(`Task #${r.number} created in ${r.column} · ${r.filename}`);
|
|
626
|
+
}
|
|
627
|
+
else if (args.command === "post") {
|
|
628
|
+
const r = await ws.writePost(md, { draft: args.draft });
|
|
629
|
+
ok(`${args.draft ? "Drafted" : "Posted"} · ${r.filename}`);
|
|
630
|
+
}
|
|
631
|
+
else {
|
|
632
|
+
const r = await ws.writeDoc(md);
|
|
633
|
+
ok(`Doc saved · ${r.filename}`);
|
|
634
|
+
}
|
|
635
|
+
return;
|
|
636
|
+
}
|
|
637
|
+
default:
|
|
638
|
+
throw new Error(`'${args.command}' is a ${LOCAL_ONLY_HINT}`);
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
const LOCAL_SHELL_HELP = `${colors.bold}sfora shell${colors.reset} ${colors.dim}— local workspace (.sfora/), plain files on disk${colors.reset}
|
|
642
|
+
|
|
643
|
+
Standard tools work on the real files:
|
|
644
|
+
ls cat grep find head tail wc sed awk echo mv cd pwd
|
|
645
|
+
|
|
646
|
+
${colors.dim}Where things live${colors.reset}
|
|
647
|
+
/board/<column>/NNNN-<slug>.md tasks — mv between columns moves a task
|
|
648
|
+
/posts/YYYY-MM-DD-<slug>.md posts
|
|
649
|
+
/docs/<slug>.md docs
|
|
650
|
+
|
|
651
|
+
${colors.dim}Try${colors.reset}
|
|
652
|
+
ls /board/01-todo
|
|
653
|
+
grep -ri todo /board
|
|
654
|
+
echo "# Fix login" > /board/01-todo/fix-login.md
|
|
655
|
+
mv /board/01-todo/0003-*.md /board/03-done/
|
|
656
|
+
|
|
657
|
+
Everything is git-versioned with your repo. ${colors.dim}Connect a team later with${colors.reset} ${colors.cyan}sfora login${colors.reset}.
|
|
658
|
+
Type ${colors.cyan}exit${colors.reset} to quit.
|
|
659
|
+
`;
|
|
500
660
|
async function main() {
|
|
501
661
|
const args = parseArgs(process.argv.slice(2));
|
|
502
662
|
if (args.help) {
|
|
@@ -504,6 +664,13 @@ async function main() {
|
|
|
504
664
|
return;
|
|
505
665
|
}
|
|
506
666
|
if (args.command === "init") {
|
|
667
|
+
if (args.local) {
|
|
668
|
+
const root = await initWorkspace(process.cwd());
|
|
669
|
+
console.log(`${colors.green}✓${colors.reset} Local workspace ready · ${root}`);
|
|
670
|
+
console.log(`${colors.dim}Tasks, posts, and docs live there as plain markdown — versioned with your repo.\n` +
|
|
671
|
+
`Try: sfora task plan.md · sfora tasks · sfora (shell). Connect a team later with sfora login.${colors.reset}`);
|
|
672
|
+
return;
|
|
673
|
+
}
|
|
507
674
|
await runInit(args);
|
|
508
675
|
return;
|
|
509
676
|
}
|
|
@@ -514,10 +681,51 @@ async function main() {
|
|
|
514
681
|
printMcpConfig(settings);
|
|
515
682
|
return;
|
|
516
683
|
}
|
|
684
|
+
if (args.command === "contexts") {
|
|
685
|
+
printContexts(cfg, settings);
|
|
686
|
+
return;
|
|
687
|
+
}
|
|
517
688
|
if (args.command === "login") {
|
|
518
689
|
await runLogin(args, baseUrl);
|
|
519
690
|
return;
|
|
520
691
|
}
|
|
692
|
+
// Local mode — a .sfora/ workspace in (an ancestor of) cwd wins, unless the
|
|
693
|
+
// user explicitly targets the cloud (--cloud / --url / --org / --key / --bot).
|
|
694
|
+
const cloudIntent = args.cloud || !!args.url || !!args.org || !!args.key || !!args.bot;
|
|
695
|
+
const localRoot = cloudIntent
|
|
696
|
+
? undefined
|
|
697
|
+
: await findWorkspace(process.cwd());
|
|
698
|
+
if (args.local && !localRoot) {
|
|
699
|
+
process.stderr.write(`${colors.red}error:${colors.reset} no .sfora/ workspace found — run \`sfora init --local\` first.\n`);
|
|
700
|
+
process.exitCode = 1;
|
|
701
|
+
return;
|
|
702
|
+
}
|
|
703
|
+
if (localRoot) {
|
|
704
|
+
if (args.command && VERBS.has(args.command)) {
|
|
705
|
+
try {
|
|
706
|
+
await runLocalVerb(args, localRoot);
|
|
707
|
+
}
|
|
708
|
+
catch (e) {
|
|
709
|
+
process.stderr.write(`${colors.red}error:${colors.reset} ${e instanceof Error ? e.message : String(e)}\n`);
|
|
710
|
+
process.exitCode = 1;
|
|
711
|
+
}
|
|
712
|
+
return;
|
|
713
|
+
}
|
|
714
|
+
if (args.mcp) {
|
|
715
|
+
await runMcpServer({
|
|
716
|
+
baseUrl,
|
|
717
|
+
apiKey: apiKey ?? "",
|
|
718
|
+
org: settings.org ?? "",
|
|
719
|
+
localRoot,
|
|
720
|
+
});
|
|
721
|
+
return;
|
|
722
|
+
}
|
|
723
|
+
const { bash, fs } = createLocalShell(localRoot, args.cwd);
|
|
724
|
+
console.log(`${colors.cyan}${colors.bold}sfora${colors.reset} ${colors.dim}— local workspace · ${localRoot}${colors.reset}`);
|
|
725
|
+
console.log(`${colors.dim}Plain markdown files, versioned with your repo. Try: ls /board · ${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`);
|
|
726
|
+
await runShell(bash, fs, LOCAL_SHELL_HELP, args.cwd);
|
|
727
|
+
return;
|
|
728
|
+
}
|
|
521
729
|
if (!apiKey) {
|
|
522
730
|
process.stderr.write(args.bot
|
|
523
731
|
? `${colors.red}error:${colors.reset} no key for bot '${args.bot}'. Run \`sfora login --bot ${args.bot}\`.\n`
|
|
@@ -532,13 +740,21 @@ async function main() {
|
|
|
532
740
|
baseUrl,
|
|
533
741
|
apiKey,
|
|
534
742
|
org: settings.org ?? "",
|
|
743
|
+
actAs: args.as,
|
|
535
744
|
});
|
|
536
|
-
const client = new SforaApiClient({ baseUrl, apiKey });
|
|
745
|
+
const client = new SforaApiClient({ baseUrl, apiKey, actAs: args.as });
|
|
537
746
|
try {
|
|
538
747
|
await runVerb(args, fs, client);
|
|
539
748
|
}
|
|
540
749
|
catch (e) {
|
|
541
|
-
|
|
750
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
751
|
+
// A 401 surfaces as EACCES once SforaFs maps it to an errno — translate it
|
|
752
|
+
// back into an actionable hint instead of a cryptic permission error.
|
|
753
|
+
const authExpired = e?.status === 401 ||
|
|
754
|
+
/EACCES|permission denied|invalid api key|unauthorized/i.test(msg);
|
|
755
|
+
process.stderr.write(`${colors.red}error:${colors.reset} ${authExpired
|
|
756
|
+
? `your key is invalid or expired — run \`sfora login\`${args.bot ? ` --bot ${args.bot}` : ""}`
|
|
757
|
+
: msg}\n`);
|
|
542
758
|
process.exitCode = 1;
|
|
543
759
|
}
|
|
544
760
|
return;
|
|
@@ -555,14 +771,10 @@ async function main() {
|
|
|
555
771
|
}
|
|
556
772
|
const org = settings.org;
|
|
557
773
|
const { bash, fs } = createSforaShell({ baseUrl, apiKey, org, cwd: args.cwd });
|
|
558
|
-
// Shell state threaded across exec() calls — just-bash does not persist cwd/env
|
|
559
|
-
// between separate exec()s, so we carry them forward ourselves.
|
|
560
|
-
let cwd = args.cwd;
|
|
561
|
-
let env;
|
|
562
774
|
// Pre-flight: confirm auth + connectivity and greet with the resolved identity.
|
|
563
775
|
let identity = "";
|
|
564
776
|
try {
|
|
565
|
-
identity = (await bash.exec("cat /me/api-key", { cwd
|
|
777
|
+
identity = (await bash.exec("cat /me/api-key", { cwd: args.cwd })).stdout.trim();
|
|
566
778
|
}
|
|
567
779
|
catch {
|
|
568
780
|
identity = "";
|
|
@@ -579,6 +791,13 @@ async function main() {
|
|
|
579
791
|
console.log(`${colors.yellow}warning:${colors.reset} could not reach ${baseUrl} or authenticate — commands may fail.`);
|
|
580
792
|
}
|
|
581
793
|
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`);
|
|
794
|
+
await runShell(bash, fs, SHELL_HELP, args.cwd);
|
|
795
|
+
}
|
|
796
|
+
async function runShell(bash, fs, helpText, initialCwd) {
|
|
797
|
+
// Shell state threaded across exec() calls — just-bash does not persist cwd/env
|
|
798
|
+
// between separate exec()s, so we carry them forward ourselves.
|
|
799
|
+
let cwd = initialCwd;
|
|
800
|
+
let env;
|
|
582
801
|
// Iterate stdin as an async iterable. This pattern serializes lines even
|
|
583
802
|
// when stdin is piped (buffered) — the `for await` body completes before the
|
|
584
803
|
// next line is consumed, and EOF naturally falls through to process exit
|
|
@@ -643,7 +862,7 @@ async function main() {
|
|
|
643
862
|
break;
|
|
644
863
|
// Show sfora's own help instead of just-bash's builtin help banner.
|
|
645
864
|
if (line === "help" || line === "?" || line.startsWith("help ")) {
|
|
646
|
-
process.stdout.write(
|
|
865
|
+
process.stdout.write(helpText);
|
|
647
866
|
if (isTty)
|
|
648
867
|
process.stdout.write(promptText());
|
|
649
868
|
continue;
|
package/dist/config.d.ts
CHANGED
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
export interface SforaProfile {
|
|
2
|
+
url: string;
|
|
3
|
+
org?: string;
|
|
4
|
+
apiKey?: string;
|
|
5
|
+
}
|
|
1
6
|
export interface SforaConfig {
|
|
2
7
|
url?: string;
|
|
3
8
|
apiKey?: string;
|
|
@@ -6,6 +11,8 @@ export interface SforaConfig {
|
|
|
6
11
|
apiKey?: string;
|
|
7
12
|
org?: string;
|
|
8
13
|
}>;
|
|
14
|
+
profiles?: Record<string, SforaProfile>;
|
|
15
|
+
activeProfile?: string;
|
|
9
16
|
}
|
|
10
17
|
export declare const CONFIG_PATH: string;
|
|
11
18
|
export declare const DEFAULT_URL = "https://www.sfora.ai";
|
|
@@ -16,6 +23,17 @@ export interface ResolvedSettings {
|
|
|
16
23
|
apiKey: string | undefined;
|
|
17
24
|
org: string | undefined;
|
|
18
25
|
}
|
|
26
|
+
export declare function normalizeHost(url: string): string;
|
|
27
|
+
export declare function profileKey(url: string, org?: string): string;
|
|
28
|
+
export declare function effectiveProfiles(cfg: SforaConfig): Record<string, SforaProfile>;
|
|
29
|
+
export declare function upsertProfile(cfg: SforaConfig, identity: {
|
|
30
|
+
url: string;
|
|
31
|
+
org?: string;
|
|
32
|
+
apiKey: string;
|
|
33
|
+
}): {
|
|
34
|
+
cfg: SforaConfig;
|
|
35
|
+
key: string;
|
|
36
|
+
};
|
|
19
37
|
export declare function resolveSettings(flags: {
|
|
20
38
|
url?: string;
|
|
21
39
|
apiKey?: string;
|
package/dist/config.js
CHANGED
|
@@ -2,6 +2,12 @@
|
|
|
2
2
|
* Persisted CLI config at ~/.sfora/config.json so you don't pass
|
|
3
3
|
* SFORA_API_KEY / SFORA_URL on every invocation. Resolution precedence:
|
|
4
4
|
* explicit flags > environment > config file > built-in default.
|
|
5
|
+
*
|
|
6
|
+
* The file holds *profiles* — one `{ url, org, apiKey }` context per
|
|
7
|
+
* deployment+org — so a single config can carry a prod key AND a dev key at
|
|
8
|
+
* once. `sfora login` adds/updates a profile instead of overwriting one shared
|
|
9
|
+
* key, which is what used to silently invalidate the other deployment's key and
|
|
10
|
+
* force constant re-auth.
|
|
5
11
|
*/
|
|
6
12
|
import { homedir } from "node:os";
|
|
7
13
|
import { join } from "node:path";
|
|
@@ -32,21 +38,107 @@ export async function writeConfig(cfg) {
|
|
|
32
38
|
function pick(...vals) {
|
|
33
39
|
return vals.find((v) => v != null && v !== "");
|
|
34
40
|
}
|
|
41
|
+
// Bare host, lowercased, for comparing/keying deployments regardless of scheme
|
|
42
|
+
// or trailing slash.
|
|
43
|
+
export function normalizeHost(url) {
|
|
44
|
+
try {
|
|
45
|
+
return new URL(url).host.toLowerCase();
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return url
|
|
49
|
+
.replace(/^https?:\/\//, "")
|
|
50
|
+
.replace(/\/+$/, "")
|
|
51
|
+
.toLowerCase();
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
function sameHost(a, b) {
|
|
55
|
+
return !!a && !!b && normalizeHost(a) === normalizeHost(b);
|
|
56
|
+
}
|
|
57
|
+
// Stable, human-ish profile name: `host/org` (or just `host` when org-less).
|
|
58
|
+
export function profileKey(url, org) {
|
|
59
|
+
const host = normalizeHost(url);
|
|
60
|
+
return org ? `${host}/${org}` : host;
|
|
61
|
+
}
|
|
62
|
+
// All contexts as a map, folding a legacy top-level identity into a synthesized
|
|
63
|
+
// profile so resolution can treat everything uniformly.
|
|
64
|
+
export function effectiveProfiles(cfg) {
|
|
65
|
+
const out = { ...(cfg.profiles ?? {}) };
|
|
66
|
+
if (cfg.apiKey) {
|
|
67
|
+
const url = cfg.url ?? DEFAULT_URL;
|
|
68
|
+
const key = profileKey(url, cfg.org);
|
|
69
|
+
if (!out[key])
|
|
70
|
+
out[key] = { url, org: cfg.org, apiKey: cfg.apiKey };
|
|
71
|
+
}
|
|
72
|
+
return out;
|
|
73
|
+
}
|
|
74
|
+
// Add (or replace) a profile for an identity and make it active. Also mirrors
|
|
75
|
+
// the identity onto the legacy top-level fields for back-compat. Returns the
|
|
76
|
+
// next config plus the profile key it was saved under.
|
|
77
|
+
export function upsertProfile(cfg, identity) {
|
|
78
|
+
const key = profileKey(identity.url, identity.org);
|
|
79
|
+
// Seed from effectiveProfiles (not raw cfg.profiles) so a first login keeps
|
|
80
|
+
// any pre-existing legacy top-level identity as its own profile instead of
|
|
81
|
+
// overwriting it.
|
|
82
|
+
const profiles = { ...effectiveProfiles(cfg) };
|
|
83
|
+
profiles[key] = {
|
|
84
|
+
url: identity.url,
|
|
85
|
+
org: identity.org,
|
|
86
|
+
apiKey: identity.apiKey,
|
|
87
|
+
};
|
|
88
|
+
const next = {
|
|
89
|
+
...cfg,
|
|
90
|
+
profiles,
|
|
91
|
+
activeProfile: key,
|
|
92
|
+
url: identity.url,
|
|
93
|
+
apiKey: identity.apiKey,
|
|
94
|
+
org: identity.org ?? cfg.org,
|
|
95
|
+
};
|
|
96
|
+
return { cfg: next, key };
|
|
97
|
+
}
|
|
35
98
|
export function resolveSettings(flags, cfg) {
|
|
36
|
-
|
|
99
|
+
// Explicit intent (may be undefined). Used both to override fields and to
|
|
100
|
+
// select which stored profile to act as.
|
|
101
|
+
const wantUrl = pick(flags.url, process.env.SFORA_URL);
|
|
102
|
+
const wantOrg = pick(flags.org, process.env.SFORA_ORG);
|
|
37
103
|
// `--bot <name>`: act as that saved bot. Don't fall back to the user's key /
|
|
38
104
|
// SFORA_API_KEY env, so the identity is never silently the wrong one.
|
|
39
105
|
if (flags.bot) {
|
|
40
106
|
const bot = cfg.bots?.[flags.bot];
|
|
41
107
|
return {
|
|
42
|
-
url,
|
|
108
|
+
url: pick(wantUrl, cfg.url) ?? DEFAULT_URL,
|
|
43
109
|
apiKey: pick(flags.apiKey, bot?.apiKey),
|
|
44
110
|
org: pick(flags.org, bot?.org, cfg.org),
|
|
45
111
|
};
|
|
46
112
|
}
|
|
113
|
+
// Pick the profile that best matches explicit intent, falling back to the
|
|
114
|
+
// active profile (last login), then the sole profile if there's only one.
|
|
115
|
+
const profiles = effectiveProfiles(cfg);
|
|
116
|
+
const list = Object.values(profiles);
|
|
117
|
+
let prof;
|
|
118
|
+
if (wantUrl && wantOrg)
|
|
119
|
+
prof = list.find((p) => sameHost(p.url, wantUrl) && p.org === wantOrg);
|
|
120
|
+
if (!prof && wantUrl)
|
|
121
|
+
prof = list.find((p) => sameHost(p.url, wantUrl));
|
|
122
|
+
if (!prof && wantOrg)
|
|
123
|
+
prof = list.find((p) => p.org === wantOrg);
|
|
124
|
+
// Convenience fallbacks (active profile, then the sole profile) apply ONLY
|
|
125
|
+
// when no explicit target was given. If the user named a url/org that matches
|
|
126
|
+
// nothing, prof stays undefined so we never substitute a different
|
|
127
|
+
// deployment's key.
|
|
128
|
+
if (!prof && !wantUrl && !wantOrg) {
|
|
129
|
+
if (cfg.activeProfile)
|
|
130
|
+
prof = profiles[cfg.activeProfile];
|
|
131
|
+
if (!prof && list.length === 1)
|
|
132
|
+
prof = list[0];
|
|
133
|
+
}
|
|
134
|
+
// Note: the legacy top-level identity is already folded into `profiles` by
|
|
135
|
+
// effectiveProfiles, so we deliberately do NOT fall back to cfg.apiKey here.
|
|
136
|
+
// If explicit url/org intent matches no profile, apiKey stays undefined and
|
|
137
|
+
// the CLI asks you to log in — rather than silently sending one deployment's
|
|
138
|
+
// key to another (the old "Invalid API key" trap).
|
|
47
139
|
return {
|
|
48
|
-
url,
|
|
49
|
-
apiKey: pick(flags.apiKey, process.env.SFORA_API_KEY,
|
|
50
|
-
org: pick(flags.org, process.env.SFORA_ORG,
|
|
140
|
+
url: pick(flags.url, process.env.SFORA_URL, prof?.url) ?? DEFAULT_URL,
|
|
141
|
+
apiKey: pick(flags.apiKey, process.env.SFORA_API_KEY, prof?.apiKey),
|
|
142
|
+
org: pick(flags.org, process.env.SFORA_ORG, prof?.org),
|
|
51
143
|
};
|
|
52
144
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from "vitest";
|
|
2
|
+
import { resolveSettings, upsertProfile, effectiveProfiles, profileKey, DEFAULT_URL, } from "./config.js";
|
|
3
|
+
const DEV_URL = "https://valuable-llama-777.eu-west-1.convex.site";
|
|
4
|
+
// resolveSettings reads SFORA_* env as a fallback — clear them so tests assert
|
|
5
|
+
// the config/flag behavior in isolation.
|
|
6
|
+
beforeEach(() => {
|
|
7
|
+
delete process.env.SFORA_URL;
|
|
8
|
+
delete process.env.SFORA_ORG;
|
|
9
|
+
delete process.env.SFORA_API_KEY;
|
|
10
|
+
});
|
|
11
|
+
describe("legacy migration", () => {
|
|
12
|
+
it("folds a legacy top-level identity into a profile", () => {
|
|
13
|
+
const cfg = {
|
|
14
|
+
url: DEFAULT_URL,
|
|
15
|
+
org: "wavyr",
|
|
16
|
+
apiKey: "sfora_ak_prod",
|
|
17
|
+
};
|
|
18
|
+
const profiles = effectiveProfiles(cfg);
|
|
19
|
+
expect(profiles[profileKey(DEFAULT_URL, "wavyr")]).toEqual({
|
|
20
|
+
url: DEFAULT_URL,
|
|
21
|
+
org: "wavyr",
|
|
22
|
+
apiKey: "sfora_ak_prod",
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
it("resolves the sole legacy identity with no flags", () => {
|
|
26
|
+
const cfg = {
|
|
27
|
+
url: DEFAULT_URL,
|
|
28
|
+
org: "wavyr",
|
|
29
|
+
apiKey: "sfora_ak_prod",
|
|
30
|
+
};
|
|
31
|
+
expect(resolveSettings({}, cfg)).toEqual({
|
|
32
|
+
url: DEFAULT_URL,
|
|
33
|
+
org: "wavyr",
|
|
34
|
+
apiKey: "sfora_ak_prod",
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
describe("no cross-deployment key reuse", () => {
|
|
39
|
+
it("does NOT send the prod key to an unmatched dev target", () => {
|
|
40
|
+
const cfg = {
|
|
41
|
+
url: DEFAULT_URL,
|
|
42
|
+
org: "wavyr",
|
|
43
|
+
apiKey: "sfora_ak_prod",
|
|
44
|
+
};
|
|
45
|
+
const r = resolveSettings({ url: DEV_URL, org: "test" }, cfg);
|
|
46
|
+
expect(r.url).toBe(DEV_URL);
|
|
47
|
+
expect(r.org).toBe("test");
|
|
48
|
+
// The whole point: no key rather than the wrong key.
|
|
49
|
+
expect(r.apiKey).toBeUndefined();
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
describe("profiles coexist (the fix)", () => {
|
|
53
|
+
it("login to dev does not clobber the prod key; each resolves by intent", () => {
|
|
54
|
+
let cfg = {
|
|
55
|
+
url: DEFAULT_URL,
|
|
56
|
+
org: "wavyr",
|
|
57
|
+
apiKey: "sfora_ak_prod",
|
|
58
|
+
};
|
|
59
|
+
// Simulate `sfora login` against dev.
|
|
60
|
+
cfg = upsertProfile(cfg, {
|
|
61
|
+
url: DEV_URL,
|
|
62
|
+
org: "test",
|
|
63
|
+
apiKey: "sfora_ak_dev",
|
|
64
|
+
}).cfg;
|
|
65
|
+
// Both keys are retained.
|
|
66
|
+
const profiles = effectiveProfiles(cfg);
|
|
67
|
+
expect(Object.keys(profiles)).toHaveLength(2);
|
|
68
|
+
// Prod still reachable by intent.
|
|
69
|
+
expect(resolveSettings({ org: "wavyr" }, cfg).apiKey).toBe("sfora_ak_prod");
|
|
70
|
+
expect(resolveSettings({ url: DEFAULT_URL }, cfg).apiKey).toBe("sfora_ak_prod");
|
|
71
|
+
// Dev reachable by intent.
|
|
72
|
+
expect(resolveSettings({ org: "test" }, cfg).apiKey).toBe("sfora_ak_dev");
|
|
73
|
+
// No intent → the last login (active profile) wins.
|
|
74
|
+
expect(resolveSettings({}, cfg)).toEqual({
|
|
75
|
+
url: DEV_URL,
|
|
76
|
+
org: "test",
|
|
77
|
+
apiKey: "sfora_ak_dev",
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
describe("bot identity", () => {
|
|
82
|
+
it("uses the named bot's key and never the personal fallback", () => {
|
|
83
|
+
const cfg = {
|
|
84
|
+
url: DEFAULT_URL,
|
|
85
|
+
org: "wavyr",
|
|
86
|
+
apiKey: "sfora_ak_prod",
|
|
87
|
+
bots: { ci: { apiKey: "sfora_ak_ci", org: "wavyr" } },
|
|
88
|
+
};
|
|
89
|
+
const r = resolveSettings({ bot: "ci" }, cfg);
|
|
90
|
+
expect(r.apiKey).toBe("sfora_ak_ci");
|
|
91
|
+
const missing = resolveSettings({ bot: "nope" }, cfg);
|
|
92
|
+
expect(missing.apiKey).toBeUndefined();
|
|
93
|
+
});
|
|
94
|
+
});
|