create-harness-vibe-coding 0.8.7 → 0.8.8
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/README-CN.md +157 -105
- package/README.md +160 -244
- package/bin/create-harness-vibe-coding.js +2 -2
- package/docs/images/harness-architecture-light.png +0 -0
- package/docs/images/harness-architecture.drawio +164 -0
- package/package.json +46 -44
- package/src/generator.js +5 -5
- package/src/index.js +14 -9
- package/src/prompts.js +37 -37
- package/templates/common/.claude/agents/architect-manager.md +45 -45
- package/templates/common/.claude/agents/context-master.md +75 -75
- package/templates/common/.claude/agents/debugger.md +41 -41
- package/templates/common/.claude/agents/explore-manager.md +41 -41
- package/templates/common/.claude/agents/implement-manager.md +49 -49
- package/templates/common/.claude/agents/implementer.md +40 -40
- package/templates/common/.claude/agents/memory-master.md +64 -64
- package/templates/common/.claude/agents/planner.md +34 -34
- package/templates/common/.claude/agents/researcher.md +41 -41
- package/templates/common/.claude/agents/review-manager.md +56 -56
- package/templates/common/.claude/agents/verifier.md +29 -29
- package/templates/common/.claude/commands/wf-help.md +1 -1
- package/templates/common/.claude/rules/ecc/common.md +44 -44
- package/templates/common/.claude/skills/wf-auto/SKILL.md +8 -6
- package/templates/common/.claude/skills/wf-readme/SKILL.md +49 -49
- package/templates/common/.claude/skills/wf-remove/SKILL.md +7 -7
- package/templates/common/.harness-version +54 -14
- package/templates/common/.opencode/agents/architect-manager.md +52 -0
- package/templates/common/.opencode/agents/architect.md +35 -0
- package/templates/common/.opencode/agents/context-master.md +81 -0
- package/templates/common/.opencode/agents/debugger.md +43 -0
- package/templates/common/.opencode/agents/docs-researcher.md +42 -0
- package/templates/common/.opencode/agents/explore-manager.md +49 -0
- package/templates/common/.opencode/agents/implement-manager.md +56 -0
- package/templates/common/.opencode/agents/implementer.md +42 -0
- package/templates/common/.opencode/agents/memory-master.md +70 -0
- package/templates/common/.opencode/agents/planner.md +38 -0
- package/templates/common/.opencode/agents/reflector.md +39 -0
- package/templates/common/.opencode/agents/researcher.md +42 -0
- package/templates/common/.opencode/agents/review-manager.md +63 -0
- package/templates/common/.opencode/agents/reviewer.md +37 -0
- package/templates/common/.opencode/agents/tdd-guide.md +83 -0
- package/templates/common/.opencode/agents/test-writer.md +54 -0
- package/templates/common/.opencode/agents/verifier.md +37 -0
- package/templates/common/.opencode/commands/wf-help.md +23 -0
- package/templates/common/CLAUDE.md +85 -88
- package/templates/common/Harness/PROGRESS.md +17 -17
- package/templates/common/Harness/README.md +16 -5
- package/templates/common/Harness/WF-AUTO-ANGLES.md +170 -0
- package/templates/common/Harness/WF-AUTO-SPARK.md +5 -5
- package/templates/common/Harness/WF-AUTO.md +85 -96
- package/templates/common/Harness/architecture.md +124 -124
- package/templates/common/Harness/context-loading.md +111 -111
- package/templates/common/Harness/extension.md +66 -66
- package/templates/common/Harness/lifecycle.md +20 -20
- package/templates/common/Harness/research/PRD.md +56 -56
- package/templates/common/Harness/research/README.md +169 -169
- package/templates/common/Harness/research/research-results.md +66 -66
- package/templates/common/Harness/subagents.md +208 -208
- package/templates/common/Harness/tasks/_template/ARTIFACTS.md +2 -2
- package/templates/common/Harness/tasks/_template/NOTES.md +2 -2
- package/templates/common/MEMORY.md +1 -1
- package/templates/common/README.md +36 -36
- package/templates/common/memory/agent-lessons-patterns.md +21 -21
- package/templates/common/memory/tool-usage-reflections.md +21 -21
- package/templates/common/memory/user-corrections-preferences.md +21 -21
- package/templates/common/opencode.json +19 -0
- package/templates/common/scripts/scan-clean.mjs +415 -415
- package/templates/common/scripts/validate-harness.mjs +274 -245
- package/templates/common/scripts/wf-remove.mjs +56 -39
- package/templates/common/scripts/wf-update-check.mjs +599 -599
- package/templates/optional/catalog.json +41 -33
- package/templates/optional/skills/browser-e2e/.claude/skills/wf-browser/SKILL.md +193 -193
- package/templates/optional/skills/browser-e2e/Harness/workflows/browser-e2e.md +48 -48
- package/templates/optional/skills/github-pr-review/Harness/workflows/github-pr-review.md +28 -28
- package/templates/optional/skills/python-backend/Harness/workflows/python-backend.md +34 -34
- package/templates/optional/skills/ts-react-frontend/Harness/workflows/ts-react-frontend.md +34 -34
- package/templates/optional/skills/ui-ux-review/Harness/workflows/ui-ux-review.md +26 -26
|
@@ -1,43 +1,43 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
/**
|
|
3
|
-
* wf-update-check.mjs — Fast harness update comparison.
|
|
4
|
-
* Fetches remote checksums, compares locally, classifies all files instantly.
|
|
5
|
-
* Only CONFLICT files need AI/user decision.
|
|
6
|
-
*
|
|
7
|
-
* Usage:
|
|
8
|
-
* node Harness/scripts/wf-update-check.mjs # full dry-run plan
|
|
9
|
-
* node Harness/scripts/wf-update-check.mjs --json # JSON output for AI consumption
|
|
10
|
-
* node Harness/scripts/wf-update-check.mjs --apply-safe # apply SAFE+NEW, leave CONFLICT for AI
|
|
11
|
-
* node Harness/scripts/wf-update-check.mjs --apply # apply only when no CONFLICT exists
|
|
12
|
-
*/
|
|
13
|
-
|
|
14
|
-
import { readFileSync, writeFileSync, existsSync, mkdirSync, lstatSync } from 'fs';
|
|
15
|
-
import { createHash } from 'crypto';
|
|
16
|
-
import { resolve, dirname, sep } from 'path';
|
|
17
|
-
import { fileURLToPath } from 'url';
|
|
18
|
-
|
|
19
|
-
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
20
|
-
const ROOT = process.env.WF_ROOT ? resolve(process.env.WF_ROOT) : resolve(__dirname, '..', '..');
|
|
21
|
-
const VERSION_FILE = resolve(ROOT, 'Harness', '.harness-version');
|
|
22
|
-
const DEFAULT_SOURCE_BASE = 'https://raw.githubusercontent.com/zingspark/create-harness-vibe-coding/main/templates/common/';
|
|
23
|
-
|
|
24
|
-
// ── Tier classification ──────────────────────────────────────────
|
|
25
|
-
|
|
26
|
-
/** Files we NEVER overwrite or delete. */
|
|
27
|
-
const PRESERVE_PATTERNS = [
|
|
28
|
-
/^Harness\/PROGRESS\.md$/,
|
|
29
|
-
/^Harness\/tasks\//,
|
|
30
|
-
/^Harness\/memory\//,
|
|
31
|
-
/^Harness\/research\/PRD\.md$/,
|
|
32
|
-
/^Harness\/research\/research-results\.md$/,
|
|
33
|
-
/^Harness\/architecture\.md$/,
|
|
34
|
-
/^README\.md$/,
|
|
35
|
-
/^\.gitignore$/,
|
|
36
|
-
/^package\.json$/,
|
|
37
|
-
/^package-lock\.json$/,
|
|
38
|
-
];
|
|
39
|
-
|
|
40
|
-
/** Files that are safe to overwrite if checksums match, otherwise need merge. */
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* wf-update-check.mjs — Fast harness update comparison.
|
|
4
|
+
* Fetches remote checksums, compares locally, classifies all files instantly.
|
|
5
|
+
* Only CONFLICT files need AI/user decision.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* node Harness/scripts/wf-update-check.mjs # full dry-run plan
|
|
9
|
+
* node Harness/scripts/wf-update-check.mjs --json # JSON output for AI consumption
|
|
10
|
+
* node Harness/scripts/wf-update-check.mjs --apply-safe # apply SAFE+NEW, leave CONFLICT for AI
|
|
11
|
+
* node Harness/scripts/wf-update-check.mjs --apply # apply only when no CONFLICT exists
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, lstatSync } from 'fs';
|
|
15
|
+
import { createHash } from 'crypto';
|
|
16
|
+
import { resolve, dirname, sep } from 'path';
|
|
17
|
+
import { fileURLToPath } from 'url';
|
|
18
|
+
|
|
19
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
20
|
+
const ROOT = process.env.WF_ROOT ? resolve(process.env.WF_ROOT) : resolve(__dirname, '..', '..');
|
|
21
|
+
const VERSION_FILE = resolve(ROOT, 'Harness', '.harness-version');
|
|
22
|
+
const DEFAULT_SOURCE_BASE = 'https://raw.githubusercontent.com/zingspark/create-harness-vibe-coding/main/templates/common/';
|
|
23
|
+
|
|
24
|
+
// ── Tier classification ──────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
/** Files we NEVER overwrite or delete. */
|
|
27
|
+
const PRESERVE_PATTERNS = [
|
|
28
|
+
/^Harness\/PROGRESS\.md$/,
|
|
29
|
+
/^Harness\/tasks\//,
|
|
30
|
+
/^Harness\/memory\//,
|
|
31
|
+
/^Harness\/research\/PRD\.md$/,
|
|
32
|
+
/^Harness\/research\/research-results\.md$/,
|
|
33
|
+
/^Harness\/architecture\.md$/,
|
|
34
|
+
/^README\.md$/,
|
|
35
|
+
/^\.gitignore$/,
|
|
36
|
+
/^package\.json$/,
|
|
37
|
+
/^package-lock\.json$/,
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
/** Files that are safe to overwrite if checksums match, otherwise need merge. */
|
|
41
41
|
const MERGE_PATTERNS = [
|
|
42
42
|
/^CLAUDE\.md$/,
|
|
43
43
|
/^AGENTS\.md$/,
|
|
@@ -51,48 +51,48 @@ const OPTIONAL_REGISTRATION_FILES = new Set([
|
|
|
51
51
|
'Harness/MEMORY.md',
|
|
52
52
|
'Harness/README.md',
|
|
53
53
|
]);
|
|
54
|
-
|
|
55
|
-
// ── Helpers ────────────────────────────────────────────────────────
|
|
56
|
-
|
|
57
|
-
/** Reject paths that escape ROOT (traversal, absolute, .., etc.). */
|
|
58
|
-
function safePath(file) {
|
|
59
|
-
let normalized = file.replace(/\\/g, '/').replace(/^\/+/, '');
|
|
60
|
-
if (/\/\//.test(normalized)) return null;
|
|
61
|
-
if (normalized.split('/').some(p => p === '..')) return null;
|
|
62
|
-
if (file.startsWith('/') || file.startsWith('\\')) return null;
|
|
63
|
-
if (normalized === '.' || normalized === '') return null;
|
|
64
|
-
const resolved = resolve(ROOT, normalized);
|
|
65
|
-
if (!resolved.startsWith(ROOT + sep) && resolved !== ROOT) return null;
|
|
66
|
-
return resolved;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
/** Canonical normalization for classification matching. */
|
|
70
|
-
function canonicalPath(file) {
|
|
71
|
-
return file.replace(/\\/g, '/').replace(/\/+/g, '/');
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
function readFlagValue(args, flagName) {
|
|
75
|
-
const idx = args.indexOf(flagName);
|
|
76
|
-
if (idx === -1) return null;
|
|
77
|
-
const value = args[idx + 1];
|
|
78
|
-
if (!value || value.startsWith('--')) return null;
|
|
79
|
-
return value;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
function readRepeatedFlagValues(args, flagName) {
|
|
83
|
-
const values = [];
|
|
84
|
-
for (let i = 0; i < args.length; i += 1) {
|
|
85
|
-
const arg = args[i];
|
|
86
|
-
if (arg === flagName && args[i + 1] && !args[i + 1].startsWith('--')) {
|
|
87
|
-
values.push(args[i + 1]);
|
|
88
|
-
i += 1;
|
|
89
|
-
} else if (arg.startsWith(`${flagName}=`)) {
|
|
90
|
-
values.push(arg.slice(flagName.length + 1));
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
return [...new Set(values.map(canonicalPath).filter(Boolean))];
|
|
94
|
-
}
|
|
95
|
-
|
|
54
|
+
|
|
55
|
+
// ── Helpers ────────────────────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
/** Reject paths that escape ROOT (traversal, absolute, .., etc.). */
|
|
58
|
+
function safePath(file) {
|
|
59
|
+
let normalized = file.replace(/\\/g, '/').replace(/^\/+/, '');
|
|
60
|
+
if (/\/\//.test(normalized)) return null;
|
|
61
|
+
if (normalized.split('/').some(p => p === '..')) return null;
|
|
62
|
+
if (file.startsWith('/') || file.startsWith('\\')) return null;
|
|
63
|
+
if (normalized === '.' || normalized === '') return null;
|
|
64
|
+
const resolved = resolve(ROOT, normalized);
|
|
65
|
+
if (!resolved.startsWith(ROOT + sep) && resolved !== ROOT) return null;
|
|
66
|
+
return resolved;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Canonical normalization for classification matching. */
|
|
70
|
+
function canonicalPath(file) {
|
|
71
|
+
return file.replace(/\\/g, '/').replace(/\/+/g, '/');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function readFlagValue(args, flagName) {
|
|
75
|
+
const idx = args.indexOf(flagName);
|
|
76
|
+
if (idx === -1) return null;
|
|
77
|
+
const value = args[idx + 1];
|
|
78
|
+
if (!value || value.startsWith('--')) return null;
|
|
79
|
+
return value;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function readRepeatedFlagValues(args, flagName) {
|
|
83
|
+
const values = [];
|
|
84
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
85
|
+
const arg = args[i];
|
|
86
|
+
if (arg === flagName && args[i + 1] && !args[i + 1].startsWith('--')) {
|
|
87
|
+
values.push(args[i + 1]);
|
|
88
|
+
i += 1;
|
|
89
|
+
} else if (arg.startsWith(`${flagName}=`)) {
|
|
90
|
+
values.push(arg.slice(flagName.length + 1));
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return [...new Set(values.map(canonicalPath).filter(Boolean))];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
96
|
function normalizeSourceBase(sourceBase) {
|
|
97
97
|
return sourceBase.endsWith('/') ? sourceBase : sourceBase + '/';
|
|
98
98
|
}
|
|
@@ -119,312 +119,312 @@ function isInstalledOptionalFile(file, localVersion) {
|
|
|
119
119
|
function hasInstalledOptions(localVersion) {
|
|
120
120
|
return selectedOptionIds(localVersion).size > 0;
|
|
121
121
|
}
|
|
122
|
-
|
|
123
|
-
/** Detect template placeholders in remote content. */
|
|
124
|
-
function isTemplate(raw) {
|
|
125
|
-
return /\{\{[a-zA-Z]+\}\}/.test(raw);
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
function sha256(content) {
|
|
129
|
-
return 'sha256-' + createHash('sha256').update(content).digest('hex');
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
function sha256File(path) {
|
|
133
|
-
if (!existsSync(path)) return null;
|
|
134
|
-
let content = readFileSync(path, 'utf-8');
|
|
135
|
-
// Normalize CRLF → LF
|
|
136
|
-
content = content.replace(/\r\n/g, '\n');
|
|
137
|
-
return sha256(content);
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
function classify(file, localHash, storedHash) {
|
|
141
|
-
// PRESERVE
|
|
142
|
-
for (const p of PRESERVE_PATTERNS) {
|
|
143
|
-
if (p.test(file)) return 'PRESERVE';
|
|
144
|
-
}
|
|
145
|
-
// MERGE — dual-purpose, check if user modified
|
|
146
|
-
for (const p of MERGE_PATTERNS) {
|
|
147
|
-
if (p.test(file)) {
|
|
148
|
-
if (localHash === storedHash) return 'SAFE'; // unmodified, safe
|
|
149
|
-
return 'CONFLICT'; // user modified, needs decision
|
|
150
|
-
}
|
|
151
|
-
}
|
|
152
|
-
// Everything else is SAFE runtime file
|
|
153
|
-
if (localHash === storedHash || localHash === null) return 'SAFE';
|
|
154
|
-
return 'CONFLICT'; // modified runtime file — unexpected
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
async function fetchRemote(url, timeoutMs = 30000) {
|
|
158
|
-
if (url.startsWith('file://')) {
|
|
159
|
-
return readFileSync(fileURLToPath(url), 'utf-8');
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
const controller = new AbortController();
|
|
163
|
-
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
164
|
-
try {
|
|
165
|
-
const res = await fetch(url, { signal: controller.signal });
|
|
166
|
-
if (!res.ok) throw new Error(`HTTP ${res.status}: ${url}`);
|
|
167
|
-
return res.text();
|
|
168
|
-
} finally {
|
|
169
|
-
clearTimeout(timer);
|
|
170
|
-
}
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
// ── Main ───────────────────────────────────────────────────────────
|
|
174
|
-
|
|
175
|
-
async function main() {
|
|
176
|
-
const args = process.argv.slice(2);
|
|
177
|
-
const apply = args.includes('--apply');
|
|
178
|
-
const applySafe = args.includes('--apply-safe') || args.includes('--safe-only');
|
|
179
|
-
const jsonOut = args.includes('--json');
|
|
180
|
-
const finalize = args.includes('--finalize');
|
|
181
|
-
const acceptLocal = readRepeatedFlagValues(args, '--accept-local');
|
|
182
|
-
const acceptMerged = readRepeatedFlagValues(args, '--accept-merged');
|
|
183
|
-
const acceptTemplate = readRepeatedFlagValues(args, '--accept-template');
|
|
184
|
-
const ignoreVersion = args.includes('--ignore-version') || args.includes('--force-check');
|
|
185
|
-
const sourceBase = normalizeSourceBase(readFlagValue(args, '--source-base') || process.env.WF_SOURCE_BASE || DEFAULT_SOURCE_BASE);
|
|
186
|
-
|
|
187
|
-
// 1. Read local state
|
|
188
|
-
if (!existsSync(VERSION_FILE)) {
|
|
189
|
-
if (jsonOut) {
|
|
190
|
-
console.log(JSON.stringify({ status: 'error', message: 'Local Harness/.harness-version not found.' }));
|
|
191
|
-
} else {
|
|
192
|
-
console.error('ERROR: Harness/.harness-version not found. Is Harness installed?');
|
|
193
|
-
}
|
|
122
|
+
|
|
123
|
+
/** Detect template placeholders in remote content. */
|
|
124
|
+
function isTemplate(raw) {
|
|
125
|
+
return /\{\{[a-zA-Z]+\}\}/.test(raw);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function sha256(content) {
|
|
129
|
+
return 'sha256-' + createHash('sha256').update(content).digest('hex');
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function sha256File(path) {
|
|
133
|
+
if (!existsSync(path)) return null;
|
|
134
|
+
let content = readFileSync(path, 'utf-8');
|
|
135
|
+
// Normalize CRLF → LF
|
|
136
|
+
content = content.replace(/\r\n/g, '\n');
|
|
137
|
+
return sha256(content);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function classify(file, localHash, storedHash) {
|
|
141
|
+
// PRESERVE
|
|
142
|
+
for (const p of PRESERVE_PATTERNS) {
|
|
143
|
+
if (p.test(file)) return 'PRESERVE';
|
|
144
|
+
}
|
|
145
|
+
// MERGE — dual-purpose, check if user modified
|
|
146
|
+
for (const p of MERGE_PATTERNS) {
|
|
147
|
+
if (p.test(file)) {
|
|
148
|
+
if (localHash === storedHash) return 'SAFE'; // unmodified, safe
|
|
149
|
+
return 'CONFLICT'; // user modified, needs decision
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
// Everything else is SAFE runtime file
|
|
153
|
+
if (localHash === storedHash || localHash === null) return 'SAFE';
|
|
154
|
+
return 'CONFLICT'; // modified runtime file — unexpected
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async function fetchRemote(url, timeoutMs = 30000) {
|
|
158
|
+
if (url.startsWith('file://')) {
|
|
159
|
+
return readFileSync(fileURLToPath(url), 'utf-8');
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const controller = new AbortController();
|
|
163
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
164
|
+
try {
|
|
165
|
+
const res = await fetch(url, { signal: controller.signal });
|
|
166
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}: ${url}`);
|
|
167
|
+
return res.text();
|
|
168
|
+
} finally {
|
|
169
|
+
clearTimeout(timer);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ── Main ───────────────────────────────────────────────────────────
|
|
174
|
+
|
|
175
|
+
async function main() {
|
|
176
|
+
const args = process.argv.slice(2);
|
|
177
|
+
const apply = args.includes('--apply');
|
|
178
|
+
const applySafe = args.includes('--apply-safe') || args.includes('--safe-only');
|
|
179
|
+
const jsonOut = args.includes('--json');
|
|
180
|
+
const finalize = args.includes('--finalize');
|
|
181
|
+
const acceptLocal = readRepeatedFlagValues(args, '--accept-local');
|
|
182
|
+
const acceptMerged = readRepeatedFlagValues(args, '--accept-merged');
|
|
183
|
+
const acceptTemplate = readRepeatedFlagValues(args, '--accept-template');
|
|
184
|
+
const ignoreVersion = args.includes('--ignore-version') || args.includes('--force-check');
|
|
185
|
+
const sourceBase = normalizeSourceBase(readFlagValue(args, '--source-base') || process.env.WF_SOURCE_BASE || DEFAULT_SOURCE_BASE);
|
|
186
|
+
|
|
187
|
+
// 1. Read local state
|
|
188
|
+
if (!existsSync(VERSION_FILE)) {
|
|
189
|
+
if (jsonOut) {
|
|
190
|
+
console.log(JSON.stringify({ status: 'error', message: 'Local Harness/.harness-version not found.' }));
|
|
191
|
+
} else {
|
|
192
|
+
console.error('ERROR: Harness/.harness-version not found. Is Harness installed?');
|
|
193
|
+
}
|
|
194
194
|
process.exitCode = 1;
|
|
195
195
|
return;
|
|
196
196
|
}
|
|
197
|
-
|
|
198
|
-
let localVersion;
|
|
199
|
-
try {
|
|
200
|
-
localVersion = JSON.parse(readFileSync(VERSION_FILE, 'utf-8'));
|
|
201
|
-
} catch (e) {
|
|
202
|
-
if (jsonOut) {
|
|
203
|
-
console.log(JSON.stringify({ status: 'error', message: 'Failed to parse Harness/.harness-version: ' + e.message }));
|
|
204
|
-
} else {
|
|
205
|
-
console.error('ERROR: Failed to parse Harness/.harness-version:', e.message);
|
|
206
|
-
console.error(' The file may be corrupted. If this is an old project, try reinstalling the harness.');
|
|
207
|
-
}
|
|
197
|
+
|
|
198
|
+
let localVersion;
|
|
199
|
+
try {
|
|
200
|
+
localVersion = JSON.parse(readFileSync(VERSION_FILE, 'utf-8'));
|
|
201
|
+
} catch (e) {
|
|
202
|
+
if (jsonOut) {
|
|
203
|
+
console.log(JSON.stringify({ status: 'error', message: 'Failed to parse Harness/.harness-version: ' + e.message }));
|
|
204
|
+
} else {
|
|
205
|
+
console.error('ERROR: Failed to parse Harness/.harness-version:', e.message);
|
|
206
|
+
console.error(' The file may be corrupted. If this is an old project, try reinstalling the harness.');
|
|
207
|
+
}
|
|
208
208
|
process.exitCode = 1;
|
|
209
|
-
return;
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
if (!localVersion || typeof localVersion !== 'object') {
|
|
213
|
-
if (jsonOut) {
|
|
214
|
-
console.log(JSON.stringify({ status: 'error', message: 'Invalid Harness/.harness-version structure.' }));
|
|
215
|
-
} else {
|
|
216
|
-
console.error('ERROR: Harness/.harness-version has unexpected structure.');
|
|
217
|
-
}
|
|
218
|
-
process.exitCode = 1;
|
|
219
|
-
return;
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
const localChecksums = localVersion.checksums || {};
|
|
223
|
-
|
|
224
|
-
// 2. Fetch remote version file
|
|
225
|
-
let remoteVersion;
|
|
226
|
-
try {
|
|
227
|
-
const raw = await fetchRemote(sourceBase + '.harness-version');
|
|
228
|
-
if (isTemplate(raw)) {
|
|
229
|
-
if (jsonOut) {
|
|
230
|
-
console.log(JSON.stringify({ status: 'template-remote', message: 'Remote .harness-version has not been generated yet.' }));
|
|
231
|
-
} else {
|
|
232
|
-
console.log('⚠ Remote .harness-version is a template (contains {{placeholders}}).');
|
|
233
|
-
console.log(' The generate step has not been run on the remote repo. No update possible.');
|
|
234
|
-
console.log(' This is expected during development — the update mechanism works once the remote is live.');
|
|
235
|
-
}
|
|
236
|
-
process.exitCode = 1;
|
|
237
|
-
return;
|
|
238
|
-
}
|
|
239
|
-
remoteVersion = JSON.parse(raw);
|
|
240
|
-
} catch (e) {
|
|
241
|
-
if (jsonOut) {
|
|
242
|
-
console.log(JSON.stringify({ status: 'offline', message: 'Cannot reach GitHub.' }));
|
|
243
|
-
} else {
|
|
244
|
-
console.error('ERROR: Cannot reach GitHub or invalid JSON. Offline?');
|
|
245
|
-
console.error(e.message);
|
|
246
|
-
}
|
|
247
|
-
process.exitCode = 1;
|
|
248
|
-
return;
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
// Compare versions — warn if remote is older (downgrade prevention)
|
|
252
|
-
function parseSemver(v) {
|
|
253
|
-
if (!v || typeof v !== 'string') return [0, 0, 0];
|
|
254
|
-
return v.replace(/^[^0-9]*/, '').split('-')[0].split('.').map(Number);
|
|
255
|
-
}
|
|
256
|
-
function cmpSemver(a, b) {
|
|
257
|
-
const va = parseSemver(a), vb = parseSemver(b);
|
|
258
|
-
for (let i = 0; i < 3; i++) { if ((va[i]||0) > (vb[i]||0)) return 1; if ((va[i]||0) < (vb[i]||0)) return -1; }
|
|
259
|
-
return 0;
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
const localGen = localVersion.generator || '0.0.0';
|
|
263
|
-
const remoteGen = remoteVersion.generator || '0.0.0';
|
|
264
|
-
const versionCmp = cmpSemver(remoteGen, localGen);
|
|
265
|
-
|
|
266
|
-
if (!ignoreVersion && versionCmp <= 0) {
|
|
267
|
-
if (jsonOut) {
|
|
268
|
-
console.log(JSON.stringify({
|
|
269
|
-
status: versionCmp < 0 ? 'downgrade-refused' : 'up-to-date',
|
|
270
|
-
version: localGen,
|
|
271
|
-
remote: remoteGen,
|
|
272
|
-
sourceBase,
|
|
273
|
-
}));
|
|
274
|
-
} else if (versionCmp < 0) {
|
|
275
|
-
console.log(`⚠ Remote (v${remoteGen}) is OLDER than local (v${localGen}). Downgrade refused.`);
|
|
276
|
-
} else {
|
|
277
|
-
console.log(`✅ Already up to date (v${localGen})`);
|
|
278
|
-
}
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (!localVersion || typeof localVersion !== 'object') {
|
|
213
|
+
if (jsonOut) {
|
|
214
|
+
console.log(JSON.stringify({ status: 'error', message: 'Invalid Harness/.harness-version structure.' }));
|
|
215
|
+
} else {
|
|
216
|
+
console.error('ERROR: Harness/.harness-version has unexpected structure.');
|
|
217
|
+
}
|
|
218
|
+
process.exitCode = 1;
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const localChecksums = localVersion.checksums || {};
|
|
223
|
+
|
|
224
|
+
// 2. Fetch remote version file
|
|
225
|
+
let remoteVersion;
|
|
226
|
+
try {
|
|
227
|
+
const raw = await fetchRemote(sourceBase + '.harness-version');
|
|
228
|
+
if (isTemplate(raw)) {
|
|
229
|
+
if (jsonOut) {
|
|
230
|
+
console.log(JSON.stringify({ status: 'template-remote', message: 'Remote .harness-version has not been generated yet.' }));
|
|
231
|
+
} else {
|
|
232
|
+
console.log('⚠ Remote .harness-version is a template (contains {{placeholders}}).');
|
|
233
|
+
console.log(' The generate step has not been run on the remote repo. No update possible.');
|
|
234
|
+
console.log(' This is expected during development — the update mechanism works once the remote is live.');
|
|
235
|
+
}
|
|
236
|
+
process.exitCode = 1;
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
remoteVersion = JSON.parse(raw);
|
|
240
|
+
} catch (e) {
|
|
241
|
+
if (jsonOut) {
|
|
242
|
+
console.log(JSON.stringify({ status: 'offline', message: 'Cannot reach GitHub.' }));
|
|
243
|
+
} else {
|
|
244
|
+
console.error('ERROR: Cannot reach GitHub or invalid JSON. Offline?');
|
|
245
|
+
console.error(e.message);
|
|
246
|
+
}
|
|
247
|
+
process.exitCode = 1;
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Compare versions — warn if remote is older (downgrade prevention)
|
|
252
|
+
function parseSemver(v) {
|
|
253
|
+
if (!v || typeof v !== 'string') return [0, 0, 0];
|
|
254
|
+
return v.replace(/^[^0-9]*/, '').split('-')[0].split('.').map(Number);
|
|
255
|
+
}
|
|
256
|
+
function cmpSemver(a, b) {
|
|
257
|
+
const va = parseSemver(a), vb = parseSemver(b);
|
|
258
|
+
for (let i = 0; i < 3; i++) { if ((va[i]||0) > (vb[i]||0)) return 1; if ((va[i]||0) < (vb[i]||0)) return -1; }
|
|
259
|
+
return 0;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const localGen = localVersion.generator || '0.0.0';
|
|
263
|
+
const remoteGen = remoteVersion.generator || '0.0.0';
|
|
264
|
+
const versionCmp = cmpSemver(remoteGen, localGen);
|
|
265
|
+
|
|
266
|
+
if (!ignoreVersion && versionCmp <= 0) {
|
|
267
|
+
if (jsonOut) {
|
|
268
|
+
console.log(JSON.stringify({
|
|
269
|
+
status: versionCmp < 0 ? 'downgrade-refused' : 'up-to-date',
|
|
270
|
+
version: localGen,
|
|
271
|
+
remote: remoteGen,
|
|
272
|
+
sourceBase,
|
|
273
|
+
}));
|
|
274
|
+
} else if (versionCmp < 0) {
|
|
275
|
+
console.log(`⚠ Remote (v${remoteGen}) is OLDER than local (v${localGen}). Downgrade refused.`);
|
|
276
|
+
} else {
|
|
277
|
+
console.log(`✅ Already up to date (v${localGen})`);
|
|
278
|
+
}
|
|
279
279
|
if (versionCmp < 0) process.exitCode = 1;
|
|
280
280
|
return;
|
|
281
281
|
}
|
|
282
282
|
|
|
283
283
|
if (ignoreVersion) {
|
|
284
|
-
if (!jsonOut) console.log('🔧 Version check bypassed (--ignore-version). Comparing files anyway.');
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
const remoteChecksums = remoteVersion.checksums || {};
|
|
288
|
-
const remoteSources = remoteVersion.sources || {};
|
|
289
|
-
localVersion.acceptedConflicts = localVersion.acceptedConflicts || {};
|
|
290
|
-
|
|
291
|
-
/** Resolve the remote template-relative path for a dest-keyed file. Falls back to the key itself for back-compat. */
|
|
292
|
-
function remotePath(file) {
|
|
293
|
-
return remoteSources[file] || file;
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
function withRemoteMeta(entry) {
|
|
297
|
-
if (!entry || !entry.file || !remoteChecksums[entry.file]) return entry;
|
|
298
|
-
const templateHint = remotePath(entry.file);
|
|
299
|
-
return {
|
|
300
|
-
...entry,
|
|
301
|
-
templateHint,
|
|
302
|
-
remoteUrl: sourceBase + templateHint,
|
|
303
|
-
};
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
function withConflictActions(entry) {
|
|
307
|
-
return {
|
|
308
|
-
...withRemoteMeta(entry),
|
|
309
|
-
choices: ['merge', 'keep-local', 'overwrite-from-template'],
|
|
310
|
-
decisionCommands: {
|
|
311
|
-
keepLocal: `node Harness/scripts/wf-update-check.mjs --accept-local ${shellArg(entry.file)} --finalize`,
|
|
312
|
-
acceptMerged: `node Harness/scripts/wf-update-check.mjs --accept-merged ${shellArg(entry.file)} --finalize`,
|
|
313
|
-
overwriteFromTemplate: `node Harness/scripts/wf-update-check.mjs --accept-template ${shellArg(entry.file)} --finalize`,
|
|
314
|
-
},
|
|
315
|
-
};
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
function shellArg(value) {
|
|
319
|
-
return /^[A-Za-z0-9@._/\\:-]+$/.test(value) ? value : JSON.stringify(value);
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
function buildJsonPlan() {
|
|
323
|
-
return {
|
|
324
|
-
updated: plan.updated.map(withRemoteMeta),
|
|
325
|
-
created: plan.created.map(withRemoteMeta),
|
|
326
|
-
adopted: plan.adopted.map(withRemoteMeta),
|
|
327
|
-
conflict: plan.conflict.map(withConflictActions),
|
|
328
|
-
skipped: plan.skipped.map(withRemoteMeta),
|
|
329
|
-
};
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
function buildAgentHints(jsonPlan) {
|
|
333
|
-
return {
|
|
334
|
-
mode: 'script-first-ai-conflicts',
|
|
335
|
-
dryRunJsonCommand: 'node Harness/scripts/wf-update-check.mjs --json',
|
|
336
|
-
safeApplyCommand: 'node Harness/scripts/wf-update-check.mjs --apply-safe',
|
|
337
|
-
strictApplyCommand: 'node Harness/scripts/wf-update-check.mjs --apply',
|
|
338
|
-
finalizeCommand: 'node Harness/scripts/wf-update-check.mjs --finalize',
|
|
339
|
-
partialUpdate: localVersion.partialUpdate || null,
|
|
340
|
-
acceptedConflicts: localVersion.acceptedConflicts || {},
|
|
341
|
-
aiMergeRequired: jsonPlan.conflict,
|
|
342
|
-
aiMergeRequiredCount: jsonPlan.conflict.length,
|
|
343
|
-
conflictPolicy: 'Use the script for SAFE/NEW/adopted files. For each CONFLICT file, compare local content with templateHint/remoteUrl, then record the decision with --accept-local, --accept-merged, or --accept-template. Do not hand-edit Harness/.harness-version.',
|
|
344
|
-
postUpdateCommands: [
|
|
345
|
-
'node Harness/scripts/validate-harness.mjs',
|
|
346
|
-
'node Harness/scripts/scan-clean.mjs --json',
|
|
347
|
-
],
|
|
348
|
-
bootstrapDebtCommand: 'node Harness/scripts/validate-harness.mjs --strict',
|
|
349
|
-
};
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
async function applyTemplateFile(file) {
|
|
353
|
-
const remoteHash = remoteChecksums[file];
|
|
354
|
-
if (!remoteHash) throw new Error(`No remote checksum for ${file}`);
|
|
355
|
-
const dest = safePath(file);
|
|
356
|
-
if (!dest) throw new Error(`Traversal rejected: ${file}`);
|
|
357
|
-
if (existsSync(dest) && lstatSync(dest).isSymbolicLink()) throw new Error(`Symlink rejected: ${file}`);
|
|
358
|
-
const content = await fetchRemote(sourceBase + remotePath(file));
|
|
359
|
-
const normalized = content.replace(/\r\n/g, '\n');
|
|
360
|
-
const fetchedHash = sha256(normalized);
|
|
361
|
-
if (fetchedHash !== remoteHash) throw new Error(`Hash mismatch: ${file}`);
|
|
362
|
-
mkdirSync(dirname(dest), { recursive: true });
|
|
363
|
-
writeFileSync(dest, normalized, 'utf-8');
|
|
364
|
-
localVersion.checksums[file] = remoteHash;
|
|
365
|
-
delete localVersion.acceptedConflicts[file];
|
|
366
|
-
}
|
|
367
|
-
|
|
368
|
-
function recordLocalDecision(file, decision, appliedAt) {
|
|
369
|
-
const remoteHash = remoteChecksums[file];
|
|
370
|
-
if (!remoteHash) throw new Error(`No remote checksum for ${file}`);
|
|
371
|
-
const diskPath = safePath(file);
|
|
372
|
-
if (!diskPath) throw new Error(`Traversal rejected: ${file}`);
|
|
373
|
-
const localHash = sha256File(diskPath);
|
|
374
|
-
if (!localHash) throw new Error(`File not found for ${decision}: ${file}`);
|
|
375
|
-
localVersion.acceptedConflicts[file] = {
|
|
376
|
-
decision,
|
|
377
|
-
targetGenerator: remoteGen,
|
|
378
|
-
localHash,
|
|
379
|
-
remoteHash,
|
|
380
|
-
templateHint: remotePath(file),
|
|
381
|
-
updatedAt: appliedAt,
|
|
382
|
-
};
|
|
383
|
-
}
|
|
384
|
-
|
|
385
|
-
function acceptedDecisionMatches(file, localHash, remoteHash) {
|
|
386
|
-
const decision = localVersion.acceptedConflicts?.[file];
|
|
387
|
-
return Boolean(
|
|
388
|
-
decision
|
|
389
|
-
&& decision.targetGenerator === remoteGen
|
|
390
|
-
&& decision.localHash === localHash
|
|
391
|
-
&& decision.remoteHash === remoteHash
|
|
392
|
-
);
|
|
393
|
-
}
|
|
394
|
-
|
|
395
|
-
let decisionMetadataDirty = false;
|
|
396
|
-
if (acceptLocal.length || acceptMerged.length || acceptTemplate.length) {
|
|
397
|
-
const appliedAt = new Date().toISOString();
|
|
398
|
-
try {
|
|
399
|
-
for (const file of acceptLocal) recordLocalDecision(file, 'accept-local', appliedAt);
|
|
400
|
-
for (const file of acceptMerged) recordLocalDecision(file, 'accept-merged', appliedAt);
|
|
401
|
-
for (const file of acceptTemplate) await applyTemplateFile(file);
|
|
402
|
-
decisionMetadataDirty = true;
|
|
403
|
-
} catch (e) {
|
|
404
|
-
if (jsonOut) {
|
|
405
|
-
console.log(JSON.stringify({ status: 'error', message: e.message }, null, 2));
|
|
406
|
-
} else {
|
|
407
|
-
console.error(`ERROR: ${e.message}`);
|
|
408
|
-
}
|
|
409
|
-
process.exitCode = 1;
|
|
410
|
-
return;
|
|
411
|
-
}
|
|
412
|
-
}
|
|
413
|
-
|
|
414
|
-
const allFiles = new Set([...Object.keys(localChecksums), ...Object.keys(remoteChecksums)]);
|
|
415
|
-
|
|
416
|
-
const plan = { updated: [], created: [], adopted: [], conflict: [], skipped: [] };
|
|
417
|
-
|
|
418
|
-
for (const file of [...allFiles].sort()) {
|
|
419
|
-
const canonical = canonicalPath(file);
|
|
420
|
-
|
|
421
|
-
// Reject paths that escape ROOT before any file access
|
|
422
|
-
const diskPath = safePath(file);
|
|
423
|
-
if (!diskPath) {
|
|
424
|
-
plan.skipped.push({ file, reason: 'path traversal rejected' });
|
|
425
|
-
continue;
|
|
426
|
-
}
|
|
427
|
-
|
|
284
|
+
if (!jsonOut) console.log('🔧 Version check bypassed (--ignore-version). Comparing files anyway.');
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const remoteChecksums = remoteVersion.checksums || {};
|
|
288
|
+
const remoteSources = remoteVersion.sources || {};
|
|
289
|
+
localVersion.acceptedConflicts = localVersion.acceptedConflicts || {};
|
|
290
|
+
|
|
291
|
+
/** Resolve the remote template-relative path for a dest-keyed file. Falls back to the key itself for back-compat. */
|
|
292
|
+
function remotePath(file) {
|
|
293
|
+
return remoteSources[file] || file;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function withRemoteMeta(entry) {
|
|
297
|
+
if (!entry || !entry.file || !remoteChecksums[entry.file]) return entry;
|
|
298
|
+
const templateHint = remotePath(entry.file);
|
|
299
|
+
return {
|
|
300
|
+
...entry,
|
|
301
|
+
templateHint,
|
|
302
|
+
remoteUrl: sourceBase + templateHint,
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function withConflictActions(entry) {
|
|
307
|
+
return {
|
|
308
|
+
...withRemoteMeta(entry),
|
|
309
|
+
choices: ['merge', 'keep-local', 'overwrite-from-template'],
|
|
310
|
+
decisionCommands: {
|
|
311
|
+
keepLocal: `node Harness/scripts/wf-update-check.mjs --accept-local ${shellArg(entry.file)} --finalize`,
|
|
312
|
+
acceptMerged: `node Harness/scripts/wf-update-check.mjs --accept-merged ${shellArg(entry.file)} --finalize`,
|
|
313
|
+
overwriteFromTemplate: `node Harness/scripts/wf-update-check.mjs --accept-template ${shellArg(entry.file)} --finalize`,
|
|
314
|
+
},
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function shellArg(value) {
|
|
319
|
+
return /^[A-Za-z0-9@._/\\:-]+$/.test(value) ? value : JSON.stringify(value);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function buildJsonPlan() {
|
|
323
|
+
return {
|
|
324
|
+
updated: plan.updated.map(withRemoteMeta),
|
|
325
|
+
created: plan.created.map(withRemoteMeta),
|
|
326
|
+
adopted: plan.adopted.map(withRemoteMeta),
|
|
327
|
+
conflict: plan.conflict.map(withConflictActions),
|
|
328
|
+
skipped: plan.skipped.map(withRemoteMeta),
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function buildAgentHints(jsonPlan) {
|
|
333
|
+
return {
|
|
334
|
+
mode: 'script-first-ai-conflicts',
|
|
335
|
+
dryRunJsonCommand: 'node Harness/scripts/wf-update-check.mjs --json',
|
|
336
|
+
safeApplyCommand: 'node Harness/scripts/wf-update-check.mjs --apply-safe',
|
|
337
|
+
strictApplyCommand: 'node Harness/scripts/wf-update-check.mjs --apply',
|
|
338
|
+
finalizeCommand: 'node Harness/scripts/wf-update-check.mjs --finalize',
|
|
339
|
+
partialUpdate: localVersion.partialUpdate || null,
|
|
340
|
+
acceptedConflicts: localVersion.acceptedConflicts || {},
|
|
341
|
+
aiMergeRequired: jsonPlan.conflict,
|
|
342
|
+
aiMergeRequiredCount: jsonPlan.conflict.length,
|
|
343
|
+
conflictPolicy: 'Use the script for SAFE/NEW/adopted files. For each CONFLICT file, compare local content with templateHint/remoteUrl, then record the decision with --accept-local, --accept-merged, or --accept-template. Do not hand-edit Harness/.harness-version.',
|
|
344
|
+
postUpdateCommands: [
|
|
345
|
+
'node Harness/scripts/validate-harness.mjs',
|
|
346
|
+
'node Harness/scripts/scan-clean.mjs --json',
|
|
347
|
+
],
|
|
348
|
+
bootstrapDebtCommand: 'node Harness/scripts/validate-harness.mjs --strict',
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
async function applyTemplateFile(file) {
|
|
353
|
+
const remoteHash = remoteChecksums[file];
|
|
354
|
+
if (!remoteHash) throw new Error(`No remote checksum for ${file}`);
|
|
355
|
+
const dest = safePath(file);
|
|
356
|
+
if (!dest) throw new Error(`Traversal rejected: ${file}`);
|
|
357
|
+
if (existsSync(dest) && lstatSync(dest).isSymbolicLink()) throw new Error(`Symlink rejected: ${file}`);
|
|
358
|
+
const content = await fetchRemote(sourceBase + remotePath(file));
|
|
359
|
+
const normalized = content.replace(/\r\n/g, '\n');
|
|
360
|
+
const fetchedHash = sha256(normalized);
|
|
361
|
+
if (fetchedHash !== remoteHash) throw new Error(`Hash mismatch: ${file}`);
|
|
362
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
363
|
+
writeFileSync(dest, normalized, 'utf-8');
|
|
364
|
+
localVersion.checksums[file] = remoteHash;
|
|
365
|
+
delete localVersion.acceptedConflicts[file];
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function recordLocalDecision(file, decision, appliedAt) {
|
|
369
|
+
const remoteHash = remoteChecksums[file];
|
|
370
|
+
if (!remoteHash) throw new Error(`No remote checksum for ${file}`);
|
|
371
|
+
const diskPath = safePath(file);
|
|
372
|
+
if (!diskPath) throw new Error(`Traversal rejected: ${file}`);
|
|
373
|
+
const localHash = sha256File(diskPath);
|
|
374
|
+
if (!localHash) throw new Error(`File not found for ${decision}: ${file}`);
|
|
375
|
+
localVersion.acceptedConflicts[file] = {
|
|
376
|
+
decision,
|
|
377
|
+
targetGenerator: remoteGen,
|
|
378
|
+
localHash,
|
|
379
|
+
remoteHash,
|
|
380
|
+
templateHint: remotePath(file),
|
|
381
|
+
updatedAt: appliedAt,
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function acceptedDecisionMatches(file, localHash, remoteHash) {
|
|
386
|
+
const decision = localVersion.acceptedConflicts?.[file];
|
|
387
|
+
return Boolean(
|
|
388
|
+
decision
|
|
389
|
+
&& decision.targetGenerator === remoteGen
|
|
390
|
+
&& decision.localHash === localHash
|
|
391
|
+
&& decision.remoteHash === remoteHash
|
|
392
|
+
);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
let decisionMetadataDirty = false;
|
|
396
|
+
if (acceptLocal.length || acceptMerged.length || acceptTemplate.length) {
|
|
397
|
+
const appliedAt = new Date().toISOString();
|
|
398
|
+
try {
|
|
399
|
+
for (const file of acceptLocal) recordLocalDecision(file, 'accept-local', appliedAt);
|
|
400
|
+
for (const file of acceptMerged) recordLocalDecision(file, 'accept-merged', appliedAt);
|
|
401
|
+
for (const file of acceptTemplate) await applyTemplateFile(file);
|
|
402
|
+
decisionMetadataDirty = true;
|
|
403
|
+
} catch (e) {
|
|
404
|
+
if (jsonOut) {
|
|
405
|
+
console.log(JSON.stringify({ status: 'error', message: e.message }, null, 2));
|
|
406
|
+
} else {
|
|
407
|
+
console.error(`ERROR: ${e.message}`);
|
|
408
|
+
}
|
|
409
|
+
process.exitCode = 1;
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
const allFiles = new Set([...Object.keys(localChecksums), ...Object.keys(remoteChecksums)]);
|
|
415
|
+
|
|
416
|
+
const plan = { updated: [], created: [], adopted: [], conflict: [], skipped: [] };
|
|
417
|
+
|
|
418
|
+
for (const file of [...allFiles].sort()) {
|
|
419
|
+
const canonical = canonicalPath(file);
|
|
420
|
+
|
|
421
|
+
// Reject paths that escape ROOT before any file access
|
|
422
|
+
const diskPath = safePath(file);
|
|
423
|
+
if (!diskPath) {
|
|
424
|
+
plan.skipped.push({ file, reason: 'path traversal rejected' });
|
|
425
|
+
continue;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
428
|
const localHash = sha256File(diskPath);
|
|
429
429
|
const storedHash = localChecksums[file];
|
|
430
430
|
const remoteHash = remoteChecksums[file];
|
|
@@ -437,31 +437,31 @@ async function main() {
|
|
|
437
437
|
plan.skipped.push({ file, reason: 'not in remote' });
|
|
438
438
|
continue;
|
|
439
439
|
}
|
|
440
|
-
|
|
441
|
-
if (!storedHash) {
|
|
442
|
-
// New file from remote — if local file exists, it's a CONFLICT
|
|
443
|
-
if (localHash) {
|
|
444
|
-
if (localHash === remoteHash) {
|
|
445
|
-
plan.adopted.push({ file, localHash, remoteHash, reason: 'new remote file already matches local file' });
|
|
446
|
-
continue;
|
|
447
|
-
}
|
|
448
|
-
if (acceptedDecisionMatches(file, localHash, remoteHash)) {
|
|
449
|
-
plan.skipped.push({ file, reason: `accepted conflict decision: ${localVersion.acceptedConflicts[file].decision}` });
|
|
450
|
-
continue;
|
|
451
|
-
}
|
|
452
|
-
plan.conflict.push({ file, localHash, storedHash: 'none', remoteHash, reason: 'new remote file conflicts with existing local file' });
|
|
453
|
-
continue;
|
|
454
|
-
}
|
|
455
|
-
// New file — still respect PRESERVE classification
|
|
456
|
-
const tier = classify(canonical, null, null);
|
|
457
|
-
if (tier === 'PRESERVE') {
|
|
458
|
-
plan.skipped.push({ file, reason: 'PRESERVE — new file would overwrite user data' });
|
|
459
|
-
} else {
|
|
460
|
-
plan.created.push({ file, remoteHash });
|
|
461
|
-
}
|
|
462
|
-
continue;
|
|
463
|
-
}
|
|
464
|
-
|
|
440
|
+
|
|
441
|
+
if (!storedHash) {
|
|
442
|
+
// New file from remote — if local file exists, it's a CONFLICT
|
|
443
|
+
if (localHash) {
|
|
444
|
+
if (localHash === remoteHash) {
|
|
445
|
+
plan.adopted.push({ file, localHash, remoteHash, reason: 'new remote file already matches local file' });
|
|
446
|
+
continue;
|
|
447
|
+
}
|
|
448
|
+
if (acceptedDecisionMatches(file, localHash, remoteHash)) {
|
|
449
|
+
plan.skipped.push({ file, reason: `accepted conflict decision: ${localVersion.acceptedConflicts[file].decision}` });
|
|
450
|
+
continue;
|
|
451
|
+
}
|
|
452
|
+
plan.conflict.push({ file, localHash, storedHash: 'none', remoteHash, reason: 'new remote file conflicts with existing local file' });
|
|
453
|
+
continue;
|
|
454
|
+
}
|
|
455
|
+
// New file — still respect PRESERVE classification
|
|
456
|
+
const tier = classify(canonical, null, null);
|
|
457
|
+
if (tier === 'PRESERVE') {
|
|
458
|
+
plan.skipped.push({ file, reason: 'PRESERVE — new file would overwrite user data' });
|
|
459
|
+
} else {
|
|
460
|
+
plan.created.push({ file, remoteHash });
|
|
461
|
+
}
|
|
462
|
+
continue;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
465
|
if (
|
|
466
466
|
storedHash
|
|
467
467
|
&& hasInstalledOptions(localVersion)
|
|
@@ -484,204 +484,204 @@ async function main() {
|
|
|
484
484
|
}
|
|
485
485
|
|
|
486
486
|
const tier = classify(canonical, localHash, storedHash);
|
|
487
|
-
|
|
488
|
-
if (tier === 'PRESERVE') {
|
|
489
|
-
plan.skipped.push({ file, reason: 'PRESERVE — user data' });
|
|
490
|
-
} else if (tier === 'SAFE') {
|
|
491
|
-
if (localHash === remoteHash) {
|
|
492
|
-
plan.skipped.push({ file, reason: 'already current' });
|
|
493
|
-
} else {
|
|
494
|
-
plan.updated.push({ file, remoteHash });
|
|
495
|
-
}
|
|
496
|
-
} else if (tier === 'CONFLICT') {
|
|
497
|
-
if (acceptedDecisionMatches(file, localHash, remoteHash)) {
|
|
498
|
-
plan.skipped.push({ file, reason: `accepted conflict decision: ${localVersion.acceptedConflicts[file].decision}` });
|
|
499
|
-
continue;
|
|
500
|
-
}
|
|
501
|
-
plan.conflict.push({
|
|
502
|
-
file,
|
|
503
|
-
localHash,
|
|
504
|
-
storedHash,
|
|
505
|
-
remoteHash,
|
|
506
|
-
reason: MERGE_PATTERNS.some(p => p.test(canonical))
|
|
507
|
-
? 'user modified MERGE file'
|
|
508
|
-
: 'user modified runtime file',
|
|
509
|
-
});
|
|
510
|
-
}
|
|
511
|
-
}
|
|
512
|
-
|
|
513
|
-
// 3. Output
|
|
514
|
-
if (jsonOut) {
|
|
515
|
-
const jsonPlan = buildJsonPlan();
|
|
516
|
-
console.log(JSON.stringify({
|
|
517
|
-
status: localVersion.partialUpdate ? 'partial-update' : 'update-available',
|
|
518
|
-
from: localGen,
|
|
519
|
-
to: remoteGen,
|
|
520
|
-
sourceBase,
|
|
521
|
-
partialUpdate: localVersion.partialUpdate || null,
|
|
522
|
-
acceptedConflicts: localVersion.acceptedConflicts || {},
|
|
523
|
-
updated: plan.updated.length,
|
|
524
|
-
created: plan.created.length,
|
|
525
|
-
adopted: plan.adopted.length,
|
|
526
|
-
conflict: plan.conflict.length,
|
|
527
|
-
skipped: plan.skipped.length,
|
|
528
|
-
plan: jsonPlan,
|
|
529
|
-
agent: buildAgentHints(jsonPlan),
|
|
530
|
-
}, null, 2));
|
|
531
|
-
return;
|
|
532
|
-
}
|
|
533
|
-
|
|
534
|
-
console.log(`\n🔄 Update: v${localGen} → v${remoteGen}`);
|
|
535
|
-
console.log(` ${plan.updated.length} safe update, ${plan.created.length} new, ${plan.conflict.length} conflict, ${plan.skipped.length} skipped\n`);
|
|
536
|
-
|
|
537
|
-
// Show conflicts (these need AI/user decision)
|
|
538
|
-
if (plan.conflict.length > 0) {
|
|
539
|
-
console.log('⚠ CONFLICTS (need your decision):');
|
|
540
|
-
for (const c of plan.conflict) {
|
|
541
|
-
console.log(` 📄 ${c.file} [${c.reason}]`);
|
|
542
|
-
}
|
|
543
|
-
if (plan.updated.length + plan.created.length > 0) {
|
|
544
|
-
console.log(' Tip: run --apply-safe to apply SAFE/NEW files first, then merge conflicts.');
|
|
545
|
-
}
|
|
546
|
-
console.log('');
|
|
547
|
-
}
|
|
548
|
-
|
|
549
|
-
// Show what will be auto-updated
|
|
550
|
-
if (plan.updated.length + plan.created.length > 0) {
|
|
551
|
-
console.log('✅ AUTO (safe to apply):');
|
|
552
|
-
for (const u of plan.updated) console.log(` ↑ ${u.file}`);
|
|
553
|
-
for (const c of plan.created) console.log(` + ${c.file}`);
|
|
554
|
-
console.log('');
|
|
555
|
-
}
|
|
556
|
-
|
|
557
|
-
// 4. Apply if requested
|
|
558
|
-
if (apply || applySafe) {
|
|
559
|
-
// Refuse to apply when conflicts exist — must resolve first
|
|
560
|
-
if (apply && !applySafe && plan.conflict.length > 0) {
|
|
561
|
-
console.log(`❌ Cannot apply: ${plan.conflict.length} conflicts must be resolved first.`);
|
|
562
|
-
console.log(' Run --apply-safe to apply SAFE/NEW files first, or resolve conflicts manually then re-run --apply.');
|
|
563
|
-
process.exitCode = 1;
|
|
564
|
-
return plan;
|
|
565
|
-
}
|
|
566
|
-
if (applySafe && plan.conflict.length > 0) {
|
|
567
|
-
console.log(`Applying SAFE/NEW files only; ${plan.conflict.length} conflicts will remain for AI/user merge.`);
|
|
568
|
-
}
|
|
569
|
-
|
|
570
|
-
const lexists = existsSync;
|
|
571
|
-
let applied = 0;
|
|
572
|
-
let failed = 0;
|
|
573
|
-
|
|
574
|
-
for (const u of plan.updated) {
|
|
575
|
-
try {
|
|
576
|
-
const dest = safePath(u.file);
|
|
577
|
-
if (!dest) { console.error(` ✗ Traversal rejected: ${u.file}`); failed++; continue; }
|
|
578
|
-
// Symlink rejection — don't follow symlinks
|
|
579
|
-
if (lexists(dest)) {
|
|
580
|
-
try { if (lstatSync(dest).isSymbolicLink()) { console.error(` ✗ Symlink rejected: ${u.file}`); failed++; continue; } } catch (_) {}
|
|
581
|
-
}
|
|
582
|
-
const content = await fetchRemote(sourceBase + remotePath(u.file));
|
|
583
|
-
const normalized = content.replace(/\r\n/g, '\n');
|
|
584
|
-
const fetchedHash = sha256(normalized);
|
|
585
|
-
if (fetchedHash !== u.remoteHash) {
|
|
586
|
-
console.error(` ✗ Hash mismatch: ${u.file}`);
|
|
587
|
-
failed++; continue;
|
|
588
|
-
}
|
|
589
|
-
mkdirSync(dirname(dest), { recursive: true });
|
|
590
|
-
writeFileSync(dest, normalized, 'utf-8');
|
|
591
|
-
applied++;
|
|
592
|
-
} catch (e) {
|
|
593
|
-
console.error(` ✗ Failed: ${u.file} — ${e.message}`);
|
|
594
|
-
failed++;
|
|
595
|
-
}
|
|
596
|
-
}
|
|
597
|
-
|
|
598
|
-
for (const c of plan.created) {
|
|
599
|
-
try {
|
|
600
|
-
const dest = safePath(c.file);
|
|
601
|
-
if (!dest) { console.error(` ✗ Traversal rejected: ${c.file}`); failed++; continue; }
|
|
602
|
-
// TOCTOU: recheck file didn't appear since planning
|
|
603
|
-
if (lexists(dest)) {
|
|
604
|
-
try { if (lstatSync(dest).isSymbolicLink()) { console.error(` ✗ Symlink rejected: ${c.file}`); failed++; continue; } } catch (_) {}
|
|
605
|
-
console.error(` ✗ File created since plan: ${c.file} — treating as CONFLICT`);
|
|
606
|
-
failed++; continue;
|
|
607
|
-
}
|
|
608
|
-
const content = await fetchRemote(sourceBase + remotePath(c.file));
|
|
609
|
-
const normalized = content.replace(/\r\n/g, '\n');
|
|
610
|
-
const fetchedHash = sha256(normalized);
|
|
611
|
-
if (fetchedHash !== c.remoteHash) {
|
|
612
|
-
console.error(` ✗ Hash mismatch: ${c.file}`);
|
|
613
|
-
failed++; continue;
|
|
614
|
-
}
|
|
615
|
-
mkdirSync(dirname(dest), { recursive: true });
|
|
616
|
-
writeFileSync(dest, normalized, 'utf-8');
|
|
617
|
-
applied++;
|
|
618
|
-
} catch (e) {
|
|
619
|
-
console.error(` ✗ Failed: ${c.file} — ${e.message}`);
|
|
620
|
-
failed++;
|
|
621
|
-
}
|
|
622
|
-
}
|
|
623
|
-
|
|
624
|
-
// Only version-track applied files after complete script success.
|
|
625
|
-
if (failed === 0) {
|
|
626
|
-
const appliedAt = new Date().toISOString();
|
|
627
|
-
localVersion.checksums = localVersion.checksums || {};
|
|
628
|
-
for (const u of plan.updated) localVersion.checksums[u.file] = u.remoteHash;
|
|
629
|
-
for (const c of plan.created) localVersion.checksums[c.file] = c.remoteHash;
|
|
630
|
-
for (const a of plan.adopted) localVersion.checksums[a.file] = a.remoteHash;
|
|
631
|
-
if (plan.conflict.length === 0) {
|
|
632
|
-
localVersion.generator = remoteGen;
|
|
633
|
-
localVersion.generated = appliedAt;
|
|
634
|
-
delete localVersion.partialUpdate;
|
|
635
|
-
} else {
|
|
636
|
-
localVersion.partialUpdate = {
|
|
637
|
-
targetGenerator: remoteGen,
|
|
638
|
-
updatedAt: appliedAt,
|
|
639
|
-
appliedFiles: [...plan.updated, ...plan.created].map(x => x.file),
|
|
640
|
-
adoptedFiles: plan.adopted.map(x => x.file),
|
|
641
|
-
conflicts: plan.conflict.map(x => x.file),
|
|
642
|
-
};
|
|
643
|
-
}
|
|
644
|
-
writeFileSync(VERSION_FILE, JSON.stringify(localVersion, null, 2) + '\n', 'utf-8');
|
|
645
|
-
if (plan.conflict.length === 0) {
|
|
646
|
-
console.log(`✅ Applied ${applied} files. Version updated to ${remoteVersion.generator}.`);
|
|
647
|
-
} else {
|
|
648
|
-
console.log(`✅ Applied ${applied} SAFE/NEW files. Version remains ${localGen}; ${plan.conflict.length} conflicts still need merge.`);
|
|
649
|
-
}
|
|
650
|
-
} else {
|
|
651
|
-
console.log(`❌ ${failed} failures. NO files were version-tracked. Fix and re-run.`);
|
|
652
|
-
process.exitCode = 1;
|
|
653
|
-
}
|
|
654
|
-
}
|
|
655
|
-
|
|
656
|
-
if (finalize) {
|
|
657
|
-
if (plan.updated.length > 0 || plan.created.length > 0 || plan.adopted.length > 0) {
|
|
658
|
-
console.log(`Cannot finalize: ${plan.updated.length} safe updates, ${plan.created.length} new files, and ${plan.adopted.length} adopted metadata entries still need --apply-safe.`);
|
|
659
|
-
if (decisionMetadataDirty) {
|
|
660
|
-
writeFileSync(VERSION_FILE, JSON.stringify(localVersion, null, 2) + '\n', 'utf-8');
|
|
661
|
-
}
|
|
662
|
-
process.exitCode = 1;
|
|
663
|
-
return plan;
|
|
664
|
-
}
|
|
665
|
-
if (plan.conflict.length > 0) {
|
|
666
|
-
console.log(`Cannot finalize: ${plan.conflict.length} conflicts still need a script-recorded decision.`);
|
|
487
|
+
|
|
488
|
+
if (tier === 'PRESERVE') {
|
|
489
|
+
plan.skipped.push({ file, reason: 'PRESERVE — user data' });
|
|
490
|
+
} else if (tier === 'SAFE') {
|
|
491
|
+
if (localHash === remoteHash) {
|
|
492
|
+
plan.skipped.push({ file, reason: 'already current' });
|
|
493
|
+
} else {
|
|
494
|
+
plan.updated.push({ file, remoteHash });
|
|
495
|
+
}
|
|
496
|
+
} else if (tier === 'CONFLICT') {
|
|
497
|
+
if (acceptedDecisionMatches(file, localHash, remoteHash)) {
|
|
498
|
+
plan.skipped.push({ file, reason: `accepted conflict decision: ${localVersion.acceptedConflicts[file].decision}` });
|
|
499
|
+
continue;
|
|
500
|
+
}
|
|
501
|
+
plan.conflict.push({
|
|
502
|
+
file,
|
|
503
|
+
localHash,
|
|
504
|
+
storedHash,
|
|
505
|
+
remoteHash,
|
|
506
|
+
reason: MERGE_PATTERNS.some(p => p.test(canonical))
|
|
507
|
+
? 'user modified MERGE file'
|
|
508
|
+
: 'user modified runtime file',
|
|
509
|
+
});
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// 3. Output
|
|
514
|
+
if (jsonOut) {
|
|
515
|
+
const jsonPlan = buildJsonPlan();
|
|
516
|
+
console.log(JSON.stringify({
|
|
517
|
+
status: localVersion.partialUpdate ? 'partial-update' : 'update-available',
|
|
518
|
+
from: localGen,
|
|
519
|
+
to: remoteGen,
|
|
520
|
+
sourceBase,
|
|
521
|
+
partialUpdate: localVersion.partialUpdate || null,
|
|
522
|
+
acceptedConflicts: localVersion.acceptedConflicts || {},
|
|
523
|
+
updated: plan.updated.length,
|
|
524
|
+
created: plan.created.length,
|
|
525
|
+
adopted: plan.adopted.length,
|
|
526
|
+
conflict: plan.conflict.length,
|
|
527
|
+
skipped: plan.skipped.length,
|
|
528
|
+
plan: jsonPlan,
|
|
529
|
+
agent: buildAgentHints(jsonPlan),
|
|
530
|
+
}, null, 2));
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
console.log(`\n🔄 Update: v${localGen} → v${remoteGen}`);
|
|
535
|
+
console.log(` ${plan.updated.length} safe update, ${plan.created.length} new, ${plan.conflict.length} conflict, ${plan.skipped.length} skipped\n`);
|
|
536
|
+
|
|
537
|
+
// Show conflicts (these need AI/user decision)
|
|
538
|
+
if (plan.conflict.length > 0) {
|
|
539
|
+
console.log('⚠ CONFLICTS (need your decision):');
|
|
540
|
+
for (const c of plan.conflict) {
|
|
541
|
+
console.log(` 📄 ${c.file} [${c.reason}]`);
|
|
542
|
+
}
|
|
543
|
+
if (plan.updated.length + plan.created.length > 0) {
|
|
544
|
+
console.log(' Tip: run --apply-safe to apply SAFE/NEW files first, then merge conflicts.');
|
|
545
|
+
}
|
|
546
|
+
console.log('');
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// Show what will be auto-updated
|
|
550
|
+
if (plan.updated.length + plan.created.length > 0) {
|
|
551
|
+
console.log('✅ AUTO (safe to apply):');
|
|
552
|
+
for (const u of plan.updated) console.log(` ↑ ${u.file}`);
|
|
553
|
+
for (const c of plan.created) console.log(` + ${c.file}`);
|
|
554
|
+
console.log('');
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
// 4. Apply if requested
|
|
558
|
+
if (apply || applySafe) {
|
|
559
|
+
// Refuse to apply when conflicts exist — must resolve first
|
|
560
|
+
if (apply && !applySafe && plan.conflict.length > 0) {
|
|
561
|
+
console.log(`❌ Cannot apply: ${plan.conflict.length} conflicts must be resolved first.`);
|
|
562
|
+
console.log(' Run --apply-safe to apply SAFE/NEW files first, or resolve conflicts manually then re-run --apply.');
|
|
563
|
+
process.exitCode = 1;
|
|
564
|
+
return plan;
|
|
565
|
+
}
|
|
566
|
+
if (applySafe && plan.conflict.length > 0) {
|
|
567
|
+
console.log(`Applying SAFE/NEW files only; ${plan.conflict.length} conflicts will remain for AI/user merge.`);
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
const lexists = existsSync;
|
|
571
|
+
let applied = 0;
|
|
572
|
+
let failed = 0;
|
|
573
|
+
|
|
574
|
+
for (const u of plan.updated) {
|
|
575
|
+
try {
|
|
576
|
+
const dest = safePath(u.file);
|
|
577
|
+
if (!dest) { console.error(` ✗ Traversal rejected: ${u.file}`); failed++; continue; }
|
|
578
|
+
// Symlink rejection — don't follow symlinks
|
|
579
|
+
if (lexists(dest)) {
|
|
580
|
+
try { if (lstatSync(dest).isSymbolicLink()) { console.error(` ✗ Symlink rejected: ${u.file}`); failed++; continue; } } catch (_) {}
|
|
581
|
+
}
|
|
582
|
+
const content = await fetchRemote(sourceBase + remotePath(u.file));
|
|
583
|
+
const normalized = content.replace(/\r\n/g, '\n');
|
|
584
|
+
const fetchedHash = sha256(normalized);
|
|
585
|
+
if (fetchedHash !== u.remoteHash) {
|
|
586
|
+
console.error(` ✗ Hash mismatch: ${u.file}`);
|
|
587
|
+
failed++; continue;
|
|
588
|
+
}
|
|
589
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
590
|
+
writeFileSync(dest, normalized, 'utf-8');
|
|
591
|
+
applied++;
|
|
592
|
+
} catch (e) {
|
|
593
|
+
console.error(` ✗ Failed: ${u.file} — ${e.message}`);
|
|
594
|
+
failed++;
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
for (const c of plan.created) {
|
|
599
|
+
try {
|
|
600
|
+
const dest = safePath(c.file);
|
|
601
|
+
if (!dest) { console.error(` ✗ Traversal rejected: ${c.file}`); failed++; continue; }
|
|
602
|
+
// TOCTOU: recheck file didn't appear since planning
|
|
603
|
+
if (lexists(dest)) {
|
|
604
|
+
try { if (lstatSync(dest).isSymbolicLink()) { console.error(` ✗ Symlink rejected: ${c.file}`); failed++; continue; } } catch (_) {}
|
|
605
|
+
console.error(` ✗ File created since plan: ${c.file} — treating as CONFLICT`);
|
|
606
|
+
failed++; continue;
|
|
607
|
+
}
|
|
608
|
+
const content = await fetchRemote(sourceBase + remotePath(c.file));
|
|
609
|
+
const normalized = content.replace(/\r\n/g, '\n');
|
|
610
|
+
const fetchedHash = sha256(normalized);
|
|
611
|
+
if (fetchedHash !== c.remoteHash) {
|
|
612
|
+
console.error(` ✗ Hash mismatch: ${c.file}`);
|
|
613
|
+
failed++; continue;
|
|
614
|
+
}
|
|
615
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
616
|
+
writeFileSync(dest, normalized, 'utf-8');
|
|
617
|
+
applied++;
|
|
618
|
+
} catch (e) {
|
|
619
|
+
console.error(` ✗ Failed: ${c.file} — ${e.message}`);
|
|
620
|
+
failed++;
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
// Only version-track applied files after complete script success.
|
|
625
|
+
if (failed === 0) {
|
|
626
|
+
const appliedAt = new Date().toISOString();
|
|
627
|
+
localVersion.checksums = localVersion.checksums || {};
|
|
628
|
+
for (const u of plan.updated) localVersion.checksums[u.file] = u.remoteHash;
|
|
629
|
+
for (const c of plan.created) localVersion.checksums[c.file] = c.remoteHash;
|
|
630
|
+
for (const a of plan.adopted) localVersion.checksums[a.file] = a.remoteHash;
|
|
631
|
+
if (plan.conflict.length === 0) {
|
|
632
|
+
localVersion.generator = remoteGen;
|
|
633
|
+
localVersion.generated = appliedAt;
|
|
634
|
+
delete localVersion.partialUpdate;
|
|
635
|
+
} else {
|
|
636
|
+
localVersion.partialUpdate = {
|
|
637
|
+
targetGenerator: remoteGen,
|
|
638
|
+
updatedAt: appliedAt,
|
|
639
|
+
appliedFiles: [...plan.updated, ...plan.created].map(x => x.file),
|
|
640
|
+
adoptedFiles: plan.adopted.map(x => x.file),
|
|
641
|
+
conflicts: plan.conflict.map(x => x.file),
|
|
642
|
+
};
|
|
643
|
+
}
|
|
644
|
+
writeFileSync(VERSION_FILE, JSON.stringify(localVersion, null, 2) + '\n', 'utf-8');
|
|
645
|
+
if (plan.conflict.length === 0) {
|
|
646
|
+
console.log(`✅ Applied ${applied} files. Version updated to ${remoteVersion.generator}.`);
|
|
647
|
+
} else {
|
|
648
|
+
console.log(`✅ Applied ${applied} SAFE/NEW files. Version remains ${localGen}; ${plan.conflict.length} conflicts still need merge.`);
|
|
649
|
+
}
|
|
650
|
+
} else {
|
|
651
|
+
console.log(`❌ ${failed} failures. NO files were version-tracked. Fix and re-run.`);
|
|
652
|
+
process.exitCode = 1;
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
if (finalize) {
|
|
657
|
+
if (plan.updated.length > 0 || plan.created.length > 0 || plan.adopted.length > 0) {
|
|
658
|
+
console.log(`Cannot finalize: ${plan.updated.length} safe updates, ${plan.created.length} new files, and ${plan.adopted.length} adopted metadata entries still need --apply-safe.`);
|
|
667
659
|
if (decisionMetadataDirty) {
|
|
668
660
|
writeFileSync(VERSION_FILE, JSON.stringify(localVersion, null, 2) + '\n', 'utf-8');
|
|
669
661
|
}
|
|
670
662
|
process.exitCode = 1;
|
|
671
663
|
return plan;
|
|
672
664
|
}
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
665
|
+
if (plan.conflict.length > 0) {
|
|
666
|
+
console.log(`Cannot finalize: ${plan.conflict.length} conflicts still need a script-recorded decision.`);
|
|
667
|
+
if (decisionMetadataDirty) {
|
|
668
|
+
writeFileSync(VERSION_FILE, JSON.stringify(localVersion, null, 2) + '\n', 'utf-8');
|
|
669
|
+
}
|
|
670
|
+
process.exitCode = 1;
|
|
671
|
+
return plan;
|
|
672
|
+
}
|
|
673
|
+
const finalizedAt = new Date().toISOString();
|
|
674
|
+
localVersion.generator = remoteGen;
|
|
675
|
+
localVersion.generated = finalizedAt;
|
|
676
|
+
delete localVersion.partialUpdate;
|
|
677
|
+
writeFileSync(VERSION_FILE, JSON.stringify(localVersion, null, 2) + '\n', 'utf-8');
|
|
678
|
+
console.log(`Finalized Harness update to ${remoteGen}.`);
|
|
679
|
+
} else if (decisionMetadataDirty) {
|
|
680
|
+
writeFileSync(VERSION_FILE, JSON.stringify(localVersion, null, 2) + '\n', 'utf-8');
|
|
681
|
+
console.log('Recorded conflict decision metadata.');
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
return plan;
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
main().catch(e => { console.error(e); process.exit(1); });
|