@productbrain/cli 0.1.0-beta.4809 → 0.1.0-beta.4818

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/dist/__tests__/config-prod-fallthrough.test.js +23 -12
  2. package/dist/__tests__/config-prod-fallthrough.test.js.map +1 -1
  3. package/dist/__tests__/config.test.js +343 -11
  4. package/dist/__tests__/config.test.js.map +1 -1
  5. package/dist/__tests__/init.test.js +51 -14
  6. package/dist/__tests__/init.test.js.map +1 -1
  7. package/dist/__tests__/state.test.js +21 -1
  8. package/dist/__tests__/state.test.js.map +1 -1
  9. package/dist/commands/__tests__/setup-state.test.js +1 -1
  10. package/dist/commands/__tests__/setup-state.test.js.map +1 -1
  11. package/dist/commands/doctor.d.ts.map +1 -1
  12. package/dist/commands/doctor.js +101 -35
  13. package/dist/commands/doctor.js.map +1 -1
  14. package/dist/commands/doctor.test.js +182 -6
  15. package/dist/commands/doctor.test.js.map +1 -1
  16. package/dist/commands/init.d.ts +1 -1
  17. package/dist/commands/init.d.ts.map +1 -1
  18. package/dist/commands/init.js +20 -9
  19. package/dist/commands/init.js.map +1 -1
  20. package/dist/commands/status.d.ts +7 -0
  21. package/dist/commands/status.d.ts.map +1 -0
  22. package/dist/commands/status.js +102 -0
  23. package/dist/commands/status.js.map +1 -0
  24. package/dist/commands/status.test.d.ts +2 -0
  25. package/dist/commands/status.test.d.ts.map +1 -0
  26. package/dist/commands/status.test.js +75 -0
  27. package/dist/commands/status.test.js.map +1 -0
  28. package/dist/commands/welcome.d.ts.map +1 -1
  29. package/dist/commands/welcome.js +5 -2
  30. package/dist/commands/welcome.js.map +1 -1
  31. package/dist/index.js +3 -112
  32. package/dist/index.js.map +1 -1
  33. package/dist/lib/config.d.ts +9 -22
  34. package/dist/lib/config.d.ts.map +1 -1
  35. package/dist/lib/config.js +37 -99
  36. package/dist/lib/config.js.map +1 -1
  37. package/dist/lib/configResult.d.ts +80 -0
  38. package/dist/lib/configResult.d.ts.map +1 -0
  39. package/dist/lib/configResult.js +176 -0
  40. package/dist/lib/configResult.js.map +1 -0
  41. package/dist/lib/localBinding.d.ts +51 -0
  42. package/dist/lib/localBinding.d.ts.map +1 -0
  43. package/dist/lib/localBinding.js +90 -0
  44. package/dist/lib/localBinding.js.map +1 -0
  45. package/dist/lib/projectConfig.d.ts +6 -0
  46. package/dist/lib/projectConfig.d.ts.map +1 -1
  47. package/dist/lib/projectConfig.js +14 -2
  48. package/dist/lib/projectConfig.js.map +1 -1
  49. package/dist/lib/state.d.ts +7 -0
  50. package/dist/lib/state.d.ts.map +1 -1
  51. package/dist/lib/state.js +7 -2
  52. package/dist/lib/state.js.map +1 -1
  53. package/package.json +1 -1
@@ -1,9 +1,12 @@
1
1
  /**
2
2
  * TEN-2382 (CLI per-site decision): the published CLI's prod default is intended,
3
- * so prod fall-through is NOT warned in general. The ONE exception is the
4
- * preview-scrub misroute: a preview binding whose apiKey is present but whose
5
- * siteUrl was scrubbed gets its key dropped (STD-261 atomic guard at config.ts:385)
6
- * and silently resolves to the production gateway. That single case warns.
3
+ * so prod fall-through is NOT warned in general. The ONE exception is a CLAIMED
4
+ * binding that cannot be completed config.local.json carrying the `preview`
5
+ * marker OR an apiKey of its own. Such a file has declared which workspace this
6
+ * repo talks to, so it must resolve to its own atomic apiKey+siteUrl pair and
7
+ * refuses before any profile pin or production fallback rather than borrowing
8
+ * another gateway (SF-3 / STD-261). The claim is the credential, not the marker:
9
+ * a scrubbed binding that kept its key still refuses even with no `preview: true`.
7
10
  */
