@prodcycle/prodcycle 0.4.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -16,11 +16,123 @@ export interface GateOptions {
16
16
  apiUrl?: string;
17
17
  config?: Record<string, unknown>;
18
18
  }
19
+ export interface ScanResult {
20
+ scanId?: string;
21
+ passed: boolean;
22
+ findingsCount?: number;
23
+ findings?: unknown[];
24
+ summary?: unknown;
25
+ prompt?: string;
26
+ status?: 'IN_PROGRESS' | 'COMPLETED' | 'FAILED';
27
+ [key: string]: unknown;
28
+ }
29
+ interface ApiErrorBody {
30
+ status: 'error';
31
+ statusCode: number;
32
+ error?: {
33
+ type?: string;
34
+ message?: string;
35
+ suggestion?: string;
36
+ details?: {
37
+ maxBytes?: number;
38
+ maxFiles?: number;
39
+ chunkSizeBytes?: number;
40
+ receivedBytes?: number;
41
+ suggestedEndpoint?: string;
42
+ [key: string]: unknown;
43
+ };
44
+ };
45
+ }
46
+ /**
47
+ * Error thrown for any non-2xx response. Carries the parsed body + status so
48
+ * callers can branch on `details.suggestedEndpoint` (413 → chunked-session
49
+ * fallback) or `Retry-After` (429 / 503 → backoff + retry).
50
+ */
51
+ export declare class ApiError extends Error {
52
+ readonly statusCode: number;
53
+ readonly body: ApiErrorBody | null;
54
+ readonly retryAfterSeconds: number | null;
55
+ constructor(statusCode: number, body: ApiErrorBody | null, retryAfterSeconds: number | null, message: string);
56
+ }
19
57
  export declare class ComplianceApiClient {
20
58
  private apiUrl;
21
59
  private apiKey;
22
60
  constructor(apiUrl?: string, apiKey?: string);
23
- validate(files: Record<string, string>, frameworks: string[], options?: ScanOptions): Promise<any>;
24
- hook(files: Record<string, string>, frameworks: string[]): Promise<any>;
25
- private post;
61
+ /**
62
+ * Synchronous validate. On a 413 with `details.suggestedEndpoint ===
63
+ * '/v1/compliance/scans'`, silently falls back to the chunked-session
64
+ * flow so large-repo CI jobs don't have to know the difference.
65
+ */
66
+ validate(files: Record<string, string>, frameworks: string[], options?: ScanOptions): Promise<ScanResult>;
67
+ /**
68
+ * Hook endpoint — small per-write call from coding agents. No
69
+ * suggestedEndpoint fallback because /hook keeps the historical 50 MB
70
+ * ceiling; if a single hook write exceeds that, the caller's batching
71
+ * is the bug to fix.
72
+ */
73
+ hook(files: Record<string, string>, frameworks: string[], options?: ScanOptions): Promise<ScanResult>;
74
+ /**
75
+ * Open a chunked scan session. Returns a `scanId` that subsequent
76
+ * `appendChunk` / `completeSession` calls reference. Server-side TTL is
77
+ * 30 minutes by default — abandoned sessions self-clean via the
78
+ * stale-session reaper.
79
+ */
80
+ openSession(frameworks: string[], options?: ScanOptions): Promise<{
81
+ scanId: string;
82
+ chunkSizeBytes: number;
83
+ maxFilesPerChunk: number;
84
+ expiresAt: string;
85
+ }>;
86
+ /**
87
+ * Append a chunk of files to an open session. Each call has its own
88
+ * /hook-style cap (50 MB / 2000 files). The server caches per-content
89
+ * findings, so re-scans of unchanged files are O(1).
90
+ */
91
+ appendChunk(scanId: string, files: Record<string, string>): Promise<{
92
+ filesScanned: number;
93
+ cachedFiles: number;
94
+ findingsAdded: number;
95
+ }>;
96
+ /**
97
+ * Finalize a chunked session: flips status to COMPLETED, computes
98
+ * summary + passed, returns final findings.
99
+ */
100
+ completeSession(scanId: string): Promise<ScanResult>;
101
+ /**
102
+ * High-level helper: open → append (in chunks) → complete. Returns the
103
+ * same shape as `validate()` so callers that auto-fallback don't have
104
+ * to special-case the result.
105
+ *
106
+ * Caller can pre-set `chunkMaxBytes` / `chunkMaxFiles` on `options.config`
107
+ * to override the conservative defaults.
108
+ */
109
+ validateChunked(files: Record<string, string>, frameworks: string[], options?: ScanOptions): Promise<ScanResult>;
110
+ /**
111
+ * Async-validate: returns a `scanId` immediately; caller polls
112
+ * `getScan(scanId)` until status is COMPLETED or FAILED. Useful for CI
113
+ * runners that don't want to hold a connection for a 60 s scan.
114
+ */
115
+ validateAsync(files: Record<string, string>, frameworks: string[], options?: ScanOptions): Promise<{
116
+ scanId: string;
117
+ }>;
118
+ /**
119
+ * Fetch the current state of any scan (sync, async, or chunked-session).
120
+ */
121
+ getScan(scanId: string): Promise<ScanResult>;
122
+ /**
123
+ * High-level helper: kicks off an async-validate, polls until terminal,
124
+ * returns the same shape as `validate()`.
125
+ */
126
+ validateAndPoll(files: Record<string, string>, frameworks: string[], options?: ScanOptions): Promise<ScanResult>;
127
+ private buildOptions;
128
+ /**
129
+ * Single HTTP request with:
130
+ * - auth header
131
+ * - 429/503 + Retry-After honoring (up to MAX_RETRY_ATTEMPTS)
132
+ * - structured error with parsed body so callers can introspect
133
+ * `details.suggestedEndpoint` etc.
134
+ */
135
+ private request;
26
136
  }
