@prodcycle/prodcycle 0.6.6 → 0.6.8

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.
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.ComplianceApiClient = exports.ApiError = void 0;
4
4
  exports.chunkFiles = chunkFiles;
5
+ const config_1 = require("./utils/config");
5
6
  /**
6
7
  * Error thrown for any non-2xx response. Carries the parsed body + status so
7
8
  * callers can branch on `details.suggestedEndpoint` (413 → chunked-session
@@ -98,8 +99,12 @@ class ComplianceApiClient {
98
99
  apiUrl;
99
100
  apiKey;
100
101
  constructor(apiUrl, apiKey) {
101
- this.apiUrl = apiUrl || process.env.PC_API_URL || DEFAULT_API_URL;
102
- this.apiKey = apiKey || process.env.PC_API_KEY || '';
102
+ // Resolution order (for both): explicit arg → env var → user config
103
+ // file default. See `utils/config.ts`. Read the config file once and
104
+ // resolve both values from it rather than re-reading per value.
105
+ const config = (0, config_1.readConfig)();
106
+ this.apiUrl = (0, config_1.resolveApiUrl)(apiUrl, config) || DEFAULT_API_URL;
107
+ this.apiKey = (0, config_1.resolveApiKey)(apiKey, config);
103
108
  if (!this.apiKey &&
104
109
  process.env.NODE_ENV !== 'test' &&
105
110
  !process.env.PC_SUPPRESS_WARNINGS) {
package/dist/cli.d.ts CHANGED
@@ -50,3 +50,42 @@ export declare function resolveHookFileKey(inputPath: string, realpathFile: stri
50
50
  ok: false;
51
51
  error: string;
52
52
  };
53
+ /**
54
+ * True when a parsed `hook` stdin payload is a Claude Code hook event.
55
+ *
56
+ * Claude Code stamps every hook payload with a `hook_event_name` field
57
+ * (e.g. `"PostToolUse"`); no other supported input shape carries it. We
58
+ * key on its presence to switch `hook` into Claude-Code-native JSON
59
+ * output — see `formatClaudeHookOutput`. Pure so it's unit-testable.
60
+ */
61
+ export declare function isClaudeCodeHookPayload(payload: unknown): boolean;
62
+ /**
63
+ * Render a `hook` scan result as Claude Code PostToolUse hook JSON.
64
+ *
65
+ * Returns `''` when the scan passed — a clean result needs no agent
66
+ * feedback, so the hook stays silent. On violations it returns
67
+ * `{"decision":"block","reason":...}`: Claude Code feeds `reason` back to
68
+ * the agent as actionable feedback, which is what closes the compliance
69
+ * self-healing loop.
70
+ *
71
+ * The caller MUST exit 0 for Claude Code to parse this — Claude Code
72
+ * ignores hook JSON on any non-zero exit. Pure so it's unit-testable.
73
+ */
74
+ export declare function formatClaudeHookOutput(response: {
75
+ exitCode: number;
76
+ passed?: boolean;
77
+ prompt?: string;
78
+ findings?: unknown[];
79
+ }): string;
80
+ /**
81
+ * Claude Code hook JSON for the "API key not usable" state.
82
+ *
83
+ * A missing or rejected key is a setup problem, not a compliance failure —
84
+ * so instead of blocking every edit with an error, we emit a `systemMessage`
85
+ * (shown to the user, never fed to the agent, never blocking) telling them
86
+ * how to fix it. `reason` distinguishes a key that is absent (`'missing'`)
87
+ * from one the API rejected (`'rejected'` — expired/revoked/wrong) so the
88
+ * message points at the right fix. The caller exits 0. Pure so it's
89
+ * unit-testable.
90
+ */
91
+ export declare function formatClaudeHookSetupNotice(reason?: 'missing' | 'rejected'): string;
package/dist/cli.js CHANGED
@@ -36,6 +36,9 @@ var __importStar = (this && this.__importStar) || (function () {
36
36
  Object.defineProperty(exports, "__esModule", { value: true });
37
37
  exports.isCiEnvironment = isCiEnvironment;
38
38
  exports.resolveHookFileKey = resolveHookFileKey;
39
+ exports.isClaudeCodeHookPayload = isClaudeCodeHookPayload;
40
+ exports.formatClaudeHookOutput = formatClaudeHookOutput;
41
+ exports.formatClaudeHookSetupNotice = formatClaudeHookSetupNotice;
39
42
  const commander_1 = require("commander");
40
43
  const child_process_1 = require("child_process");
41
44
  const fs = __importStar(require("fs"));
@@ -44,6 +47,7 @@ const index_1 = require("./index");
44
47
  const table_1 = require("./formatters/table");
45
48
  const sarif_1 = require("./formatters/sarif");
46
49
  const prompt_1 = require("./formatters/prompt");
50
+ const config_1 = require("./utils/config");
47
51
  const KNOWN_COMMANDS = new Set([
48
52
  'scan',
49
53
  'scans',
@@ -340,25 +344,63 @@ program
340
344
  .option('--api-url <url>', 'Compliance API base URL (or PC_API_URL env)')
341
345
  .option('--api-key <key>', 'API key for compliance API (or PC_API_KEY env)')
342
346
  .action(async (opts) => {
347
+ // Hoisted so the catch block can branch on it (see the auth-error case).
348
+ let claudeHook = false;
343
349
  try {
344
350
  const frameworks = parseList(opts.framework) ?? ['soc2', 'hipaa', 'nist-csf'];
345
351
  const format = (opts.format ?? 'prompt');
346
- const files = await collectHookFiles(opts.file);
347
- if (!files || Object.keys(files).length === 0) {
352
+ const collected = await collectHookFiles(opts.file);
353
+ if (!collected || Object.keys(collected.files).length === 0) {
348
354
  // No files to check — exit clean so the agent proceeds.
349
355
  process.exit(0);
350
356
  return;
351
357
  }
358
+ claudeHook = collected.claudeHook;
359
+ // Graceful unconfigured state: a missing API key is a setup problem,
360
+ // not a compliance failure. For a Claude Code hook, surface a one-line
361
+ // setup notice and exit 0 so the agent is not blocked on every edit.
362
+ // Non-Claude callers (CI) fall through and hard-fail as before.
363
+ if (claudeHook && !(0, config_1.resolveApiKey)(opts.apiKey)) {
364
+ process.stdout.write(formatClaudeHookSetupNotice('missing') + '\n');
365
+ process.exit(0);
366
+ return;
367
+ }
352
368
  const response = await (0, index_1.gate)({
353
- files,
369
+ files: collected.files,
354
370
  frameworks,
355
371
  apiUrl: opts.apiUrl,
356
372
  apiKey: opts.apiKey,
357
373
  });
374
+ if (claudeHook && response.exitCode !== 2) {
375
+ // Invoked as a Claude Code PostToolUse hook. Emit Claude Code's
376
+ // native hook JSON on stdout so a violation is fed back to the
377
+ // agent as actionable feedback — this is what closes the compliance
378
+ // self-healing loop. Claude Code only parses hook JSON when the
379
+ // command exits 0, so we exit 0 here even on violations and let
380
+ // `decision: "block"` carry the signal.
381
+ //
382
+ // exitCode 2 (scanner unavailable) deliberately falls through to
383
+ // the generic path below: it exits 2, and Claude Code surfaces a
384
+ // hook's stderr on a 2 — gate() already wrote the scanner-error
385
+ // warning there, so the agent still learns the scan was inconclusive.
386
+ const claudeJson = formatClaudeHookOutput(response);
387
+ if (claudeJson)
388
+ process.stdout.write(claudeJson + '\n');
389
+ process.exit(0);
390
+ return;
391
+ }
358
392
  writeOutput(renderReport(response, format), opts.output);
359
393
  process.exit(response.exitCode);
360
394
  }
361
395
  catch (error) {
396
+ // A rejected key (401/403) is a setup problem too \u2014 on the Claude Code
397
+ // hook path, treat it like the missing-key case rather than blocking
398
+ // every edit with an error.
399
+ if (claudeHook && isAuthError(error)) {
400
+ process.stdout.write(formatClaudeHookSetupNotice('rejected') + '\n');
401
+ process.exit(0);
402
+ return;
403
+ }
362
404
  console.error(`\u2717 Error: ${error.message}`);
363
405
  process.exit(2);
364
406
  }
@@ -409,6 +451,68 @@ function resolveHookFileKey(inputPath, realpathFile, realpathCwd) {
409
451
  }
410
452
  return { ok: true, key: relative };
411
453
  }
454
+ /**
455
+ * True when a parsed `hook` stdin payload is a Claude Code hook event.
456
+ *
457
+ * Claude Code stamps every hook payload with a `hook_event_name` field
458
+ * (e.g. `"PostToolUse"`); no other supported input shape carries it. We
459
+ * key on its presence to switch `hook` into Claude-Code-native JSON
460
+ * output — see `formatClaudeHookOutput`. Pure so it's unit-testable.
461
+ */
462
+ function isClaudeCodeHookPayload(payload) {
463
+ return (typeof payload === 'object' &&
464
+ payload !== null &&
465
+ typeof payload.hook_event_name === 'string');
466
+ }
467
+ /**
468
+ * Render a `hook` scan result as Claude Code PostToolUse hook JSON.
469
+ *
470
+ * Returns `''` when the scan passed — a clean result needs no agent
471
+ * feedback, so the hook stays silent. On violations it returns
472
+ * `{"decision":"block","reason":...}`: Claude Code feeds `reason` back to
473
+ * the agent as actionable feedback, which is what closes the compliance
474
+ * self-healing loop.
475
+ *
476
+ * The caller MUST exit 0 for Claude Code to parse this — Claude Code
477
+ * ignores hook JSON on any non-zero exit. Pure so it's unit-testable.
478
+ */
479
+ function formatClaudeHookOutput(response) {
480
+ if (response.exitCode === 0)
481
+ return '';
482
+ const reason = typeof response.prompt === 'string' && response.prompt.trim()
483
+ ? response.prompt
484
+ : (0, prompt_1.formatPrompt)(response);
485
+ return JSON.stringify({ decision: 'block', reason });
486
+ }
487
+ /**
488
+ * Claude Code hook JSON for the "API key not usable" state.
489
+ *
490
+ * A missing or rejected key is a setup problem, not a compliance failure —
491
+ * so instead of blocking every edit with an error, we emit a `systemMessage`
492
+ * (shown to the user, never fed to the agent, never blocking) telling them
493
+ * how to fix it. `reason` distinguishes a key that is absent (`'missing'`)
494
+ * from one the API rejected (`'rejected'` — expired/revoked/wrong) so the
495
+ * message points at the right fix. The caller exits 0. Pure so it's
496
+ * unit-testable.
497
+ */
498
+ function formatClaudeHookSetupNotice(reason = 'missing') {
499
+ const detail = reason === 'rejected'
500
+ ? 'the configured API key was rejected (it may be expired or revoked)'
501
+ : 'no API key found';
502
+ return JSON.stringify({
503
+ systemMessage: `ProdCycle compliance scanning is inactive — ${detail}. ` +
504
+ 'Run `prodcycle init --api-key pc_...` to save a valid key, or set the ' +
505
+ 'PC_API_KEY environment variable. See https://docs.prodcycle.com.',
506
+ });
507
+ }
508
+ /**
509
+ * True when an error is the API rejecting our credentials (401/403) — i.e.
510
+ * a key that is missing, wrong, or revoked. Treated as a setup problem, not
511
+ * a scan failure, on the Claude Code hook path.
512
+ */
513
+ function isAuthError(error) {
514
+ return error instanceof index_1.ApiError && (error.statusCode === 401 || error.statusCode === 403);
515
+ }
412
516
  async function collectHookFiles(filePath) {
413
517
  if (filePath) {
414
518
  const absolute = path.resolve(filePath);
@@ -427,7 +531,8 @@ async function collectHookFiles(filePath) {
427
531
  console.error(resolved.error);
428
532
  process.exit(2);
429
533
  }
430
- return { [resolved.key]: content };
534
+ // `--file` is a manual / non-agent invocation — never Claude Code.
535
+ return { files: { [resolved.key]: content }, claudeHook: false };
431
536
  }
432
537
  const stdin = await readStdin();
433
538
  if (!stdin.trim()) {
@@ -442,22 +547,25 @@ async function collectHookFiles(filePath) {
442
547
  console.error(`hook: invalid JSON on stdin: ${e.message}`);
443
548
  process.exit(2);
444
549
  }
550
+ // Claude Code stamps its hook payloads with `hook_event_name`; detect it
551
+ // once here so every return below can flag a Claude Code invocation.
552
+ const claudeHook = isClaudeCodeHookPayload(payload);
445
553
  // Shape 1: {"files": {path: content}} — gate-compatible
446
554
  if (payload && typeof payload.files === 'object' && payload.files !== null) {
447
- return payload.files;
555
+ return { files: payload.files, claudeHook };
448
556
  }
449
557
  // Shape 2: top-level single file. Shape 3: Claude Code tool_input nesting.
450
558
  const candidate = payload?.tool_input ?? payload;
451
559
  const hookFilePath = candidate?.file_path ?? candidate?.path;
452
560
  const hookContent = candidate?.content ?? candidate?.new_string;
453
561
  if (hookFilePath && typeof hookContent === 'string') {
454
- return { [hookFilePath]: hookContent };
562
+ return { files: { [hookFilePath]: hookContent }, claudeHook };
455
563
  }
456
564
  if (hookFilePath && fs.existsSync(hookFilePath)) {
457
565
  // Only a path was given — read from disk so post-edit hooks still work
458
566
  // when the agent doesn't ship the content inline.
459
567
  const content = fs.readFileSync(hookFilePath, 'utf8');
460
- return { [hookFilePath]: content };
568
+ return { files: { [hookFilePath]: content }, claudeHook };
461
569
  }
462
570
  console.error('hook: stdin payload not recognized. Expected one of:\n' +
463
571
  ' {"files": {"path": "content"}}\n' +
@@ -474,16 +582,28 @@ program
474
582
  .option('--ci <providers>', 'Comma-separated CI providers to scaffold (github, gitlab, circleci). Use "all" for every provider. Opt-in only \u2014 never auto-detected.')
475
583
  .option('--force', 'Overwrite existing compliance hook entries')
476
584
  .option('--dir <path>', 'Project directory to configure', '.')
585
+ .option('--api-key <key>', 'Save this API key to the user config file (~/.config/prodcycle/config.json, chmod 600) so hooks authenticate without an env var')
477
586
  .action((opts) => {
478
587
  try {
479
588
  const dir = path.resolve(opts.dir ?? '.');
589
+ // Persist the API key first so the post-install hints below see it.
590
+ if (opts.apiKey) {
591
+ const savedTo = (0, config_1.writeApiKey)(opts.apiKey);
592
+ process.stdout.write(`[config] saved API key to ${savedTo} (readable only by you).\n`);
593
+ }
480
594
  const agents = resolveAgents(opts.agent, dir);
481
595
  const ciProviders = resolveCiProviders(opts.ci);
482
596
  if (agents.length === 0 && ciProviders.length === 0) {
597
+ if (opts.apiKey) {
598
+ // Saving the key was itself the requested action — nothing else to do.
599
+ process.exit(0);
600
+ return;
601
+ }
483
602
  console.error('init: nothing to do. ' +
484
603
  'Use --agent <name> to configure a coding agent (claude, cursor, codex, ' +
485
- 'opencode, github-copilot, gemini-cli, or "all"), and/or --ci <provider> ' +
486
- 'to scaffold CI workflows (github, gitlab, circleci, or "all"). ' +
604
+ 'opencode, github-copilot, gemini-cli, or "all"), --ci <provider> ' +
605
+ 'to scaffold CI workflows (github, gitlab, circleci, or "all"), ' +
606
+ 'and/or --api-key <key> to save your credentials. ' +
487
607
  'Without --agent the CLI also auto-detects agents already in use.');
488
608
  process.exit(2);
489
609
  }
@@ -570,6 +690,21 @@ function configureAgent(agent, dir, force, writtenPaths) {
570
690
  }
571
691
  const CLAUDE_MATCHER = 'Write|Edit|MultiEdit';
572
692
  const CLAUDE_COMMAND = 'prodcycle hook';
693
+ /**
694
+ * Build the post-install hint about API-key setup. The hook authenticates
695
+ * against the hosted API using a key resolved from `--api-key`, the
696
+ * `PC_API_KEY` env var, or the user config file (see `utils/config.ts`).
697
+ * The config file is the robust option — unlike an env var, a GUI-launched
698
+ * editor picks it up with no relaunch — so when no key is found we point
699
+ * the user there.
700
+ */
701
+ function apiKeyHint() {
702
+ return (0, config_1.resolveApiKey)()
703
+ ? 'API key configured ✓'
704
+ : '⚠ No API key configured — run `prodcycle init --api-key pc_...` to ' +
705
+ 'save one (or set the PC_API_KEY env var). The hook calls the hosted ' +
706
+ 'API and fails without it.';
707
+ }
573
708
  function configureClaudeCode(dir, force) {
574
709
  const claudeDir = path.join(dir, '.claude');
575
710
  const settingsPath = path.join(claudeDir, 'settings.json');
@@ -617,7 +752,7 @@ function configureClaudeCode(dir, force) {
617
752
  fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
618
753
  return {
619
754
  status: 'installed',
620
- message: `[claude] wrote PostToolUse hook to ${settingsPath}. Requires PC_API_KEY in the environment when Claude Code runs.`,
755
+ message: `[claude] wrote PostToolUse hook to ${settingsPath}. ${apiKeyHint()}`,
621
756
  };
622
757
  }
623
758
  const CURSOR_COMMAND = 'prodcycle hook';
@@ -665,7 +800,7 @@ function configureCursor(dir, force) {
665
800
  fs.writeFileSync(hooksPath, JSON.stringify(config, null, 2) + '\n');
666
801
  return {
667
802
  status: 'installed',
668
- message: `[cursor] wrote afterFileEdit hook to ${hooksPath}. Requires PC_API_KEY in the environment when Cursor runs.`,
803
+ message: `[cursor] wrote afterFileEdit hook to ${hooksPath}. ${apiKeyHint()}`,
669
804
  };
670
805
  }
671
806
  // ── Instruction-file agents (codex, opencode, github-copilot, gemini-cli) ───
@@ -0,0 +1,37 @@
1
+ export interface ProdcycleConfig {
2
+ api_key?: string;
3
+ api_url?: string;
4
+ }
5
+ /**
6
+ * Path to the user-level config file:
7
+ * `$XDG_CONFIG_HOME/prodcycle/config.json`, falling back to
8
+ * `~/.config/prodcycle/config.json`. A non-absolute `XDG_CONFIG_HOME` is
9
+ * ignored, per the XDG Base Directory spec.
10
+ */
11
+ export declare function configFilePath(): string;
12
+ /**
13
+ * Read the user-level config file. Returns `{}` when the file is absent,
14
+ * unreadable, or malformed — a broken config must never crash a scan.
15
+ */
16
+ export declare function readConfig(): ProdcycleConfig;
17
+ /**
18
+ * Persist the API key to the user-level config file, merging into any keys
19
+ * already present. The file is written `0600` (directory `0700`) so the
20
+ * credential is not world-readable. Returns the path written.
21
+ */
22
+ export declare function writeApiKey(apiKey: string): string;
23
+ /**
24
+ * Resolve the API key, in precedence order: an explicit value (CLI
25
+ * `--api-key`), the `PC_API_KEY` env var, then the user-level config file.
26
+ * Returns `''` when none is configured.
27
+ *
28
+ * Pass an already-read `config` to avoid re-reading the file when the
29
+ * caller resolves several values at once (see `ComplianceApiClient`).
30
+ */
31
+ export declare function resolveApiKey(explicit?: string, config?: ProdcycleConfig): string;
32
+ /**
33
+ * Resolve the API URL: explicit value → `PC_API_URL` env → config file →
34
+ * `undefined` (the caller then applies its own default). Accepts an
35
+ * already-read `config` — see `resolveApiKey`.
36
+ */
37
+ export declare function resolveApiUrl(explicit?: string, config?: ProdcycleConfig): string | undefined;
@@ -0,0 +1,129 @@
1
+ "use strict";
2
+ // User-level ProdCycle configuration.
3
+ //
4
+ // Lets a developer save their API key once, on disk, instead of exporting
5
+ // `PC_API_KEY` into every shell — and, critically, into the environment a
6
+ // GUI-launched editor runs in, which does NOT inherit shell exports. The
7
+ // hook reads this file regardless of how the agent was launched, so
8
+ // `prodcycle init --api-key pc_...` is a one-time setup with no relaunch.
9
+ //
10
+ // Mirrors `python/src/prodcycle/utils/config.py` — the two must stay in
11
+ // lockstep.
12
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
13
+ if (k2 === undefined) k2 = k;
14
+ var desc = Object.getOwnPropertyDescriptor(m, k);
15
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
16
+ desc = { enumerable: true, get: function() { return m[k]; } };
17
+ }
18
+ Object.defineProperty(o, k2, desc);
19
+ }) : (function(o, m, k, k2) {
20
+ if (k2 === undefined) k2 = k;
21
+ o[k2] = m[k];
22
+ }));
23
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
24
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
25
+ }) : function(o, v) {
26
+ o["default"] = v;
27
+ });
28
+ var __importStar = (this && this.__importStar) || (function () {
29
+ var ownKeys = function(o) {
30
+ ownKeys = Object.getOwnPropertyNames || function (o) {
31
+ var ar = [];
32
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
33
+ return ar;
34
+ };
35
+ return ownKeys(o);
36
+ };
37
+ return function (mod) {
38
+ if (mod && mod.__esModule) return mod;
39
+ var result = {};
40
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
41
+ __setModuleDefault(result, mod);
42
+ return result;
43
+ };
44
+ })();
45
+ Object.defineProperty(exports, "__esModule", { value: true });
46
+ exports.configFilePath = configFilePath;
47
+ exports.readConfig = readConfig;
48
+ exports.writeApiKey = writeApiKey;
49
+ exports.resolveApiKey = resolveApiKey;
50
+ exports.resolveApiUrl = resolveApiUrl;
51
+ const fs = __importStar(require("fs"));
52
+ const os = __importStar(require("os"));
53
+ const path = __importStar(require("path"));
54
+ // One-shot guard so a malformed config file warns at most once per process
55
+ // (both `resolveApiKey` and `resolveApiUrl` call `readConfig`).
56
+ let warnedMalformed = false;
57
+ /**
58
+ * Path to the user-level config file:
59
+ * `$XDG_CONFIG_HOME/prodcycle/config.json`, falling back to
60
+ * `~/.config/prodcycle/config.json`. A non-absolute `XDG_CONFIG_HOME` is
61
+ * ignored, per the XDG Base Directory spec.
62
+ */
63
+ function configFilePath() {
64
+ const xdg = process.env.XDG_CONFIG_HOME;
65
+ const base = xdg && path.isAbsolute(xdg) ? xdg : path.join(os.homedir(), '.config');
66
+ return path.join(base, 'prodcycle', 'config.json');
67
+ }
68
+ /**
69
+ * Read the user-level config file. Returns `{}` when the file is absent,
70
+ * unreadable, or malformed — a broken config must never crash a scan.
71
+ */
72
+ function readConfig() {
73
+ let raw;
74
+ try {
75
+ raw = fs.readFileSync(configFilePath(), 'utf8');
76
+ }
77
+ catch {
78
+ return {}; // absent / unreadable — the expected case, not an error
79
+ }
80
+ try {
81
+ const parsed = JSON.parse(raw);
82
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
83
+ return parsed;
84
+ }
85
+ }
86
+ catch {
87
+ // fall through to the warning below
88
+ }
89
+ if (!warnedMalformed) {
90
+ warnedMalformed = true;
91
+ process.stderr.write(`Warning: ${configFilePath()} is not valid JSON — ignoring it.\n`);
92
+ }
93
+ return {};
94
+ }
95
+ /**
96
+ * Persist the API key to the user-level config file, merging into any keys
97
+ * already present. The file is written `0600` (directory `0700`) so the
98
+ * credential is not world-readable. Returns the path written.
99
+ */
100
+ function writeApiKey(apiKey) {
101
+ const file = configFilePath();
102
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
103
+ const config = readConfig();
104
+ config.api_key = apiKey;
105
+ fs.writeFileSync(file, JSON.stringify(config, null, 2) + '\n', { mode: 0o600 });
106
+ // `mode` on writeFileSync is ignored when the file already exists, so set
107
+ // it explicitly — the credential must never be left world-readable.
108
+ fs.chmodSync(file, 0o600);
109
+ return file;
110
+ }
111
+ /**
112
+ * Resolve the API key, in precedence order: an explicit value (CLI
113
+ * `--api-key`), the `PC_API_KEY` env var, then the user-level config file.
114
+ * Returns `''` when none is configured.
115
+ *
116
+ * Pass an already-read `config` to avoid re-reading the file when the
117
+ * caller resolves several values at once (see `ComplianceApiClient`).
118
+ */
119
+ function resolveApiKey(explicit, config) {
120
+ return explicit || process.env.PC_API_KEY || (config ?? readConfig()).api_key || '';
121
+ }
122
+ /**
123
+ * Resolve the API URL: explicit value → `PC_API_URL` env → config file →
124
+ * `undefined` (the caller then applies its own default). Accepts an
125
+ * already-read `config` — see `resolveApiKey`.
126
+ */
127
+ function resolveApiUrl(explicit, config) {
128
+ return explicit || process.env.PC_API_URL || (config ?? readConfig()).api_url || undefined;
129
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@prodcycle/prodcycle",
3
- "version": "0.6.6",
3
+ "version": "0.6.8",
4
4
  "description": "Multi-framework policy-as-code compliance scanner for infrastructure and application code.",
5
5
  "homepage": "https://docs.prodcycle.com",
6
6
  "repository": {