commitgate 0.1.2 → 0.2.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/bin/init.ts +115 -5
- package/package.json +66 -63
package/bin/init.ts
CHANGED
|
@@ -25,6 +25,7 @@ import { resolve, join, dirname, relative } from 'node:path'
|
|
|
25
25
|
import { fileURLToPath, pathToFileURL } from 'node:url'
|
|
26
26
|
import { loadConfig, stripBom, type PackageManager } from '../scripts/req/lib/config'
|
|
27
27
|
import { createGitAdapter } from '../scripts/req/lib/adapters'
|
|
28
|
+
import * as semver from 'semver'
|
|
28
29
|
|
|
29
30
|
/** 이 패키지 루트(bin/ 기준 1단계 위). 복사 원본. */
|
|
30
31
|
const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
|
@@ -37,10 +38,13 @@ const REQ_SCRIPTS: Record<string, string> = {
|
|
|
37
38
|
'req:commit': 'tsx scripts/req/req-commit.ts',
|
|
38
39
|
}
|
|
39
40
|
|
|
41
|
+
/** cross-spawn 주입 spec(= 보안 하한 SSOT). 진단(#1)과 주입이 이 값을 공유. */
|
|
42
|
+
const CROSS_SPAWN_SPEC = '^7.0.6'
|
|
43
|
+
|
|
40
44
|
/** 대상 package.json에 주입할 devDeps(워크플로 실행 전제). cross-spawn = 복사된 adapters.ts의 안전 spawn(P1) 런타임 의존. */
|
|
41
45
|
const REQ_DEV_DEPS: Record<string, string> = {
|
|
42
46
|
ajv: '^8.20.0',
|
|
43
|
-
'cross-spawn':
|
|
47
|
+
'cross-spawn': CROSS_SPAWN_SPEC,
|
|
44
48
|
tsx: '^4.19.1',
|
|
45
49
|
}
|
|
46
50
|
|
|
@@ -48,6 +52,7 @@ export interface InitOptions {
|
|
|
48
52
|
dir: string
|
|
49
53
|
force: boolean
|
|
50
54
|
dryRun: boolean
|
|
55
|
+
strict: boolean // cross-spawn 하한 미만이면 WARN 대신 throw(#1)
|
|
51
56
|
}
|
|
52
57
|
|
|
53
58
|
export interface InitResult {
|
|
@@ -59,6 +64,7 @@ export interface InitResult {
|
|
|
59
64
|
packageJsonAdded: string[] // 추가된 script/devDep 키
|
|
60
65
|
agentsCreated: boolean
|
|
61
66
|
packageManager: PackageManager
|
|
67
|
+
crossSpawnFloorWarned: boolean // 기존 cross-spawn이 보안 하한 미만이라 경고(#1)
|
|
62
68
|
dryRun: boolean
|
|
63
69
|
}
|
|
64
70
|
|
|
@@ -142,6 +148,92 @@ function parseJsonObject(path: string, label: string): Record<string, unknown> {
|
|
|
142
148
|
return parsed as Record<string, unknown>
|
|
143
149
|
}
|
|
144
150
|
|
|
151
|
+
// ─────────────────────────────── cross-spawn 버전 하한 진단 (#1) ──
|
|
152
|
+
|
|
153
|
+
/** 보안 하한 = 주입 spec의 최소버전(SSOT — 하드코딩 이중화 금지). '^7.0.6' → 7.0.6. */
|
|
154
|
+
const CROSS_SPAWN_FLOOR = semver.minVersion(CROSS_SPAWN_SPEC)
|
|
155
|
+
|
|
156
|
+
/** obj가 plain object일 때 obj[key](문자열). 아니면 undefined. */
|
|
157
|
+
function stringField(obj: unknown, key: string): string | undefined {
|
|
158
|
+
if (obj && typeof obj === 'object' && !Array.isArray(obj)) {
|
|
159
|
+
const v = (obj as Record<string, unknown>)[key]
|
|
160
|
+
if (typeof v === 'string') return v
|
|
161
|
+
}
|
|
162
|
+
return undefined
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** 대상의 기존 cross-spawn spec(devDeps 우선, 없으면 deps). 없으면 null. */
|
|
166
|
+
function existingCrossSpawnSpec(pkg: Record<string, unknown>): string | null {
|
|
167
|
+
return stringField(pkg.devDependencies, 'cross-spawn') ?? stringField(pkg.dependencies, 'cross-spawn') ?? null
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** node_modules에 실제 설치된 cross-spawn 버전(valid semver). 없으면 null. */
|
|
171
|
+
function installedCrossSpawnVersion(targetRoot: string): string | null {
|
|
172
|
+
const p = join(targetRoot, 'node_modules', 'cross-spawn', 'package.json')
|
|
173
|
+
if (!existsSync(p)) return null
|
|
174
|
+
try {
|
|
175
|
+
const v = (JSON.parse(stripBom(readFileSync(p, 'utf8'))) as { version?: unknown }).version
|
|
176
|
+
return typeof v === 'string' && semver.valid(v) ? v : null
|
|
177
|
+
} catch {
|
|
178
|
+
return null
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** lockfile 해소 cross-spawn 버전(package-lock v2/v3 JSON 우선, pnpm/yarn best-effort). 없으면 null. */
|
|
183
|
+
function lockedCrossSpawnVersion(targetRoot: string): string | null {
|
|
184
|
+
const pl = join(targetRoot, 'package-lock.json')
|
|
185
|
+
if (existsSync(pl)) {
|
|
186
|
+
try {
|
|
187
|
+
const j = JSON.parse(stripBom(readFileSync(pl, 'utf8'))) as {
|
|
188
|
+
packages?: Record<string, { version?: unknown }>
|
|
189
|
+
dependencies?: Record<string, { version?: unknown }>
|
|
190
|
+
}
|
|
191
|
+
const v = j.packages?.['node_modules/cross-spawn']?.version ?? j.dependencies?.['cross-spawn']?.version
|
|
192
|
+
if (typeof v === 'string' && semver.valid(v)) return v
|
|
193
|
+
} catch {
|
|
194
|
+
/* best-effort */
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
for (const [file, re] of [
|
|
198
|
+
['pnpm-lock.yaml', /cross-spawn@(\d+\.\d+\.\d+)/],
|
|
199
|
+
['yarn.lock', /(?:^|\n)"?cross-spawn@[^\n]*:[\s\S]*?\n\s+version:?\s+"?(\d+\.\d+\.\d+)"?/],
|
|
200
|
+
] as const) {
|
|
201
|
+
const fp = join(targetRoot, file)
|
|
202
|
+
if (!existsSync(fp)) continue
|
|
203
|
+
try {
|
|
204
|
+
const m = readFileSync(fp, 'utf8').match(re)
|
|
205
|
+
if (m?.[1] && semver.valid(m[1])) return m[1]
|
|
206
|
+
} catch {
|
|
207
|
+
/* best-effort */
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return null
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* 기존 cross-spawn이 보안 하한 미만인지 판정(#1). 우선순위: **설치버전 → lockfile 해소버전 → range**.
|
|
215
|
+
* range fallback은 `>=floor`로 절대 해소 불가한 spec만 below로 본다(‘^7.0.0’·‘~7.0.1’ 오탐 방지 — R1 P2).
|
|
216
|
+
* 기존 cross-spawn 없으면 null(우리가 `^7.0.6` 주입 → 진단 불필요).
|
|
217
|
+
*/
|
|
218
|
+
export function crossSpawnBelowFloor(
|
|
219
|
+
targetRoot: string,
|
|
220
|
+
pkg: Record<string, unknown>,
|
|
221
|
+
): { below: boolean; detail: string } | null {
|
|
222
|
+
if (!CROSS_SPAWN_FLOOR) return null // 이론상 도달 불가(REQ_DEV_DEPS 고정값)
|
|
223
|
+
const floor = CROSS_SPAWN_FLOOR.version
|
|
224
|
+
const spec = existingCrossSpawnSpec(pkg)
|
|
225
|
+
if (!spec) return null
|
|
226
|
+
|
|
227
|
+
const installed = installedCrossSpawnVersion(targetRoot)
|
|
228
|
+
if (installed) return { below: semver.lt(installed, floor), detail: `설치버전 ${installed}` }
|
|
229
|
+
|
|
230
|
+
const locked = lockedCrossSpawnVersion(targetRoot)
|
|
231
|
+
if (locked) return { below: semver.lt(locked, floor), detail: `lockfile ${locked}` }
|
|
232
|
+
|
|
233
|
+
if (semver.validRange(spec)) return { below: !semver.intersects(spec, `>=${floor}`), detail: `범위 ${spec}` }
|
|
234
|
+
return { below: false, detail: `범위 ${spec}(파싱 불가 — 무경고)` }
|
|
235
|
+
}
|
|
236
|
+
|
|
145
237
|
/**
|
|
146
238
|
* 설치 코어. IO는 여기서만(테스트가 임시 repo로 직접 호출).
|
|
147
239
|
* **Preflight(전 검증·파싱) → Apply(쓰기) 2단계** — malformed 입력에 대해 어떤 파일도 복사·수정하기 전에 실패한다(부분 설치 방지, design R2 P2).
|
|
@@ -161,11 +253,23 @@ export function runInit(opts: InitOptions): InitResult {
|
|
|
161
253
|
scripts?: Record<string, string>
|
|
162
254
|
devDependencies?: Record<string, string>
|
|
163
255
|
}
|
|
164
|
-
// scripts·devDependencies가 존재하면 반드시 plain object — 배열/원시면 patch
|
|
165
|
-
|
|
256
|
+
// scripts·devDependencies·dependencies가 존재하면 반드시 plain object — 배열/원시면 patch 유실(scripts/devDeps, phase R1 P2)
|
|
257
|
+
// 또는 cross-spawn 진단(dependencies, design R1 P3) 오동작. 읽기 전에 shape 검증(fail-closed).
|
|
258
|
+
for (const field of ['scripts', 'devDependencies', 'dependencies'] as const) {
|
|
166
259
|
const v = (pkg as Record<string, unknown>)[field]
|
|
167
260
|
if (v !== undefined && (typeof v !== 'object' || v === null || Array.isArray(v)))
|
|
168
|
-
throw new Error(`package.json의 ${field} 필드가 객체가 아님(${pkgPath}) —
|
|
261
|
+
throw new Error(`package.json의 ${field} 필드가 객체가 아님(${pkgPath}) — 배열/원시 미지원.`)
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// cross-spawn 보안 하한 진단(#1): 기존 cross-spawn이 하한 미만이면 WARN(기본)/throw(--strict). preflight라 strict throw 시 부분 설치 없음.
|
|
265
|
+
let crossSpawnFloorWarned = false
|
|
266
|
+
const floorCheck = crossSpawnBelowFloor(targetRoot, pkg as Record<string, unknown>)
|
|
267
|
+
if (floorCheck?.below) {
|
|
268
|
+
const spec = CROSS_SPAWN_SPEC
|
|
269
|
+
const msg = `기존 cross-spawn(${floorCheck.detail})이 보안 하한 >=${CROSS_SPAWN_FLOOR?.version} 미만 — CommitGate 안전 경계(safeSpawnSync)는 ${spec} 검증분입니다. 'npm i -D cross-spawn@${spec}' 권장.`
|
|
270
|
+
if (opts.strict) throw new Error(`[--strict] ${msg}`)
|
|
271
|
+
console.warn(`⚠️ ${msg} (설치는 계속 — 강제 중단하려면 --strict)`)
|
|
272
|
+
crossSpawnFloorWarned = true
|
|
169
273
|
}
|
|
170
274
|
|
|
171
275
|
const cfgPath = join(targetRoot, 'req.config.json')
|
|
@@ -247,6 +351,7 @@ export function runInit(opts: InitOptions): InitResult {
|
|
|
247
351
|
packageJsonAdded,
|
|
248
352
|
agentsCreated,
|
|
249
353
|
packageManager,
|
|
354
|
+
crossSpawnFloorWarned,
|
|
250
355
|
dryRun: opts.dryRun,
|
|
251
356
|
}
|
|
252
357
|
}
|
|
@@ -255,6 +360,7 @@ export function parseArgs(argv: string[]): InitOptions {
|
|
|
255
360
|
let dir = process.cwd()
|
|
256
361
|
let force = false
|
|
257
362
|
let dryRun = false
|
|
363
|
+
let strict = false
|
|
258
364
|
for (let i = 0; i < argv.length; i++) {
|
|
259
365
|
const a = argv[i]
|
|
260
366
|
if (a === '--dir') {
|
|
@@ -266,6 +372,8 @@ export function parseArgs(argv: string[]): InitOptions {
|
|
|
266
372
|
force = true
|
|
267
373
|
} else if (a === '--dry-run') {
|
|
268
374
|
dryRun = true
|
|
375
|
+
} else if (a === '--strict') {
|
|
376
|
+
strict = true
|
|
269
377
|
} else if (a === '-h' || a === '--help') {
|
|
270
378
|
printHelp()
|
|
271
379
|
process.exit(0)
|
|
@@ -273,7 +381,7 @@ export function parseArgs(argv: string[]): InitOptions {
|
|
|
273
381
|
throw new Error(`알 수 없는 인자: ${a}`)
|
|
274
382
|
}
|
|
275
383
|
}
|
|
276
|
-
return { dir: resolve(dir), force, dryRun }
|
|
384
|
+
return { dir: resolve(dir), force, dryRun, strict }
|
|
277
385
|
}
|
|
278
386
|
|
|
279
387
|
function printHelp(): void {
|
|
@@ -286,6 +394,7 @@ function printHelp(): void {
|
|
|
286
394
|
--dir <path> 대상 repo 루트(기본: 현재 디렉터리)
|
|
287
395
|
--force 기존 kit 파일 덮어쓰기(기본: 스킵)
|
|
288
396
|
--dry-run 변경 없이 수행 예정 목록만 출력
|
|
397
|
+
--strict 기존 cross-spawn이 보안 하한(>=7.0.6) 미만이면 경고 대신 중단(fail-closed)
|
|
289
398
|
-h, --help 도움말
|
|
290
399
|
|
|
291
400
|
설치 후:
|
|
@@ -315,6 +424,7 @@ export function main(argv: string[]): void {
|
|
|
315
424
|
`${tag} package.json: ${r.packageJsonAdded.length > 0 ? '추가 ' + r.packageJsonAdded.join(', ') : '변경 없음'}`,
|
|
316
425
|
)
|
|
317
426
|
console.log(`${tag} AGENTS.md: ${r.agentsCreated ? '템플릿 생성' : '이미 존재(유지)'}`)
|
|
427
|
+
if (r.crossSpawnFloorWarned) console.log(`${tag} ⚠️ cross-spawn 버전 하한 경고(위 참조) — 강제 중단은 --strict`)
|
|
318
428
|
if (!r.dryRun) {
|
|
319
429
|
console.log(`\n다음:`)
|
|
320
430
|
console.log(` 1. cd ${r.targetRoot} && ${r.packageManager} install`)
|
package/package.json
CHANGED
|
@@ -1,63 +1,66 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "commitgate",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "CommitGate — Builder↔Reviewer(Claude↔Codex) fail-closed 커밋 게이트: 리뷰·승인·증거 없인 커밋을 통과시키지 않는 AI REQ 워크플로 kit (req:new/review-codex/doctor/commit)",
|
|
5
|
-
"type": "module",
|
|
6
|
-
"license": "MIT",
|
|
7
|
-
"author": "sol5288",
|
|
8
|
-
"repository": {
|
|
9
|
-
"type": "git",
|
|
10
|
-
"url": "git+https://github.com/sol5288/commitgate.git"
|
|
11
|
-
},
|
|
12
|
-
"homepage": "https://github.com/sol5288/commitgate#readme",
|
|
13
|
-
"bugs": {
|
|
14
|
-
"url": "https://github.com/sol5288/commitgate/issues"
|
|
15
|
-
},
|
|
16
|
-
"keywords": [
|
|
17
|
-
"ai",
|
|
18
|
-
"code-review",
|
|
19
|
-
"workflow",
|
|
20
|
-
"codex",
|
|
21
|
-
"claude",
|
|
22
|
-
"git",
|
|
23
|
-
"commit-gate",
|
|
24
|
-
"fail-closed",
|
|
25
|
-
"review-gate",
|
|
26
|
-
"cli"
|
|
27
|
-
],
|
|
28
|
-
"bin": {
|
|
29
|
-
"commitgate": "bin/commitgate.mjs"
|
|
30
|
-
},
|
|
31
|
-
"scripts": {
|
|
32
|
-
"req:new": "tsx scripts/req/req-new.ts",
|
|
33
|
-
"req:review-codex": "tsx scripts/req/review-codex.ts",
|
|
34
|
-
"req:doctor": "tsx scripts/req/req-doctor.ts",
|
|
35
|
-
"req:commit": "tsx scripts/req/req-commit.ts",
|
|
36
|
-
"test": "vitest run",
|
|
37
|
-
"typecheck": "tsc --noEmit"
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
"
|
|
42
|
-
"workflow/
|
|
43
|
-
"
|
|
44
|
-
"
|
|
45
|
-
"
|
|
46
|
-
"
|
|
47
|
-
"README.
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
"
|
|
55
|
-
"
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
"
|
|
61
|
-
"
|
|
62
|
-
|
|
63
|
-
|
|
1
|
+
{
|
|
2
|
+
"name": "commitgate",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "CommitGate — Builder↔Reviewer(Claude↔Codex) fail-closed 커밋 게이트: 리뷰·승인·증거 없인 커밋을 통과시키지 않는 AI REQ 워크플로 kit (req:new/review-codex/doctor/commit)",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "sol5288",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/sol5288/commitgate.git"
|
|
11
|
+
},
|
|
12
|
+
"homepage": "https://github.com/sol5288/commitgate#readme",
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/sol5288/commitgate/issues"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"ai",
|
|
18
|
+
"code-review",
|
|
19
|
+
"workflow",
|
|
20
|
+
"codex",
|
|
21
|
+
"claude",
|
|
22
|
+
"git",
|
|
23
|
+
"commit-gate",
|
|
24
|
+
"fail-closed",
|
|
25
|
+
"review-gate",
|
|
26
|
+
"cli"
|
|
27
|
+
],
|
|
28
|
+
"bin": {
|
|
29
|
+
"commitgate": "bin/commitgate.mjs"
|
|
30
|
+
},
|
|
31
|
+
"scripts": {
|
|
32
|
+
"req:new": "tsx scripts/req/req-new.ts",
|
|
33
|
+
"req:review-codex": "tsx scripts/req/review-codex.ts",
|
|
34
|
+
"req:doctor": "tsx scripts/req/req-doctor.ts",
|
|
35
|
+
"req:commit": "tsx scripts/req/req-commit.ts",
|
|
36
|
+
"test": "vitest run",
|
|
37
|
+
"typecheck": "tsc --noEmit",
|
|
38
|
+
"smoke": "node scripts/smoke.mjs"
|
|
39
|
+
},
|
|
40
|
+
"files": [
|
|
41
|
+
"scripts/req",
|
|
42
|
+
"workflow/machine.schema.json",
|
|
43
|
+
"workflow/req.config.schema.json",
|
|
44
|
+
"bin",
|
|
45
|
+
"AGENTS.template.md",
|
|
46
|
+
"req.config.json.sample",
|
|
47
|
+
"README.md",
|
|
48
|
+
"README.en.md"
|
|
49
|
+
],
|
|
50
|
+
"engines": {
|
|
51
|
+
"node": ">=18.17"
|
|
52
|
+
},
|
|
53
|
+
"dependencies": {
|
|
54
|
+
"ajv": "^8.20.0",
|
|
55
|
+
"cross-spawn": "^7.0.6",
|
|
56
|
+
"semver": "^7.6.3",
|
|
57
|
+
"tsx": "^4.19.1"
|
|
58
|
+
},
|
|
59
|
+
"devDependencies": {
|
|
60
|
+
"@types/cross-spawn": "^6.0.6",
|
|
61
|
+
"@types/node": "^22.7.4",
|
|
62
|
+
"@types/semver": "^7.5.8",
|
|
63
|
+
"typescript": "^5.6.2",
|
|
64
|
+
"vitest": "^2.1.2"
|
|
65
|
+
}
|
|
66
|
+
}
|