137
+ export declare function chunkFiles(files: Record<string, string>, maxBytes: number, maxFiles: number): Record<string, string>[];
138
+ export {};
@@ -1,53 +1,424 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.ComplianceApiClient = void 0;
3
+ exports.ComplianceApiClient = exports.ApiError = void 0;
4
+ exports.chunkFiles = chunkFiles;
5
+ /**
6
+ * Error thrown for any non-2xx response. Carries the parsed body + status so
7
+ * callers can branch on `details.suggestedEndpoint` (413 → chunked-session
8
+ * fallback) or `Retry-After` (429 / 503 → backoff + retry).
9
+ */
10
+ class ApiError extends Error {
11
+ statusCode;
12
+ body;
13
+ retryAfterSeconds;
14
+ constructor(statusCode, body, retryAfterSeconds, message) {
15
+ super(message);
16
+ this.statusCode = statusCode;
17
+ this.body = body;
18
+ this.retryAfterSeconds = retryAfterSeconds;
19
+ this.name = 'ApiError';
20
+ }
21
+ }
22
+ exports.ApiError = ApiError;
23
+ const DEFAULT_API_URL = 'https://api.prodcycle.com';
24
+ /**
25
+ * Read a positive integer from an env var or fall back to a default. Used
26
+ * for the timeout / retry knobs below so operators can tune behavior in CI
27
+ * without forking the CLI.
28
+ */
29
+ function envInt(name, fallback) {
30
+ const raw = process.env[name];
31
+ if (!raw)
32
+ return fallback;
33
+ const parsed = Number(raw);
34
+ return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : fallback;
35
+ }
36
+ /**
37
+ * Maximum retry attempts for 429/503 responses. After this many tries we
38
+ * give up and surface the error to the caller.
39
+ */
40
+ const MAX_RETRY_ATTEMPTS = envInt('PC_MAX_RETRY_ATTEMPTS', 4);
41
+ /**
42
+ * Hard ceiling on Retry-After (seconds). Even if the server asks for more
43
+ * than this we cap it so the CLI doesn't appear to hang indefinitely on a
44
+ * misconfigured server.
45
+ */
46
+ const MAX_RETRY_AFTER_SECONDS = envInt('PC_MAX_RETRY_AFTER_SECONDS', 300);
47
+ /**
48
+ * Per-request fetch timeout. Without this a stalled connection would tie
49
+ * up the CLI indefinitely, bypassing both the retry cap and the async-poll
50
+ * deadline. Default is 2 minutes — long enough for the largest non-async
51
+ * sync `/validate` call, short enough that a hung TCP socket gets aborted.
52
+ */
53
+ const REQUEST_TIMEOUT_MS = envInt('PC_REQUEST_TIMEOUT_MS', 120_000);
54
+ /**
55
+ * Conservative client-side chunk sizing for the chunked-session flow. The
56
+ * /chunks endpoint accepts up to 50 MB / 2000 files per request, but most
57
+ * customer payloads are well under this and smaller chunks shorten
58
+ * tail-latency on a single saturated chunk. The server's per-content
59
+ * findings cache means re-scans of unchanged files are O(1) regardless of
60
+ * chunk size, so picking on the smaller side costs little.
61
+ */
62
+ const DEFAULT_CHUNK_MAX_BYTES = envInt('PC_DEFAULT_CHUNK_MAX_BYTES', 5 * 1024 * 1024);
63
+ const DEFAULT_CHUNK_MAX_FILES = envInt('PC_DEFAULT_CHUNK_MAX_FILES', 200);
64
+ /**
65
+ * Async-validate poll cadence. The server typically completes scans in
66
+ * 10–60 s; polling every 2 s keeps the round-trip overhead bounded while
67
+ * still feeling responsive in interactive use.
68
+ */
69
+ const ASYNC_POLL_INTERVAL_MS = envInt('PC_ASYNC_POLL_INTERVAL_MS', 2000);
70
+ const ASYNC_POLL_TIMEOUT_MS = envInt('PC_ASYNC_POLL_TIMEOUT_MS', 10 * 60 * 1000);
71
+ /**
72
+ * Keys in `options.config` that route client-side behavior (sync /
73
+ * async / chunked dispatch, chunk sizing) and MUST NOT be forwarded to
74
+ * the server. Some endpoints validate `options` strictly and would 400
75
+ * on unknown keys, so leaking these would silently break every call.
76
+ */
77
+ const CLIENT_ONLY_CONFIG_KEYS = new Set([
78
+ 'mode',
79
+ 'chunkMaxBytes',
80
+ 'chunkMaxFiles',
81
+ ]);
4
82
  class ComplianceApiClient {
5
83
  apiUrl;
6
84
  apiKey;
7
85
  constructor(apiUrl, apiKey) {
8
- this.apiUrl = apiUrl || process.env.PC_API_URL || 'https://api.prodcycle.com';
86
+ this.apiUrl = apiUrl || process.env.PC_API_URL || DEFAULT_API_URL;
9
87
  this.apiKey = apiKey || process.env.PC_API_KEY || '';
10
- if (!this.apiKey && process.env.NODE_ENV !== 'test') {
11
- console.warn('Warning: PC_API_KEY is not set. API calls will likely fail.');
88
+ if (!this.apiKey &&
89
+ process.env.NODE_ENV !== 'test' &&
90
+ !process.env.PC_SUPPRESS_WARNINGS) {
91
+ process.stderr.write('Warning: PC_API_KEY is not set. API calls will likely fail.\n');
12
92
  }
13
93
  }
94
+ /**
95
+ * Synchronous validate. On a 413 with `details.suggestedEndpoint ===
96
+ * '/v1/compliance/scans'`, silently falls back to the chunked-session
97
+ * flow so large-repo CI jobs don't have to know the difference.
98
+ */
14
99
  async validate(files, frameworks, options = {}) {
15
- return this.post('/v1/compliance/validate', {
100
+ try {
101
+ return await this.request('POST', '/v1/compliance/validate', {
102
+ files,
103
+ frameworks,
104
+ options: this.buildOptions(options),
105
+ });
106
+ }
107
+ catch (err) {
108
+ if (err instanceof ApiError &&
109
+ err.statusCode === 413 &&
110
+ err.body?.error?.details?.suggestedEndpoint === '/v1/compliance/scans') {
111
+ // Server says: this payload won't fit, use chunked sessions instead.
112
+ // Fall back transparently — the caller asked for `validate`, the
113
+ // semantics (single scanId with final findings) are preserved.
114
+ return this.validateChunked(files, frameworks, options);
115
+ }
116
+ throw err;
117
+ }
118
+ }
119
+ /**
120
+ * Hook endpoint — small per-write call from coding agents. No
121
+ * suggestedEndpoint fallback because /hook keeps the historical 50 MB
122
+ * ceiling; if a single hook write exceeds that, the caller's batching
123
+ * is the bug to fix.
124
+ */
125
+ async hook(files, frameworks, options = {}) {
126
+ return this.request('POST', '/v1/compliance/hook', {
16
127
  files,
17
128
  frameworks,
18
- options: {
19
- severity_threshold: options.severityThreshold,
20
- fail_on: options.failOn,
21
- ...options.config,
22
- },
129
+ options: this.buildOptions(options),
23
130
  });
24
131
  }
25
- async hook(files, frameworks) {
26
- return this.post('/v1/compliance/hook', {
132
+ // ─── Chunked sessions ───────────────────────────────────────────────────
133
+ /**
134
+ * Open a chunked scan session. Returns a `scanId` that subsequent
135
+ * `appendChunk` / `completeSession` calls reference. Server-side TTL is
136
+ * 30 minutes by default — abandoned sessions self-clean via the
137
+ * stale-session reaper.
138
+ */
139
+ async openSession(frameworks, options = {}) {
140
+ return this.request('POST', '/v1/compliance/scans', {
141
+ frameworks,
142
+ options: this.buildOptions(options),
143
+ });
144
+ }
145
+ /**
146
+ * Append a chunk of files to an open session. Each call has its own
147
+ * /hook-style cap (50 MB / 2000 files). The server caches per-content
148
+ * findings, so re-scans of unchanged files are O(1).
149
+ */
150
+ async appendChunk(scanId, files) {
151
+ return this.request('POST', `/v1/compliance/scans/${encodeURIComponent(scanId)}/chunks`, {
152
+ files,
153
+ });
154
+ }
155
+ /**
156
+ * Finalize a chunked session: flips status to COMPLETED, computes
157
+ * summary + passed, returns final findings.
158
+ */
159
+ async completeSession(scanId) {
160
+ return this.request('POST', `/v1/compliance/scans/${encodeURIComponent(scanId)}/complete`, {});
161
+ }
162
+ /**
163
+ * High-level helper: open → append (in chunks) → complete. Returns the
164
+ * same shape as `validate()` so callers that auto-fallback don't have
165
+ * to special-case the result.
166
+ *
167
+ * Caller can pre-set `chunkMaxBytes` / `chunkMaxFiles` on `options.config`
168
+ * to override the conservative defaults.
169
+ */
170
+ async validateChunked(files, frameworks, options = {}) {
171
+ const chunkMaxBytes = options.config?.chunkMaxBytes ?? DEFAULT_CHUNK_MAX_BYTES;
172
+ const chunkMaxFiles = options.config?.chunkMaxFiles ?? DEFAULT_CHUNK_MAX_FILES;
173
+ const session = await this.openSession(frameworks, options);
174
+ const chunks = chunkFiles(files, chunkMaxBytes, chunkMaxFiles);
175
+ for (const chunk of chunks) {
176
+ await this.appendChunk(session.scanId, chunk);
177
+ }
178
+ const result = await this.completeSession(session.scanId);
179
+ return { scanId: session.scanId, ...result };
180
+ }
181
+ // ─── Async validate ─────────────────────────────────────────────────────
182
+ /**
183
+ * Async-validate: returns a `scanId` immediately; caller polls
184
+ * `getScan(scanId)` until status is COMPLETED or FAILED. Useful for CI
185
+ * runners that don't want to hold a connection for a 60 s scan.
186
+ */
187
+ async validateAsync(files, frameworks, options = {}) {
188
+ return this.request('POST', '/v1/compliance/validate?async=true', {
27
189
  files,
28
190
  frameworks,
191
+ options: this.buildOptions(options),
29
192
  });
30
193
  }
31
- async post(endpoint, data) {
32
- const url = `${this.apiUrl}${endpoint}`;
33
- try {
34
- const response = await fetch(url, {
35
- method: 'POST',
36
- headers: {
37
- 'Authorization': `Bearer ${this.apiKey}`,
38
- 'Content-Type': 'application/json',
39
- },
40
- body: JSON.stringify(data),
41
- });
42
- const responseData = await response.json();
43
- if (!response.ok) {
44
- throw new Error(responseData.error?.message || `API request failed with status ${response.status}`);
194
+ /**
195
+ * Fetch the current state of any scan (sync, async, or chunked-session).
196
+ */
197
+ async getScan(scanId) {
198
+ return this.request('GET', `/v1/compliance/scans/${encodeURIComponent(scanId)}`, null);
199
+ }
200
+ /**
201
+ * High-level helper: kicks off an async-validate, polls until terminal,
202
+ * returns the same shape as `validate()`.
203
+ */
204
+ async validateAndPoll(files, frameworks, options = {}) {
205
+ const { scanId } = await this.validateAsync(files, frameworks, options);
206
+ const deadline = Date.now() + ASYNC_POLL_TIMEOUT_MS;
207
+ // Always poll at least once, and always do one final poll *after*
208
+ // the last sleep before declaring a timeout — otherwise a scan that
209
+ // completes during the trailing sleep window would be reported as a
210
+ // timeout even though the result is sitting on the server.
211
+ while (true) {
212
+ const scan = await this.getScan(scanId);
213
+ if (scan.status === 'COMPLETED' || scan.status === 'FAILED') {
214
+ return { scanId, ...scan };
45
215
  }
46
- return responseData;
216
+ if (Date.now() >= deadline)
217
+ break;
218
+ await sleep(ASYNC_POLL_INTERVAL_MS);
47
219
  }
48
- catch (error) {
49
- throw new Error(`Failed to connect to ProdCycle API: ${error.message}`);
220
+ throw new Error(`Async validate scan ${scanId} did not complete within ${ASYNC_POLL_TIMEOUT_MS / 1000}s. Re-run with the same scanId to keep polling: prodcycle scans ${scanId}`);
221
+ }
222
+ // ─── Internals ──────────────────────────────────────────────────────────
223
+ buildOptions(options) {
224
+ const merged = {
225
+ severity_threshold: options.severityThreshold,
226
+ fail_on: options.failOn,
227
+ };
228
+ if (options.config && typeof options.config === 'object') {
229
+ // Strip client-routing keys (mode / chunkMaxBytes / chunkMaxFiles)
230
+ // before forwarding — they steer this SDK, not the server. The
231
+ // server's `options` schema may reject unknown keys strictly, in
232
+ // which case leaking these would cause 400s on every call.
233
+ for (const [key, value] of Object.entries(options.config)) {
234
+ if (CLIENT_ONLY_CONFIG_KEYS.has(key))
235
+ continue;
236
+ merged[key] = value;
237
+ }
50
238
  }
239
+ return merged;
240
+ }
241
+ /**
242
+ * Single HTTP request with:
243
+ * - auth header
244
+ * - 429/503 + Retry-After honoring (up to MAX_RETRY_ATTEMPTS)
245
+ * - structured error with parsed body so callers can introspect
246
+ * `details.suggestedEndpoint` etc.
247
+ */
248
+ async request(method, endpoint, data) {
249
+ const url = `${this.apiUrl.replace(/\/+$/, '')}${endpoint}`;
250
+ let lastError = null;
251
+ for (let attempt = 0; attempt < MAX_RETRY_ATTEMPTS; attempt++) {
252
+ let response;
253
+ try {
254
+ response = await fetch(url, {
255
+ method,
256
+ headers: {
257
+ Authorization: `Bearer ${this.apiKey}`,
258
+ ...(method === 'POST' ? { 'Content-Type': 'application/json' } : {}),
259
+ },
260
+ ...(data !== null ? { body: JSON.stringify(data) } : {}),
261
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
262
+ });
263
+ }
264
+ catch (networkErr) {
265
+ // Connection-level failures (DNS, TCP, TLS). Treat as retryable up
266
+ // to the same cap as 503 — the server may be momentarily down or
267
+ // the network blip may resolve.
268
+ lastError =
269
+ networkErr instanceof Error ? networkErr : new Error(String(networkErr));
270
+ if (attempt < MAX_RETRY_ATTEMPTS - 1) {
271
+ await sleep(retryBackoffMs(attempt));
272
+ continue;
273
+ }
274
+ throw new Error(`Failed to connect to ProdCycle API: ${lastError.message}`);
275
+ }
276
+ const responseText = await response.text();
277
+ let parsed = null;
278
+ try {
279
+ parsed = responseText ? JSON.parse(responseText) : null;
280
+ }
281
+ catch {
282
+ // Non-JSON body (e.g. ALB-level 502/504). Leave parsed as null;
283
+ // the retry path below handles based on status code.
284
+ }
285
+ if (response.ok) {
286
+ // Unwrap the API envelope only when the discriminant
287
+ // {status: "success" | "error", statusCode, data} is present.
288
+ // Bare key presence isn't enough: a `ScanResult` already has a
289
+ // `status` field (PASSED/FAILED/IN_PROGRESS) and an open index
290
+ // signature, so checking only for `'status' in parsed && 'data'
291
+ // in parsed` would misidentify a scan result that happens to
292
+ // include a `data` key as an envelope and silently drop the
293
+ // top-level `passed`, `scanId`, `findings` fields.
294
+ if (isApiEnvelope(parsed)) {
295
+ return parsed.data;
296
+ }
297
+ return parsed ?? {};
298
+ }
299
+ // Non-2xx. Inspect the body + Retry-After to decide whether to retry.
300
+ const retryAfterSeconds = parseRetryAfter(response.headers.get('retry-after'));
301
+ const errorBody = parsed ?? null;
302
+ const errorMessage = errorBody?.error?.message ?? `API request failed with status ${response.status}`;
303
+ const isRetryable = response.status === 429 || response.status === 503;
304
+ if (isRetryable && attempt < MAX_RETRY_ATTEMPTS - 1) {
305
+ const delayMs = retryAfterSeconds != null ? retryAfterSeconds * 1000 : retryBackoffMs(attempt);
306
+ const cappedDelayMs = Math.min(delayMs, MAX_RETRY_AFTER_SECONDS * 1000);
307
+ await sleep(cappedDelayMs);
308
+ continue;
309
+ }
310
+ throw new ApiError(response.status, errorBody, retryAfterSeconds, errorMessage);
311
+ }
312
+ // Unreachable in practice: every iteration returns, throws, or
313
+ // continues. Kept only to satisfy the type-checker that this method
314
+ // always returns or throws.
315
+ /* istanbul ignore next */
316
+ throw lastError ?? new Error('Exhausted retries without a response');
51
317
  }
52
318
  }
53
319
  exports.ComplianceApiClient = ComplianceApiClient;
320
+ // ─── Helpers ──────────────────────────────────────────────────────────────
321
+ function sleep(ms) {
322
+ return new Promise((resolve) => setTimeout(resolve, ms));
323
+ }
324
+ /**
325
+ * Exponential backoff with jitter for retryable errors that don't carry an
326
+ * explicit Retry-After (network failures, malformed 503).
327
+ */
328
+ function retryBackoffMs(attempt) {
329
+ const base = 1000 * 2 ** attempt; // 1s, 2s, 4s, 8s, ...
330
+ const jitter = Math.random() * 500;
331
+ return base + jitter;
332
+ }
333
+ /**
334
+ * Parse Retry-After header. Spec allows either:
335
+ * - delta-seconds (an integer)
336
+ * - HTTP-date
337
+ * We support both. Returns seconds as a non-negative integer, or null if
338
+ * the header is missing/unparseable.
339
+ */
340
+ /**
341
+ * Detect the API's *success* envelope shape strictly: a plain object
342
+ * whose `status` is exactly "success" and that carries a `data`
343
+ * payload. We deliberately do NOT match `status: "error"` here because
344
+ * `isApiEnvelope` is only consulted on the 2xx branch — accepting an
345
+ * error envelope on a 200 response would silently return `null as T`
346
+ * to the caller without ever raising, so downstream code would see
347
+ * `passed: undefined` with no signal that anything went wrong. Non-2xx
348
+ * error envelopes are handled by the explicit error path below.
349
+ *
350
+ * The strict-discriminant check is also necessary because `ScanResult`
351
+ * itself has a `status` field (PASSED/FAILED/IN_PROGRESS) and an open
352
+ * index signature, so a bare scan result with a `data` key would be
353
+ * misidentified as an envelope and have its top-level fields dropped.
354
+ */
355
+ function isApiEnvelope(value) {
356
+ if (!value || typeof value !== 'object')
357
+ return false;
358
+ const v = value;
359
+ return v.status === 'success' && 'data' in v;
360
+ }
361
+ function parseRetryAfter(value) {
362
+ if (!value)
363
+ return null;
364
+ const asInt = Number.parseInt(value, 10);
365
+ if (!Number.isNaN(asInt))
366
+ return Math.max(0, asInt);
367
+ const asDate = Date.parse(value);
368
+ if (!Number.isNaN(asDate)) {
369
+ return Math.max(0, Math.ceil((asDate - Date.now()) / 1000));
370
+ }
371
+ return null;
372
+ }
373
+ /**
374
+ * Split a `{ path: content }` map into chunks that respect both a byte
375
+ * cap and a file-count cap. UTF-8 byte-length is used since the server
376
+ * counts the request body's bytes after JSON serialisation; this is a
377
+ * conservative client-side approximation.
378
+ */
379
+ // Per-file overhead in the JSON-serialized request body. Each entry is
380
+ // `"path":"content",` which adds two pairs of quotes (4 bytes), one
381
+ // colon, one comma, and a small margin for backslash-escapes inside the
382
+ // content. 16 bytes is a conservative upper bound; the goal is to avoid
383
+ // undersizing — the server enforces the real cap.
384
+ const PER_FILE_JSON_OVERHEAD = 16;
385
+ // One-time wrapper overhead for the chunk request body itself
386
+ // (`{"files":{...}}`).
387
+ const PER_CHUNK_JSON_OVERHEAD = 16;
388
+ function chunkFiles(files, maxBytes, maxFiles) {
389
+ const chunks = [];
390
+ let current = {};
391
+ let currentBytes = PER_CHUNK_JSON_OVERHEAD;
392
+ let currentCount = 0;
393
+ for (const [filePath, content] of Object.entries(files)) {
394
+ const fileBytes = Buffer.byteLength(content, 'utf8') +
395
+ Buffer.byteLength(filePath, 'utf8') +
396
+ PER_FILE_JSON_OVERHEAD;
397
+ // If a single file exceeds the cap on its own we can't split it further
398
+ // here — emit it as its own chunk and let the server's per-file cap (if
399
+ // any) reject if needed. Common case: huge SQL dumps, generated bundles.
400
+ if (fileBytes + PER_CHUNK_JSON_OVERHEAD > maxBytes) {
401
+ if (currentCount > 0) {
402
+ chunks.push(current);
403
+ current = {};
404
+ currentBytes = PER_CHUNK_JSON_OVERHEAD;
405
+ currentCount = 0;
406
+ }
407
+ chunks.push({ [filePath]: content });
408
+ continue;
409
+ }
410
+ if (currentBytes + fileBytes > maxBytes || currentCount + 1 > maxFiles) {
411
+ chunks.push(current);
412
+ current = {};
413
+ currentBytes = PER_CHUNK_JSON_OVERHEAD;
414
+ currentCount = 0;
415
+ }
416
+ current[filePath] = content;
417
+ currentBytes += fileBytes;
418
+ currentCount += 1;
419
+ }
420
+ if (currentCount > 0) {
421
+ chunks.push(current);
422
+ }
423
+ return chunks;
424
+ }
package/dist/cli.js CHANGED
@@ -43,6 +43,7 @@ const sarif_1 = require("./formatters/sarif");
43
43
  const prompt_1 = require("./formatters/prompt");
44
44
  const KNOWN_COMMANDS = new Set([
45
45
  'scan',
46
+ 'scans',
46
47
  'gate',
47
48
  'hook',
48
49
  'init',
@@ -95,10 +96,21 @@ function parseList(val) {
95
96
  .filter(Boolean);
96
97
  }
97
98
  const program = new commander_1.Command();
99
+ // Load version from package.json at runtime so CLI --version stays in sync with
100
+ // the published package version without requiring a source edit per release.
101
+ const PKG_VERSION = (() => {
102
+ try {
103
+ const pkgPath = path.join(__dirname, '..', 'package.json');
104
+ return JSON.parse(fs.readFileSync(pkgPath, 'utf-8')).version ?? '0.0.0';
105
+ }
106
+ catch {
107
+ return '0.0.0';
108
+ }
109
+ })();
98
110
  program
99
111
  .name('prodcycle')
100
112
  .description('Multi-framework policy-as-code compliance scanner for infrastructure and application code.')
101
- .version('0.4.0');
113
+ .version(PKG_VERSION);
102
114
  // ── scan ────────────────────────────────────────────────────────────────────
103
115
  program
104
116
  .command('scan [repo_path]')
@@ -112,13 +124,29 @@ program
112
124
  .option('--output <file>', 'Write report to file')
113
125
  .option('--api-url <url>', 'Compliance API base URL (or PC_API_URL env)')
114
126
  .option('--api-key <key>', 'API key for compliance API (or PC_API_KEY env)')
127
+ .option('--async', 'Use the async-validate flow (server returns 202 immediately; CLI polls until COMPLETED). Useful for large scans where holding a connection isn’t practical.')
128
+ .option('--chunked', 'Force the chunked-session flow regardless of payload size. The default already auto-falls-back to chunked when /validate returns 413 with a chunked-endpoint suggestion.')
115
129
  .action(async (repoPath, opts) => {
116
130
  try {
117
131
  const target = repoPath ?? '.';
118
132
  const frameworks = parseList(opts.framework) ?? ['soc2'];
119
133
  const failOn = parseList(opts.failOn) ?? ['critical', 'high'];
120
134
  const format = (opts.format ?? 'table');
121
- console.error(`Scanning ${path.resolve(target)} for ${frameworks.join(', ')}...`);
135
+ // --async and --chunked are mutually exclusive; pick the explicit
136
+ // mode if either flag is set, otherwise let `scan()` pick (sync
137
+ // with auto-fallback to chunked on 413).
138
+ let mode = 'sync';
139
+ if (opts.async && opts.chunked) {
140
+ console.error('scan: --async and --chunked are mutually exclusive.');
141
+ process.exit(2);
142
+ }
143
+ if (opts.async)
144
+ mode = 'async';
145
+ else if (opts.chunked)
146
+ mode = 'chunked';
147
+ console.error(`Scanning ${path.resolve(target)} for ${frameworks.join(', ')}` +
148
+ (mode === 'sync' ? '' : ` (${mode} mode)`) +
149
+ '...');
122
150
  const response = await (0, index_1.scan)({
123
151
  repoPath: target,
124
152
  frameworks,
@@ -129,6 +157,7 @@ program
129
157
  exclude: parseList(opts.exclude),
130
158
  apiUrl: opts.apiUrl,
131
159
  apiKey: opts.apiKey,
160
+ config: { mode },
132
161
  },
133
162
  });
134
163
  writeOutput(renderReport(response, format), opts.output);
@@ -185,6 +214,46 @@ program
185
214
  process.exit(2);
186
215
  }
187
216
  });
217
+ // ── scans ───────────────────────────────────────────────────────────────────
218
+ // Fetch the current status / final result of any scan by ID. Useful with
219
+ // `--async` to resume a poll loop after a CI step boundary, or to inspect
220
+ // a chunked session that was abandoned mid-flight.
221
+ program
222
+ .command('scans <scanId>')
223
+ .description('Get the status + findings of a scan by ID')
224
+ .option('--format <format>', 'Output format: json, sarif, table, prompt', 'json')
225
+ .option('--output <file>', 'Write report to file')
226
+ .option('--api-url <url>', 'Compliance API base URL (or PC_API_URL env)')
227
+ .option('--api-key <key>', 'API key for compliance API (or PC_API_KEY env)')
228
+ .action(async (scanId, opts) => {
229
+ try {
230
+ const format = (opts.format ?? 'json');
231
+ const { ComplianceApiClient } = await Promise.resolve().then(() => __importStar(require('./api-client')));
232
+ const client = new ComplianceApiClient(opts.apiUrl, opts.apiKey);
233
+ const scan = await client.getScan(scanId);
234
+ const payload = {
235
+ scanId,
236
+ passed: scan.passed,
237
+ status: scan.status ?? 'COMPLETED',
238
+ findings: scan.findings ?? [],
239
+ summary: scan.summary,
240
+ exitCode: scan.passed ? 0 : 1,
241
+ };
242
+ // Use the same renderer as `scan` so format=table/sarif/prompt all work.
243
+ writeOutput(renderReport(payload, format), opts.output);
244
+ // Exit 2 if scan is still in progress — the CLI run shouldn't gate on
245
+ // an indeterminate result.
246
+ if (scan.status === 'IN_PROGRESS') {
247
+ console.error(`Scan ${scanId} is still IN_PROGRESS. Re-run the same command to keep polling, or use 'pc scan --async' to wait for completion.`);
248
+ process.exit(2);
249
+ }
250
+ process.exit(payload.exitCode);
251
+ }
252
+ catch (error) {
253
+ console.error(`✗ Error: ${error.message}`);
254
+ process.exit(2);
255
+ }
256
+ });
188
257
  // ── hook ────────────────────────────────────────────────────────────────────
189
258
  program
190
259
  .command('hook')
package/dist/index.d.ts CHANGED
@@ -3,33 +3,37 @@ export * from './api-client';
3
3
  export * from './formatters/table';
4
4
  export * from './formatters/prompt';
5
5
  export * from './formatters/sarif';
6
+ interface ScanReturn {
7
+ scanId?: string;
8
+ passed: boolean;
9
+ exitCode: number;
10
+ findings: unknown[];
11
+ report: unknown;
12
+ summary: unknown;
13
+ }
6
14
  /**
7
- * Scan a repository by collecting files and sending them to the API
15
+ * Scan a repository by collecting files and sending them to the API.
16
+ *
17
+ * Modes (selectable via `options.config`):
18
+ * - default: synchronous validate; auto-falls-back to chunked sessions
19
+ * if the server returns 413 with `suggestedEndpoint=/v1/compliance/scans`
20
+ * - `mode: 'async'`: kicks off a 202 async-validate and polls until
21
+ * terminal (returns same shape as default)
22
+ * - `mode: 'chunked'`: explicit chunked-session flow regardless of size
8
23
  */
9
24
  export declare function scan(params: {
10
25
  repoPath: string;
11
26
  frameworks?: string[];
12
27
  options?: ScanOptions;
13
- }): Promise<{
14
- passed: boolean;
15
- exitCode: number;
16
- findings: never[];
17
- report: null;
18
- summary?: undefined;
19
- } | {
20
- passed: any;
21
- exitCode: number;
22
- findings: any;
23
- report: any;
24
- summary: any;
25
- }>;
28
+ }): Promise<ScanReturn>;
26
29
  /**
27
- * Gate code strings directly without writing to disk
30
+ * Gate code strings directly without writing to disk (low-latency hook
31
+ * endpoint, used by coding-agent post-edit hooks).
28
32
  */
29
33
  export declare function gate(options: GateOptions): Promise<{
30
- passed: any;
34
+ passed: boolean;
31
35
  exitCode: number;
32
- findings: any;
33
- prompt: any;
34
- summary: any;
36
+ findings: unknown[];
37
+ prompt: string | undefined;
38
+ summary: unknown;
35
39
  }>;
package/dist/index.js CHANGED
@@ -23,42 +23,61 @@ __exportStar(require("./formatters/table"), exports);
23
23
  __exportStar(require("./formatters/prompt"), exports);
24
24
  __exportStar(require("./formatters/sarif"), exports);
25
25
  /**
26
- * Scan a repository by collecting files and sending them to the API
26
+ * Scan a repository by collecting files and sending them to the API.
27
+ *
28
+ * Modes (selectable via `options.config`):
29
+ * - default: synchronous validate; auto-falls-back to chunked sessions
30
+ * if the server returns 413 with `suggestedEndpoint=/v1/compliance/scans`
31
+ * - `mode: 'async'`: kicks off a 202 async-validate and polls until
32
+ * terminal (returns same shape as default)
33
+ * - `mode: 'chunked'`: explicit chunked-session flow regardless of size
27
34
  */
28
35
  async function scan(params) {
29
36
  const { repoPath, frameworks = ['soc2'], options = {} } = params;
30
- // Collect files
31
37
  const files = await (0, fs_1.collectFiles)(repoPath, options.include, options.exclude);
32
38
  if (Object.keys(files).length === 0) {
33
39
  return {
34
40
  passed: true,
35
41
  exitCode: 0,
36
42
  findings: [],
37
- report: null
43
+ report: null,
44
+ summary: undefined,
38
45
  };
39
46
  }
40
47
  const client = new api_client_1.ComplianceApiClient(options.apiUrl, options.apiKey);
41
- const response = await client.validate(files, frameworks, options);
48
+ const mode = options.config?.mode ?? 'sync';
49
+ let response;
50
+ if (mode === 'async') {
51
+ response = await client.validateAndPoll(files, frameworks, options);
52
+ }
53
+ else if (mode === 'chunked') {
54
+ response = await client.validateChunked(files, frameworks, options);
55
+ }
56
+ else {
57
+ response = await client.validate(files, frameworks, options);
58
+ }
42
59
  return {
60
+ scanId: response.scanId,
43
61
  passed: response.passed,
44
62
  exitCode: response.passed ? 0 : 1,
45
- findings: response.findings || [],
46
- report: response.report, // The API should return the full report object if requested, or we synthesize it
47
- summary: response.summary
63
+ findings: response.findings ?? [],
64
+ report: response.report ?? null,
65
+ summary: response.summary,
48
66
  };
49
67
  }
50
68
  /**
51
- * Gate code strings directly without writing to disk
69
+ * Gate code strings directly without writing to disk (low-latency hook
70
+ * endpoint, used by coding-agent post-edit hooks).
52
71
  */
53
72
  async function gate(options) {
54
73
  const { files, frameworks = ['soc2'], ...scanOpts } = options;
55
74
  const client = new api_client_1.ComplianceApiClient(options.apiUrl, options.apiKey);
56
- const response = await client.hook(files, frameworks);
75
+ const response = await client.hook(files, frameworks, scanOpts);
57
76
  return {
58
77
  passed: response.passed,
59
78
  exitCode: response.passed ? 0 : 1,
60
- findings: response.findings || [],
79
+ findings: response.findings ?? [],
61
80
  prompt: response.prompt,
62
- summary: response.summary
81
+ summary: response.summary,
63
82
  };
64
83
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@prodcycle/prodcycle",
3
- "version": "0.4.1",
3
+ "version": "0.5.0",
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": {