sfora-cli 0.7.0 → 0.9.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 +252 -23
- 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 +38 -0
- package/dist/format/cardMarkdown.js +85 -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 +80 -0
- package/dist/local/workspace.js +343 -0
- package/dist/mcp-server.d.ts +2 -0
- package/dist/mcp-server.js +24 -10
- 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, migrateWorkspaceStages, } 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.
|
|
@@ -484,7 +544,8 @@ async function runVerb(args, fs, client) {
|
|
|
484
544
|
.catch(() => []);
|
|
485
545
|
if (cols.length === 0)
|
|
486
546
|
throw new Error(`no board columns in ${project}`);
|
|
487
|
-
|
|
547
|
+
// Default to "To do" (02-todo) — new work is up for grabs, not in triage.
|
|
548
|
+
let col = cols.find((c) => c.replace(/^\d+-/, "") === "todo") ?? cols[0];
|
|
488
549
|
if (args.column) {
|
|
489
550
|
const want = args.column
|
|
490
551
|
.toLowerCase()
|
|
@@ -497,6 +558,115 @@ async function runVerb(args, fs, client) {
|
|
|
497
558
|
return;
|
|
498
559
|
}
|
|
499
560
|
}
|
|
561
|
+
// ─── Local mode (a .sfora/ directory — no server, no account) ─────
|
|
562
|
+
const LOCAL_ONLY_HINT = "cloud command — run it with --cloud (after `sfora login`), or outside the .sfora/ repo";
|
|
563
|
+
async function runLocalVerb(args, root) {
|
|
564
|
+
// One Flow: silently reshape a legacy board onto the four fixed stage dirs on
|
|
565
|
+
// open (idempotent — a no-op once the board is canonical).
|
|
566
|
+
const reshaped = await migrateWorkspaceStages(root);
|
|
567
|
+
if (reshaped.migrated) {
|
|
568
|
+
console.error(`${colors.dim}· reshaped board to the four stages (triage · to do · in progress · done); moved ${reshaped.moved} task${reshaped.moved === 1 ? "" : "s"}${colors.reset}`);
|
|
569
|
+
}
|
|
570
|
+
const ws = new LocalWorkspace(root);
|
|
571
|
+
const { fs } = createLocalShell(root);
|
|
572
|
+
const ok = (msg) => console.log(`${colors.green}✓${colors.reset} ${msg}`);
|
|
573
|
+
const emitJson = (v) => console.log(JSON.stringify(v, null, 2));
|
|
574
|
+
switch (args.command) {
|
|
575
|
+
case "ls": {
|
|
576
|
+
const entries = await fs.readdir(args.rest[0] ?? "/");
|
|
577
|
+
if (args.json)
|
|
578
|
+
return emitJson(entries);
|
|
579
|
+
console.log(entries.join("\n"));
|
|
580
|
+
return;
|
|
581
|
+
}
|
|
582
|
+
case "cat": {
|
|
583
|
+
if (!args.rest[0])
|
|
584
|
+
throw new Error("usage: sfora cat <path>");
|
|
585
|
+
process.stdout.write(await fs.readFile(args.rest[0], "utf8"));
|
|
586
|
+
return;
|
|
587
|
+
}
|
|
588
|
+
case "tasks": {
|
|
589
|
+
const tasks = await ws.listTasks(args.column);
|
|
590
|
+
if (args.json)
|
|
591
|
+
return emitJson(tasks);
|
|
592
|
+
const columns = await ws.listColumns();
|
|
593
|
+
for (const col of args.column ? [] : columns) {
|
|
594
|
+
const inCol = tasks.filter((t) => t.column === col);
|
|
595
|
+
console.log(`${colors.bold}${col.replace(/^\d+-/, "")}${colors.reset} ${colors.dim}(${inCol.length})${colors.reset}`);
|
|
596
|
+
for (const t of inCol) {
|
|
597
|
+
const dot = t.status === "closed" ? colors.green : colors.cyan;
|
|
598
|
+
console.log(` ${dot}●${colors.reset} ${colors.dim}#${t.number ?? "?"}${colors.reset} ${t.title}`);
|
|
599
|
+
}
|
|
600
|
+
if (inCol.length === 0)
|
|
601
|
+
console.log(` ${colors.dim}(empty)${colors.reset}`);
|
|
602
|
+
}
|
|
603
|
+
if (args.column) {
|
|
604
|
+
for (const t of tasks)
|
|
605
|
+
console.log(`${colors.dim}#${t.number ?? "?"}${colors.reset} ${t.title}`);
|
|
606
|
+
}
|
|
607
|
+
return;
|
|
608
|
+
}
|
|
609
|
+
case "posts": {
|
|
610
|
+
const files = await ws.listPosts(args.draft ? "drafts" : "posts");
|
|
611
|
+
if (args.json)
|
|
612
|
+
return emitJson(files);
|
|
613
|
+
console.log(files.length ? files.join("\n") : `${colors.dim}(no posts)${colors.reset}`);
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
case "me":
|
|
617
|
+
case "whoami": {
|
|
618
|
+
if (args.json)
|
|
619
|
+
return emitJson({ mode: "local", workspace: root });
|
|
620
|
+
console.log(`local workspace ${colors.dim}· ${root}${colors.reset}`);
|
|
621
|
+
return;
|
|
622
|
+
}
|
|
623
|
+
case "task":
|
|
624
|
+
case "post":
|
|
625
|
+
case "doc": {
|
|
626
|
+
const file = args.rest[0];
|
|
627
|
+
if (!file)
|
|
628
|
+
throw new Error(`usage: sfora ${args.command} <file.md>`);
|
|
629
|
+
const md = await readLocalFile(file, "utf8");
|
|
630
|
+
if (args.command === "task") {
|
|
631
|
+
const r = await ws.writeTask(md, { column: args.column });
|
|
632
|
+
ok(`Task #${r.number} created in ${r.column} · ${r.filename}`);
|
|
633
|
+
}
|
|
634
|
+
else if (args.command === "post") {
|
|
635
|
+
const r = await ws.writePost(md, { draft: args.draft });
|
|
636
|
+
ok(`${args.draft ? "Drafted" : "Posted"} · ${r.filename}`);
|
|
637
|
+
}
|
|
638
|
+
else {
|
|
639
|
+
const r = await ws.writeDoc(md);
|
|
640
|
+
ok(`Doc saved · ${r.filename}`);
|
|
641
|
+
}
|
|
642
|
+
return;
|
|
643
|
+
}
|
|
644
|
+
default:
|
|
645
|
+
throw new Error(`'${args.command}' is a ${LOCAL_ONLY_HINT}`);
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
const LOCAL_SHELL_HELP = `${colors.bold}sfora shell${colors.reset} ${colors.dim}— local workspace (.sfora/), plain files on disk${colors.reset}
|
|
649
|
+
|
|
650
|
+
Standard tools work on the real files:
|
|
651
|
+
ls cat grep find head tail wc sed awk echo mv cd pwd
|
|
652
|
+
|
|
653
|
+
${colors.dim}Where things live${colors.reset}
|
|
654
|
+
/board/<NN-stage>/NNNN-<slug>.md tasks — four fixed columns: 01-triage /
|
|
655
|
+
02-todo / 03-in-progress / 04-done. mv
|
|
656
|
+
between them moves a task; mv into 04-done
|
|
657
|
+
marks it done.
|
|
658
|
+
/posts/YYYY-MM-DD-<slug>.md posts
|
|
659
|
+
/docs/<slug>.md docs
|
|
660
|
+
|
|
661
|
+
${colors.dim}Try${colors.reset}
|
|
662
|
+
ls /board/02-todo
|
|
663
|
+
grep -ri todo /board
|
|
664
|
+
echo "# Fix login" > /board/02-todo/fix-login.md
|
|
665
|
+
mv /board/02-todo/0003-*.md /board/04-done/
|
|
666
|
+
|
|
667
|
+
Everything is git-versioned with your repo. ${colors.dim}Connect a team later with${colors.reset} ${colors.cyan}sfora login${colors.reset}.
|
|
668
|
+
Type ${colors.cyan}exit${colors.reset} to quit.
|
|
669
|
+
`;
|
|
500
670
|
async function main() {
|
|
501
671
|
const args = parseArgs(process.argv.slice(2));
|
|
502
672
|
if (args.help) {
|
|
@@ -504,6 +674,13 @@ async function main() {
|
|
|
504
674
|
return;
|
|
505
675
|
}
|
|
506
676
|
if (args.command === "init") {
|
|
677
|
+
if (args.local) {
|
|
678
|
+
const root = await initWorkspace(process.cwd());
|
|
679
|
+
console.log(`${colors.green}✓${colors.reset} Local workspace ready · ${root}`);
|
|
680
|
+
console.log(`${colors.dim}Tasks, posts, and docs live there as plain markdown — versioned with your repo.\n` +
|
|
681
|
+
`Try: sfora task plan.md · sfora tasks · sfora (shell). Connect a team later with sfora login.${colors.reset}`);
|
|
682
|
+
return;
|
|
683
|
+
}
|
|
507
684
|
await runInit(args);
|
|
508
685
|
return;
|
|
509
686
|
}
|
|
@@ -514,10 +691,51 @@ async function main() {
|
|
|
514
691
|
printMcpConfig(settings);
|
|
515
692
|
return;
|
|
516
693
|
}
|
|
694
|
+
if (args.command === "contexts") {
|
|
695
|
+
printContexts(cfg, settings);
|
|
696
|
+
return;
|
|
697
|
+
}
|
|
517
698
|
if (args.command === "login") {
|
|
518
699
|
await runLogin(args, baseUrl);
|
|
519
700
|
return;
|
|
520
701
|
}
|
|
702
|
+
// Local mode — a .sfora/ workspace in (an ancestor of) cwd wins, unless the
|
|
703
|
+
// user explicitly targets the cloud (--cloud / --url / --org / --key / --bot).
|
|
704
|
+
const cloudIntent = args.cloud || !!args.url || !!args.org || !!args.key || !!args.bot;
|
|
705
|
+
const localRoot = cloudIntent
|
|
706
|
+
? undefined
|
|
707
|
+
: await findWorkspace(process.cwd());
|
|
708
|
+
if (args.local && !localRoot) {
|
|
709
|
+
process.stderr.write(`${colors.red}error:${colors.reset} no .sfora/ workspace found — run \`sfora init --local\` first.\n`);
|
|
710
|
+
process.exitCode = 1;
|
|
711
|
+
return;
|
|
712
|
+
}
|
|
713
|
+
if (localRoot) {
|
|
714
|
+
if (args.command && VERBS.has(args.command)) {
|
|
715
|
+
try {
|
|
716
|
+
await runLocalVerb(args, localRoot);
|
|
717
|
+
}
|
|
718
|
+
catch (e) {
|
|
719
|
+
process.stderr.write(`${colors.red}error:${colors.reset} ${e instanceof Error ? e.message : String(e)}\n`);
|
|
720
|
+
process.exitCode = 1;
|
|
721
|
+
}
|
|
722
|
+
return;
|
|
723
|
+
}
|
|
724
|
+
if (args.mcp) {
|
|
725
|
+
await runMcpServer({
|
|
726
|
+
baseUrl,
|
|
727
|
+
apiKey: apiKey ?? "",
|
|
728
|
+
org: settings.org ?? "",
|
|
729
|
+
localRoot,
|
|
730
|
+
});
|
|
731
|
+
return;
|
|
732
|
+
}
|
|
733
|
+
const { bash, fs } = createLocalShell(localRoot, args.cwd);
|
|
734
|
+
console.log(`${colors.cyan}${colors.bold}sfora${colors.reset} ${colors.dim}— local workspace · ${localRoot}${colors.reset}`);
|
|
735
|
+
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`);
|
|
736
|
+
await runShell(bash, fs, LOCAL_SHELL_HELP, args.cwd);
|
|
737
|
+
return;
|
|
738
|
+
}
|
|
521
739
|
if (!apiKey) {
|
|
522
740
|
process.stderr.write(args.bot
|
|
523
741
|
? `${colors.red}error:${colors.reset} no key for bot '${args.bot}'. Run \`sfora login --bot ${args.bot}\`.\n`
|
|
@@ -532,13 +750,21 @@ async function main() {
|
|
|
532
750
|
baseUrl,
|
|
533
751
|
apiKey,
|
|
534
752
|
org: settings.org ?? "",
|
|
753
|
+
actAs: args.as,
|
|
535
754
|
});
|
|
536
|
-
const client = new SforaApiClient({ baseUrl, apiKey });
|
|
755
|
+
const client = new SforaApiClient({ baseUrl, apiKey, actAs: args.as });
|
|
537
756
|
try {
|
|
538
757
|
await runVerb(args, fs, client);
|
|
539
758
|
}
|
|
540
759
|
catch (e) {
|
|
541
|
-
|
|
760
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
761
|
+
// A 401 surfaces as EACCES once SforaFs maps it to an errno — translate it
|
|
762
|
+
// back into an actionable hint instead of a cryptic permission error.
|
|
763
|
+
const authExpired = e?.status === 401 ||
|
|
764
|
+
/EACCES|permission denied|invalid api key|unauthorized/i.test(msg);
|
|
765
|
+
process.stderr.write(`${colors.red}error:${colors.reset} ${authExpired
|
|
766
|
+
? `your key is invalid or expired — run \`sfora login\`${args.bot ? ` --bot ${args.bot}` : ""}`
|
|
767
|
+
: msg}\n`);
|
|
542
768
|
process.exitCode = 1;
|
|
543
769
|
}
|
|
544
770
|
return;
|
|
@@ -555,14 +781,10 @@ async function main() {
|
|
|
555
781
|
}
|
|
556
782
|
const org = settings.org;
|
|
557
783
|
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
784
|
// Pre-flight: confirm auth + connectivity and greet with the resolved identity.
|
|
563
785
|
let identity = "";
|
|
564
786
|
try {
|
|
565
|
-
identity = (await bash.exec("cat /me/api-key", { cwd
|
|
787
|
+
identity = (await bash.exec("cat /me/api-key", { cwd: args.cwd })).stdout.trim();
|
|
566
788
|
}
|
|
567
789
|
catch {
|
|
568
790
|
identity = "";
|
|
@@ -579,6 +801,13 @@ async function main() {
|
|
|
579
801
|
console.log(`${colors.yellow}warning:${colors.reset} could not reach ${baseUrl} or authenticate — commands may fail.`);
|
|
580
802
|
}
|
|
581
803
|
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);
|
|
805
|
+
}
|
|
806
|
+
async function runShell(bash, fs, helpText, initialCwd) {
|
|
807
|
+
// Shell state threaded across exec() calls — just-bash does not persist cwd/env
|
|
808
|
+
// between separate exec()s, so we carry them forward ourselves.
|
|
809
|
+
let cwd = initialCwd;
|
|
810
|
+
let env;
|
|
582
811
|
// Iterate stdin as an async iterable. This pattern serializes lines even
|
|
583
812
|
// when stdin is piped (buffered) — the `for await` body completes before the
|
|
584
813
|
// next line is consumed, and EOF naturally falls through to process exit
|
|
@@ -643,7 +872,7 @@ async function main() {
|
|
|
643
872
|
break;
|
|
644
873
|
// Show sfora's own help instead of just-bash's builtin help banner.
|
|
645
874
|
if (line === "help" || line === "?" || line.startsWith("help ")) {
|
|
646
|
-
process.stdout.write(
|
|
875
|
+
process.stdout.write(helpText);
|
|
647
876
|
if (isTty)
|
|
648
877
|
process.stdout.write(promptText());
|
|
649
878
|
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
|
+
});
|