@prodcycle/prodcycle 0.6.7 → 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
@@ -77,3 +77,15 @@ export declare function formatClaudeHookOutput(response: {
77
77
  prompt?: string;
78
78
  findings?: unknown[];
79
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
@@ -38,6 +38,7 @@ exports.isCiEnvironment = isCiEnvironment;
38
38
  exports.resolveHookFileKey = resolveHookFileKey;
39
39
  exports.isClaudeCodeHookPayload = isClaudeCodeHookPayload;
40
40
  exports.formatClaudeHookOutput = formatClaudeHookOutput;
41
+ exports.formatClaudeHookSetupNotice = formatClaudeHookSetupNotice;
41
42
  const commander_1 = require("commander");
42
43
  const child_process_1 = require("child_process");
43
44
  const fs = __importStar(require("fs"));
@@ -46,6 +47,7 @@ const index_1 = require("./index");
46
47
  const table_1 = require("./formatters/table");
47
48
  const sarif_1 = require("./formatters/sarif");
48
49
  const prompt_1 = require("./formatters/prompt");
50
+ const config_1 = require("./utils/config");
49
51
  const KNOWN_COMMANDS = new Set([
50
52
  'scan',
51
53
  'scans',
@@ -342,6 +344,8 @@ program
342
344
  .option('--api-url <url>', 'Compliance API base URL (or PC_API_URL env)')
343
345
  .option('--api-key <key>', 'API key for compliance API (or PC_API_KEY env)')
344
346
  .action(async (opts) => {
347
+ // Hoisted so the catch block can branch on it (see the auth-error case).
348
+ let claudeHook = false;
345
349
  try {
346
350
  const frameworks = parseList(opts.framework) ?? ['soc2', 'hipaa', 'nist-csf'];
347
351
  const format = (opts.format ?? 'prompt');
@@ -351,13 +355,23 @@ program
351
355
  process.exit(0);
352
356
  return;
353
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
+ }
354
368
  const response = await (0, index_1.gate)({
355
369
  files: collected.files,
356
370
  frameworks,
357
371
  apiUrl: opts.apiUrl,
358
372
  apiKey: opts.apiKey,
359
373
  });
360
- if (collected.claudeHook && response.exitCode !== 2) {
374
+ if (claudeHook && response.exitCode !== 2) {
361
375
  // Invoked as a Claude Code PostToolUse hook. Emit Claude Code's
362
376
  // native hook JSON on stdout so a violation is fed back to the
363
377
  // agent as actionable feedback — this is what closes the compliance
@@ -379,6 +393,14 @@ program
379
393
  process.exit(response.exitCode);
380
394
  }
381
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
+ }
382
404
  console.error(`\u2717 Error: ${error.message}`);
383
405
  process.exit(2);
384
406
  }
@@ -462,6 +484,35 @@ function formatClaudeHookOutput(response) {
462
484
  : (0, prompt_1.formatPrompt)(response);
463
485
  return JSON.stringify({ decision: 'block', reason });
464
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
+ }
465
516
  async function collectHookFiles(filePath) {
466
517
  if (filePath) {
467
518
  const absolute = path.resolve(filePath);
@@ -531,16 +582,28 @@ program
531
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.')
532
583
  .option('--force', 'Overwrite existing compliance hook entries')
533
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')
534
586
  .action((opts) => {
535
587
  try {
536
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
+ }
537
594
  const agents = resolveAgents(opts.agent, dir);
538
595
  const ciProviders = resolveCiProviders(opts.ci);
539
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
+ }
540
602
  console.error('init: nothing to do. ' +
541
603
  'Use --agent <name> to configure a coding agent (claude, cursor, codex, ' +
542
- 'opencode, github-copilot, gemini-cli, or "all"), and/or --ci <provider> ' +
543
- '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. ' +
544
607
  'Without --agent the CLI also auto-detects agents already in use.');
545
608
  process.exit(2);
546
609
  }
@@ -628,20 +691,19 @@ function configureAgent(agent, dir, force, writtenPaths) {
628
691
  const CLAUDE_MATCHER = 'Write|Edit|MultiEdit';
629
692
  const CLAUDE_COMMAND = 'prodcycle hook';
630
693
  /**
631
- * Build the post-install hint about `PC_API_KEY`. The hook calls the hosted
632
- * API, which needs the key in the environment the agent runs in the most
633
- * common reason a freshly-installed hook fails. We can only inspect the shell
634
- * running `init`, which is NOT necessarily the environment the agent runs in
635
- * (e.g. a macOS GUI-launched editor does not inherit shell exports), so the
636
- * "set" branch points the user at making the key available there too.
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.
637
700
  */
638
- function apiKeyHint(agentLabel) {
639
- return process.env.PC_API_KEY
640
- ? `PC_API_KEY is set in this shell — make sure it is also available in ` +
641
- `the environment ${agentLabel} runs in (e.g. add it to your shell profile).`
642
- : `⚠ Set PC_API_KEY before launching ${agentLabel} run ` +
643
- '`export PC_API_KEY=pc_...` in that shell (or add it to your shell ' +
644
- 'profile). The hook calls the hosted API and fails without it.';
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.';
645
707
  }
646
708
  function configureClaudeCode(dir, force) {
647
709
  const claudeDir = path.join(dir, '.claude');
@@ -690,7 +752,7 @@ function configureClaudeCode(dir, force) {
690
752
  fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
691
753
  return {
692
754
  status: 'installed',
693
- message: `[claude] wrote PostToolUse hook to ${settingsPath}. ${apiKeyHint('Claude Code')}`,
755
+ message: `[claude] wrote PostToolUse hook to ${settingsPath}. ${apiKeyHint()}`,
694
756
  };
695
757
  }
696
758
  const CURSOR_COMMAND = 'prodcycle hook';
@@ -738,7 +800,7 @@ function configureCursor(dir, force) {
738
800
  fs.writeFileSync(hooksPath, JSON.stringify(config, null, 2) + '\n');
739
801
  return {
740
802
  status: 'installed',
741
- message: `[cursor] wrote afterFileEdit hook to ${hooksPath}. ${apiKeyHint('Cursor')}`,
803
+ message: `[cursor] wrote afterFileEdit hook to ${hooksPath}. ${apiKeyHint()}`,
742
804
  };
743
805
  }
744
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.7",
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": {