@cheongfish/bouncer 0.0.0-stage → 0.1.13
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 +202 -0
- package/README.md +31 -2
- package/bin/bouncer +26 -0
- package/bin/bouncer-root +25 -0
- package/bin/build-stamp.js +78 -0
- package/bin/register-path.js +71 -0
- package/dist/.build-stamp +1 -0
- package/dist/approval-snapshot.js +99 -0
- package/dist/bouncer-root.js +80 -0
- package/dist/canonical-json.js +37 -0
- package/dist/cli-commit-guard-command.js +74 -0
- package/dist/cli-config-command.js +47 -0
- package/dist/cli-context-review-command.js +273 -0
- package/dist/cli-current-command.js +374 -0
- package/dist/cli-dispatch-command.js +119 -0
- package/dist/cli-doc-commands.js +352 -0
- package/dist/cli-epic-command.js +130 -0
- package/dist/cli-flags.js +50 -0
- package/dist/cli-git-commands.js +931 -0
- package/dist/cli-intent-command.js +491 -0
- package/dist/cli-metrics-command.js +134 -0
- package/dist/cli-ontology-command.js +109 -0
- package/dist/cli-project-commands.js +580 -0
- package/dist/cli-retro-command.js +138 -0
- package/dist/cli-review-command.js +251 -0
- package/dist/cli-review-dispatch-command.js +338 -0
- package/dist/cli-usage.js +72 -0
- package/dist/cli.js +70 -0
- package/dist/codex-agents.js +189 -0
- package/dist/command-argv.js +125 -0
- package/dist/commit-guard.js +72 -0
- package/dist/commit-hook.js +470 -0
- package/dist/commit-message.js +77 -0
- package/dist/commit-sha.js +107 -0
- package/dist/commit.js +519 -0
- package/dist/composite-gates.js +129 -0
- package/dist/comprehension.js +127 -0
- package/dist/config.js +298 -0
- package/dist/context-ids.js +288 -0
- package/dist/context-migration-rewrite.js +421 -0
- package/dist/context-ontology.js +746 -0
- package/dist/context-review-contracts.js +2 -0
- package/dist/context-review-projection.js +147 -0
- package/dist/context-review-state.js +824 -0
- package/dist/context-review-store.js +602 -0
- package/dist/context-review-transitions.js +412 -0
- package/dist/coordinate-advance.js +253 -0
- package/dist/coordinate-next.js +998 -0
- package/dist/coordinate-output.js +57 -0
- package/dist/coordinator-command-contracts.js +2 -0
- package/dist/coordinator-commands.js +1908 -0
- package/dist/coordinator-fanin.js +1021 -0
- package/dist/coordinator-git.js +121 -0
- package/dist/coordinator-input.js +37 -0
- package/dist/coordinator-ledger.js +381 -0
- package/dist/coordinator-prepare.js +1047 -0
- package/dist/coordinator-repair.js +1416 -0
- package/dist/coordinator-verification.js +42 -0
- package/dist/coordinator-worker.js +388 -0
- package/dist/coordinator.js +7 -0
- package/dist/cross-drive.js +85 -0
- package/dist/current.js +1148 -0
- package/dist/dag-shape.js +93 -0
- package/dist/doc-shape-contracts.js +241 -0
- package/dist/drive-metrics.js +691 -0
- package/dist/epic-index.js +347 -0
- package/dist/epic-registry.js +453 -0
- package/dist/execute-prepare.js +262 -0
- package/dist/execution-contracts.js +46 -0
- package/dist/execution-dag.js +148 -0
- package/dist/finalize-checks.js +177 -0
- package/dist/finalize-collect.js +343 -0
- package/dist/finalize-comprehension.js +116 -0
- package/dist/finalize-digest-collect.js +388 -0
- package/dist/finalize-digest-model.js +595 -0
- package/dist/finalize-digest.js +164 -0
- package/dist/finalize-explain.js +176 -0
- package/dist/finalize-pr.js +610 -0
- package/dist/finalize-release-main.js +652 -0
- package/dist/finalize.js +714 -0
- package/dist/frontmatter.js +22 -0
- package/dist/git-ops.js +49 -0
- package/dist/graph-exec.js +179 -0
- package/dist/graph-scope.js +268 -0
- package/dist/graphify.js +995 -0
- package/dist/guards.js +82 -0
- package/dist/import-git.js +78 -0
- package/dist/import-history.js +392 -0
- package/dist/import-render.js +59 -0
- package/dist/import-types.js +2 -0
- package/dist/init.js +571 -0
- package/dist/intent-bundle.js +936 -0
- package/dist/intent-provenance.js +927 -0
- package/dist/layout.js +131 -0
- package/dist/lease.js +145 -0
- package/dist/legacy-comments.js +71 -0
- package/dist/migrate-context-slugs.js +262 -0
- package/dist/migrate-merge-legacy-epics.js +392 -0
- package/dist/migrate-review-scope.js +144 -0
- package/dist/migrate-task-layout.js +194 -0
- package/dist/path-overlap.js +38 -0
- package/dist/paths.js +45 -0
- package/dist/plan-approve.js +218 -0
- package/dist/plan-inspect.js +575 -0
- package/dist/plan-prerequisites.js +197 -0
- package/dist/plan-snapshot-contract.js +98 -0
- package/dist/plan-snapshot.js +138 -0
- package/dist/plugin-root.js +237 -0
- package/dist/posix-path.js +14 -0
- package/dist/pre-commit-hook.js +170 -0
- package/dist/print-dispatch.js +285 -0
- package/dist/render.js +8 -0
- package/dist/retention-migration.js +595 -0
- package/dist/retro-read.js +259 -0
- package/dist/retro-sources.js +318 -0
- package/dist/review-dispatch.js +1054 -0
- package/dist/review-policy.js +28 -0
- package/dist/review-record.js +447 -0
- package/dist/review-repair-state.js +52 -0
- package/dist/review-snapshot.js +659 -0
- package/dist/run-detach.js +165 -0
- package/dist/run-preflight.js +368 -0
- package/dist/runtime-state.js +1038 -0
- package/dist/scaffold.js +533 -0
- package/dist/schema.js +294 -0
- package/dist/scope.js +1073 -0
- package/dist/seed-worktree.js +926 -0
- package/dist/session-graph.js +337 -0
- package/dist/subagents.js +86 -0
- package/dist/symbol-index.js +1390 -0
- package/dist/task-brief-hash.js +34 -0
- package/dist/task-commits.js +124 -0
- package/dist/tasks-docs.js +192 -0
- package/dist/templates.js +469 -0
- package/dist/time.js +21 -0
- package/dist/validate-docs.js +302 -0
- package/dist/validate-gates-finalize.js +303 -0
- package/dist/validate-gates-plan.js +720 -0
- package/dist/validate-gates-run.js +420 -0
- package/dist/validate-gates-shared.js +124 -0
- package/dist/validate-gates.js +87 -0
- package/dist/validate-sections.js +654 -0
- package/dist/validate-structural.js +347 -0
- package/dist/validate.js +417 -0
- package/dist/verification-contracts.js +26 -0
- package/dist/verification-evidence.js +233 -0
- package/dist/verification-execution.js +163 -0
- package/dist/verification-identity.js +413 -0
- package/dist/verification-target.js +51 -0
- package/dist/verification.js +383 -0
- package/dist/windows-npm.js +38 -0
- package/package.json +32 -4
- package/resources/graphify-compat.json +9 -0
- package/vendor/README.md +23 -0
- package/vendor/js-yaml.LICENSE +21 -0
- package/vendor/js-yaml.js +3022 -0
package/dist/graphify.js
ADDED
|
@@ -0,0 +1,995 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const guards = require("./guards");
|
|
3
|
+
const { catchMessageOrString } = guards;
|
|
4
|
+
const fs = require('node:fs');
|
|
5
|
+
const path = require('node:path');
|
|
6
|
+
const { execFileSync } = require('node:child_process');
|
|
7
|
+
const config = require("./config");
|
|
8
|
+
const { readConfig } = config;
|
|
9
|
+
const runtimeState = require("./runtime-state");
|
|
10
|
+
const { runtimePaths } = runtimeState;
|
|
11
|
+
const time = require("./time");
|
|
12
|
+
const { nowIsoKst } = time;
|
|
13
|
+
/**
|
|
14
|
+
* 레거시·비-git 폴백용 저장소-상대 경로.
|
|
15
|
+
* Windows만 Scripts/ + .exe; 그 외는 bin/. 구분자는 항상 POSIX(/)로 고정해
|
|
16
|
+
* config·테스트·로그가 OS cwd 구분자에 흔들리지 않게 한다.
|
|
17
|
+
* 신규 설치 위치는 preferredVenvDirAbs — 이 값은 기존 소비자 config와
|
|
18
|
+
* `.bouncer/.venv` 재사용 후보에만 쓴다.
|
|
19
|
+
*/
|
|
20
|
+
function venvBinRel(platform) {
|
|
21
|
+
if (platform === 'win32')
|
|
22
|
+
return '.bouncer/.venv/Scripts/graphify.exe';
|
|
23
|
+
return '.bouncer/.venv/bin/graphify';
|
|
24
|
+
}
|
|
25
|
+
// venv 루트 기준 실행 파일. 신규 위치·레거시 위치가 같은 Scripts/ vs bin/ 규칙을 공유한다.
|
|
26
|
+
function venvExecRel(platform, kind) {
|
|
27
|
+
if (platform === 'win32') {
|
|
28
|
+
return kind === 'pip' ? 'Scripts/pip.exe' : 'Scripts/graphify.exe';
|
|
29
|
+
}
|
|
30
|
+
return kind === 'pip' ? 'bin/pip' : 'bin/graphify';
|
|
31
|
+
}
|
|
32
|
+
function preferredVenvDirAbs(repoRoot) {
|
|
33
|
+
// runtime-state와 같은 rev-parse --git-common-dir. 새 경로 개념을 만들지 않는다.
|
|
34
|
+
// common dir 아래면 worktree git add 대상이 아니므로 B1 재현이 끊긴다.
|
|
35
|
+
const paths = runtimePaths({ repoRoot });
|
|
36
|
+
if (!paths.unavailable && typeof paths.commonGitDir === 'string' && paths.commonGitDir) {
|
|
37
|
+
return path.join(paths.commonGitDir, 'bouncer', 'venv');
|
|
38
|
+
}
|
|
39
|
+
// 비-git 디렉터리(init 픽스처 포함)는 common dir이 없다. 기존 위치로 폴백.
|
|
40
|
+
return path.join(repoRoot, '.bouncer', '.venv');
|
|
41
|
+
}
|
|
42
|
+
function venvBinAbsAt(venvDir, platform) {
|
|
43
|
+
return path.join(venvDir, venvExecRel(platform, 'graphify'));
|
|
44
|
+
}
|
|
45
|
+
function venvPythonAbsAt(venvDir, platform) {
|
|
46
|
+
return path.join(venvDir, platform === 'win32' ? 'Scripts/python.exe' : 'bin/python');
|
|
47
|
+
}
|
|
48
|
+
function uvBinFor(platform) {
|
|
49
|
+
return platform === 'win32' ? 'uv.exe' : 'uv';
|
|
50
|
+
}
|
|
51
|
+
// Windows의 python.org 설치본은 `python`과 `py` 런처만 두고 `python3`은 없거나 Store 별칭 stub이다.
|
|
52
|
+
// 그 밖의 플랫폼은 기존처럼 `python3` 하나만 쓴다.
|
|
53
|
+
function pythonCandidates(platform) {
|
|
54
|
+
if (platform === 'win32') {
|
|
55
|
+
return [
|
|
56
|
+
{ file: 'python', args: [] },
|
|
57
|
+
{ file: 'py', args: ['-3'] },
|
|
58
|
+
{ file: 'python3', args: [] },
|
|
59
|
+
];
|
|
60
|
+
}
|
|
61
|
+
return [{ file: 'python3', args: [] }];
|
|
62
|
+
}
|
|
63
|
+
// uv의 `--python` 값. 런처 `py`는 이름으로 넘길 수 없어 후보 중 첫 이름을 쓴다.
|
|
64
|
+
function uvPythonFor(platform) {
|
|
65
|
+
return pythonCandidates(platform)[0].file;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* 후보를 차례로 실행해 처음 성공한 결과를 돌려준다. 후보가 없거나 실패하면(Store stub의 비정상 종료 포함)
|
|
69
|
+
* 다음 후보로 넘어가고, 모두 실패하면 시도한 이름을 담아 던진다.
|
|
70
|
+
*
|
|
71
|
+
* @param {ExecFn} run - process 실행기
|
|
72
|
+
* @param {string} platform - 대상 플랫폼
|
|
73
|
+
* @param {string[]} args - python에 넘길 인자
|
|
74
|
+
* @param {object} options - run 옵션
|
|
75
|
+
* @returns {ReturnType<ExecFn>} 성공한 실행 결과
|
|
76
|
+
*/
|
|
77
|
+
function runPython(run, platform, args, options) {
|
|
78
|
+
const tried = [];
|
|
79
|
+
let last;
|
|
80
|
+
for (const candidate of pythonCandidates(platform)) {
|
|
81
|
+
try {
|
|
82
|
+
return run(candidate.file, [...candidate.args, ...args], options);
|
|
83
|
+
}
|
|
84
|
+
catch (e) {
|
|
85
|
+
tried.push([candidate.file, ...candidate.args].join(' '));
|
|
86
|
+
last = e;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
if (tried.length === 1)
|
|
90
|
+
throw last;
|
|
91
|
+
throw new Error(`no usable python (tried: ${tried.join(', ')}): ${catchMessageOrString(last)}`, { cause: last });
|
|
92
|
+
}
|
|
93
|
+
function removeCreatedVenvDir(venvDir, createdThisRun) {
|
|
94
|
+
// 이번 실행이 mkdir/venv 한 디렉터리만 지운다. 이미 있던 잔해·공유 bouncer/ 는 건드리지 않는다.
|
|
95
|
+
if (!createdThisRun)
|
|
96
|
+
return;
|
|
97
|
+
try {
|
|
98
|
+
fs.rmSync(venvDir, { recursive: true, force: true });
|
|
99
|
+
}
|
|
100
|
+
catch (_e) {
|
|
101
|
+
// 정리 실패는 원래 설치 실패 reason을 덮지 않는다.
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
function defaultHasOnPath() {
|
|
105
|
+
// session-graph의 구 realHasGraphify와 동일 순서: 직접 실행 → command -v.
|
|
106
|
+
// PATH 탐색만 담당; config/venv 후보는 호출 쪽에서 이미 걸러진다.
|
|
107
|
+
try {
|
|
108
|
+
execFileSync('graphify', ['--version'], { stdio: 'ignore' });
|
|
109
|
+
return true;
|
|
110
|
+
}
|
|
111
|
+
catch (_e) {
|
|
112
|
+
try {
|
|
113
|
+
execFileSync('command', ['-v', 'graphify'], { stdio: 'ignore', shell: true });
|
|
114
|
+
return true;
|
|
115
|
+
}
|
|
116
|
+
catch (_e2) {
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* graphify 실행 파일 단일 해석기.
|
|
123
|
+
* 후보 순서: config.graphify.bin → venv(신규 common-dir, 그다음 레거시) → PATH의 `graphify`.
|
|
124
|
+
* 어떤 입력에도 throw하지 않으며, 후보가 없으면 { bin: null, source: null }.
|
|
125
|
+
*
|
|
126
|
+
* @param {{
|
|
127
|
+
* repoRoot: string,
|
|
128
|
+
* config?: any,
|
|
129
|
+
* platform?: string,
|
|
130
|
+
* exists?: (abs: string) => boolean,
|
|
131
|
+
* hasOnPath?: () => boolean,
|
|
132
|
+
* }} opts
|
|
133
|
+
* @returns {{ bin: string | null, source: 'config' | 'venv' | 'path' | null }}
|
|
134
|
+
*/
|
|
135
|
+
function resolveGraphifyBin({ repoRoot, config, platform, exists, hasOnPath, } = {}) {
|
|
136
|
+
try {
|
|
137
|
+
const root = typeof repoRoot === 'string' ? repoRoot : process.cwd();
|
|
138
|
+
const plat = typeof platform === 'string' ? platform : process.platform;
|
|
139
|
+
const fileExists = typeof exists === 'function'
|
|
140
|
+
? exists
|
|
141
|
+
: (p) => fs.existsSync(p);
|
|
142
|
+
const onPath = typeof hasOnPath === 'function' ? hasOnPath : defaultHasOnPath;
|
|
143
|
+
// 주입이 없으면 디스크에서 읽되, 실패는 null — ?? {}를 붙이면
|
|
144
|
+
// 아래 cfg && typeof cfg === 'object' 가 빈 객체를 설정 있음으로 본다.
|
|
145
|
+
const cfg = config === undefined ? readConfig(root) : config;
|
|
146
|
+
const rawBin = cfg && typeof cfg === 'object' && !Array.isArray(cfg)
|
|
147
|
+
&& cfg.graphify
|
|
148
|
+
&& typeof cfg.graphify === 'object'
|
|
149
|
+
? cfg.graphify.bin
|
|
150
|
+
: undefined;
|
|
151
|
+
// 비문자열·빈 문자열은 "설정 없음" — 다음 후보로 내려간다.
|
|
152
|
+
if (typeof rawBin === 'string' && rawBin.length > 0) {
|
|
153
|
+
const abs = path.isAbsolute(rawBin) ? rawBin : path.join(root, rawBin);
|
|
154
|
+
if (fileExists(abs)) {
|
|
155
|
+
return { bin: abs, source: 'config' };
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
const preferredBin = venvBinAbsAt(preferredVenvDirAbs(root), plat);
|
|
159
|
+
if (fileExists(preferredBin)) {
|
|
160
|
+
return { bin: preferredBin, source: 'venv' };
|
|
161
|
+
}
|
|
162
|
+
const legacyBin = path.join(root, venvBinRel(plat));
|
|
163
|
+
if (legacyBin !== preferredBin && fileExists(legacyBin)) {
|
|
164
|
+
return { bin: legacyBin, source: 'venv' };
|
|
165
|
+
}
|
|
166
|
+
if (onPath()) {
|
|
167
|
+
// PATH 폴백은 이름만 반환 — 절대 경로로 which하지 않는다(소비자가 execFile에 넘김).
|
|
168
|
+
return { bin: 'graphify', source: 'path' };
|
|
169
|
+
}
|
|
170
|
+
return { bin: null, source: null };
|
|
171
|
+
}
|
|
172
|
+
catch (_e) {
|
|
173
|
+
// 계약: 어떤 입력·주입 실패에도 throw하지 않는다.
|
|
174
|
+
return { bin: null, source: null };
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
const GRAPHIFY_LOCK_REL = '.bouncer/graphify.lock.json';
|
|
178
|
+
// leftover graphify-out/context 는 승격 대상이 아니다 — snapshot·restore·stamp에서 뺀다.
|
|
179
|
+
const GRAPH_OUT_DIRS = ['graphify-out/source', 'graphify-out/test'];
|
|
180
|
+
function packageRootFromDist() {
|
|
181
|
+
// 런타임 자산(resources, package.json)은 packages/cli 아래에 있으므로 dist 기준 한 단계 위를 읽는다.
|
|
182
|
+
return path.join(__dirname, '..');
|
|
183
|
+
}
|
|
184
|
+
function defaultManifestPath() {
|
|
185
|
+
return path.join(packageRootFromDist(), 'resources', 'graphify-compat.json');
|
|
186
|
+
}
|
|
187
|
+
function parseDottedVersion(text) {
|
|
188
|
+
const match = String(text || '').match(/(\d+\.\d+\.\d+)/);
|
|
189
|
+
return match ? match[1] : null;
|
|
190
|
+
}
|
|
191
|
+
function packageVersionFromSpec(installSpec) {
|
|
192
|
+
const match = String(installSpec || '').match(/==(.+)$/);
|
|
193
|
+
return match ? match[1] : null;
|
|
194
|
+
}
|
|
195
|
+
function isNonEmptyString(value) {
|
|
196
|
+
return typeof value === 'string' && value.length > 0;
|
|
197
|
+
}
|
|
198
|
+
function readPluginVersion() {
|
|
199
|
+
try {
|
|
200
|
+
const body = JSON.parse(fs.readFileSync(path.join(packageRootFromDist(), 'package.json'), 'utf8'));
|
|
201
|
+
if (body.name === '@cheongfish/bouncer' && isNonEmptyString(body.version))
|
|
202
|
+
return body.version;
|
|
203
|
+
}
|
|
204
|
+
catch (_e) {
|
|
205
|
+
// 배포 package metadata가 없으면 호환성 lock에 안전한 sentinel을 기록한다.
|
|
206
|
+
}
|
|
207
|
+
return '0.0.0';
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* 플러그인 소유 Graphify 호환 계약을 읽는다. 소비 저장소 config에 권장 버전을
|
|
211
|
+
* 복제하지 않고, 이 파일만 정본으로 쓴다.
|
|
212
|
+
*
|
|
213
|
+
* @param {{ manifestPath?: string }} [opts] - 테스트 주입용 절대 경로
|
|
214
|
+
* @returns {{ ok: true, value: CompatManifest } | { ok: false, reason: string }}
|
|
215
|
+
*/
|
|
216
|
+
function loadCompatManifest(opts = {}) {
|
|
217
|
+
const abs = typeof opts.manifestPath === 'string' && opts.manifestPath
|
|
218
|
+
? opts.manifestPath
|
|
219
|
+
: defaultManifestPath();
|
|
220
|
+
try {
|
|
221
|
+
const raw = JSON.parse(fs.readFileSync(abs, 'utf8'));
|
|
222
|
+
if (raw.schema_version !== 1
|
|
223
|
+
|| !isNonEmptyString(raw.package)
|
|
224
|
+
|| !isNonEmptyString(raw.install_spec)
|
|
225
|
+
|| !isNonEmptyString(raw.cli_version)
|
|
226
|
+
|| !isNonEmptyString(raw.graph_schema_version)
|
|
227
|
+
|| !isNonEmptyString(raw.python_minimum)) {
|
|
228
|
+
return { ok: false, reason: 'invalid' };
|
|
229
|
+
}
|
|
230
|
+
const value = {
|
|
231
|
+
schema_version: 1,
|
|
232
|
+
package: raw.package,
|
|
233
|
+
install_spec: raw.install_spec,
|
|
234
|
+
cli_version: raw.cli_version,
|
|
235
|
+
graph_schema_version: raw.graph_schema_version,
|
|
236
|
+
python_minimum: raw.python_minimum,
|
|
237
|
+
};
|
|
238
|
+
if (isNonEmptyString(raw.skill_version))
|
|
239
|
+
value.skill_version = raw.skill_version;
|
|
240
|
+
return { ok: true, value };
|
|
241
|
+
}
|
|
242
|
+
catch (error) {
|
|
243
|
+
const err = error;
|
|
244
|
+
return {
|
|
245
|
+
ok: false,
|
|
246
|
+
reason: err && err.code === 'ENOENT' ? 'missing' : 'corrupt',
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
function lockPathFor(repoRoot) {
|
|
251
|
+
return path.join(repoRoot, GRAPHIFY_LOCK_REL);
|
|
252
|
+
}
|
|
253
|
+
function asGraphifyLock(raw) {
|
|
254
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw))
|
|
255
|
+
return null;
|
|
256
|
+
const rec = raw;
|
|
257
|
+
if (rec.schema_version !== 1
|
|
258
|
+
|| !isNonEmptyString(rec.package)
|
|
259
|
+
|| !isNonEmptyString(rec.package_version)
|
|
260
|
+
|| !isNonEmptyString(rec.cli_version)
|
|
261
|
+
|| !isNonEmptyString(rec.bouncer_version)
|
|
262
|
+
|| !isNonEmptyString(rec.graph_schema_version)
|
|
263
|
+
|| !isNonEmptyString(rec.installed_at)) {
|
|
264
|
+
return null;
|
|
265
|
+
}
|
|
266
|
+
const lock = {
|
|
267
|
+
schema_version: 1,
|
|
268
|
+
package: rec.package,
|
|
269
|
+
package_version: rec.package_version,
|
|
270
|
+
cli_version: rec.cli_version,
|
|
271
|
+
bouncer_version: rec.bouncer_version,
|
|
272
|
+
graph_schema_version: rec.graph_schema_version,
|
|
273
|
+
installed_at: rec.installed_at,
|
|
274
|
+
};
|
|
275
|
+
if (isNonEmptyString(rec.executable))
|
|
276
|
+
lock.executable = rec.executable;
|
|
277
|
+
return lock;
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* 소비 저장소 lock을 읽는다. 조회 경로에서는 파일 읽기만 하고 쓰지 않는다.
|
|
281
|
+
*
|
|
282
|
+
* @param {{ repoRoot: string }} opts - 소비 저장소 루트
|
|
283
|
+
* @returns {{ ok: true, value: GraphifyLock } | { ok: false, reason: string }}
|
|
284
|
+
*/
|
|
285
|
+
function readGraphifyLock({ repoRoot } = {}) {
|
|
286
|
+
const root = typeof repoRoot === 'string' ? repoRoot : process.cwd();
|
|
287
|
+
const abs = lockPathFor(root);
|
|
288
|
+
if (!fs.existsSync(abs))
|
|
289
|
+
return { ok: false, reason: 'missing' };
|
|
290
|
+
try {
|
|
291
|
+
const lock = asGraphifyLock(JSON.parse(fs.readFileSync(abs, 'utf8')));
|
|
292
|
+
if (!lock)
|
|
293
|
+
return { ok: false, reason: 'invalid' };
|
|
294
|
+
return { ok: true, value: lock };
|
|
295
|
+
}
|
|
296
|
+
catch (_e) {
|
|
297
|
+
return { ok: false, reason: 'corrupt' };
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
/**
|
|
301
|
+
* lock을 같은 디렉터리 tmp + rename으로 원자 기록한다. 다른 볼륨 tmp는
|
|
302
|
+
* 쓰지 않는다 — rename이 copy가 되면 중간 파일이 남는다.
|
|
303
|
+
*
|
|
304
|
+
* @param {{ repoRoot: string, lock: GraphifyLock }} opts - 기록 대상
|
|
305
|
+
* @returns {{ ok: boolean, path: string, reason?: string }}
|
|
306
|
+
*/
|
|
307
|
+
function writeGraphifyLock({ repoRoot, lock }) {
|
|
308
|
+
const parsed = asGraphifyLock(lock);
|
|
309
|
+
const abs = lockPathFor(repoRoot);
|
|
310
|
+
if (!parsed)
|
|
311
|
+
return { ok: false, path: abs, reason: 'invalid' };
|
|
312
|
+
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
313
|
+
const temporary = `${abs}.${process.pid}.tmp`;
|
|
314
|
+
try {
|
|
315
|
+
fs.writeFileSync(temporary, `${JSON.stringify(parsed, null, 2)}\n`);
|
|
316
|
+
fs.renameSync(temporary, abs);
|
|
317
|
+
return { ok: true, path: abs };
|
|
318
|
+
}
|
|
319
|
+
catch (error) {
|
|
320
|
+
try {
|
|
321
|
+
fs.rmSync(temporary, { force: true });
|
|
322
|
+
}
|
|
323
|
+
catch (_cleanup) {
|
|
324
|
+
// tmp 정리는 best-effort. rename 실패 원인을 가리지 않는다.
|
|
325
|
+
}
|
|
326
|
+
return { ok: false, path: abs, reason: catchMessageOrString(error) };
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
function lockMatchesManifest(lock, manifest) {
|
|
330
|
+
const reasons = [];
|
|
331
|
+
const expectedPkg = packageVersionFromSpec(manifest.install_spec);
|
|
332
|
+
if (lock.package !== manifest.package) {
|
|
333
|
+
reasons.push(`package ${lock.package} != ${manifest.package}`);
|
|
334
|
+
}
|
|
335
|
+
if (expectedPkg && lock.package_version !== expectedPkg) {
|
|
336
|
+
reasons.push(`package_version ${lock.package_version} != ${expectedPkg}`);
|
|
337
|
+
}
|
|
338
|
+
if (lock.cli_version !== manifest.cli_version) {
|
|
339
|
+
reasons.push(`cli_version ${lock.cli_version} != ${manifest.cli_version}`);
|
|
340
|
+
}
|
|
341
|
+
if (lock.graph_schema_version !== manifest.graph_schema_version) {
|
|
342
|
+
reasons.push(`graph_schema_version ${lock.graph_schema_version} != ${manifest.graph_schema_version}`);
|
|
343
|
+
}
|
|
344
|
+
return reasons;
|
|
345
|
+
}
|
|
346
|
+
function probeCliVersion(bin, run) {
|
|
347
|
+
try {
|
|
348
|
+
const out = run(bin, ['--version'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
|
|
349
|
+
return parseDottedVersion(String(out || ''));
|
|
350
|
+
}
|
|
351
|
+
catch (_e) {
|
|
352
|
+
return null;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
function probePackageVersion(pipAbs, pkg, run) {
|
|
356
|
+
try {
|
|
357
|
+
const out = run(pipAbs, ['show', pkg], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
|
|
358
|
+
const match = String(out || '').match(/^Version:\s*(\S+)/m);
|
|
359
|
+
return match ? match[1] : parseDottedVersion(String(out || ''));
|
|
360
|
+
}
|
|
361
|
+
catch (_e) {
|
|
362
|
+
return null;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
function pythonMeetsMinimum(minimum, run, platform) {
|
|
366
|
+
try {
|
|
367
|
+
const out = runPython(run, platform, ['--version'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
|
|
368
|
+
const match = String(out || '').match(/(\d+)\.(\d+)/);
|
|
369
|
+
if (!match)
|
|
370
|
+
return null;
|
|
371
|
+
const [minMaj, minMin] = String(minimum).split('.').map((n) => Number(n) || 0);
|
|
372
|
+
const maj = Number(match[1]);
|
|
373
|
+
const min = Number(match[2]);
|
|
374
|
+
return maj > minMaj || (maj === minMaj && min >= minMin);
|
|
375
|
+
}
|
|
376
|
+
catch (_e) {
|
|
377
|
+
return null;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
/**
|
|
381
|
+
* 설치가 확인한 CLI·패키지 버전을 lock에 기록한다. write 실패를 삼키면
|
|
382
|
+
* 승격이 upgraded로 끝나 새 venv와 옛 lock(또는 lock 없음)이 어긋난다.
|
|
383
|
+
*
|
|
384
|
+
* @param {{ repoRoot: string, bin: string, pipAbs: string, run: ExecFn,
|
|
385
|
+
* now: () => string, manifest: CompatManifest }} opts
|
|
386
|
+
* @returns {{ ok: true } | { ok: false, reason: string }}
|
|
387
|
+
*/
|
|
388
|
+
function recordLockFromInstall({ repoRoot, bin, pipAbs, run, now, manifest, }) {
|
|
389
|
+
const cli = probeCliVersion(bin, run) || manifest.cli_version;
|
|
390
|
+
const pkg = probePackageVersion(pipAbs, manifest.package, run)
|
|
391
|
+
|| packageVersionFromSpec(manifest.install_spec)
|
|
392
|
+
|| manifest.cli_version;
|
|
393
|
+
const written = writeGraphifyLock({
|
|
394
|
+
repoRoot,
|
|
395
|
+
lock: {
|
|
396
|
+
schema_version: 1,
|
|
397
|
+
package: manifest.package,
|
|
398
|
+
package_version: pkg,
|
|
399
|
+
cli_version: cli,
|
|
400
|
+
bouncer_version: readPluginVersion(),
|
|
401
|
+
graph_schema_version: manifest.graph_schema_version,
|
|
402
|
+
installed_at: now(),
|
|
403
|
+
executable: bin,
|
|
404
|
+
},
|
|
405
|
+
});
|
|
406
|
+
if (!written.ok) {
|
|
407
|
+
return { ok: false, reason: written.reason || 'lock-write' };
|
|
408
|
+
}
|
|
409
|
+
return { ok: true };
|
|
410
|
+
}
|
|
411
|
+
/**
|
|
412
|
+
* lock·manifest·실행 파일 버전을 비교한다. 조회 경로는 파일 읽기와 process
|
|
413
|
+
* 실행만 허용하며 pip·venv·network 쓰기를 하지 않는다.
|
|
414
|
+
*
|
|
415
|
+
* @param {{ repoRoot?: string, exec?: typeof execFileSync, probeSkillVersion?: () => string | null,
|
|
416
|
+
* platform?: string }} opts
|
|
417
|
+
* @returns {CompatResult} compatible | version-incompatible
|
|
418
|
+
*/
|
|
419
|
+
function checkGraphifyCompatibility({ repoRoot, exec, probeSkillVersion, platform, } = {}) {
|
|
420
|
+
const root = typeof repoRoot === 'string' ? repoRoot : process.cwd();
|
|
421
|
+
const run = typeof exec === 'function' ? exec : execFileSync;
|
|
422
|
+
const warnings = [];
|
|
423
|
+
const manifestLoad = loadCompatManifest();
|
|
424
|
+
if (!manifestLoad.ok) {
|
|
425
|
+
return {
|
|
426
|
+
status: 'version-incompatible',
|
|
427
|
+
reasons: [`manifest ${manifestLoad.reason}`],
|
|
428
|
+
warnings,
|
|
429
|
+
lock: null,
|
|
430
|
+
manifest: null,
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
const manifest = manifestLoad.value;
|
|
434
|
+
const lockLoad = readGraphifyLock({ repoRoot: root });
|
|
435
|
+
if (!lockLoad.ok) {
|
|
436
|
+
return {
|
|
437
|
+
status: 'version-incompatible',
|
|
438
|
+
reasons: [`lock ${lockLoad.reason}`],
|
|
439
|
+
warnings,
|
|
440
|
+
lock: null,
|
|
441
|
+
manifest,
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
const lock = lockLoad.value;
|
|
445
|
+
const reasons = lockMatchesManifest(lock, manifest);
|
|
446
|
+
const pythonOk = pythonMeetsMinimum(manifest.python_minimum, run, platform || process.platform);
|
|
447
|
+
if (pythonOk === false) {
|
|
448
|
+
reasons.push(`python < ${manifest.python_minimum}`);
|
|
449
|
+
}
|
|
450
|
+
const resolved = resolveGraphifyBin({ repoRoot: root });
|
|
451
|
+
let probeBin = null;
|
|
452
|
+
if (isNonEmptyString(lock.executable) && fs.existsSync(lock.executable)) {
|
|
453
|
+
probeBin = lock.executable;
|
|
454
|
+
}
|
|
455
|
+
else if (resolved.source === 'config' || resolved.source === 'venv') {
|
|
456
|
+
probeBin = resolved.bin;
|
|
457
|
+
}
|
|
458
|
+
// PATH 후보는 소비 저장소 lock이 가리키는 설치가 아니다. lock↔manifest만 본다.
|
|
459
|
+
if (probeBin) {
|
|
460
|
+
const probed = probeCliVersion(probeBin, run);
|
|
461
|
+
if (probed && probed !== manifest.cli_version) {
|
|
462
|
+
reasons.push(`probed cli ${probed} != ${manifest.cli_version}`);
|
|
463
|
+
}
|
|
464
|
+
if (probed && probed !== lock.cli_version) {
|
|
465
|
+
reasons.push(`probed cli ${probed} != lock ${lock.cli_version}`);
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
if (typeof probeSkillVersion === 'function' && isNonEmptyString(manifest.skill_version)) {
|
|
469
|
+
const skill = probeSkillVersion();
|
|
470
|
+
if (isNonEmptyString(skill) && skill !== manifest.skill_version) {
|
|
471
|
+
warnings.push(`skill version ${skill} != ${manifest.skill_version}`);
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
return {
|
|
475
|
+
status: reasons.length ? 'version-incompatible' : 'compatible',
|
|
476
|
+
reasons,
|
|
477
|
+
warnings,
|
|
478
|
+
lock,
|
|
479
|
+
manifest,
|
|
480
|
+
};
|
|
481
|
+
}
|
|
482
|
+
/**
|
|
483
|
+
* outDir 아래 일반 파일만 모은다. 디렉터리 엔트리는 파일 경로 mkdir로
|
|
484
|
+
* 되살리므로 따로 저장하지 않는다. 심볼릭 링크는 따라가지 않는다 —
|
|
485
|
+
* graph 산출물에 링크가 있으면 승격 스냅샷이 저장소 밖을 읽지 않게 한다.
|
|
486
|
+
*
|
|
487
|
+
* @param {string} abs - graph 출력 디렉터리 절대 경로
|
|
488
|
+
* @returns {Array<{ rel: string, body: Buffer }>}
|
|
489
|
+
*/
|
|
490
|
+
function listGraphTreeFiles(abs) {
|
|
491
|
+
const files = [];
|
|
492
|
+
const walk = (dir) => {
|
|
493
|
+
let entries;
|
|
494
|
+
try {
|
|
495
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
496
|
+
}
|
|
497
|
+
catch (_e) {
|
|
498
|
+
// 순회 중 삭제된 하위 디렉터리는 빈 목록으로 둔다.
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
for (const entry of entries) {
|
|
502
|
+
const child = path.join(dir, entry.name);
|
|
503
|
+
if (entry.isDirectory()) {
|
|
504
|
+
walk(child);
|
|
505
|
+
}
|
|
506
|
+
else if (entry.isFile()) {
|
|
507
|
+
files.push({ rel: path.relative(abs, child), body: fs.readFileSync(child) });
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
};
|
|
511
|
+
walk(abs);
|
|
512
|
+
return files;
|
|
513
|
+
}
|
|
514
|
+
/**
|
|
515
|
+
* source·test graph 출력 트리 전체를 스냅샷한다. graph.json만 되돌리면 rebuild가
|
|
516
|
+
* 만든 sidecar·하위 파일이 실패 후에도 남고, 원래 없던 트리는 삭제되지 않는다.
|
|
517
|
+
* leftover context 트리는 승격 대상이 아니므로 여기 넣지 않는다.
|
|
518
|
+
*
|
|
519
|
+
* @param {string} repoRoot - 소비 저장소 루트
|
|
520
|
+
* @returns {GraphTreeSnap[]}
|
|
521
|
+
*/
|
|
522
|
+
function snapshotGraphs(repoRoot) {
|
|
523
|
+
return GRAPH_OUT_DIRS.map((outDir) => {
|
|
524
|
+
const abs = path.join(repoRoot, outDir);
|
|
525
|
+
try {
|
|
526
|
+
const st = fs.statSync(abs);
|
|
527
|
+
if (!st.isDirectory()) {
|
|
528
|
+
return { abs, existed: false, files: [] };
|
|
529
|
+
}
|
|
530
|
+
return { abs, existed: true, files: listGraphTreeFiles(abs) };
|
|
531
|
+
}
|
|
532
|
+
catch (_e) {
|
|
533
|
+
return { abs, existed: false, files: [] };
|
|
534
|
+
}
|
|
535
|
+
});
|
|
536
|
+
}
|
|
537
|
+
/**
|
|
538
|
+
* 스냅샷 트리를 통째로 되돌린다. graph.json만 덮으면 rebuild sidecar가 남고,
|
|
539
|
+
* 원래 없던 출력 디렉터리는 삭제해야 승격 실패가 반쯤 새 graph를 남기지 않는다.
|
|
540
|
+
*
|
|
541
|
+
* @param {GraphTreeSnap[]} snapshot - snapshotGraphs 결과
|
|
542
|
+
* @returns {void}
|
|
543
|
+
*/
|
|
544
|
+
function restoreGraphs(snapshot) {
|
|
545
|
+
for (const entry of snapshot) {
|
|
546
|
+
removeDirIfExists(entry.abs);
|
|
547
|
+
if (!entry.existed)
|
|
548
|
+
continue;
|
|
549
|
+
fs.mkdirSync(entry.abs, { recursive: true });
|
|
550
|
+
for (const file of entry.files) {
|
|
551
|
+
const dest = path.join(entry.abs, file.rel);
|
|
552
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
553
|
+
fs.writeFileSync(dest, file.body);
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
/**
|
|
558
|
+
* source·test graph.json에 플러그인이 검증한 schema 버전을 찍는다. Graphify 원본
|
|
559
|
+
* 필드와 섞이지 않게 metadata 아래로만 넣는다. leftover context 는 건드리지 않는다.
|
|
560
|
+
*
|
|
561
|
+
* @param {string} repoRoot - 소비 저장소 루트
|
|
562
|
+
* @param {string} schemaVersion - manifest.graph_schema_version
|
|
563
|
+
* @returns {void}
|
|
564
|
+
*/
|
|
565
|
+
function stampGraphSchema(repoRoot, schemaVersion) {
|
|
566
|
+
for (const outDir of GRAPH_OUT_DIRS) {
|
|
567
|
+
const abs = path.join(repoRoot, outDir, 'graph.json');
|
|
568
|
+
if (!fs.existsSync(abs))
|
|
569
|
+
continue;
|
|
570
|
+
try {
|
|
571
|
+
const graph = JSON.parse(fs.readFileSync(abs, 'utf8'));
|
|
572
|
+
if (!graph || typeof graph !== 'object' || Array.isArray(graph))
|
|
573
|
+
continue;
|
|
574
|
+
const prev = graph.metadata && typeof graph.metadata === 'object' && !Array.isArray(graph.metadata)
|
|
575
|
+
? graph.metadata
|
|
576
|
+
: {};
|
|
577
|
+
graph.metadata = { ...prev, graph_schema_version: schemaVersion };
|
|
578
|
+
fs.writeFileSync(abs, JSON.stringify(graph));
|
|
579
|
+
}
|
|
580
|
+
catch (_e) {
|
|
581
|
+
// 깨진 graph는 스탬프를 건너뛴다. upgrade 실패로 올리지 않는다.
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
// CLI syncSessionGraphs(force)가 빌드 없이 돌려주는 skip. failed[]가 비어도
|
|
586
|
+
// 승격 성공으로 치면 stampGraphSchema가 옛 graph.json만 새 schema로 찍는다.
|
|
587
|
+
const REBUILD_SKIP_WITHOUT_BUILD = new Set([
|
|
588
|
+
'skip-version-incompatible',
|
|
589
|
+
'skip-graph-disabled',
|
|
590
|
+
'skip-partial-bootstrap',
|
|
591
|
+
'skip-legacy-bootstrap',
|
|
592
|
+
'skip-no-graphify',
|
|
593
|
+
'skip-fresh',
|
|
594
|
+
]);
|
|
595
|
+
/**
|
|
596
|
+
* force rebuild 결과가 승격을 커밋해도 되는지 본다. failed[]만 보면
|
|
597
|
+
* skip-version-incompatible 등이 성공으로 위장되고 옛 graph가 stamp된다.
|
|
598
|
+
* skip-no-dirs는 소스 디렉터리가 없는 순수 no-op이라 실패가 아니다.
|
|
599
|
+
* 테스트 스텁({ ok: true } 등 action 없음)은 기존처럼 failed[]만 본다.
|
|
600
|
+
*
|
|
601
|
+
* @param {unknown} rebuilt - rebuild 콜백 반환값
|
|
602
|
+
* @returns {string | null} 실패 reason. 커밋해도 되면 null
|
|
603
|
+
*/
|
|
604
|
+
function rebuildFailureReason(rebuilt) {
|
|
605
|
+
if (!rebuilt || typeof rebuilt !== 'object')
|
|
606
|
+
return null;
|
|
607
|
+
const rec = rebuilt;
|
|
608
|
+
if (Array.isArray(rec.failed) && rec.failed.length > 0) {
|
|
609
|
+
const detail = rec.failed
|
|
610
|
+
.map((entry) => {
|
|
611
|
+
if (!entry || typeof entry !== 'object')
|
|
612
|
+
return String(entry);
|
|
613
|
+
const row = entry;
|
|
614
|
+
const name = typeof row.name === 'string' ? row.name : 'unknown';
|
|
615
|
+
const message = typeof row.message === 'string' ? row.message : 'failed';
|
|
616
|
+
return `${name}: ${message}`;
|
|
617
|
+
})
|
|
618
|
+
.join('; ');
|
|
619
|
+
return detail ? `rebuild-failed (${detail})` : 'rebuild-failed';
|
|
620
|
+
}
|
|
621
|
+
const action = rec.action;
|
|
622
|
+
if (typeof action === 'string' && REBUILD_SKIP_WITHOUT_BUILD.has(action)) {
|
|
623
|
+
return `rebuild-${action}`;
|
|
624
|
+
}
|
|
625
|
+
return null;
|
|
626
|
+
}
|
|
627
|
+
function upgradeLockPath(venvDir) {
|
|
628
|
+
return path.join(path.dirname(venvDir), 'upgrade.lock');
|
|
629
|
+
}
|
|
630
|
+
function removeDirIfExists(abs) {
|
|
631
|
+
try {
|
|
632
|
+
fs.rmSync(abs, { recursive: true, force: true });
|
|
633
|
+
}
|
|
634
|
+
catch (_e) {
|
|
635
|
+
// 스테이징 정리는 best-effort.
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
/**
|
|
639
|
+
* 표준 venv가 ensurepip 부재로 실패하는 최소 Linux 설치에서도 로컬 uv를
|
|
640
|
+
* 폴백으로 쓴다. uv에는 pip 실행 파일이 필요하므로 --seed를, upgrade의
|
|
641
|
+
* staging rename 뒤 console-script shebang이 깨지지 않도록 --relocatable을 붙인다.
|
|
642
|
+
* 기존 디렉터리는 호출자가 이번 시도용으로 새로 만든 것이 확실할 때만 지운다.
|
|
643
|
+
*/
|
|
644
|
+
function createSeededVenv({ root, venvDir, plat, run, replaceOnFallback, }) {
|
|
645
|
+
try {
|
|
646
|
+
runPython(run, plat, ['-m', 'venv', venvDir], { cwd: root, stdio: 'pipe' });
|
|
647
|
+
return { ok: true, provider: 'python' };
|
|
648
|
+
}
|
|
649
|
+
catch (pythonError) {
|
|
650
|
+
if (!replaceOnFallback) {
|
|
651
|
+
return { ok: false, reason: `venv: ${catchMessageOrString(pythonError)}` };
|
|
652
|
+
}
|
|
653
|
+
// 실패한 python venv가 남긴 부분 디렉터리만 정리한다. upgrade staging과
|
|
654
|
+
// setup이 이번 실행에서 처음 만든 경로만 replaceOnFallback=true를 넘긴다.
|
|
655
|
+
removeDirIfExists(venvDir);
|
|
656
|
+
const uvBin = uvBinFor(plat);
|
|
657
|
+
try {
|
|
658
|
+
run(uvBin, ['venv', '--seed', '--relocatable', '--python', uvPythonFor(plat), venvDir], {
|
|
659
|
+
cwd: root,
|
|
660
|
+
stdio: 'pipe',
|
|
661
|
+
});
|
|
662
|
+
return { ok: true, provider: 'uv' };
|
|
663
|
+
}
|
|
664
|
+
catch (uvError) {
|
|
665
|
+
return {
|
|
666
|
+
ok: false,
|
|
667
|
+
reason: `venv: ${catchMessageOrString(pythonError)}; uv fallback: ${catchMessageOrString(uvError)}`,
|
|
668
|
+
};
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
function installExactSpec({ root, venvDir, plat, run, spec, }) {
|
|
673
|
+
const venv = createSeededVenv({
|
|
674
|
+
root,
|
|
675
|
+
venvDir,
|
|
676
|
+
plat,
|
|
677
|
+
run,
|
|
678
|
+
// upgrade의 stagingDir는 호출 직전 항상 제거한 전용 경로다.
|
|
679
|
+
replaceOnFallback: true,
|
|
680
|
+
});
|
|
681
|
+
if (!venv.ok)
|
|
682
|
+
return venv;
|
|
683
|
+
if (!fs.existsSync(venvDir))
|
|
684
|
+
fs.mkdirSync(venvDir, { recursive: true });
|
|
685
|
+
const pipAbs = path.join(venvDir, venvExecRel(plat, 'pip'));
|
|
686
|
+
const binAbs = venvBinAbsAt(venvDir, plat);
|
|
687
|
+
try {
|
|
688
|
+
if (venv.provider === 'uv') {
|
|
689
|
+
run(uvBinFor(plat), [
|
|
690
|
+
'pip',
|
|
691
|
+
'install',
|
|
692
|
+
'--python',
|
|
693
|
+
venvPythonAbsAt(venvDir, plat),
|
|
694
|
+
spec,
|
|
695
|
+
], { cwd: root, stdio: 'pipe' });
|
|
696
|
+
}
|
|
697
|
+
else {
|
|
698
|
+
run(pipAbs, ['install', spec], { cwd: root, stdio: 'pipe' });
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
catch (e) {
|
|
702
|
+
return { ok: false, reason: `pip: ${catchMessageOrString(e)}` };
|
|
703
|
+
}
|
|
704
|
+
try {
|
|
705
|
+
run(binAbs, ['install'], { cwd: root, stdio: 'pipe' });
|
|
706
|
+
}
|
|
707
|
+
catch (e) {
|
|
708
|
+
return { ok: false, reason: `graphify install: ${catchMessageOrString(e)}` };
|
|
709
|
+
}
|
|
710
|
+
return { ok: true };
|
|
711
|
+
}
|
|
712
|
+
/**
|
|
713
|
+
* graphify venv 최초 설치(또는 재사용). 이미 bin이 있으면 pip하지 않고
|
|
714
|
+
* 기존 lock을 보존한다. 신규 설치만 manifest exact spec을 쓰고 lock을 기록한다.
|
|
715
|
+
*
|
|
716
|
+
* @param {{ repoRoot?: string, exec?: typeof execFileSync, platform?: string,
|
|
717
|
+
* now?: () => string, manifestPath?: string }} opts
|
|
718
|
+
* @returns {{ status: 'reused' | 'installed' | 'failed', bin: string | null, reason?: string }}
|
|
719
|
+
*/
|
|
720
|
+
function setupGraphify({ repoRoot, exec, platform, now, manifestPath, } = {}) {
|
|
721
|
+
try {
|
|
722
|
+
const root = typeof repoRoot === 'string' ? repoRoot : process.cwd();
|
|
723
|
+
const plat = typeof platform === 'string' ? platform : process.platform;
|
|
724
|
+
const run = typeof exec === 'function' ? exec : execFileSync;
|
|
725
|
+
const stamp = typeof now === 'function' ? now : () => nowIsoKst();
|
|
726
|
+
const binRel = venvBinRel(plat);
|
|
727
|
+
const legacyBinAbs = path.join(root, binRel);
|
|
728
|
+
const venvDir = preferredVenvDirAbs(root);
|
|
729
|
+
const binAbs = venvBinAbsAt(venvDir, plat);
|
|
730
|
+
// 레거시를 먼저 본다. 있으면 이전·삭제 없이 상대 경로를 그대로 돌려
|
|
731
|
+
// 기존 config 기록 형태를 유지한다. lock도 여기서 다시 쓰지 않는다.
|
|
732
|
+
if (fs.existsSync(legacyBinAbs)) {
|
|
733
|
+
return { status: 'reused', bin: binRel };
|
|
734
|
+
}
|
|
735
|
+
if (fs.existsSync(binAbs)) {
|
|
736
|
+
return { status: 'reused', bin: binAbs };
|
|
737
|
+
}
|
|
738
|
+
const createdThisRun = !fs.existsSync(venvDir);
|
|
739
|
+
// 하드코드 spec 폴백은 정본이 없을 때도 pip이 돌아가 lock 없이 venv만
|
|
740
|
+
// 남긴다. manifest가 SSOT이므로 로드 실패는 설치를 시작하지 않는다.
|
|
741
|
+
const manifestLoad = loadCompatManifest(typeof manifestPath === 'string' ? { manifestPath } : {});
|
|
742
|
+
if (!manifestLoad.ok) {
|
|
743
|
+
return {
|
|
744
|
+
status: 'failed',
|
|
745
|
+
bin: null,
|
|
746
|
+
reason: `manifest ${manifestLoad.reason}`,
|
|
747
|
+
};
|
|
748
|
+
}
|
|
749
|
+
const spec = manifestLoad.value.install_spec;
|
|
750
|
+
// 1) venv 생성 — 절대 경로라 cwd와 무관하게 common dir / 폴백 위치를 가리킨다.
|
|
751
|
+
const venv = createSeededVenv({
|
|
752
|
+
root,
|
|
753
|
+
venvDir,
|
|
754
|
+
plat,
|
|
755
|
+
run,
|
|
756
|
+
replaceOnFallback: createdThisRun,
|
|
757
|
+
});
|
|
758
|
+
if (!venv.ok) {
|
|
759
|
+
removeCreatedVenvDir(venvDir, createdThisRun);
|
|
760
|
+
return {
|
|
761
|
+
status: 'failed',
|
|
762
|
+
bin: null,
|
|
763
|
+
reason: venv.reason,
|
|
764
|
+
};
|
|
765
|
+
}
|
|
766
|
+
// 2) pip은 exact spec만. 언핀 graphifyy는 조회마다 다른 환경을 만든다.
|
|
767
|
+
const pipAbs = path.join(venvDir, venvExecRel(plat, 'pip'));
|
|
768
|
+
try {
|
|
769
|
+
if (venv.provider === 'uv') {
|
|
770
|
+
run(uvBinFor(plat), [
|
|
771
|
+
'pip',
|
|
772
|
+
'install',
|
|
773
|
+
'--python',
|
|
774
|
+
venvPythonAbsAt(venvDir, plat),
|
|
775
|
+
spec,
|
|
776
|
+
], { cwd: root, stdio: 'pipe' });
|
|
777
|
+
}
|
|
778
|
+
else {
|
|
779
|
+
run(pipAbs, ['install', spec], { cwd: root, stdio: 'pipe' });
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
catch (e) {
|
|
783
|
+
removeCreatedVenvDir(venvDir, createdThisRun);
|
|
784
|
+
return {
|
|
785
|
+
status: 'failed',
|
|
786
|
+
bin: null,
|
|
787
|
+
reason: `pip: ${catchMessageOrString(e)}`,
|
|
788
|
+
};
|
|
789
|
+
}
|
|
790
|
+
// 3) graphify 자체의 install(에이전트 훅 등) — activate 없이 bin 경로 직접 실행.
|
|
791
|
+
try {
|
|
792
|
+
run(binAbs, ['install'], { cwd: root, stdio: 'pipe' });
|
|
793
|
+
}
|
|
794
|
+
catch (e) {
|
|
795
|
+
removeCreatedVenvDir(venvDir, createdThisRun);
|
|
796
|
+
return {
|
|
797
|
+
status: 'failed',
|
|
798
|
+
bin: null,
|
|
799
|
+
reason: `graphify install: ${catchMessageOrString(e)}`,
|
|
800
|
+
};
|
|
801
|
+
}
|
|
802
|
+
const recorded = recordLockFromInstall({
|
|
803
|
+
repoRoot: root,
|
|
804
|
+
bin: binAbs,
|
|
805
|
+
pipAbs,
|
|
806
|
+
run,
|
|
807
|
+
now: stamp,
|
|
808
|
+
manifest: manifestLoad.value,
|
|
809
|
+
});
|
|
810
|
+
if (!recorded.ok) {
|
|
811
|
+
removeCreatedVenvDir(venvDir, createdThisRun);
|
|
812
|
+
return {
|
|
813
|
+
status: 'failed',
|
|
814
|
+
bin: null,
|
|
815
|
+
reason: `lock: ${recorded.reason}`,
|
|
816
|
+
};
|
|
817
|
+
}
|
|
818
|
+
return { status: 'installed', bin: binAbs };
|
|
819
|
+
}
|
|
820
|
+
catch (e) {
|
|
821
|
+
// 계약: setupGraphify 자신은 어떤 실패에도 throw하지 않는다.
|
|
822
|
+
return {
|
|
823
|
+
status: 'failed',
|
|
824
|
+
bin: null,
|
|
825
|
+
reason: catchMessageOrString(e),
|
|
826
|
+
};
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
/**
|
|
830
|
+
* 명시적 승격. 공유 common-dir venv를 staging에 설치한 뒤 성공할 때만 live와
|
|
831
|
+
* 맞바꾸고 세 graph를 재생성한다. 실패 시 lock·venv·graph는 승격 전 상태로 남긴다.
|
|
832
|
+
*
|
|
833
|
+
* @param {{ repoRoot?: string, exec?: typeof execFileSync, platform?: string,
|
|
834
|
+
* now?: () => string, rebuild?: Function }} opts
|
|
835
|
+
* @returns {{ status: 'upgraded' | 'failed', bin: string | null, reason?: string }}
|
|
836
|
+
*/
|
|
837
|
+
function upgradeGraphify({ repoRoot, exec, platform, now, rebuild, } = {}) {
|
|
838
|
+
try {
|
|
839
|
+
return runUpgradeGraphify({ repoRoot, exec, platform, now, rebuild });
|
|
840
|
+
}
|
|
841
|
+
catch (error) {
|
|
842
|
+
return { status: 'failed', bin: null, reason: catchMessageOrString(error) };
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
function runUpgradeGraphify({ repoRoot, exec, platform, now, rebuild, }) {
|
|
846
|
+
const root = typeof repoRoot === 'string' ? repoRoot : process.cwd();
|
|
847
|
+
const plat = typeof platform === 'string' ? platform : process.platform;
|
|
848
|
+
const run = typeof exec === 'function' ? exec : execFileSync;
|
|
849
|
+
const stamp = typeof now === 'function' ? now : () => nowIsoKst();
|
|
850
|
+
const venvDir = preferredVenvDirAbs(root);
|
|
851
|
+
const stagingDir = `${venvDir}.next`;
|
|
852
|
+
const backupDir = `${venvDir}.prev`;
|
|
853
|
+
const exclusivePath = upgradeLockPath(venvDir);
|
|
854
|
+
const manifestLoad = loadCompatManifest();
|
|
855
|
+
if (!manifestLoad.ok) {
|
|
856
|
+
return { status: 'failed', bin: null, reason: `manifest ${manifestLoad.reason}` };
|
|
857
|
+
}
|
|
858
|
+
const manifest = manifestLoad.value;
|
|
859
|
+
const lockAbs = lockPathFor(root);
|
|
860
|
+
let priorLock = null;
|
|
861
|
+
try {
|
|
862
|
+
priorLock = fs.readFileSync(lockAbs);
|
|
863
|
+
}
|
|
864
|
+
catch (_e) {
|
|
865
|
+
priorLock = null;
|
|
866
|
+
}
|
|
867
|
+
const graphSnap = snapshotGraphs(root);
|
|
868
|
+
fs.mkdirSync(path.dirname(exclusivePath), { recursive: true });
|
|
869
|
+
let lockFd;
|
|
870
|
+
try {
|
|
871
|
+
lockFd = fs.openSync(exclusivePath, 'wx');
|
|
872
|
+
}
|
|
873
|
+
catch (error) {
|
|
874
|
+
const err = error;
|
|
875
|
+
if (err && err.code === 'EEXIST') {
|
|
876
|
+
return { status: 'failed', bin: null, reason: 'concurrent-upgrade' };
|
|
877
|
+
}
|
|
878
|
+
return { status: 'failed', bin: null, reason: catchMessageOrString(error) };
|
|
879
|
+
}
|
|
880
|
+
const restorePrior = (swapped) => {
|
|
881
|
+
// graph·venv를 lock보다 먼저 되돌린다. lock 경로가 디렉터리이거나
|
|
882
|
+
// writeFileSync가 throw하면 예전에는 이 두 복구가 통째로 건너뛰어
|
|
883
|
+
// 스왑된 venv와 반쯤 새 graph가 남았다.
|
|
884
|
+
restoreGraphs(graphSnap);
|
|
885
|
+
if (swapped) {
|
|
886
|
+
removeDirIfExists(venvDir);
|
|
887
|
+
if (fs.existsSync(backupDir)) {
|
|
888
|
+
try {
|
|
889
|
+
fs.renameSync(backupDir, venvDir);
|
|
890
|
+
}
|
|
891
|
+
catch (_e) {
|
|
892
|
+
// live 복구 실패는 아래 reason이 가리킨다.
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
if (priorLock) {
|
|
897
|
+
try {
|
|
898
|
+
fs.mkdirSync(path.dirname(lockAbs), { recursive: true });
|
|
899
|
+
fs.writeFileSync(lockAbs, priorLock);
|
|
900
|
+
}
|
|
901
|
+
catch (_e) {
|
|
902
|
+
// lock 경로가 디렉터리·권한 오류여도 graph·venv 복구는 이미 끝냈다.
|
|
903
|
+
// 삭제는 목표(lock 없음)와 같고, 여기서는 옛 바이트를 못 쓴 것뿐이다.
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
else {
|
|
907
|
+
// 승격 전 lock이 없었으면 스왑 후 기록한 새 lock을 남기지 않는다.
|
|
908
|
+
// 복원 바이트가 없는데 삭제를 빼면, 복구된 옛 venv와 새 lock이 짝이 안 맞는다.
|
|
909
|
+
try {
|
|
910
|
+
fs.rmSync(lockAbs, { force: true });
|
|
911
|
+
}
|
|
912
|
+
catch (_e) {
|
|
913
|
+
// lock 부재는 목표 상태와 같다.
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
removeDirIfExists(stagingDir);
|
|
917
|
+
};
|
|
918
|
+
let swapped = false;
|
|
919
|
+
try {
|
|
920
|
+
removeDirIfExists(stagingDir);
|
|
921
|
+
const installed = installExactSpec({
|
|
922
|
+
root,
|
|
923
|
+
venvDir: stagingDir,
|
|
924
|
+
plat,
|
|
925
|
+
run,
|
|
926
|
+
spec: manifest.install_spec,
|
|
927
|
+
});
|
|
928
|
+
if (!installed.ok) {
|
|
929
|
+
restorePrior(false);
|
|
930
|
+
return { status: 'failed', bin: null, reason: installed.reason };
|
|
931
|
+
}
|
|
932
|
+
if (fs.existsSync(venvDir)) {
|
|
933
|
+
removeDirIfExists(backupDir);
|
|
934
|
+
fs.renameSync(venvDir, backupDir);
|
|
935
|
+
}
|
|
936
|
+
fs.renameSync(stagingDir, venvDir);
|
|
937
|
+
swapped = true;
|
|
938
|
+
const binAbs = venvBinAbsAt(venvDir, plat);
|
|
939
|
+
const pipAbs = path.join(venvDir, venvExecRel(plat, 'pip'));
|
|
940
|
+
// rebuild가 호환성 검사에서 옛 lock을 보고 skip하지 않게, 스왑 직후 lock을 먼저 쓴다.
|
|
941
|
+
// write 실패를 무시하면 swapped venv 위에 새 lock이 없거나 불완전해도 upgraded가 된다.
|
|
942
|
+
const recorded = recordLockFromInstall({
|
|
943
|
+
repoRoot: root,
|
|
944
|
+
bin: binAbs,
|
|
945
|
+
pipAbs,
|
|
946
|
+
run,
|
|
947
|
+
now: stamp,
|
|
948
|
+
manifest,
|
|
949
|
+
});
|
|
950
|
+
if (!recorded.ok) {
|
|
951
|
+
restorePrior(true);
|
|
952
|
+
return { status: 'failed', bin: null, reason: `lock: ${recorded.reason}` };
|
|
953
|
+
}
|
|
954
|
+
if (typeof rebuild === 'function') {
|
|
955
|
+
const rebuilt = rebuild({ repoRoot: root });
|
|
956
|
+
const rebuildReason = rebuildFailureReason(rebuilt);
|
|
957
|
+
if (rebuildReason) {
|
|
958
|
+
restorePrior(true);
|
|
959
|
+
return { status: 'failed', bin: null, reason: rebuildReason };
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
stampGraphSchema(root, manifest.graph_schema_version);
|
|
963
|
+
removeDirIfExists(backupDir);
|
|
964
|
+
return { status: 'upgraded', bin: binAbs };
|
|
965
|
+
}
|
|
966
|
+
catch (error) {
|
|
967
|
+
restorePrior(swapped);
|
|
968
|
+
return { status: 'failed', bin: null, reason: catchMessageOrString(error) };
|
|
969
|
+
}
|
|
970
|
+
finally {
|
|
971
|
+
try {
|
|
972
|
+
fs.closeSync(lockFd);
|
|
973
|
+
}
|
|
974
|
+
catch (_e) {
|
|
975
|
+
// fd 정리는 best-effort.
|
|
976
|
+
}
|
|
977
|
+
try {
|
|
978
|
+
fs.unlinkSync(exclusivePath);
|
|
979
|
+
}
|
|
980
|
+
catch (_e) {
|
|
981
|
+
// 락 파일 잔류는 다음 승격이 concurrent로 거절하게 한다.
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
module.exports = {
|
|
986
|
+
venvBinRel,
|
|
987
|
+
resolveGraphifyBin,
|
|
988
|
+
setupGraphify,
|
|
989
|
+
upgradeGraphify,
|
|
990
|
+
loadCompatManifest,
|
|
991
|
+
readGraphifyLock,
|
|
992
|
+
writeGraphifyLock,
|
|
993
|
+
checkGraphifyCompatibility,
|
|
994
|
+
GRAPHIFY_LOCK_REL,
|
|
995
|
+
};
|