8
11
  import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
9
12
  import { mkdirSync, writeFileSync, rmSync } from 'fs';
@@ -23,7 +26,7 @@ vi.mock('os', async (importOriginal) => {
23
26
  homedir: vi.fn(() => '/tmp/pb-test-fake-home-' + process.pid),
24
27
  };
25
28
  });
26
- describe('TEN-2382 CLI preview-scrub misroute warning', () => {
29
+ describe('TEN-2382 CLI preview-scrub misroute refusal', () => {
27
30
  let tempDir;
28
31
  const originalCwd = process.cwd;
29
32
  const savedEnv = {};
@@ -56,13 +59,11 @@ describe('TEN-2382 CLI preview-scrub misroute warning', () => {
56
59
  vi.restoreAllMocks();
57
60
  });
58
61
  const writeLocalConfig = (obj) => writeFileSync(resolve(tempDir, '.productbrain', 'config.local.json'), JSON.stringify(obj));
59
- it('warns when a preview key has no siteUrl and resolution falls to prod', () => {
60
- writeLocalConfig({ apiKey: 'pb_sk_preview_test' }); // no siteUrl — the scrub edge
62
+ it('refuses when a marked preview binding has no siteUrl', () => {
63
+ writeLocalConfig({ apiKey: 'pb_sk_preview_test', preview: true });
61
64
  const stderr = vi.spyOn(process.stderr, 'write').mockReturnValue(true);
62
- const cfg = resolveConfig();
63
- expect(cfg.source).toBe('default');
64
- expect(cfg.siteUrl).toBe(DEFAULT_SITE_URL.replace(/\/$/, ''));
65
- expect(stderr.mock.calls.some((c) => String(c[0]).includes(DEFAULT_SITE_URL))).toBe(true);
65
+ expect(() => resolveConfig()).toThrow(/incomplete preview binding.*missing deployment URL/i);
66
+ expect(stderr).not.toHaveBeenCalled();
66
67
  });
67
68
  it('does NOT warn for a plain unconfigured CLI (no local binding)', () => {
68
69
  const stderr = vi.spyOn(process.stderr, 'write').mockReturnValue(true);
@@ -70,10 +71,20 @@ describe('TEN-2382 CLI preview-scrub misroute warning', () => {
70
71
  expect(cfg.source).toBe('default');
71
72
  expect(stderr.mock.calls.some((c) => String(c[0]).includes(DEFAULT_SITE_URL))).toBe(false);
72
73
  });
73
- it('does NOT warn when CONVEX_SITE_URL redirects away from prod', () => {
74
+ it('refuses a marker-less key rather than borrowing CONVEX_SITE_URL from the shell', () => {
75
+ // Same shape as the marked case above minus the marker — a scrub that cleared `preview`
76
+ // and `siteUrl` but kept the key. An exported CONVEX_SITE_URL is another gateway, not
77
+ // this binding's own, so it must not complete the pair.
74
78
  writeLocalConfig({ apiKey: 'pb_sk_preview_test' });
75
79
  process.env.CONVEX_SITE_URL = 'https://my-preview.convex.site';
76
80
  const stderr = vi.spyOn(process.stderr, 'write').mockReturnValue(true);
81
+ expect(() => resolveConfig()).toThrow(/incomplete preview binding.*missing deployment URL/i);
82
+ expect(stderr).not.toHaveBeenCalled();
83
+ });
84
+ it('does NOT warn when CONVEX_SITE_URL redirects away from prod and no local key claims a binding', () => {
85
+ // The file's original point: an ordinary redirect is not a misroute, so nothing is warned.
86
+ process.env.CONVEX_SITE_URL = 'https://my-preview.convex.site';
87
+ const stderr = vi.spyOn(process.stderr, 'write').mockReturnValue(true);
77
88
  const cfg = resolveConfig();
78
89
  expect(cfg.siteUrl).toBe('https://my-preview.convex.site');
79
90
  expect(stderr.mock.calls.some((c) => String(c[0]).includes(DEFAULT_SITE_URL))).toBe(false);
@@ -1 +1 @@
1
- {"version":3,"file":"config-prod-fallthrough.test.js","sourceRoot":"","sources":["../../src/__tests__/config-prod-fallthrough.test.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"config-prod-fallthrough.test.js","sourceRoot":"","sources":["../../src/__tests__/config-prod-fallthrough.test.ts"],"names":[],"mappings":"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"}
@@ -6,7 +6,7 @@
6
6
  */
7
7
  import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
8
8
  import { mkdirSync, writeFileSync, readFileSync, rmSync } from 'fs';
9
- import { resolve } from 'path';
9
+ import { dirname, resolve } from 'path';
10
10
  import { tmpdir } from 'os';
11
11
  import { randomUUID } from 'crypto';
12
12
  // Mock the profiles module to prevent real profile config from leaking in
@@ -318,6 +318,208 @@ describe('config resolution', () => {
318
318
  expect(result.reason).toContain('No API key');
319
319
  }
320
320
  });
321
+ it('names the FILE, not CONVEX_SITE_URL, when a file supplied the non-HTTPS URL', () => {
322
+ // A file-sourced siteUrl outranks CONVEX_SITE_URL everywhere except an env-key override, so
323
+ // "set CONVEX_SITE_URL" would send the operator back through the same failure.
324
+ process.env.CONVEX_SITE_URL = 'https://this-would-never-win.example.com';
325
+ const pbDir = resolve(tempDir, '.productbrain');
326
+ mkdirSync(pbDir, { recursive: true });
327
+ writeFileSync(resolve(pbDir, 'config.local.json'), JSON.stringify({
328
+ apiKey: 'pb_sk_preview_key_0123456789abcdef',
329
+ siteUrl: 'http://insecure.example.com',
330
+ preview: true,
331
+ }));
332
+ resetConfigCache();
333
+ const result = getConfigSafe();
334
+ expect(result.ok).toBe(false);
335
+ if (!result.ok) {
336
+ expect(result.kind).toBe('invalid-url');
337
+ expect(result.reason).toMatch(/must use HTTPS/i);
338
+ // The binding supplies its own URL, so the repair is that file alone — naming a profile
339
+ // it may also pin would point at something that does not control this URL.
340
+ expect(result.guidance).toContain('.productbrain/config.local.json');
341
+ expect(result.guidance).not.toMatch(/profile/i);
342
+ expect(result.guidance).not.toMatch(/CONVEX_SITE_URL/);
343
+ }
344
+ });
345
+ it('names CONVEX_SITE_URL when the environment did supply the non-HTTPS URL', () => {
346
+ process.env.PRODUCTBRAIN_API_KEY = 'pb_sk_env_key';
347
+ process.env.CONVEX_SITE_URL = 'http://insecure.example.com';
348
+ resetConfigCache();
349
+ const result = getConfigSafe();
350
+ expect(result.ok).toBe(false);
351
+ if (!result.ok) {
352
+ expect(result.kind).toBe('invalid-url');
353
+ expect(result.guidance).toContain('CONVEX_SITE_URL');
354
+ }
355
+ });
356
+ // ── The URL's origin is NOT the credential's origin ────────────────────────────────────
357
+ //
358
+ // Every credential branch below step 2a prefers `.productbrain/config.json`'s siteUrl over
359
+ // its own, so a result labelled `profile`/`cwd` can still be carrying config.json's URL.
360
+ // Naming the credential's source would send the operator to edit a file that does not
361
+ // control the failing URL — the same failure, one detour later.
362
+ it('names config.json — not the profile — when config.json supplied the non-HTTPS URL', async () => {
363
+ const { resolveProfileConfig } = await import('../lib/profiles.js');
364
+ resolveProfileConfig.mockReturnValue({
365
+ apiKey: 'pb_sk_global_profile_key',
366
+ siteUrl: 'https://profile-would-have-been-fine.example.com',
367
+ });
368
+ const pbDir = resolve(tempDir, '.productbrain');
369
+ mkdirSync(pbDir, { recursive: true });
370
+ writeFileSync(resolve(pbDir, 'config.json'), JSON.stringify({ siteUrl: 'http://insecure.example.com' }));
371
+ resetConfigCache();
372
+ const result = getConfigSafe();
373
+ expect(result.ok).toBe(false);
374
+ if (!result.ok) {
375
+ expect(result.kind).toBe('invalid-url');
376
+ expect(result.guidance).toContain('.productbrain/config.json');
377
+ expect(result.guidance).not.toMatch(/profile/i);
378
+ expect(result.reason).toContain('.productbrain/config.json');
379
+ }
380
+ });
381
+ it('names config.json — not the .env file — when a CWD .env supplied the credential', () => {
382
+ const pbDir = resolve(tempDir, '.productbrain');
383
+ mkdirSync(pbDir, { recursive: true });
384
+ writeFileSync(resolve(pbDir, 'config.json'), JSON.stringify({ siteUrl: 'http://insecure.example.com' }));
385
+ writeFileSync(resolve(tempDir, '.env'), 'PRODUCTBRAIN_API_KEY=pb_sk_cwd_key\nCONVEX_SITE_URL=https://cwd-would-have-been-fine.example.com\n');
386
+ resetConfigCache();
387
+ const result = getConfigSafe();
388
+ expect(result.ok).toBe(false);
389
+ if (!result.ok) {
390
+ expect(result.kind).toBe('invalid-url');
391
+ expect(result.guidance).toContain('.productbrain/config.json');
392
+ expect(result.guidance).not.toMatch(/\.env file/);
393
+ }
394
+ });
395
+ it('names the profile the repo pin points at, not just "the profile it pins"', async () => {
396
+ const { resolveNamedProfileConfig } = await import('../lib/profiles.js');
397
+ resolveNamedProfileConfig.mockReturnValue({
398
+ apiKey: 'pb_sk_pinned_key_0123456789',
399
+ siteUrl: 'http://insecure.example.com',
400
+ });
401
+ const pbDir = resolve(tempDir, '.productbrain');
402
+ mkdirSync(pbDir, { recursive: true });
403
+ writeFileSync(resolve(pbDir, 'config.local.json'), JSON.stringify({ profile: 'pinned-workspace' }));
404
+ resetConfigCache();
405
+ const result = getConfigSafe();
406
+ expect(result.ok).toBe(false);
407
+ if (!result.ok) {
408
+ expect(result.kind).toBe('invalid-url');
409
+ expect(result.guidance).toContain('pinned-workspace');
410
+ }
411
+ });
412
+ it('names the CWD env file that actually won, not the generic .env', () => {
413
+ // CWD_ENV_PATHS scans .env.mcp first, so a generic "the .env file" label would send the
414
+ // operator to edit a file the cascade never read.
415
+ writeFileSync(resolve(tempDir, '.env.mcp'), 'PRODUCTBRAIN_API_KEY=pb_sk_cwd_key\nCONVEX_SITE_URL=http://insecure.example.com\n');
416
+ resetConfigCache();
417
+ const result = getConfigSafe();
418
+ expect(result.ok).toBe(false);
419
+ if (!result.ok) {
420
+ expect(result.kind).toBe('invalid-url');
421
+ expect(result.guidance).toContain('.env.mcp');
422
+ }
423
+ });
424
+ it('tells the operator to unset PRODUCTBRAIN_API_KEY — not to run pb login — for a bad exported key', () => {
425
+ // An exported key wins step 1 of the cascade, and `pb login` writes to a profile or the
426
+ // legacy home file — both of which lose to it. "Run pb login" would loop forever.
427
+ process.env.PRODUCTBRAIN_API_KEY = 'bad_prefix_key';
428
+ resetConfigCache();
429
+ const result = getConfigSafe();
430
+ expect(result.ok).toBe(false);
431
+ if (!result.ok) {
432
+ expect(result.kind).toBe('invalid-key');
433
+ expect(result.guidance).toMatch(/Unset PRODUCTBRAIN_API_KEY|replace it/);
434
+ // `pb login` may only be named to explain why it cannot help — never as the instruction.
435
+ expect(result.guidance).not.toMatch(/Run `pb login`/);
436
+ expect(result.guidance).toMatch(/cannot repair/);
437
+ expect(result.nextCommand).not.toBe('pb login');
438
+ }
439
+ });
440
+ it('still points a stored malformed key at pb login — login CAN repair that one', async () => {
441
+ const { resolveProfileConfig } = await import('../lib/profiles.js');
442
+ resolveProfileConfig.mockReturnValue({
443
+ apiKey: 'bad_prefix_key',
444
+ siteUrl: 'https://profile.example.com',
445
+ });
446
+ resetConfigCache();
447
+ const result = getConfigSafe();
448
+ expect(result.ok).toBe(false);
449
+ if (!result.ok) {
450
+ expect(result.kind).toBe('invalid-key');
451
+ expect(result.guidance).toMatch(/pb login/);
452
+ expect(result.nextCommand).toBe('pb login');
453
+ }
454
+ });
455
+ // ── Which profile actually supplied it ──────────────────────────────────────────────────
456
+ //
457
+ // resolveProfileConfig() reaches a credential four ways — PB_PROFILE, the active-profile
458
+ // marker, a `default` fallback, and the legacy ~/.config/productbrain/.env — and reports the
459
+ // winner's name. A fixed "the active profile" label sends the operator to edit a profile that
460
+ // supplied nothing, so the same failure comes straight back.
461
+ it('names the PB_PROFILE-selected profile, not "the active profile", for its non-HTTPS URL', async () => {
462
+ const { resolveProfileConfig, getGlobalActiveProfile } = await import('../lib/profiles.js');
463
+ // A DIFFERENT profile is globally active. PB_PROFILE outranks it in profiles.ts, which
464
+ // returns the selected name alongside the credential (profiles.ts resolveProfileConfig 1).
465
+ getGlobalActiveProfile.mockReturnValue('main-workspace');
466
+ process.env.PB_PROFILE = 'preview-workspace';
467
+ resolveProfileConfig.mockReturnValue({
468
+ apiKey: 'pb_sk_selected_profile_key',
469
+ siteUrl: 'http://insecure.example.com',
470
+ profileName: 'preview-workspace',
471
+ });
472
+ resetConfigCache();
473
+ const result = getConfigSafe();
474
+ expect(result.ok).toBe(false);
475
+ if (!result.ok) {
476
+ expect(result.kind).toBe('invalid-url');
477
+ expect(result.guidance).toContain('the profile "preview-workspace"');
478
+ expect(result.guidance).not.toContain('the active profile');
479
+ // The globally active profile did not supply this URL and must not be named.
480
+ expect(result.guidance).not.toContain('main-workspace');
481
+ }
482
+ });
483
+ it('names the `default` fallback profile for a malformed stored key', async () => {
484
+ const { resolveProfileConfig } = await import('../lib/profiles.js');
485
+ // No PB_PROFILE and no active-profile marker: profiles.ts falls back to `default`, which is
486
+ // a real file the operator can open — "the active profile" is not.
487
+ resolveProfileConfig.mockReturnValue({
488
+ apiKey: 'bad_prefix_key',
489
+ siteUrl: 'https://profile.example.com',
490
+ profileName: 'default',
491
+ });
492
+ resetConfigCache();
493
+ const result = getConfigSafe();
494
+ expect(result.ok).toBe(false);
495
+ if (!result.ok) {
496
+ expect(result.kind).toBe('invalid-key');
497
+ expect(result.reason).toContain('the profile "default"');
498
+ expect(result.reason).not.toContain('the active profile');
499
+ }
500
+ });
501
+ it('does NOT call the legacy home config "the active profile" — it is not a named profile', async () => {
502
+ // resolveConfig() step 4 labels the legacy ~/.config/productbrain/.env `profile`, but it
503
+ // carries no profile name at all, so naming a profile there would be a fabrication.
504
+ const { HOME_ENV_PATH } = await import('../lib/config.js');
505
+ mkdirSync(dirname(HOME_ENV_PATH), { recursive: true });
506
+ writeFileSync(HOME_ENV_PATH, 'PRODUCTBRAIN_API_KEY=bad_prefix_key\n');
507
+ try {
508
+ resetConfigCache();
509
+ const result = getConfigSafe();
510
+ expect(result.ok).toBe(false);
511
+ if (!result.ok) {
512
+ expect(result.kind).toBe('invalid-key');
513
+ expect(result.reason).not.toContain('the active profile');
514
+ expect(result.reason).toContain('the stored profile configuration');
515
+ }
516
+ }
517
+ finally {
518
+ // The fake home is shared by every test in this file; a stray legacy .env would supply a
519
+ // credential to the cases that assert none is found.
520
+ rmSync(HOME_ENV_PATH, { force: true });
521
+ }
522
+ });
321
523
  it('returns { ok: true } with correct source when key exists in env', () => {
322
524
  process.env.PRODUCTBRAIN_API_KEY = 'pb_sk_test_key';
323
525
  process.env.CONVEX_SITE_URL = 'https://test.example.com';
@@ -542,7 +744,7 @@ describe('config resolution', () => {
542
744
  expect(result.apiKey).toBe('pb_sk_profile_key');
543
745
  expect(result.source).toBe('project');
544
746
  });
545
- it('rejects apiKey that does not start with API_KEY_PREFIX', () => {
747
+ it('rejects a preview binding whose apiKey does not start with API_KEY_PREFIX', () => {
546
748
  const pbDir = resolve(tempDir, '.productbrain');
547
749
  mkdirSync(pbDir, { recursive: true });
548
750
  writeFileSync(resolve(pbDir, 'config.local.json'), JSON.stringify({
@@ -551,10 +753,9 @@ describe('config resolution', () => {
551
753
  preview: true,
552
754
  }));
553
755
  resetConfigCache();
554
- const result = resolveConfig();
555
- // Should not pick up the invalid-prefix key
556
- expect(result.apiKey).toBeUndefined();
557
- expect(result.source).toBe('default');
756
+ // A present-but-malformed key is unusable, not absent — the refusal must say so, or the
757
+ // operator goes looking for a key that is sitting right there in the file.
758
+ expect(() => resolveConfig()).toThrow(/incomplete preview binding.*not a valid pb_sk_ key/i);
558
759
  });
559
760
  it('SF-3: does NOT bind a preview apiKey that has no siteUrl (never borrows config.json gateway)', () => {
560
761
  const pbDir = resolve(tempDir, '.productbrain');
@@ -564,12 +765,143 @@ describe('config resolution', () => {
564
765
  // Preview key present but its OWN siteUrl is absent (hand-edit / partial preview.json).
565
766
  writeFileSync(resolve(pbDir, 'config.local.json'), JSON.stringify({ apiKey: 'pb_sk_preview_key_0123456789abcdef', preview: true }));
566
767
  resetConfigCache();
768
+ // The preview key must NOT be bound or allowed to fall through. Pairing it with
769
+ // config.json's real-dev gateway would send a preview credential to the wrong deployment.
770
+ expect(() => resolveConfig()).toThrow(/incomplete preview binding.*missing deployment URL/i);
771
+ });
772
+ it('S5: an incomplete preview binding is diagnosed before a sibling profile pin', async () => {
773
+ const { resolveNamedProfileConfig } = await import('../lib/profiles.js');
774
+ resolveNamedProfileConfig.mockReturnValue(null);
775
+ const pbDir = resolve(tempDir, '.productbrain');
776
+ mkdirSync(pbDir, { recursive: true });
777
+ writeFileSync(resolve(pbDir, 'config.local.json'), JSON.stringify({
778
+ profile: 'repo-pin',
779
+ apiKey: 'pb_sk_preview_key_0123456789abcdef',
780
+ preview: true,
781
+ workspaceName: 'Preview Workspace',
782
+ }));
783
+ resetConfigCache();
784
+ expect(() => resolveConfig()).toThrow(/incomplete preview binding.*missing deployment URL/i);
785
+ expect(() => getConfig()).toThrow(/incomplete preview binding.*missing deployment URL/i);
786
+ });
787
+ // ── Marker-less bindings: the claim is the credential, not the `preview` stamp ──────────
788
+ //
789
+ // A config.local.json carrying an apiKey is asserting which workspace this repo talks to
790
+ // whether or not `pb:preview` stamped `preview: true` — a hand-written or partially-scrubbed
791
+ // file is exactly the case that must not be skipped.
792
+ it('binds a marker-less apiKey + siteUrl pair and does NOT defer to a sibling profile pin', async () => {
793
+ const { resolveNamedProfileConfig } = await import('../lib/profiles.js');
794
+ // A perfectly resolvable pin sits in the same file — it names a DIFFERENT tenant, so
795
+ // adopting it would silently retarget every read and write.
796
+ resolveNamedProfileConfig.mockReturnValue({
797
+ apiKey: 'pb_sk_sibling_pin_key',
798
+ siteUrl: 'https://sibling-pin.example.com',
799
+ });
800
+ const pbDir = resolve(tempDir, '.productbrain');
801
+ mkdirSync(pbDir, { recursive: true });
802
+ writeFileSync(resolve(pbDir, 'config.local.json'), JSON.stringify({
803
+ profile: 'sibling-pin',
804
+ apiKey: 'pb_sk_markerless_key_0123456789',
805
+ siteUrl: 'https://markerless.convex.site',
806
+ // no `preview: true` marker
807
+ }));
808
+ resetConfigCache();
567
809
  const result = resolveConfig();
568
- // The preview key must NOT be bound — pairing it with config.json's real-dev gateway would
569
- // send a preview credential to a non-preview deployment (STD-261). Branch is skipped entirely.
570
- expect(result.apiKey).not.toBe('pb_sk_preview_key_0123456789abcdef');
571
- expect(result.apiKey).toBeUndefined();
572
- expect(result.source).toBe('default');
810
+ expect(result.apiKey).toBe('pb_sk_markerless_key_0123456789');
811
+ expect(result.siteUrl).toBe('https://markerless.convex.site');
812
+ expect(result.source).toBe('project');
813
+ });
814
+ it('refuses a marker-less apiKey with no siteUrl instead of resolving to the default gateway', () => {
815
+ const pbDir = resolve(tempDir, '.productbrain');
816
+ mkdirSync(pbDir, { recursive: true });
817
+ writeFileSync(resolve(pbDir, 'config.local.json'), JSON.stringify({ apiKey: 'pb_sk_markerless_key_0123456789' }));
818
+ resetConfigCache();
819
+ // Silently falling through would send this credential to the production gateway (STD-261).
820
+ expect(() => resolveConfig()).toThrow(/incomplete preview binding.*missing deployment URL/i);
821
+ });
822
+ it('S5: the marker-less refusal also holds through the loadEnv() cascade', () => {
823
+ const pbDir = resolve(tempDir, '.productbrain');
824
+ mkdirSync(pbDir, { recursive: true });
825
+ writeFileSync(resolve(pbDir, 'config.local.json'), JSON.stringify({ apiKey: 'pb_sk_markerless_key_0123456789' }));
826
+ resetConfigCache();
827
+ expect(() => getConfig()).toThrow(/incomplete preview binding.*missing deployment URL/i);
828
+ // The refusal must not leave a half-bound credential behind for the next caller.
829
+ expect(process.env.PRODUCTBRAIN_API_KEY).toBeUndefined();
830
+ });
831
+ // A present-but-non-string apiKey is a corrupt or hand-written binding, not an absent one.
832
+ // Treating it as absent would let the sibling profile pin supply a DIFFERENT tenant's
833
+ // credential — the workspace substitution this path exists to prevent.
834
+ it.each([
835
+ ['a number', 12345],
836
+ ['an empty string', ''],
837
+ ['a boolean', true],
838
+ ['an object', { key: 'pb_sk_nested' }],
839
+ ])('refuses a marker-less binding whose apiKey is %s, rather than adopting the sibling pin', async (_label, apiKey) => {
840
+ const { resolveNamedProfileConfig } = await import('../lib/profiles.js');
841
+ // A perfectly resolvable pin naming a DIFFERENT tenant sits in the same file.
842
+ resolveNamedProfileConfig.mockReturnValue({
843
+ apiKey: 'pb_sk_sibling_pin_key',
844
+ siteUrl: 'https://sibling-pin.example.com',
845
+ });
846
+ const pbDir = resolve(tempDir, '.productbrain');
847
+ mkdirSync(pbDir, { recursive: true });
848
+ writeFileSync(resolve(pbDir, 'config.local.json'), JSON.stringify({ profile: 'sibling-pin', apiKey, siteUrl: 'https://markerless.convex.site' }));
849
+ resetConfigCache();
850
+ expect(() => resolveConfig()).toThrow(/not a valid pb_sk_ key/i);
851
+ });
852
+ it('treats an explicitly null apiKey as absent — JSON null means "no value"', async () => {
853
+ const { resolveNamedProfileConfig } = await import('../lib/profiles.js');
854
+ resolveNamedProfileConfig.mockReturnValue({
855
+ apiKey: 'pb_sk_pin_key',
856
+ siteUrl: 'https://pinned.example.com',
857
+ });
858
+ const pbDir = resolve(tempDir, '.productbrain');
859
+ mkdirSync(pbDir, { recursive: true });
860
+ writeFileSync(resolve(pbDir, 'config.local.json'), JSON.stringify({ profile: 'pinned-workspace', apiKey: null }));
861
+ resetConfigCache();
862
+ const result = resolveConfig();
863
+ expect(result.apiKey).toBe('pb_sk_pin_key');
864
+ expect(result.source).toBe('project');
865
+ });
866
+ it('leaves the ordinary pin-only path untouched — the widened claim test must not swallow it', async () => {
867
+ const { resolveNamedProfileConfig } = await import('../lib/profiles.js');
868
+ resolveNamedProfileConfig.mockReturnValue({
869
+ apiKey: 'pb_sk_pin_only_key',
870
+ });
871
+ const pbDir = resolve(tempDir, '.productbrain');
872
+ mkdirSync(pbDir, { recursive: true });
873
+ // Committed gateway + pin with no siteUrl of its own: the pin path's normal shape.
874
+ writeFileSync(resolve(pbDir, 'config.json'), JSON.stringify({ siteUrl: 'https://project-gateway.example.com' }));
875
+ writeFileSync(resolve(pbDir, 'config.local.json'), JSON.stringify({ profile: 'pin-only-workspace' }));
876
+ resetConfigCache();
877
+ const result = resolveConfig();
878
+ expect(result.apiKey).toBe('pb_sk_pin_only_key');
879
+ expect(result.siteUrl).toBe('https://project-gateway.example.com');
880
+ expect(result.source).toBe('project');
881
+ });
882
+ });
883
+ describe('readLocalProjectConfig() — apiKey validity', () => {
884
+ it('withholds a malformed apiKey and flags it so diagnostics can name the fault', async () => {
885
+ const pbDir = resolve(tempDir, '.productbrain');
886
+ mkdirSync(pbDir, { recursive: true });
887
+ writeFileSync(resolve(pbDir, 'config.local.json'), JSON.stringify({ apiKey: 'sk_bad_prefix_key', siteUrl: 'https://my-preview.convex.site' }));
888
+ const { readLocalProjectConfig } = await import('../lib/config.js');
889
+ const local = readLocalProjectConfig();
890
+ // Withheld from `apiKey` so it can never bind, but not reported as absent either.
891
+ expect(local?.apiKey).toBeUndefined();
892
+ expect(local?.apiKeyMalformed).toBe(true);
893
+ });
894
+ it('loads a valid apiKey with no malformed flag', async () => {
895
+ const pbDir = resolve(tempDir, '.productbrain');
896
+ mkdirSync(pbDir, { recursive: true });
897
+ writeFileSync(resolve(pbDir, 'config.local.json'), JSON.stringify({
898
+ apiKey: 'pb_sk_preview_key_0123456789abcdef',
899
+ siteUrl: 'https://my-preview.convex.site',
900
+ }));
901
+ const { readLocalProjectConfig } = await import('../lib/config.js');
902
+ const local = readLocalProjectConfig();
903
+ expect(local?.apiKey).toBe('pb_sk_preview_key_0123456789abcdef');
904
+ expect(local?.apiKeyMalformed).toBeUndefined();
573
905
  });
574
906
  });
575
907
  describe('getPreviewBinding()', () => {