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/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 login Authorize as you (saves your key)
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
- Run as a bot: add --bot <name> to any command (default identity is you).
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 path = await writeConfig({ url, apiKey: key, org: org || cfg.org });
177
- console.log(`${colors.green}✓${colors.reset} Saved ${path}`);
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
- const next = { ...cfg, url: base };
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.bots = {
244
- ...cfg.bots,
245
- [args.bot]: { apiKey: data.apiKey, org: data.orgSlug ?? undefined },
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
- next.apiKey = data.apiKey;
250
- next.org = data.orgSlug ?? cfg.org;
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
- process.stderr.write(`${colors.red}error:${colors.reset} ${e instanceof Error ? e.message : String(e)}\n`);
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, env })).stdout.trim();
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(SHELL_HELP);
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
- const url = pick(flags.url, process.env.SFORA_URL, cfg.url) ?? DEFAULT_URL;
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, cfg.apiKey),
50
- org: pick(flags.org, process.env.SFORA_ORG, cfg.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
+ });