@prodcycle/prodcycle 0.6.10 → 0.6.12
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/LICENSE +186 -37
- package/README.md +15 -3
- package/dist/api-client.d.ts +5 -5
- package/dist/api-client.js +27 -12
- package/dist/cli.d.ts +32 -4
- package/dist/cli.js +145 -49
- package/dist/index.js +20 -6
- package/dist/utils/config.d.ts +4 -2
- package/dist/utils/config.js +38 -6
- package/dist/utils/vcs.d.ts +20 -0
- package/dist/utils/vcs.js +139 -0
- package/package.json +5 -2
package/LICENSE
CHANGED
|
@@ -1,52 +1,201 @@
|
|
|
1
|
-
|
|
1
|
+
Apache License
|
|
2
|
+
Version 2.0, January 2004
|
|
3
|
+
http://www.apache.org/licenses/
|
|
2
4
|
|
|
3
|
-
|
|
4
|
-
proprietary property of ProdCycle, Inc. and are protected by copyright law.
|
|
5
|
+
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
|
5
6
|
|
|
6
|
-
|
|
7
|
+
1. Definitions.
|
|
7
8
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
non-transferable, revocable license to use the Software solely for your
|
|
11
|
-
internal business purposes.
|
|
9
|
+
"License" shall mean the terms and conditions for use, reproduction,
|
|
10
|
+
and distribution as defined by Sections 1 through 9 of this document.
|
|
12
11
|
|
|
13
|
-
|
|
12
|
+
"Licensor" shall mean the copyright owner or entity authorized by
|
|
13
|
+
the copyright owner that is granting the License.
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
"Legal Entity" shall mean the union of the acting entity and all
|
|
16
|
+
other entities that control, are controlled by, or are under common
|
|
17
|
+
control with that entity. For the purposes of this definition,
|
|
18
|
+
"control" means (i) the power, direct or indirect, to cause the
|
|
19
|
+
direction or management of such entity, whether by contract or
|
|
20
|
+
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
|
21
|
+
outstanding shares, or (iii) beneficial ownership of such entity.
|
|
16
22
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
third party;
|
|
20
|
-
3. Reverse engineer, decompile, or disassemble the Software;
|
|
21
|
-
4. Remove or alter any proprietary notices, labels, or marks on the
|
|
22
|
-
Software;
|
|
23
|
-
5. Use the Software to build a competing product or service.
|
|
23
|
+
"You" (or "Your") shall mean an individual or Legal Entity
|
|
24
|
+
exercising permissions granted by this License.
|
|
24
25
|
|
|
25
|
-
|
|
26
|
+
"Source" form shall mean the preferred form for making modifications,
|
|
27
|
+
including but not limited to software source code, documentation
|
|
28
|
+
source, and configuration files.
|
|
26
29
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
30
|
+
"Object" form shall mean any form resulting from mechanical
|
|
31
|
+
transformation or translation of a Source form, including but
|
|
32
|
+
not limited to compiled object code, generated documentation,
|
|
33
|
+
and conversions to other media types.
|
|
31
34
|
|
|
32
|
-
|
|
35
|
+
"Work" shall mean the work of authorship, whether in Source or
|
|
36
|
+
Object form, made available under the License, as indicated by a
|
|
37
|
+
copyright notice that is included in or attached to the work
|
|
38
|
+
(an example is provided in the Appendix below).
|
|
33
39
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
40
|
+
"Derivative Works" shall mean any work, whether in Source or Object
|
|
41
|
+
form, that is based on (or derived from) the Work and for which the
|
|
42
|
+
editorial revisions, annotations, elaborations, or other modifications
|
|
43
|
+
represent, as a whole, an original work of authorship. For the purposes
|
|
44
|
+
of this License, Derivative Works shall not include works that remain
|
|
45
|
+
separable from, or merely link (or bind by name) to the interfaces of,
|
|
46
|
+
the Work and Derivative Works thereof.
|
|
37
47
|
|
|
38
|
-
|
|
48
|
+
"Contribution" shall mean any work of authorship, including
|
|
49
|
+
the original version of the Work and any modifications or additions
|
|
50
|
+
to that Work or Derivative Works thereof, that is intentionally
|
|
51
|
+
submitted to Licensor for inclusion in the Work by the copyright owner
|
|
52
|
+
or by an individual or Legal Entity authorized to submit on behalf of
|
|
53
|
+
the copyright owner. For the purposes of this definition, "submitted"
|
|
54
|
+
means any form of electronic, verbal, or written communication sent
|
|
55
|
+
to the Licensor or its representatives, including but not limited to
|
|
56
|
+
communication on electronic mailing lists, source code control systems,
|
|
57
|
+
and issue tracking systems that are managed by, or on behalf of, the
|
|
58
|
+
Licensor for the purpose of discussing and improving the Work, but
|
|
59
|
+
excluding communication that is conspicuously marked or otherwise
|
|
60
|
+
designated in writing by the copyright owner as "Not a Contribution."
|
|
39
61
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
SOFTWARE.
|
|
62
|
+
"Contributor" shall mean Licensor and any individual or Legal Entity
|
|
63
|
+
on behalf of whom a Contribution has been received by Licensor and
|
|
64
|
+
subsequently incorporated within the Work.
|
|
44
65
|
|
|
45
|
-
|
|
66
|
+
2. Grant of Copyright License. Subject to the terms and conditions of
|
|
67
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
68
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
69
|
+
copyright license to reproduce, prepare Derivative Works of,
|
|
70
|
+
publicly display, publicly perform, sublicense, and distribute the
|
|
71
|
+
Work and such Derivative Works in Source or Object form.
|
|
46
72
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
73
|
+
3. Grant of Patent License. Subject to the terms and conditions of
|
|
74
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
75
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
76
|
+
(except as stated in this section) patent license to make, have made,
|
|
77
|
+
use, offer to sell, sell, import, and otherwise transfer the Work,
|
|
78
|
+
where such license applies only to those patent claims licensable
|
|
79
|
+
by such Contributor that are necessarily infringed by their
|
|
80
|
+
Contribution(s) alone or by combination of their Contribution(s)
|
|
81
|
+
with the Work to which such Contribution(s) was submitted. If You
|
|
82
|
+
institute patent litigation against any entity (including a
|
|
83
|
+
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
|
84
|
+
or a Contribution incorporated within the Work constitutes direct
|
|
85
|
+
or contributory patent infringement, then any patent licenses
|
|
86
|
+
granted to You under this License for that Work shall terminate
|
|
87
|
+
as of the date such litigation is filed.
|
|
51
88
|
|
|
52
|
-
|
|
89
|
+
4. Redistribution. You may reproduce and distribute copies of the
|
|
90
|
+
Work or Derivative Works thereof in any medium, with or without
|
|
91
|
+
modifications, and in Source or Object form, provided that You
|
|
92
|
+
meet the following conditions:
|
|
93
|
+
|
|
94
|
+
(a) You must give any other recipients of the Work or
|
|
95
|
+
Derivative Works a copy of this License; and
|
|
96
|
+
|
|
97
|
+
(b) You must cause any modified files to carry prominent notices
|
|
98
|
+
stating that You changed the files; and
|
|
99
|
+
|
|
100
|
+
(c) You must retain, in the Source form of any Derivative Works
|
|
101
|
+
that You distribute, all copyright, patent, trademark, and
|
|
102
|
+
attribution notices from the Source form of the Work,
|
|
103
|
+
excluding those notices that do not pertain to any part of
|
|
104
|
+
the Derivative Works; and
|
|
105
|
+
|
|
106
|
+
(d) If the Work includes a "NOTICE" text file as part of its
|
|
107
|
+
distribution, then any Derivative Works that You distribute must
|
|
108
|
+
include a readable copy of the attribution notices contained
|
|
109
|
+
within such NOTICE file, excluding those notices that do not
|
|
110
|
+
pertain to any part of the Derivative Works, in at least one
|
|
111
|
+
of the following places: within a NOTICE text file distributed
|
|
112
|
+
as part of the Derivative Works; within the Source form or
|
|
113
|
+
documentation, if provided along with the Derivative Works; or,
|
|
114
|
+
within a display generated by the Derivative Works, if and
|
|
115
|
+
wherever such third-party notices normally appear. The contents
|
|
116
|
+
of the NOTICE file are for informational purposes only and
|
|
117
|
+
do not modify the License. You may add Your own attribution
|
|
118
|
+
notices within Derivative Works that You distribute, alongside
|
|
119
|
+
or as an addendum to the NOTICE text from the Work, provided
|
|
120
|
+
that such additional attribution notices cannot be construed
|
|
121
|
+
as modifying the License.
|
|
122
|
+
|
|
123
|
+
You may add Your own copyright statement to Your modifications and
|
|
124
|
+
may provide additional or different license terms and conditions
|
|
125
|
+
for use, reproduction, or distribution of Your modifications, or
|
|
126
|
+
for any such Derivative Works as a whole, provided Your use,
|
|
127
|
+
reproduction, and distribution of the Work otherwise complies with
|
|
128
|
+
the conditions stated in this License.
|
|
129
|
+
|
|
130
|
+
5. Submission of Contributions. Unless You explicitly state otherwise,
|
|
131
|
+
any Contribution intentionally submitted for inclusion in the Work
|
|
132
|
+
by You to the Licensor shall be under the terms and conditions of
|
|
133
|
+
this License, without any additional terms or conditions.
|
|
134
|
+
Notwithstanding the above, nothing herein shall supersede or modify
|
|
135
|
+
the terms of any separate license agreement you may have executed
|
|
136
|
+
with Licensor regarding such Contributions.
|
|
137
|
+
|
|
138
|
+
6. Trademarks. This License does not grant permission to use the trade
|
|
139
|
+
names, trademarks, service marks, or product names of the Licensor,
|
|
140
|
+
except as required for reasonable and customary use in describing the
|
|
141
|
+
origin of the Work and reproducing the content of the NOTICE file.
|
|
142
|
+
|
|
143
|
+
7. Disclaimer of Warranty. Unless required by applicable law or
|
|
144
|
+
agreed to in writing, Licensor provides the Work (and each
|
|
145
|
+
Contributor provides its Contributions) on an "AS IS" BASIS,
|
|
146
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
|
147
|
+
implied, including, without limitation, any warranties or conditions
|
|
148
|
+
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
|
149
|
+
PARTICULAR PURPOSE. You are solely responsible for determining the
|
|
150
|
+
appropriateness of using or redistributing the Work and assume any
|
|
151
|
+
risks associated with Your exercise of permissions under this License.
|
|
152
|
+
|
|
153
|
+
8. Limitation of Liability. In no event and under no legal theory,
|
|
154
|
+
whether in tort (including negligence), contract, or otherwise,
|
|
155
|
+
unless required by applicable law (such as deliberate and grossly
|
|
156
|
+
negligent acts) or agreed to in writing, shall any Contributor be
|
|
157
|
+
liable to You for damages, including any direct, indirect, special,
|
|
158
|
+
incidental, or consequential damages of any character arising as a
|
|
159
|
+
result of this License or out of the use or inability to use the
|
|
160
|
+
Work (including but not limited to damages for loss of goodwill,
|
|
161
|
+
work stoppage, computer failure or malfunction, or any and all
|
|
162
|
+
other commercial damages or losses), even if such Contributor
|
|
163
|
+
has been advised of the possibility of such damages.
|
|
164
|
+
|
|
165
|
+
9. Accepting Warranty or Additional Liability. While redistributing
|
|
166
|
+
the Work or Derivative Works thereof, You may choose to offer,
|
|
167
|
+
and charge a fee for, acceptance of support, warranty, indemnity,
|
|
168
|
+
or other liability obligations and/or rights consistent with this
|
|
169
|
+
License. However, in accepting such obligations, You may act only
|
|
170
|
+
on Your own behalf and on Your sole responsibility, not on behalf
|
|
171
|
+
of any other Contributor, and only if You agree to indemnify,
|
|
172
|
+
defend, and hold each Contributor harmless for any liability
|
|
173
|
+
incurred by, or claims asserted against, such Contributor by reason
|
|
174
|
+
of your accepting any such warranty or additional liability.
|
|
175
|
+
|
|
176
|
+
END OF TERMS AND CONDITIONS
|
|
177
|
+
|
|
178
|
+
APPENDIX: How to apply the Apache License to your work.
|
|
179
|
+
|
|
180
|
+
To apply the Apache License to your work, attach the following
|
|
181
|
+
boilerplate notice, with the fields enclosed by brackets "[]"
|
|
182
|
+
replaced with your own identifying information. (Don't include
|
|
183
|
+
the brackets!) The text should be enclosed in the appropriate
|
|
184
|
+
comment syntax for the file format. We also recommend that a
|
|
185
|
+
file or class name and description of purpose be included on the
|
|
186
|
+
same "printed page" as the copyright notice for easier
|
|
187
|
+
identification within third-party archives.
|
|
188
|
+
|
|
189
|
+
Copyright 2025-2026 ProdCycle, Inc.
|
|
190
|
+
|
|
191
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
192
|
+
you may not use this file except in compliance with the License.
|
|
193
|
+
You may obtain a copy of the License at
|
|
194
|
+
|
|
195
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
196
|
+
|
|
197
|
+
Unless required by applicable law or agreed to in writing, software
|
|
198
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
199
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
200
|
+
See the License for the specific language governing permissions and
|
|
201
|
+
limitations under the License.
|
package/README.md
CHANGED
|
@@ -94,15 +94,27 @@ if (!result.passed) {
|
|
|
94
94
|
An API key is required for production use to authenticate with ProdCycle. Set it via environment variable:
|
|
95
95
|
|
|
96
96
|
```bash
|
|
97
|
-
export
|
|
97
|
+
export PRODCYCLE_API_KEY=pc_your_api_key_here
|
|
98
98
|
```
|
|
99
99
|
|
|
100
100
|
API keys are created through the ProdCycle dashboard.
|
|
101
101
|
|
|
102
|
+
## Exit codes
|
|
103
|
+
|
|
104
|
+
`scan`, `gate`, and `hook` use exit codes designed for CI gates and agent hooks:
|
|
105
|
+
|
|
106
|
+
| Code | Meaning |
|
|
107
|
+
| --- | --- |
|
|
108
|
+
| `0` | Pass — no findings at or above the `--fail-on` threshold (default: `critical,high`). |
|
|
109
|
+
| `1` | Findings present — code is not clean. |
|
|
110
|
+
| `2` | Scanner unavailable or usage error — the scan could not be certified either way (fail-closed). Distinguish this from `1` so CI/agents can treat "fix your code" differently from "investigate the scanner." |
|
|
111
|
+
|
|
112
|
+
> The Claude Code `hook` path is special-cased: a missing/rejected API key or a clean result exits `0` so the agent is never blocked on setup; violations are returned to the agent as structured feedback.
|
|
113
|
+
|
|
102
114
|
## Requirements
|
|
103
115
|
|
|
104
|
-
- Node.js >=
|
|
116
|
+
- Node.js >= 22.0.0
|
|
105
117
|
|
|
106
118
|
## License
|
|
107
119
|
|
|
108
|
-
|
|
120
|
+
Apache-2.0 — see [LICENSE](./LICENSE).
|
package/dist/api-client.d.ts
CHANGED
|
@@ -88,7 +88,7 @@ export declare class ComplianceApiClient {
|
|
|
88
88
|
* '/v1/compliance/scans'`, silently falls back to the chunked-session
|
|
89
89
|
* flow so large-repo CI jobs don't have to know the difference.
|
|
90
90
|
*/
|
|
91
|
-
validate(files: Record<string, string>, frameworks: string[], options?: ScanOptions): Promise<ScanResult>;
|
|
91
|
+
validate(files: Record<string, string>, frameworks: string[], options?: ScanOptions, vcsLocalOnly?: string[]): Promise<ScanResult>;
|
|
92
92
|
/**
|
|
93
93
|
* Hook endpoint — small per-write call from coding agents. No
|
|
94
94
|
* suggestedEndpoint fallback because /hook keeps the historical 50 MB
|
|
@@ -102,7 +102,7 @@ export declare class ComplianceApiClient {
|
|
|
102
102
|
* 30 minutes by default — abandoned sessions self-clean via the
|
|
103
103
|
* stale-session reaper.
|
|
104
104
|
*/
|
|
105
|
-
openSession(frameworks: string[], options?: ScanOptions): Promise<{
|
|
105
|
+
openSession(frameworks: string[], options?: ScanOptions, vcsLocalOnly?: string[]): Promise<{
|
|
106
106
|
scanId: string;
|
|
107
107
|
chunkSizeBytes: number;
|
|
108
108
|
maxFilesPerChunk: number;
|
|
@@ -131,7 +131,7 @@ export declare class ComplianceApiClient {
|
|
|
131
131
|
* Caller can pre-set `chunkMaxBytes` / `chunkMaxFiles` on `options.config`
|
|
132
132
|
* to override the conservative defaults.
|
|
133
133
|
*/
|
|
134
|
-
validateChunked(files: Record<string, string>, frameworks: string[], options?: ScanOptions): Promise<ScanResult>;
|
|
134
|
+
validateChunked(files: Record<string, string>, frameworks: string[], options?: ScanOptions, vcsLocalOnly?: string[]): Promise<ScanResult>;
|
|
135
135
|
/**
|
|
136
136
|
* Some server versions of `POST /scans/:id/complete` return only the summary,
|
|
137
137
|
* leaving `findings` empty even when `summary.total > 0`. The findings are
|
|
@@ -153,7 +153,7 @@ export declare class ComplianceApiClient {
|
|
|
153
153
|
* `getScan(scanId)` until status is COMPLETED or FAILED. Useful for CI
|
|
154
154
|
* runners that don't want to hold a connection for a 60 s scan.
|
|
155
155
|
*/
|
|
156
|
-
validateAsync(files: Record<string, string>, frameworks: string[], options?: ScanOptions): Promise<{
|
|
156
|
+
validateAsync(files: Record<string, string>, frameworks: string[], options?: ScanOptions, vcsLocalOnly?: string[]): Promise<{
|
|
157
157
|
scanId: string;
|
|
158
158
|
}>;
|
|
159
159
|
/**
|
|
@@ -164,7 +164,7 @@ export declare class ComplianceApiClient {
|
|
|
164
164
|
* High-level helper: kicks off an async-validate, polls until terminal,
|
|
165
165
|
* returns the same shape as `validate()`.
|
|
166
166
|
*/
|
|
167
|
-
validateAndPoll(files: Record<string, string>, frameworks: string[], options?: ScanOptions): Promise<ScanResult>;
|
|
167
|
+
validateAndPoll(files: Record<string, string>, frameworks: string[], options?: ScanOptions, vcsLocalOnly?: string[]): Promise<ScanResult>;
|
|
168
168
|
private buildOptions;
|
|
169
169
|
/**
|
|
170
170
|
* Single HTTP request with:
|
package/dist/api-client.js
CHANGED
|
@@ -105,10 +105,15 @@ class ComplianceApiClient {
|
|
|
105
105
|
const config = (0, config_1.readConfig)();
|
|
106
106
|
this.apiUrl = (0, config_1.resolveApiUrl)(apiUrl, config) || DEFAULT_API_URL;
|
|
107
107
|
this.apiKey = (0, config_1.resolveApiKey)(apiKey, config);
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
108
|
+
// Fail fast with an actionable error rather than warning and letting the
|
|
109
|
+
// request fall over later as an opaque `fetch failed` / 401. A missing key
|
|
110
|
+
// is a setup problem the user can fix immediately. The Claude Code hook
|
|
111
|
+
// path pre-checks `resolveApiKey` and exits 0 *before* constructing a
|
|
112
|
+
// client, so this throw never blocks an agent on every edit.
|
|
113
|
+
if (!this.apiKey) {
|
|
114
|
+
throw new Error('No ProdCycle API key found. Set the PRODCYCLE_API_KEY environment variable ' +
|
|
115
|
+
'or run `prodcycle init --api-key pc_...` to save one. ' +
|
|
116
|
+
'See https://docs.prodcycle.com.');
|
|
112
117
|
}
|
|
113
118
|
}
|
|
114
119
|
/**
|
|
@@ -116,12 +121,17 @@ class ComplianceApiClient {
|
|
|
116
121
|
* '/v1/compliance/scans'`, silently falls back to the chunked-session
|
|
117
122
|
* flow so large-repo CI jobs don't have to know the difference.
|
|
118
123
|
*/
|
|
119
|
-
async validate(files, frameworks, options = {}) {
|
|
124
|
+
async validate(files, frameworks, options = {}, vcsLocalOnly = []) {
|
|
120
125
|
try {
|
|
121
126
|
return await this.request('POST', '/v1/compliance/validate', {
|
|
122
127
|
files,
|
|
123
128
|
frameworks,
|
|
124
129
|
options: this.buildOptions(options),
|
|
130
|
+
// Top-level hint (sibling of `files`/`frameworks`, NOT an option) so
|
|
131
|
+
// the scanner can downgrade secrets that exist only in the local
|
|
132
|
+
// working tree. Omitted entirely when empty so payloads are unchanged
|
|
133
|
+
// for repos without local-only files.
|
|
134
|
+
...(vcsLocalOnly.length > 0 && { vcsLocalOnly }),
|
|
125
135
|
});
|
|
126
136
|
}
|
|
127
137
|
catch (err) {
|
|
@@ -131,7 +141,7 @@ class ComplianceApiClient {
|
|
|
131
141
|
// Server says: this payload won't fit, use chunked sessions instead.
|
|
132
142
|
// Fall back transparently — the caller asked for `validate`, the
|
|
133
143
|
// semantics (single scanId with final findings) are preserved.
|
|
134
|
-
return this.validateChunked(files, frameworks, options);
|
|
144
|
+
return this.validateChunked(files, frameworks, options, vcsLocalOnly);
|
|
135
145
|
}
|
|
136
146
|
throw err;
|
|
137
147
|
}
|
|
@@ -156,10 +166,13 @@ class ComplianceApiClient {
|
|
|
156
166
|
* 30 minutes by default — abandoned sessions self-clean via the
|
|
157
167
|
* stale-session reaper.
|
|
158
168
|
*/
|
|
159
|
-
async openSession(frameworks, options = {}) {
|
|
169
|
+
async openSession(frameworks, options = {}, vcsLocalOnly = []) {
|
|
160
170
|
return this.request('POST', '/v1/compliance/scans', {
|
|
161
171
|
frameworks,
|
|
162
172
|
options: this.buildOptions(options),
|
|
173
|
+
// Sent once at session-open (NOT on appendChunk) — the scanner applies
|
|
174
|
+
// it to the whole chunked scan. Omitted when empty (see `validate`).
|
|
175
|
+
...(vcsLocalOnly.length > 0 && { vcsLocalOnly }),
|
|
163
176
|
});
|
|
164
177
|
}
|
|
165
178
|
/**
|
|
@@ -187,10 +200,10 @@ class ComplianceApiClient {
|
|
|
187
200
|
* Caller can pre-set `chunkMaxBytes` / `chunkMaxFiles` on `options.config`
|
|
188
201
|
* to override the conservative defaults.
|
|
189
202
|
*/
|
|
190
|
-
async validateChunked(files, frameworks, options = {}) {
|
|
203
|
+
async validateChunked(files, frameworks, options = {}, vcsLocalOnly = []) {
|
|
191
204
|
const chunkMaxBytes = options.config?.chunkMaxBytes ?? DEFAULT_CHUNK_MAX_BYTES;
|
|
192
205
|
const chunkMaxFiles = options.config?.chunkMaxFiles ?? DEFAULT_CHUNK_MAX_FILES;
|
|
193
|
-
const session = await this.openSession(frameworks, options);
|
|
206
|
+
const session = await this.openSession(frameworks, options, vcsLocalOnly);
|
|
194
207
|
const chunks = chunkFiles(files, chunkMaxBytes, chunkMaxFiles);
|
|
195
208
|
for (const chunk of chunks) {
|
|
196
209
|
await this.appendChunk(session.scanId, chunk);
|
|
@@ -276,11 +289,13 @@ class ComplianceApiClient {
|
|
|
276
289
|
* `getScan(scanId)` until status is COMPLETED or FAILED. Useful for CI
|
|
277
290
|
* runners that don't want to hold a connection for a 60 s scan.
|
|
278
291
|
*/
|
|
279
|
-
async validateAsync(files, frameworks, options = {}) {
|
|
292
|
+
async validateAsync(files, frameworks, options = {}, vcsLocalOnly = []) {
|
|
280
293
|
return this.request('POST', '/v1/compliance/validate?async=true', {
|
|
281
294
|
files,
|
|
282
295
|
frameworks,
|
|
283
296
|
options: this.buildOptions(options),
|
|
297
|
+
// Top-level local-only hint; omitted when empty (see `validate`).
|
|
298
|
+
...(vcsLocalOnly.length > 0 && { vcsLocalOnly }),
|
|
284
299
|
});
|
|
285
300
|
}
|
|
286
301
|
/**
|
|
@@ -293,8 +308,8 @@ class ComplianceApiClient {
|
|
|
293
308
|
* High-level helper: kicks off an async-validate, polls until terminal,
|
|
294
309
|
* returns the same shape as `validate()`.
|
|
295
310
|
*/
|
|
296
|
-
async validateAndPoll(files, frameworks, options = {}) {
|
|
297
|
-
const { scanId } = await this.validateAsync(files, frameworks, options);
|
|
311
|
+
async validateAndPoll(files, frameworks, options = {}, vcsLocalOnly = []) {
|
|
312
|
+
const { scanId } = await this.validateAsync(files, frameworks, options, vcsLocalOnly);
|
|
298
313
|
const deadline = Date.now() + ASYNC_POLL_TIMEOUT_MS;
|
|
299
314
|
// Always poll at least once, and always do one final poll *after*
|
|
300
315
|
// the last sleep before declaring a timeout — otherwise a scan that
|
package/dist/cli.d.ts
CHANGED
|
@@ -50,15 +50,43 @@ export declare function resolveHookFileKey(inputPath: string, realpathFile: stri
|
|
|
50
50
|
ok: false;
|
|
51
51
|
error: string;
|
|
52
52
|
};
|
|
53
|
+
/**
|
|
54
|
+
* Cursor hook events that carry `hook_event_name` but are not Claude Code.
|
|
55
|
+
* Cursor and Claude both stamp payloads; only Claude PostToolUse (etc.)
|
|
56
|
+
* should use Claude-Code-native JSON output.
|
|
57
|
+
*
|
|
58
|
+
* @see https://cursor.com/docs/hooks
|
|
59
|
+
*/
|
|
60
|
+
export declare const CURSOR_HOOK_EVENT_NAMES: Set<string>;
|
|
61
|
+
/**
|
|
62
|
+
* True when stdin is a Cursor hooks payload (e.g. `afterFileEdit`).
|
|
63
|
+
* Pure so it's unit-testable.
|
|
64
|
+
*/
|
|
65
|
+
export declare function isCursorHookPayload(payload: unknown): boolean;
|
|
53
66
|
/**
|
|
54
67
|
* True when a parsed `hook` stdin payload is a Claude Code hook event.
|
|
55
68
|
*
|
|
56
|
-
* Claude Code stamps
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
69
|
+
* Claude Code stamps hook payloads with `hook_event_name` (e.g.
|
|
70
|
+
* `"PostToolUse"`). Cursor uses the same field for its own events — exclude
|
|
71
|
+
* those so `hook` prints a human-readable report for Cursor's notification
|
|
72
|
+
* hooks instead of Claude's `{"decision":"block",...}` JSON. Pure so it's
|
|
73
|
+
* unit-testable.
|
|
60
74
|
*/
|
|
61
75
|
export declare function isClaudeCodeHookPayload(payload: unknown): boolean;
|
|
76
|
+
/** True when a hooks.json command invokes `prodcycle hook`. */
|
|
77
|
+
export declare function isProdcycleHookCommand(command: string): boolean;
|
|
78
|
+
/**
|
|
79
|
+
* Build `prodcycle hook` for agent hook configs from a resolved binary path.
|
|
80
|
+
* Quotes paths that need it so hooks.json commands survive spaces in usernames.
|
|
81
|
+
* Pure so it's unit-testable. Mirrors Python `_format_prodcycle_hook_command`.
|
|
82
|
+
*/
|
|
83
|
+
export declare function formatProdcycleHookCommand(bin: string): string;
|
|
84
|
+
/**
|
|
85
|
+
* Resolve `prodcycle hook` for agent hook configs. GUI-launched editors
|
|
86
|
+
* (Cursor, Claude Code) often lack nvm/shims on PATH — write the absolute
|
|
87
|
+
* binary path when `which`/`where` finds one.
|
|
88
|
+
*/
|
|
89
|
+
export declare function resolveProdcycleHookCommand(): string;
|
|
62
90
|
/**
|
|
63
91
|
* Render a `hook` scan result as Claude Code PostToolUse hook JSON.
|
|
64
92
|
*
|
package/dist/cli.js
CHANGED
|
@@ -34,9 +34,14 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
34
34
|
};
|
|
35
35
|
})();
|
|
36
36
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
37
|
+
exports.CURSOR_HOOK_EVENT_NAMES = void 0;
|
|
37
38
|
exports.isCiEnvironment = isCiEnvironment;
|
|
38
39
|
exports.resolveHookFileKey = resolveHookFileKey;
|
|
40
|
+
exports.isCursorHookPayload = isCursorHookPayload;
|
|
39
41
|
exports.isClaudeCodeHookPayload = isClaudeCodeHookPayload;
|
|
42
|
+
exports.isProdcycleHookCommand = isProdcycleHookCommand;
|
|
43
|
+
exports.formatProdcycleHookCommand = formatProdcycleHookCommand;
|
|
44
|
+
exports.resolveProdcycleHookCommand = resolveProdcycleHookCommand;
|
|
40
45
|
exports.formatClaudeHookOutput = formatClaudeHookOutput;
|
|
41
46
|
exports.formatClaudeHookSetupNotice = formatClaudeHookSetupNotice;
|
|
42
47
|
const commander_1 = require("commander");
|
|
@@ -171,8 +176,8 @@ program
|
|
|
171
176
|
.option('--include <patterns>', 'Comma-separated glob patterns to include')
|
|
172
177
|
.option('--exclude <patterns>', 'Comma-separated glob patterns to exclude')
|
|
173
178
|
.option('--output <file>', 'Write report to file')
|
|
174
|
-
.option('--api-url <url>', 'Compliance API base URL (or
|
|
175
|
-
.option('--api-key <key>', 'API key for compliance API (or
|
|
179
|
+
.option('--api-url <url>', 'Compliance API base URL (or PRODCYCLE_API_URL env)')
|
|
180
|
+
.option('--api-key <key>', 'API key for compliance API (or PRODCYCLE_API_KEY env)')
|
|
176
181
|
.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.')
|
|
177
182
|
.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.')
|
|
178
183
|
.option('--pr <range>', 'Scan only files changed in a git diff range (e.g. "origin/main..HEAD"). Cuts CI scan time on large repos by skipping unchanged files. Requires baseDir to be the git repo root.')
|
|
@@ -245,8 +250,8 @@ program
|
|
|
245
250
|
.option('--framework <ids>', 'Comma-separated framework IDs to evaluate', 'soc2,hipaa,nist-csf')
|
|
246
251
|
.option('--format <format>', 'Output format: json, sarif, table, prompt', 'prompt')
|
|
247
252
|
.option('--output <file>', 'Write report to file')
|
|
248
|
-
.option('--api-url <url>', 'Compliance API base URL (or
|
|
249
|
-
.option('--api-key <key>', 'API key for compliance API (or
|
|
253
|
+
.option('--api-url <url>', 'Compliance API base URL (or PRODCYCLE_API_URL env)')
|
|
254
|
+
.option('--api-key <key>', 'API key for compliance API (or PRODCYCLE_API_KEY env)')
|
|
250
255
|
.action(async (opts) => {
|
|
251
256
|
try {
|
|
252
257
|
const frameworks = parseList(opts.framework) ?? ['soc2', 'hipaa', 'nist-csf'];
|
|
@@ -293,8 +298,8 @@ program
|
|
|
293
298
|
.description('Get the status + findings of a scan by ID')
|
|
294
299
|
.option('--format <format>', 'Output format: json, sarif, table, prompt', 'json')
|
|
295
300
|
.option('--output <file>', 'Write report to file')
|
|
296
|
-
.option('--api-url <url>', 'Compliance API base URL (or
|
|
297
|
-
.option('--api-key <key>', 'API key for compliance API (or
|
|
301
|
+
.option('--api-url <url>', 'Compliance API base URL (or PRODCYCLE_API_URL env)')
|
|
302
|
+
.option('--api-key <key>', 'API key for compliance API (or PRODCYCLE_API_KEY env)')
|
|
298
303
|
.action(async (scanId, opts) => {
|
|
299
304
|
try {
|
|
300
305
|
const format = (opts.format ?? 'json');
|
|
@@ -341,13 +346,14 @@ program
|
|
|
341
346
|
.option('--file <path>', 'Scan this file from disk (alternative to reading content from stdin)')
|
|
342
347
|
.option('--fail-on <levels>', 'Severities that cause non-zero exit', 'critical,high')
|
|
343
348
|
.option('--output <file>', 'Write report to file')
|
|
344
|
-
.option('--api-url <url>', 'Compliance API base URL (or
|
|
345
|
-
.option('--api-key <key>', 'API key for compliance API (or
|
|
349
|
+
.option('--api-url <url>', 'Compliance API base URL (or PRODCYCLE_API_URL env)')
|
|
350
|
+
.option('--api-key <key>', 'API key for compliance API (or PRODCYCLE_API_KEY env)')
|
|
346
351
|
.action(async (opts) => {
|
|
347
352
|
// Hoisted so the catch block can branch on it (see the auth-error case).
|
|
348
353
|
let claudeHook = false;
|
|
349
354
|
try {
|
|
350
355
|
const frameworks = parseList(opts.framework) ?? ['soc2', 'hipaa', 'nist-csf'];
|
|
356
|
+
const failOn = parseList(opts.failOn) ?? ['critical', 'high'];
|
|
351
357
|
const format = (opts.format ?? 'prompt');
|
|
352
358
|
const collected = await collectHookFiles(opts.file);
|
|
353
359
|
if (!collected || Object.keys(collected.files).length === 0) {
|
|
@@ -356,6 +362,7 @@ program
|
|
|
356
362
|
return;
|
|
357
363
|
}
|
|
358
364
|
claudeHook = collected.claudeHook;
|
|
365
|
+
const cursorHook = collected.cursorHook;
|
|
359
366
|
// Graceful unconfigured state: a missing API key is a setup problem,
|
|
360
367
|
// not a compliance failure. For a Claude Code hook, surface a one-line
|
|
361
368
|
// setup notice and exit 0 so the agent is not blocked on every edit.
|
|
@@ -368,6 +375,7 @@ program
|
|
|
368
375
|
const response = await (0, index_1.gate)({
|
|
369
376
|
files: collected.files,
|
|
370
377
|
frameworks,
|
|
378
|
+
failOn: failOn,
|
|
371
379
|
apiUrl: opts.apiUrl,
|
|
372
380
|
apiKey: opts.apiKey,
|
|
373
381
|
});
|
|
@@ -389,7 +397,13 @@ program
|
|
|
389
397
|
process.exit(0);
|
|
390
398
|
return;
|
|
391
399
|
}
|
|
392
|
-
|
|
400
|
+
const report = renderReport(response, format);
|
|
401
|
+
writeOutput(report, opts.output);
|
|
402
|
+
// Cursor's afterFileEdit hook is notification-only (no agent feedback).
|
|
403
|
+
// Mirror violations to stderr so they appear in the Hooks output channel.
|
|
404
|
+
if (cursorHook && report.trim() && !opts.output) {
|
|
405
|
+
process.stderr.write(report.endsWith('\n') ? report : report + '\n');
|
|
406
|
+
}
|
|
393
407
|
process.exit(response.exitCode);
|
|
394
408
|
}
|
|
395
409
|
catch (error) {
|
|
@@ -451,18 +465,97 @@ function resolveHookFileKey(inputPath, realpathFile, realpathCwd) {
|
|
|
451
465
|
}
|
|
452
466
|
return { ok: true, key: relative };
|
|
453
467
|
}
|
|
468
|
+
/**
|
|
469
|
+
* Cursor hook events that carry `hook_event_name` but are not Claude Code.
|
|
470
|
+
* Cursor and Claude both stamp payloads; only Claude PostToolUse (etc.)
|
|
471
|
+
* should use Claude-Code-native JSON output.
|
|
472
|
+
*
|
|
473
|
+
* @see https://cursor.com/docs/hooks
|
|
474
|
+
*/
|
|
475
|
+
exports.CURSOR_HOOK_EVENT_NAMES = new Set([
|
|
476
|
+
'afterFileEdit',
|
|
477
|
+
'afterTabFileEdit',
|
|
478
|
+
'afterAgentResponse',
|
|
479
|
+
'afterAgentThought',
|
|
480
|
+
'afterMCPExecution',
|
|
481
|
+
'afterShellExecution',
|
|
482
|
+
'beforeMCPExecution',
|
|
483
|
+
'beforeReadFile',
|
|
484
|
+
'beforeShellExecution',
|
|
485
|
+
'beforeSubmitPrompt',
|
|
486
|
+
'beforeTabFileRead',
|
|
487
|
+
'postToolUse',
|
|
488
|
+
'postToolUseFailure',
|
|
489
|
+
'preCompact',
|
|
490
|
+
'preToolUse',
|
|
491
|
+
'sessionEnd',
|
|
492
|
+
'sessionStart',
|
|
493
|
+
'stop',
|
|
494
|
+
'subagentStart',
|
|
495
|
+
'subagentStop',
|
|
496
|
+
'workspaceOpen',
|
|
497
|
+
]);
|
|
498
|
+
/**
|
|
499
|
+
* True when stdin is a Cursor hooks payload (e.g. `afterFileEdit`).
|
|
500
|
+
* Pure so it's unit-testable.
|
|
501
|
+
*/
|
|
502
|
+
function isCursorHookPayload(payload) {
|
|
503
|
+
if (typeof payload !== 'object' || payload === null)
|
|
504
|
+
return false;
|
|
505
|
+
const name = payload.hook_event_name;
|
|
506
|
+
return typeof name === 'string' && exports.CURSOR_HOOK_EVENT_NAMES.has(name);
|
|
507
|
+
}
|
|
454
508
|
/**
|
|
455
509
|
* True when a parsed `hook` stdin payload is a Claude Code hook event.
|
|
456
510
|
*
|
|
457
|
-
* Claude Code stamps
|
|
458
|
-
*
|
|
459
|
-
*
|
|
460
|
-
*
|
|
511
|
+
* Claude Code stamps hook payloads with `hook_event_name` (e.g.
|
|
512
|
+
* `"PostToolUse"`). Cursor uses the same field for its own events — exclude
|
|
513
|
+
* those so `hook` prints a human-readable report for Cursor's notification
|
|
514
|
+
* hooks instead of Claude's `{"decision":"block",...}` JSON. Pure so it's
|
|
515
|
+
* unit-testable.
|
|
461
516
|
*/
|
|
462
517
|
function isClaudeCodeHookPayload(payload) {
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
518
|
+
if (typeof payload !== 'object' || payload === null)
|
|
519
|
+
return false;
|
|
520
|
+
const name = payload.hook_event_name;
|
|
521
|
+
if (typeof name !== 'string')
|
|
522
|
+
return false;
|
|
523
|
+
return !exports.CURSOR_HOOK_EVENT_NAMES.has(name);
|
|
524
|
+
}
|
|
525
|
+
/** True when a hooks.json command invokes `prodcycle hook`. */
|
|
526
|
+
function isProdcycleHookCommand(command) {
|
|
527
|
+
const trimmed = command.trim();
|
|
528
|
+
return /\bprodcycle\b/.test(trimmed) && /\bhook\b/.test(trimmed);
|
|
529
|
+
}
|
|
530
|
+
/**
|
|
531
|
+
* Build `prodcycle hook` for agent hook configs from a resolved binary path.
|
|
532
|
+
* Quotes paths that need it so hooks.json commands survive spaces in usernames.
|
|
533
|
+
* Pure so it's unit-testable. Mirrors Python `_format_prodcycle_hook_command`.
|
|
534
|
+
*/
|
|
535
|
+
function formatProdcycleHookCommand(bin) {
|
|
536
|
+
const needsQuotes = /[\s"]/.test(bin);
|
|
537
|
+
const executable = needsQuotes
|
|
538
|
+
? `"${bin.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`
|
|
539
|
+
: bin;
|
|
540
|
+
return `${executable} hook`;
|
|
541
|
+
}
|
|
542
|
+
/**
|
|
543
|
+
* Resolve `prodcycle hook` for agent hook configs. GUI-launched editors
|
|
544
|
+
* (Cursor, Claude Code) often lack nvm/shims on PATH — write the absolute
|
|
545
|
+
* binary path when `which`/`where` finds one.
|
|
546
|
+
*/
|
|
547
|
+
function resolveProdcycleHookCommand() {
|
|
548
|
+
try {
|
|
549
|
+
const whichCmd = process.platform === 'win32' ? 'where' : 'which';
|
|
550
|
+
const out = (0, child_process_1.execFileSync)(whichCmd, ['prodcycle'], { encoding: 'utf8' }).trim();
|
|
551
|
+
const bin = out.split(/\r?\n/)[0]?.trim();
|
|
552
|
+
if (bin)
|
|
553
|
+
return formatProdcycleHookCommand(bin);
|
|
554
|
+
}
|
|
555
|
+
catch {
|
|
556
|
+
/* not on PATH */
|
|
557
|
+
}
|
|
558
|
+
return 'prodcycle hook';
|
|
466
559
|
}
|
|
467
560
|
/**
|
|
468
561
|
* Render a `hook` scan result as Claude Code PostToolUse hook JSON.
|
|
@@ -502,7 +595,7 @@ function formatClaudeHookSetupNotice(reason = 'missing') {
|
|
|
502
595
|
return JSON.stringify({
|
|
503
596
|
systemMessage: `ProdCycle compliance scanning is inactive — ${detail}. ` +
|
|
504
597
|
'Run `prodcycle init --api-key pc_...` to save a valid key, or set the ' +
|
|
505
|
-
'
|
|
598
|
+
'PRODCYCLE_API_KEY environment variable. See https://docs.prodcycle.com.',
|
|
506
599
|
});
|
|
507
600
|
}
|
|
508
601
|
/**
|
|
@@ -531,8 +624,8 @@ async function collectHookFiles(filePath) {
|
|
|
531
624
|
console.error(resolved.error);
|
|
532
625
|
process.exit(2);
|
|
533
626
|
}
|
|
534
|
-
// `--file` is a manual / non-agent invocation — never Claude Code.
|
|
535
|
-
return { files: { [resolved.key]: content }, claudeHook: false };
|
|
627
|
+
// `--file` is a manual / non-agent invocation — never Claude Code or Cursor.
|
|
628
|
+
return { files: { [resolved.key]: content }, claudeHook: false, cursorHook: false };
|
|
536
629
|
}
|
|
537
630
|
const stdin = await readStdin();
|
|
538
631
|
if (!stdin.trim()) {
|
|
@@ -547,12 +640,11 @@ async function collectHookFiles(filePath) {
|
|
|
547
640
|
console.error(`hook: invalid JSON on stdin: ${e.message}`);
|
|
548
641
|
process.exit(2);
|
|
549
642
|
}
|
|
550
|
-
|
|
551
|
-
// once here so every return below can flag a Claude Code invocation.
|
|
643
|
+
const cursorHook = isCursorHookPayload(payload);
|
|
552
644
|
const claudeHook = isClaudeCodeHookPayload(payload);
|
|
553
645
|
// Shape 1: {"files": {path: content}} — gate-compatible
|
|
554
646
|
if (payload && typeof payload.files === 'object' && payload.files !== null) {
|
|
555
|
-
return { files: payload.files, claudeHook };
|
|
647
|
+
return { files: payload.files, claudeHook, cursorHook };
|
|
556
648
|
}
|
|
557
649
|
// Shape 2: top-level single file. Shape 3: Claude Code tool_input nesting.
|
|
558
650
|
const candidate = payload?.tool_input ?? payload;
|
|
@@ -571,9 +663,13 @@ async function collectHookFiles(filePath) {
|
|
|
571
663
|
const realpathCwd = fs.realpathSync(process.cwd());
|
|
572
664
|
const resolved = resolveHookFileKey(hookFilePath, realpathFile, realpathCwd);
|
|
573
665
|
if (!resolved.ok) {
|
|
574
|
-
//
|
|
575
|
-
|
|
576
|
-
|
|
666
|
+
// Out-of-repo edit: the API only accepts repo-relative paths, so a file
|
|
667
|
+
// outside the repo root isn't scannable. Skip it (exit 0 via the caller's
|
|
668
|
+
// empty-collection check) rather than hard-erroring — matches how
|
|
669
|
+
// non-scannable files are handled, and keeps an agent from being blocked
|
|
670
|
+
// when it edits files outside the project (dotfiles, sibling repos, …).
|
|
671
|
+
process.stderr.write(`hook: skipping ${hookFilePath} — outside the repo root (${process.cwd()}).\n`);
|
|
672
|
+
return null;
|
|
577
673
|
}
|
|
578
674
|
hookFileKey = resolved.key;
|
|
579
675
|
}
|
|
@@ -588,21 +684,21 @@ async function collectHookFiles(filePath) {
|
|
|
588
684
|
}
|
|
589
685
|
const rel = path.relative(process.cwd(), hookFilePath);
|
|
590
686
|
if (rel.startsWith('..') || path.isAbsolute(rel)) {
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
687
|
+
// Out-of-repo edit (see above) — skip rather than block the agent.
|
|
688
|
+
process.stderr.write(`hook: skipping ${hookFilePath} — outside the repo root (${process.cwd()}).\n`);
|
|
689
|
+
return null;
|
|
594
690
|
}
|
|
595
691
|
hookFileKey = rel;
|
|
596
692
|
}
|
|
597
693
|
}
|
|
598
694
|
if (hookFileKey && typeof hookContent === 'string') {
|
|
599
|
-
return { files: { [hookFileKey]: hookContent }, claudeHook };
|
|
695
|
+
return { files: { [hookFileKey]: hookContent }, claudeHook, cursorHook };
|
|
600
696
|
}
|
|
601
697
|
if (hookFilePath && hookFileKey && fs.existsSync(hookFilePath)) {
|
|
602
698
|
// Only a path was given — read from disk so post-edit hooks still work
|
|
603
|
-
// when the agent doesn't ship the content inline.
|
|
699
|
+
// when the agent doesn't ship the content inline (Cursor afterFileEdit).
|
|
604
700
|
const content = fs.readFileSync(hookFilePath, 'utf8');
|
|
605
|
-
return { files: { [hookFileKey]: content }, claudeHook };
|
|
701
|
+
return { files: { [hookFileKey]: content }, claudeHook, cursorHook };
|
|
606
702
|
}
|
|
607
703
|
console.error('hook: stdin payload not recognized. Expected one of:\n' +
|
|
608
704
|
' {"files": {"path": "content"}}\n' +
|
|
@@ -726,11 +822,10 @@ function configureAgent(agent, dir, force, writtenPaths) {
|
|
|
726
822
|
}
|
|
727
823
|
}
|
|
728
824
|
const CLAUDE_MATCHER = 'Write|Edit|MultiEdit';
|
|
729
|
-
const CLAUDE_COMMAND = 'prodcycle hook';
|
|
730
825
|
/**
|
|
731
826
|
* Build the post-install hint about API-key setup. The hook authenticates
|
|
732
827
|
* against the hosted API using a key resolved from `--api-key`, the
|
|
733
|
-
* `
|
|
828
|
+
* `PRODCYCLE_API_KEY` env var, or the user config file (see `utils/config.ts`).
|
|
734
829
|
* The config file is the robust option — unlike an env var, a GUI-launched
|
|
735
830
|
* editor picks it up with no relaunch — so when no key is found we point
|
|
736
831
|
* the user there.
|
|
@@ -739,7 +834,7 @@ function apiKeyHint() {
|
|
|
739
834
|
return (0, config_1.resolveApiKey)()
|
|
740
835
|
? 'API key configured ✓'
|
|
741
836
|
: '⚠ No API key configured — run `prodcycle init --api-key pc_...` to ' +
|
|
742
|
-
'save one (or set the
|
|
837
|
+
'save one (or set the PRODCYCLE_API_KEY env var). The hook calls the hosted ' +
|
|
743
838
|
'API and fails without it.';
|
|
744
839
|
}
|
|
745
840
|
function configureClaudeCode(dir, force) {
|
|
@@ -766,7 +861,7 @@ function configureClaudeCode(dir, force) {
|
|
|
766
861
|
const hooks = (settings.hooks ??= {});
|
|
767
862
|
const postToolUse = (hooks.PostToolUse ??= []);
|
|
768
863
|
// Look for an existing prodcycle entry
|
|
769
|
-
const existing = postToolUse.find((b) => b.hooks?.some((h) => h.type === 'command' && h.command
|
|
864
|
+
const existing = postToolUse.find((b) => b.hooks?.some((h) => h.type === 'command' && isProdcycleHookCommand(h.command)));
|
|
770
865
|
if (existing && !force) {
|
|
771
866
|
return {
|
|
772
867
|
status: 'already',
|
|
@@ -776,12 +871,12 @@ function configureClaudeCode(dir, force) {
|
|
|
776
871
|
if (existing && force) {
|
|
777
872
|
// Replace in place — preserve the matcher, rewrite the command to the canonical form
|
|
778
873
|
existing.matcher = CLAUDE_MATCHER;
|
|
779
|
-
existing.hooks = [{ type: 'command', command:
|
|
874
|
+
existing.hooks = [{ type: 'command', command: resolveProdcycleHookCommand() }];
|
|
780
875
|
}
|
|
781
876
|
else {
|
|
782
877
|
postToolUse.push({
|
|
783
878
|
matcher: CLAUDE_MATCHER,
|
|
784
|
-
hooks: [{ type: 'command', command:
|
|
879
|
+
hooks: [{ type: 'command', command: resolveProdcycleHookCommand() }],
|
|
785
880
|
});
|
|
786
881
|
}
|
|
787
882
|
if (!fs.existsSync(claudeDir))
|
|
@@ -792,8 +887,8 @@ function configureClaudeCode(dir, force) {
|
|
|
792
887
|
message: `[claude] wrote PostToolUse hook to ${settingsPath}. ${apiKeyHint()}`,
|
|
793
888
|
};
|
|
794
889
|
}
|
|
795
|
-
const CURSOR_COMMAND = 'prodcycle hook';
|
|
796
890
|
function configureCursor(dir, force) {
|
|
891
|
+
const cursorCommand = resolveProdcycleHookCommand();
|
|
797
892
|
const cursorDir = path.join(dir, '.cursor');
|
|
798
893
|
const hooksPath = path.join(cursorDir, 'hooks.json');
|
|
799
894
|
let config = { version: 1 };
|
|
@@ -819,7 +914,7 @@ function configureCursor(dir, force) {
|
|
|
819
914
|
config.version = 1;
|
|
820
915
|
const hooks = (config.hooks ??= {});
|
|
821
916
|
const afterFileEdit = (hooks.afterFileEdit ??= []);
|
|
822
|
-
const existing = afterFileEdit.find((h) => typeof h.command === 'string' && h.command
|
|
917
|
+
const existing = afterFileEdit.find((h) => typeof h.command === 'string' && isProdcycleHookCommand(h.command));
|
|
823
918
|
if (existing && !force) {
|
|
824
919
|
return {
|
|
825
920
|
status: 'already',
|
|
@@ -827,10 +922,10 @@ function configureCursor(dir, force) {
|
|
|
827
922
|
};
|
|
828
923
|
}
|
|
829
924
|
if (existing && force) {
|
|
830
|
-
existing.command =
|
|
925
|
+
existing.command = cursorCommand;
|
|
831
926
|
}
|
|
832
927
|
else {
|
|
833
|
-
afterFileEdit.push({ command:
|
|
928
|
+
afterFileEdit.push({ command: cursorCommand });
|
|
834
929
|
}
|
|
835
930
|
if (!fs.existsSync(cursorDir))
|
|
836
931
|
fs.mkdirSync(cursorDir, { recursive: true });
|
|
@@ -949,15 +1044,16 @@ function writeCiFile(provider, dir, relPath, content, force) {
|
|
|
949
1044
|
if (!fs.existsSync(parent))
|
|
950
1045
|
fs.mkdirSync(parent, { recursive: true });
|
|
951
1046
|
fs.writeFileSync(fullPath, content);
|
|
952
|
-
// GitHub uses the `prodcycle/actions/compliance` action, which reads
|
|
953
|
-
//
|
|
954
|
-
//
|
|
1047
|
+
// GitHub uses the `prodcycle/actions/compliance` action, which reads its
|
|
1048
|
+
// key from `secrets.PRODCYCLE_API_KEY`. GitLab and CircleCI invoke the CLI
|
|
1049
|
+
// directly, which reads `PRODCYCLE_API_KEY` from the environment — so the
|
|
1050
|
+
// secret/variable name is the same across all three providers.
|
|
955
1051
|
const followup = provider === 'gitlab'
|
|
956
1052
|
? `Include it from .gitlab-ci.yml: \`include: '${relPath}'\`. `
|
|
957
1053
|
: provider === 'circleci'
|
|
958
1054
|
? `Reference it from .circleci/config.yml or merge the contents in. `
|
|
959
1055
|
: '';
|
|
960
|
-
const secretName =
|
|
1056
|
+
const secretName = 'PRODCYCLE_API_KEY';
|
|
961
1057
|
return {
|
|
962
1058
|
status: 'installed',
|
|
963
1059
|
message: `[ci:${provider}] wrote ${fullPath}. ` +
|
|
@@ -1000,8 +1096,8 @@ const GITLAB_WORKFLOW = `# Prodcycle compliance scan. Include from your main .gi
|
|
|
1000
1096
|
# include:
|
|
1001
1097
|
# - local: .gitlab-ci.prodcycle.yml
|
|
1002
1098
|
#
|
|
1003
|
-
# Set
|
|
1004
|
-
# the first run. Mark it Masked + Protected.
|
|
1099
|
+
# Set PRODCYCLE_API_KEY as a CI/CD variable (Settings → CI/CD → Variables)
|
|
1100
|
+
# before the first run. Mark it Masked + Protected.
|
|
1005
1101
|
|
|
1006
1102
|
prodcycle:
|
|
1007
1103
|
stage: test
|
|
@@ -1039,8 +1135,8 @@ const CIRCLECI_WORKFLOW = `# Prodcycle compliance scan. To use this, either repl
|
|
|
1039
1135
|
# jobs:
|
|
1040
1136
|
# - prodcycle-scan
|
|
1041
1137
|
#
|
|
1042
|
-
# Set
|
|
1043
|
-
# first run.
|
|
1138
|
+
# Set PRODCYCLE_API_KEY as a project environment variable in CircleCI before
|
|
1139
|
+
# the first run.
|
|
1044
1140
|
#
|
|
1045
1141
|
# CircleCI does not expose the PR target branch as a built-in env var
|
|
1046
1142
|
# (\`CIRCLE_BASE_BRANCH\` does not exist; see
|
package/dist/index.js
CHANGED
|
@@ -19,6 +19,7 @@ exports.scan = scan;
|
|
|
19
19
|
exports.gate = gate;
|
|
20
20
|
const api_client_1 = require("./api-client");
|
|
21
21
|
const fs_1 = require("./utils/fs");
|
|
22
|
+
const vcs_1 = require("./utils/vcs");
|
|
22
23
|
__exportStar(require("./api-client"), exports);
|
|
23
24
|
__exportStar(require("./formatters/table"), exports);
|
|
24
25
|
__exportStar(require("./formatters/prompt"), exports);
|
|
@@ -56,17 +57,24 @@ async function scan(params) {
|
|
|
56
57
|
summary: undefined,
|
|
57
58
|
};
|
|
58
59
|
}
|
|
60
|
+
// Compute the local-only files (git-ignored AND untracked) from the real
|
|
61
|
+
// `.git` in this checkout. The hosted API collects files in a sandbox
|
|
62
|
+
// without a real `.git`, so it can't derive this itself — we send it as a
|
|
63
|
+
// top-level `vcsLocalOnly` hint so the scanner downgrades secrets that live
|
|
64
|
+
// only in the local working tree (e.g. a developer's `.env`). Fail-safe:
|
|
65
|
+
// returns [] on any git error / non-repo, in which case nothing changes.
|
|
66
|
+
const vcsLocalOnly = await (0, vcs_1.computeVcsLocalOnly)(repoPath, Object.keys(files));
|
|
59
67
|
const client = new api_client_1.ComplianceApiClient(options.apiUrl, options.apiKey);
|
|
60
68
|
const mode = options.config?.mode ?? 'sync';
|
|
61
69
|
let response;
|
|
62
70
|
if (mode === 'async') {
|
|
63
|
-
response = await client.validateAndPoll(files, frameworks, options);
|
|
71
|
+
response = await client.validateAndPoll(files, frameworks, options, vcsLocalOnly);
|
|
64
72
|
}
|
|
65
73
|
else if (mode === 'chunked') {
|
|
66
|
-
response = await client.validateChunked(files, frameworks, options);
|
|
74
|
+
response = await client.validateChunked(files, frameworks, options, vcsLocalOnly);
|
|
67
75
|
}
|
|
68
76
|
else {
|
|
69
|
-
response = await client.validate(files, frameworks, options);
|
|
77
|
+
response = await client.validate(files, frameworks, options, vcsLocalOnly);
|
|
70
78
|
}
|
|
71
79
|
// Pull `scannerError` through if the API set it. Picking the field
|
|
72
80
|
// explicitly (rather than `...response`) so the CLI's public surface
|
|
@@ -109,9 +117,15 @@ async function scan(params) {
|
|
|
109
117
|
* endpoint, used by coding-agent post-edit hooks).
|
|
110
118
|
*/
|
|
111
119
|
async function gate(options) {
|
|
112
|
-
const { files, frameworks = ['soc2'],
|
|
113
|
-
const client = new api_client_1.ComplianceApiClient(
|
|
114
|
-
|
|
120
|
+
const { files, frameworks = ['soc2'], severityThreshold = 'medium', failOn = ['critical', 'high'], config = {}, apiKey, apiUrl, } = options;
|
|
121
|
+
const client = new api_client_1.ComplianceApiClient(apiUrl, apiKey);
|
|
122
|
+
// Forward severityThreshold/failOn/config to the hook endpoint so programmatic
|
|
123
|
+
// callers can influence server-side filtering the same way scan() does — and
|
|
124
|
+
// so Node and Python gate() send identical defaults (medium / [critical,high])
|
|
125
|
+
// for the same input. Applying the defaults here (rather than relying on a
|
|
126
|
+
// rest-spread of caller-supplied keys) keeps the two SDKs in lockstep with the
|
|
127
|
+
// documented contract; see python/src/prodcycle/__init__.py `gate()`.
|
|
128
|
+
const response = await client.hook(files, frameworks, { severityThreshold, failOn, config });
|
|
115
129
|
// Same scannerError plumbing as scan() above. Coding-agent hooks
|
|
116
130
|
// especially need to distinguish "code is clean" from "scanner is
|
|
117
131
|
// down" — agents should NOT proceed on the latter.
|
package/dist/utils/config.d.ts
CHANGED
|
@@ -22,7 +22,8 @@ export declare function readConfig(): ProdcycleConfig;
|
|
|
22
22
|
export declare function writeApiKey(apiKey: string): string;
|
|
23
23
|
/**
|
|
24
24
|
* Resolve the API key, in precedence order: an explicit value (CLI
|
|
25
|
-
* `--api-key`), the `
|
|
25
|
+
* `--api-key`), the `PRODCYCLE_API_KEY` env var (legacy `PC_API_KEY` still
|
|
26
|
+
* accepted with a deprecation notice), then the user-level config file.
|
|
26
27
|
* Returns `''` when none is configured.
|
|
27
28
|
*
|
|
28
29
|
* Pass an already-read `config` to avoid re-reading the file when the
|
|
@@ -30,7 +31,8 @@ export declare function writeApiKey(apiKey: string): string;
|
|
|
30
31
|
*/
|
|
31
32
|
export declare function resolveApiKey(explicit?: string, config?: ProdcycleConfig): string;
|
|
32
33
|
/**
|
|
33
|
-
* Resolve the API URL: explicit value → `
|
|
34
|
+
* Resolve the API URL: explicit value → `PRODCYCLE_API_URL` env (legacy
|
|
35
|
+
* `PC_API_URL` still accepted with a deprecation notice) → config file →
|
|
34
36
|
* `undefined` (the caller then applies its own default). Accepts an
|
|
35
37
|
* already-read `config` — see `resolveApiKey`.
|
|
36
38
|
*/
|
package/dist/utils/config.js
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
// User-level ProdCycle configuration.
|
|
3
3
|
//
|
|
4
4
|
// Lets a developer save their API key once, on disk, instead of exporting
|
|
5
|
-
// `
|
|
6
|
-
// GUI-launched editor runs in, which does NOT inherit shell exports. The
|
|
5
|
+
// `PRODCYCLE_API_KEY` into every shell — and, critically, into the environment
|
|
6
|
+
// a GUI-launched editor runs in, which does NOT inherit shell exports. The
|
|
7
7
|
// hook reads this file regardless of how the agent was launched, so
|
|
8
8
|
// `prodcycle init --api-key pc_...` is a one-time setup with no relaunch.
|
|
9
9
|
//
|
|
@@ -54,6 +54,30 @@ const path = __importStar(require("path"));
|
|
|
54
54
|
// One-shot guard so a malformed config file warns at most once per process
|
|
55
55
|
// (both `resolveApiKey` and `resolveApiUrl` call `readConfig`).
|
|
56
56
|
let warnedMalformed = false;
|
|
57
|
+
// Track which deprecated env vars we've already warned about, so a long-lived
|
|
58
|
+
// process (e.g. a watch-mode agent hook) warns at most once per variable.
|
|
59
|
+
const warnedDeprecatedEnv = new Set();
|
|
60
|
+
/**
|
|
61
|
+
* Read an env var, preferring `preferred` and falling back to the legacy
|
|
62
|
+
* `deprecated` name. When only the legacy name is set, emit a one-time
|
|
63
|
+
* deprecation notice on stderr so existing users keep working but are nudged
|
|
64
|
+
* to the new name. Returns `undefined` when neither is set.
|
|
65
|
+
*
|
|
66
|
+
* The env vars were renamed `PC_*` → `PRODCYCLE_*` to unify with the
|
|
67
|
+
* `prodcycle/actions` GitHub Action (which already used `PRODCYCLE_API_KEY`).
|
|
68
|
+
*/
|
|
69
|
+
function envWithDeprecatedFallback(preferred, deprecated) {
|
|
70
|
+
const current = process.env[preferred];
|
|
71
|
+
if (current)
|
|
72
|
+
return current;
|
|
73
|
+
const legacy = process.env[deprecated];
|
|
74
|
+
if (legacy && !warnedDeprecatedEnv.has(deprecated)) {
|
|
75
|
+
warnedDeprecatedEnv.add(deprecated);
|
|
76
|
+
process.stderr.write(`Warning: ${deprecated} is deprecated and will be removed in a future ` +
|
|
77
|
+
`release; use ${preferred} instead.\n`);
|
|
78
|
+
}
|
|
79
|
+
return legacy;
|
|
80
|
+
}
|
|
57
81
|
/**
|
|
58
82
|
* Path to the user-level config file:
|
|
59
83
|
* `$XDG_CONFIG_HOME/prodcycle/config.json`, falling back to
|
|
@@ -110,20 +134,28 @@ function writeApiKey(apiKey) {
|
|
|
110
134
|
}
|
|
111
135
|
/**
|
|
112
136
|
* Resolve the API key, in precedence order: an explicit value (CLI
|
|
113
|
-
* `--api-key`), the `
|
|
137
|
+
* `--api-key`), the `PRODCYCLE_API_KEY` env var (legacy `PC_API_KEY` still
|
|
138
|
+
* accepted with a deprecation notice), then the user-level config file.
|
|
114
139
|
* Returns `''` when none is configured.
|
|
115
140
|
*
|
|
116
141
|
* Pass an already-read `config` to avoid re-reading the file when the
|
|
117
142
|
* caller resolves several values at once (see `ComplianceApiClient`).
|
|
118
143
|
*/
|
|
119
144
|
function resolveApiKey(explicit, config) {
|
|
120
|
-
return explicit ||
|
|
145
|
+
return (explicit ||
|
|
146
|
+
envWithDeprecatedFallback('PRODCYCLE_API_KEY', 'PC_API_KEY') ||
|
|
147
|
+
(config ?? readConfig()).api_key ||
|
|
148
|
+
'');
|
|
121
149
|
}
|
|
122
150
|
/**
|
|
123
|
-
* Resolve the API URL: explicit value → `
|
|
151
|
+
* Resolve the API URL: explicit value → `PRODCYCLE_API_URL` env (legacy
|
|
152
|
+
* `PC_API_URL` still accepted with a deprecation notice) → config file →
|
|
124
153
|
* `undefined` (the caller then applies its own default). Accepts an
|
|
125
154
|
* already-read `config` — see `resolveApiKey`.
|
|
126
155
|
*/
|
|
127
156
|
function resolveApiUrl(explicit, config) {
|
|
128
|
-
return explicit ||
|
|
157
|
+
return (explicit ||
|
|
158
|
+
envWithDeprecatedFallback('PRODCYCLE_API_URL', 'PC_API_URL') ||
|
|
159
|
+
(config ?? readConfig()).api_url ||
|
|
160
|
+
undefined);
|
|
129
161
|
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Repo-relative paths (subset of candidatePaths) that are local-only: git
|
|
3
|
+
* IGNORES them, does NOT track them, AND they were never committed in any
|
|
4
|
+
* reachable history. Async (non-blocking) variant of the engine's
|
|
5
|
+
* `computeVcsLocalOnly` in @prodcycle/compliance-code-scanner; the LOGIC is
|
|
6
|
+
* identical, so keep the two in sync. The hosted API (which collects in a
|
|
7
|
+
* sandbox without a real `.git`) relies on this hint to downgrade secrets in
|
|
8
|
+
* local-only files.
|
|
9
|
+
*
|
|
10
|
+
* Recall-safe by construction:
|
|
11
|
+
* - tracked files (even force-added past `.gitignore`) are filtered out before
|
|
12
|
+
* the ignore check, so a currently-committed secret is never local-only;
|
|
13
|
+
* - a file that was committed then `rm --cached`'d + gitignored is untracked
|
|
14
|
+
* AND ignored, yet still recoverable from `git log --all` — the history
|
|
15
|
+
* check excludes it so its secret stays critical (the real-leak case).
|
|
16
|
+
* Fail-safe: returns [] on any git error / non-repo / shallow clone — a
|
|
17
|
+
* truncated history can't prove a path was never committed, so we downgrade
|
|
18
|
+
* nothing rather than risk masking a leak.
|
|
19
|
+
*/
|
|
20
|
+
export declare function computeVcsLocalOnly(repoRoot: string, candidatePaths: string[]): Promise<string[]>;
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.computeVcsLocalOnly = computeVcsLocalOnly;
|
|
7
|
+
const node_child_process_1 = require("node:child_process");
|
|
8
|
+
const node_fs_1 = require("node:fs");
|
|
9
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
10
|
+
const GIT_TIMEOUT_MS = 10_000;
|
|
11
|
+
const toPosix = (p) => p.split(node_path_1.default.sep).join('/');
|
|
12
|
+
/**
|
|
13
|
+
* Run `git -C <repoRoot> <args>` WITHOUT blocking the event loop — the CLI runs
|
|
14
|
+
* this mid-scan alongside async HTTP, and `git ls-files` on a large repo can
|
|
15
|
+
* take seconds. Optional `input` is written to stdin (for `check-ignore
|
|
16
|
+
* --stdin`). Resolves with stdout + exit code; rejects only when git cannot be
|
|
17
|
+
* spawned (e.g. not installed).
|
|
18
|
+
*/
|
|
19
|
+
function runGit(repoRoot, args, input) {
|
|
20
|
+
return new Promise((resolve, reject) => {
|
|
21
|
+
const child = (0, node_child_process_1.spawn)('git', ['-C', repoRoot, ...args], {
|
|
22
|
+
stdio: ['pipe', 'pipe', 'ignore'],
|
|
23
|
+
timeout: GIT_TIMEOUT_MS,
|
|
24
|
+
});
|
|
25
|
+
let stdout = '';
|
|
26
|
+
child.stdout.setEncoding('utf-8');
|
|
27
|
+
child.stdout.on('data', (chunk) => {
|
|
28
|
+
stdout += chunk;
|
|
29
|
+
});
|
|
30
|
+
child.once('error', reject); // git missing / spawn failure
|
|
31
|
+
child.once('close', (code) => resolve({ stdout, code: code ?? 1 }));
|
|
32
|
+
if (input !== undefined)
|
|
33
|
+
child.stdin.write(input);
|
|
34
|
+
child.stdin.end();
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Repo-relative paths (subset of candidatePaths) that are local-only: git
|
|
39
|
+
* IGNORES them, does NOT track them, AND they were never committed in any
|
|
40
|
+
* reachable history. Async (non-blocking) variant of the engine's
|
|
41
|
+
* `computeVcsLocalOnly` in @prodcycle/compliance-code-scanner; the LOGIC is
|
|
42
|
+
* identical, so keep the two in sync. The hosted API (which collects in a
|
|
43
|
+
* sandbox without a real `.git`) relies on this hint to downgrade secrets in
|
|
44
|
+
* local-only files.
|
|
45
|
+
*
|
|
46
|
+
* Recall-safe by construction:
|
|
47
|
+
* - tracked files (even force-added past `.gitignore`) are filtered out before
|
|
48
|
+
* the ignore check, so a currently-committed secret is never local-only;
|
|
49
|
+
* - a file that was committed then `rm --cached`'d + gitignored is untracked
|
|
50
|
+
* AND ignored, yet still recoverable from `git log --all` — the history
|
|
51
|
+
* check excludes it so its secret stays critical (the real-leak case).
|
|
52
|
+
* Fail-safe: returns [] on any git error / non-repo / shallow clone — a
|
|
53
|
+
* truncated history can't prove a path was never committed, so we downgrade
|
|
54
|
+
* nothing rather than risk masking a leak.
|
|
55
|
+
*/
|
|
56
|
+
async function computeVcsLocalOnly(repoRoot, candidatePaths) {
|
|
57
|
+
if (candidatePaths.length === 0)
|
|
58
|
+
return [];
|
|
59
|
+
const root = node_path_1.default.resolve(repoRoot);
|
|
60
|
+
let tracked;
|
|
61
|
+
try {
|
|
62
|
+
const { stdout, code } = await runGit(root, ['ls-files', '-z']);
|
|
63
|
+
if (code !== 0)
|
|
64
|
+
return []; // not a git work tree / git error → fail safe
|
|
65
|
+
tracked = new Set(stdout.split('\0').filter(Boolean));
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return []; // git not available → fail safe
|
|
69
|
+
}
|
|
70
|
+
const candidates = [...new Set(candidatePaths.map(toPosix))].filter((p) => !tracked.has(p));
|
|
71
|
+
if (candidates.length === 0)
|
|
72
|
+
return [];
|
|
73
|
+
let ignoredUntracked;
|
|
74
|
+
try {
|
|
75
|
+
// check-ignore exits 0 when ≥1 path is ignored, 1 when none (stdout empty),
|
|
76
|
+
// 128 on error. Treat anything other than 0/1 as a failure → fail safe.
|
|
77
|
+
const { stdout, code } = await runGit(root, ['check-ignore', '--stdin', '-z'], candidates.join('\0'));
|
|
78
|
+
if (code !== 0 && code !== 1)
|
|
79
|
+
return [];
|
|
80
|
+
const ignored = new Set(stdout.split('\0').filter(Boolean));
|
|
81
|
+
ignoredUntracked = candidates.filter((p) => ignored.has(p));
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
return [];
|
|
85
|
+
}
|
|
86
|
+
if (ignoredUntracked.length === 0)
|
|
87
|
+
return [];
|
|
88
|
+
// Truncated history can't disprove a past commit → keep everything critical.
|
|
89
|
+
// Detect shallowness via the marker file located with `--git-path shallow`
|
|
90
|
+
// (git ≥ 2.5) rather than `--is-shallow-repository` (git ≥ 2.15), so the
|
|
91
|
+
// downgrade feature isn't silently disabled on older git toolchains.
|
|
92
|
+
try {
|
|
93
|
+
const { stdout, code } = await runGit(root, ['rev-parse', '--git-path', 'shallow']);
|
|
94
|
+
if (code !== 0)
|
|
95
|
+
return []; // old/unsupported git → fail safe
|
|
96
|
+
const marker = stdout.trim();
|
|
97
|
+
if (!marker)
|
|
98
|
+
return [];
|
|
99
|
+
const abs = node_path_1.default.isAbsolute(marker) ? marker : node_path_1.default.join(root, marker);
|
|
100
|
+
if ((0, node_fs_1.existsSync)(abs))
|
|
101
|
+
return []; // shallow clone → downgrade nothing
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
return [];
|
|
105
|
+
}
|
|
106
|
+
// Exclude any candidate that appears in committed history (every branch, tag,
|
|
107
|
+
// remote-tracking ref). `--all` walks all refs; `--diff-filter=A` emits only
|
|
108
|
+
// ADDED entries so a wide commit contributes just the candidate's
|
|
109
|
+
// introduction (proving it was once in history) while keeping output small.
|
|
110
|
+
// `-m` decomposes merge commits into per-parent diffs — without it, a merge's
|
|
111
|
+
// COMBINED diff omits a file added on only one side of a true 2-parent merge
|
|
112
|
+
// (a recall hole); `-m` surfaces it as "A" against the mainline parent.
|
|
113
|
+
// `--no-renames` keeps names literal so a renamed-away path still matches its
|
|
114
|
+
// old name. The `-- <paths>` pathspec restricts the walk to our candidates.
|
|
115
|
+
try {
|
|
116
|
+
const { stdout, code } = await runGit(root, [
|
|
117
|
+
'log',
|
|
118
|
+
'--all',
|
|
119
|
+
'-m',
|
|
120
|
+
'--no-renames',
|
|
121
|
+
'--diff-filter=A',
|
|
122
|
+
'--format=',
|
|
123
|
+
'--name-only',
|
|
124
|
+
'-z',
|
|
125
|
+
'--',
|
|
126
|
+
...ignoredUntracked,
|
|
127
|
+
]);
|
|
128
|
+
if (code !== 0)
|
|
129
|
+
return []; // fail safe: assume in history
|
|
130
|
+
const candSet = new Set(ignoredUntracked);
|
|
131
|
+
// -z separates with NUL; an empty --format still emits stray newlines. Split
|
|
132
|
+
// on both and keep only candidate tokens (membership is all we need).
|
|
133
|
+
const everCommitted = new Set(stdout.split(/[\0\n]/).filter((t) => candSet.has(t)));
|
|
134
|
+
return ignoredUntracked.filter((p) => !everCommitted.has(p));
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
return [];
|
|
138
|
+
}
|
|
139
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@prodcycle/prodcycle",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.12",
|
|
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": {
|
|
@@ -32,7 +32,10 @@
|
|
|
32
32
|
"security"
|
|
33
33
|
],
|
|
34
34
|
"author": "ProdCycle, Inc. <engineering@prodcycle.com>",
|
|
35
|
-
"license": "
|
|
35
|
+
"license": "Apache-2.0",
|
|
36
|
+
"engines": {
|
|
37
|
+
"node": ">=22"
|
|
38
|
+
},
|
|
36
39
|
"dependencies": {
|
|
37
40
|
"commander": "^12.0.0",
|
|
38
41
|
"minimatch": "^9.0.3"
|