@prodcycle/prodcycle 0.4.2 → 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.
- package/dist/api-client.d.ts +115 -3
- package/dist/api-client.js +400 -34
- package/dist/cli.js +59 -1
- package/dist/index.d.ts +23 -19
- package/dist/index.js +29 -10
- package/package.json +1 -1
package/dist/api-client.d.ts
CHANGED
|
@@ -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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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 {};
|
package/dist/api-client.js
CHANGED
|
@@ -1,58 +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 ||
|
|
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 &&
|
|
11
|
-
|
|
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
|
-
|
|
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
|
-
|
|
26
|
-
|
|
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,
|
|
29
|
-
options:
|
|
30
|
-
severity_threshold: options.severityThreshold,
|
|
31
|
-
fail_on: options.failOn,
|
|
32
|
-
...options.config,
|
|
33
|
-
},
|
|
191
|
+
options: this.buildOptions(options),
|
|
34
192
|
});
|
|
35
193
|
}
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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 };
|
|
215
|
+
}
|
|
216
|
+
if (Date.now() >= deadline)
|
|
217
|
+
break;
|
|
218
|
+
await sleep(ASYNC_POLL_INTERVAL_MS);
|
|
219
|
+
}
|
|
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;
|
|
50
237
|
}
|
|
51
|
-
return responseData;
|
|
52
238
|
}
|
|
53
|
-
|
|
54
|
-
|
|
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);
|
|
55
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');
|
|
56
317
|
}
|
|
57
318
|
}
|
|
58
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',
|
|
@@ -123,13 +124,29 @@ program
|
|
|
123
124
|
.option('--output <file>', 'Write report to file')
|
|
124
125
|
.option('--api-url <url>', 'Compliance API base URL (or PC_API_URL env)')
|
|
125
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.')
|
|
126
129
|
.action(async (repoPath, opts) => {
|
|
127
130
|
try {
|
|
128
131
|
const target = repoPath ?? '.';
|
|
129
132
|
const frameworks = parseList(opts.framework) ?? ['soc2'];
|
|
130
133
|
const failOn = parseList(opts.failOn) ?? ['critical', 'high'];
|
|
131
134
|
const format = (opts.format ?? 'table');
|
|
132
|
-
|
|
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
|
+
'...');
|
|
133
150
|
const response = await (0, index_1.scan)({
|
|
134
151
|
repoPath: target,
|
|
135
152
|
frameworks,
|
|
@@ -140,6 +157,7 @@ program
|
|
|
140
157
|
exclude: parseList(opts.exclude),
|
|
141
158
|
apiUrl: opts.apiUrl,
|
|
142
159
|
apiKey: opts.apiKey,
|
|
160
|
+
config: { mode },
|
|
143
161
|
},
|
|
144
162
|
});
|
|
145
163
|
writeOutput(renderReport(response, format), opts.output);
|
|
@@ -196,6 +214,46 @@ program
|
|
|
196
214
|
process.exit(2);
|
|
197
215
|
}
|
|
198
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
|
+
});
|
|
199
257
|
// ── hook ────────────────────────────────────────────────────────────────────
|
|
200
258
|
program
|
|
201
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:
|
|
34
|
+
passed: boolean;
|
|
31
35
|
exitCode: number;
|
|
32
|
-
findings:
|
|
33
|
-
prompt:
|
|
34
|
-
summary:
|
|
36
|
+
findings: unknown[];
|
|
37
|
+
prompt: string | undefined;
|
|
38
|
+
summary: unknown;
|
|
35
39
|
}>;
|
package/dist/index.js
CHANGED
|
@@ -23,32 +23,51 @@ __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
|
|
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
|
|
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;
|
|
@@ -57,8 +76,8 @@ async function gate(options) {
|
|
|
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