@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.
- package/dist/__tests__/config-prod-fallthrough.test.js +23 -12
- package/dist/__tests__/config-prod-fallthrough.test.js.map +1 -1
- package/dist/__tests__/config.test.js +343 -11
- package/dist/__tests__/config.test.js.map +1 -1
- package/dist/__tests__/init.test.js +51 -14
- package/dist/__tests__/init.test.js.map +1 -1
- package/dist/__tests__/state.test.js +21 -1
- package/dist/__tests__/state.test.js.map +1 -1
- package/dist/commands/__tests__/setup-state.test.js +1 -1
- package/dist/commands/__tests__/setup-state.test.js.map +1 -1
- package/dist/commands/doctor.d.ts.map +1 -1
- package/dist/commands/doctor.js +101 -35
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/doctor.test.js +182 -6
- package/dist/commands/doctor.test.js.map +1 -1
- package/dist/commands/init.d.ts +1 -1
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +20 -9
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/status.d.ts +7 -0
- package/dist/commands/status.d.ts.map +1 -0
- package/dist/commands/status.js +102 -0
- package/dist/commands/status.js.map +1 -0
- package/dist/commands/status.test.d.ts +2 -0
- package/dist/commands/status.test.d.ts.map +1 -0
- package/dist/commands/status.test.js +75 -0
- package/dist/commands/status.test.js.map +1 -0
- package/dist/commands/welcome.d.ts.map +1 -1
- package/dist/commands/welcome.js +5 -2
- package/dist/commands/welcome.js.map +1 -1
- package/dist/index.js +3 -112
- package/dist/index.js.map +1 -1
- package/dist/lib/config.d.ts +9 -22
- package/dist/lib/config.d.ts.map +1 -1
- package/dist/lib/config.js +37 -99
- package/dist/lib/config.js.map +1 -1
- package/dist/lib/configResult.d.ts +80 -0
- package/dist/lib/configResult.d.ts.map +1 -0
- package/dist/lib/configResult.js +176 -0
- package/dist/lib/configResult.js.map +1 -0
- package/dist/lib/localBinding.d.ts +51 -0
- package/dist/lib/localBinding.d.ts.map +1 -0
- package/dist/lib/localBinding.js +90 -0
- package/dist/lib/localBinding.js.map +1 -0
- package/dist/lib/projectConfig.d.ts +6 -0
- package/dist/lib/projectConfig.d.ts.map +1 -1
- package/dist/lib/projectConfig.js +14 -2
- package/dist/lib/projectConfig.js.map +1 -1
- package/dist/lib/state.d.ts +7 -0
- package/dist/lib/state.d.ts.map +1 -1
- package/dist/lib/state.js +7 -2
- package/dist/lib/state.js.map +1 -1
- 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
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
|
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('
|
|
60
|
-
writeLocalConfig({ apiKey: 'pb_sk_preview_test' });
|
|
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
|
-
|
|
63
|
-
expect(
|
|
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('
|
|
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":"AAAA
|
|
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
|
|
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
|
-
|
|
555
|
-
//
|
|
556
|
-
expect(
|
|
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
|
-
|
|
569
|
-
|
|
570
|
-
expect(result.
|
|
571
|
-
|
|
572
|
-
|
|
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()', () => {
|