create-harness-vibe-coding 0.8.6 → 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 +30 -11
- package/src/index.js +143 -14
- 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/reflector.md +35 -0
- 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 +34 -32
- package/templates/common/.claude/commands/wf-help.md +2 -3
- package/templates/common/.claude/rules/ecc/common.md +44 -44
- package/templates/common/.claude/skills/subagent-orchestrator/SKILL.md +10 -4
- package/templates/common/.claude/skills/wf/SKILL.md +7 -3
- package/templates/common/.claude/skills/wf-auto/SKILL.md +61 -107
- package/templates/common/.claude/skills/wf-auto-spark/SKILL.md +19 -17
- package/templates/common/.claude/skills/wf-max/SKILL.md +40 -21
- 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/.claude/skills/wf-update/SKILL.md +9 -4
- package/templates/common/.codex/config.toml +5 -0
- package/templates/common/.harness-version +78 -36
- 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/AGENTS.md +26 -25
- package/templates/common/CLAUDE.md +86 -88
- package/templates/common/Harness/ACCEPTANCE_PROTOCOL.md +12 -4
- package/templates/common/Harness/PROGRESS.md +17 -17
- package/templates/common/Harness/README.md +26 -16
- package/templates/common/Harness/WF-AUTO-ANGLES.md +170 -0
- package/templates/common/Harness/WF-AUTO-SPARK.md +23 -6
- package/templates/common/Harness/WF-AUTO.md +508 -493
- package/templates/common/Harness/WF-MAX.md +284 -232
- package/templates/common/Harness/WF.md +47 -29
- package/templates/common/Harness/agent-workflow.md +108 -76
- package/templates/common/Harness/architecture.md +124 -124
- package/templates/common/Harness/context-loading.md +111 -111
- package/templates/common/Harness/dispatch.md +96 -95
- package/templates/common/Harness/extension.md +67 -67
- 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 +192 -170
- package/templates/common/Harness/tasks/_template/ARTIFACTS.md +3 -3
- package/templates/common/Harness/tasks/_template/NOTES.md +3 -3
- package/templates/common/Harness/tasks/_template/PLAN.md +53 -60
- package/templates/common/Harness/tasks/_template/PROGRESS.md +26 -29
- package/templates/common/MEMORY.md +27 -30
- package/templates/common/README.md +36 -36
- package/templates/common/SETUP.md +1 -1
- 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 +487 -448
- package/templates/common/scripts/validate-harness.mjs +245 -146
- package/templates/common/scripts/wf-remove.mjs +311 -293
- package/templates/common/scripts/wf-update-check.mjs +511 -311
- package/templates/optional/catalog.json +41 -33
- package/templates/optional/skills/browser-e2e/.claude/skills/wf-browser/SKILL.md +194 -194
- package/templates/optional/skills/browser-e2e/Harness/workflows/browser-e2e.md +105 -69
- 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,66 +1,72 @@
|
|
|
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
|
-
*
|
|
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
7
|
* Usage:
|
|
8
8
|
* node Harness/scripts/wf-update-check.mjs # full dry-run plan
|
|
9
9
|
* node Harness/scripts/wf-update-check.mjs --json # JSON output for AI consumption
|
|
10
10
|
* node Harness/scripts/wf-update-check.mjs --apply-safe # apply SAFE+NEW, leave CONFLICT for AI
|
|
11
11
|
* node Harness/scripts/wf-update-check.mjs --apply # apply only when no CONFLICT exists
|
|
12
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
|
-
|
|
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
19
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
20
20
|
const ROOT = process.env.WF_ROOT ? resolve(process.env.WF_ROOT) : resolve(__dirname, '..', '..');
|
|
21
21
|
const VERSION_FILE = resolve(ROOT, 'Harness', '.harness-version');
|
|
22
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
|
-
const MERGE_PATTERNS = [
|
|
42
|
-
/^CLAUDE\.md$/,
|
|
43
|
-
/^AGENTS\.md$/,
|
|
44
|
-
/^MEMORY\.md$/,
|
|
45
|
-
/^Harness\/MEMORY\.md$/,
|
|
46
|
-
/^Harness\/README\.md$/,
|
|
47
|
-
];
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
return
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
+
const MERGE_PATTERNS = [
|
|
42
|
+
/^CLAUDE\.md$/,
|
|
43
|
+
/^AGENTS\.md$/,
|
|
44
|
+
/^MEMORY\.md$/,
|
|
45
|
+
/^Harness\/MEMORY\.md$/,
|
|
46
|
+
/^Harness\/README\.md$/,
|
|
47
|
+
];
|
|
48
|
+
|
|
49
|
+
const OPTIONAL_REGISTRATION_FILES = new Set([
|
|
50
|
+
'.claude/commands/wf-help.md',
|
|
51
|
+
'Harness/MEMORY.md',
|
|
52
|
+
'Harness/README.md',
|
|
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. */
|
|
64
70
|
function canonicalPath(file) {
|
|
65
71
|
return file.replace(/\\/g, '/').replace(/\/+/g, '/');
|
|
66
72
|
}
|
|
@@ -73,44 +79,81 @@ function readFlagValue(args, flagName) {
|
|
|
73
79
|
return value;
|
|
74
80
|
}
|
|
75
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
|
+
|
|
76
96
|
function normalizeSourceBase(sourceBase) {
|
|
77
97
|
return sourceBase.endsWith('/') ? sourceBase : sourceBase + '/';
|
|
78
98
|
}
|
|
79
99
|
|
|
100
|
+
function selectedOptionIds(localVersion) {
|
|
101
|
+
return new Set(
|
|
102
|
+
Array.isArray(localVersion?.options)
|
|
103
|
+
? localVersion.options.map(String).filter(Boolean)
|
|
104
|
+
: [],
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function optionalSkillFromSource(source) {
|
|
109
|
+
const normalized = canonicalPath(source || '');
|
|
110
|
+
const match = normalized.match(/^skills\/([^/]+)\//);
|
|
111
|
+
return match ? match[1] : null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function isInstalledOptionalFile(file, localVersion) {
|
|
115
|
+
const skillId = optionalSkillFromSource(localVersion?.sources?.[file]);
|
|
116
|
+
return Boolean(skillId && selectedOptionIds(localVersion).has(skillId));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function hasInstalledOptions(localVersion) {
|
|
120
|
+
return selectedOptionIds(localVersion).size > 0;
|
|
121
|
+
}
|
|
122
|
+
|
|
80
123
|
/** Detect template placeholders in remote content. */
|
|
81
124
|
function isTemplate(raw) {
|
|
82
125
|
return /\{\{[a-zA-Z]+\}\}/.test(raw);
|
|
83
126
|
}
|
|
84
|
-
|
|
85
|
-
function sha256(content) {
|
|
86
|
-
return 'sha256-' + createHash('sha256').update(content).digest('hex');
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
function sha256File(path) {
|
|
90
|
-
if (!existsSync(path)) return null;
|
|
91
|
-
let content = readFileSync(path, 'utf-8');
|
|
92
|
-
// Normalize CRLF → LF
|
|
93
|
-
content = content.replace(/\r\n/g, '\n');
|
|
94
|
-
return sha256(content);
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
function classify(file, localHash, storedHash) {
|
|
98
|
-
// PRESERVE
|
|
99
|
-
for (const p of PRESERVE_PATTERNS) {
|
|
100
|
-
if (p.test(file)) return 'PRESERVE';
|
|
101
|
-
}
|
|
102
|
-
// MERGE — dual-purpose, check if user modified
|
|
103
|
-
for (const p of MERGE_PATTERNS) {
|
|
104
|
-
if (p.test(file)) {
|
|
105
|
-
if (localHash === storedHash) return 'SAFE'; // unmodified, safe
|
|
106
|
-
return 'CONFLICT'; // user modified, needs decision
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
// Everything else is SAFE runtime file
|
|
110
|
-
if (localHash === storedHash || localHash === null) return 'SAFE';
|
|
111
|
-
return 'CONFLICT'; // modified runtime file — unexpected
|
|
112
|
-
}
|
|
113
|
-
|
|
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
|
+
|
|
114
157
|
async function fetchRemote(url, timeoutMs = 30000) {
|
|
115
158
|
if (url.startsWith('file://')) {
|
|
116
159
|
return readFileSync(fileURLToPath(url), 'utf-8');
|
|
@@ -118,95 +161,104 @@ async function fetchRemote(url, timeoutMs = 30000) {
|
|
|
118
161
|
|
|
119
162
|
const controller = new AbortController();
|
|
120
163
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
121
|
-
try {
|
|
122
|
-
const res = await fetch(url, { signal: controller.signal });
|
|
123
|
-
if (!res.ok) throw new Error(`HTTP ${res.status}: ${url}`);
|
|
124
|
-
return res.text();
|
|
125
|
-
} finally {
|
|
126
|
-
clearTimeout(timer);
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
// ── Main ───────────────────────────────────────────────────────────
|
|
131
|
-
|
|
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
|
+
|
|
132
175
|
async function main() {
|
|
133
176
|
const args = process.argv.slice(2);
|
|
134
177
|
const apply = args.includes('--apply');
|
|
135
178
|
const applySafe = args.includes('--apply-safe') || args.includes('--safe-only');
|
|
136
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');
|
|
137
184
|
const ignoreVersion = args.includes('--ignore-version') || args.includes('--force-check');
|
|
138
185
|
const sourceBase = normalizeSourceBase(readFlagValue(args, '--source-base') || process.env.WF_SOURCE_BASE || DEFAULT_SOURCE_BASE);
|
|
139
|
-
|
|
140
|
-
// 1. Read local state
|
|
141
|
-
if (!existsSync(VERSION_FILE)) {
|
|
142
|
-
if (jsonOut) {
|
|
143
|
-
console.log(JSON.stringify({ status: 'error', message: 'Local Harness/.harness-version not found.' }));
|
|
144
|
-
} else {
|
|
145
|
-
console.error('ERROR: Harness/.harness-version not found. Is Harness installed?');
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
console.error('
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
console.
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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
|
+
process.exitCode = 1;
|
|
195
|
+
return;
|
|
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
|
+
}
|
|
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 {
|
|
177
227
|
const raw = await fetchRemote(sourceBase + '.harness-version');
|
|
178
|
-
if (isTemplate(raw)) {
|
|
179
|
-
if (jsonOut) {
|
|
180
|
-
console.log(JSON.stringify({ status: 'template-remote', message: 'Remote .harness-version has not been generated yet.' }));
|
|
181
|
-
} else {
|
|
182
|
-
console.log('⚠ Remote .harness-version is a template (contains {{placeholders}}).');
|
|
183
|
-
console.log(' The generate step has not been run on the remote repo. No update possible.');
|
|
184
|
-
console.log(' This is expected during development — the update mechanism works once the remote is live.');
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
console.error(
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
+
|
|
210
262
|
const localGen = localVersion.generator || '0.0.0';
|
|
211
263
|
const remoteGen = remoteVersion.generator || '0.0.0';
|
|
212
264
|
const versionCmp = cmpSemver(remoteGen, localGen);
|
|
@@ -222,19 +274,21 @@ async function main() {
|
|
|
222
274
|
} else if (versionCmp < 0) {
|
|
223
275
|
console.log(`⚠ Remote (v${remoteGen}) is OLDER than local (v${localGen}). Downgrade refused.`);
|
|
224
276
|
} else {
|
|
225
|
-
console.log(`✅ Already up to date (v${localGen})`);
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
const
|
|
236
|
-
|
|
237
|
-
|
|
277
|
+
console.log(`✅ Already up to date (v${localGen})`);
|
|
278
|
+
}
|
|
279
|
+
if (versionCmp < 0) process.exitCode = 1;
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
|
|
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. */
|
|
238
292
|
function remotePath(file) {
|
|
239
293
|
return remoteSources[file] || file;
|
|
240
294
|
}
|
|
@@ -253,13 +307,23 @@ async function main() {
|
|
|
253
307
|
return {
|
|
254
308
|
...withRemoteMeta(entry),
|
|
255
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
|
+
},
|
|
256
315
|
};
|
|
257
316
|
}
|
|
258
317
|
|
|
318
|
+
function shellArg(value) {
|
|
319
|
+
return /^[A-Za-z0-9@._/\\:-]+$/.test(value) ? value : JSON.stringify(value);
|
|
320
|
+
}
|
|
321
|
+
|
|
259
322
|
function buildJsonPlan() {
|
|
260
323
|
return {
|
|
261
324
|
updated: plan.updated.map(withRemoteMeta),
|
|
262
325
|
created: plan.created.map(withRemoteMeta),
|
|
326
|
+
adopted: plan.adopted.map(withRemoteMeta),
|
|
263
327
|
conflict: plan.conflict.map(withConflictActions),
|
|
264
328
|
skipped: plan.skipped.map(withRemoteMeta),
|
|
265
329
|
};
|
|
@@ -271,58 +335,156 @@ async function main() {
|
|
|
271
335
|
dryRunJsonCommand: 'node Harness/scripts/wf-update-check.mjs --json',
|
|
272
336
|
safeApplyCommand: 'node Harness/scripts/wf-update-check.mjs --apply-safe',
|
|
273
337
|
strictApplyCommand: 'node Harness/scripts/wf-update-check.mjs --apply',
|
|
338
|
+
finalizeCommand: 'node Harness/scripts/wf-update-check.mjs --finalize',
|
|
274
339
|
partialUpdate: localVersion.partialUpdate || null,
|
|
340
|
+
acceptedConflicts: localVersion.acceptedConflicts || {},
|
|
275
341
|
aiMergeRequired: jsonPlan.conflict,
|
|
276
342
|
aiMergeRequiredCount: jsonPlan.conflict.length,
|
|
277
|
-
conflictPolicy: 'Use the script for SAFE/NEW files. For each CONFLICT file, compare local content with templateHint/remoteUrl
|
|
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.',
|
|
278
344
|
postUpdateCommands: [
|
|
279
|
-
'node Harness/scripts/validate-harness.mjs
|
|
280
|
-
'node Harness/scripts/scan-clean.mjs',
|
|
345
|
+
'node Harness/scripts/validate-harness.mjs',
|
|
346
|
+
'node Harness/scripts/scan-clean.mjs --json',
|
|
281
347
|
],
|
|
348
|
+
bootstrapDebtCommand: 'node Harness/scripts/validate-harness.mjs --strict',
|
|
282
349
|
};
|
|
283
350
|
}
|
|
284
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
|
+
|
|
285
414
|
const allFiles = new Set([...Object.keys(localChecksums), ...Object.keys(remoteChecksums)]);
|
|
286
|
-
|
|
287
|
-
const plan = { updated: [], created: [], conflict: [], skipped: [] };
|
|
288
|
-
|
|
289
|
-
for (const file of [...allFiles].sort()) {
|
|
290
|
-
const canonical = canonicalPath(file);
|
|
291
|
-
|
|
292
|
-
// Reject paths that escape ROOT before any file access
|
|
293
|
-
const diskPath = safePath(file);
|
|
294
|
-
if (!diskPath) {
|
|
295
|
-
plan.skipped.push({ file, reason: 'path traversal rejected' });
|
|
296
|
-
continue;
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
const localHash = sha256File(diskPath);
|
|
300
|
-
const storedHash = localChecksums[file];
|
|
301
|
-
const remoteHash = remoteChecksums[file];
|
|
302
|
-
|
|
303
|
-
if (!remoteHash) {
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
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
|
+
const localHash = sha256File(diskPath);
|
|
429
|
+
const storedHash = localChecksums[file];
|
|
430
|
+
const remoteHash = remoteChecksums[file];
|
|
431
|
+
|
|
432
|
+
if (!remoteHash) {
|
|
433
|
+
if (storedHash && isInstalledOptionalFile(file, localVersion)) {
|
|
434
|
+
plan.skipped.push({ file, reason: 'installed optional workflow file' });
|
|
435
|
+
continue;
|
|
436
|
+
}
|
|
437
|
+
plan.skipped.push({ file, reason: 'not in remote' });
|
|
438
|
+
continue;
|
|
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
|
+
|
|
465
|
+
if (
|
|
466
|
+
storedHash
|
|
467
|
+
&& hasInstalledOptions(localVersion)
|
|
468
|
+
&& OPTIONAL_REGISTRATION_FILES.has(canonical)
|
|
469
|
+
&& localHash === storedHash
|
|
470
|
+
&& localHash !== remoteHash
|
|
471
|
+
) {
|
|
472
|
+
if (acceptedDecisionMatches(file, localHash, remoteHash)) {
|
|
473
|
+
plan.skipped.push({ file, reason: `accepted conflict decision: ${localVersion.acceptedConflicts[file].decision}` });
|
|
474
|
+
continue;
|
|
475
|
+
}
|
|
476
|
+
plan.conflict.push({
|
|
477
|
+
file,
|
|
478
|
+
localHash,
|
|
479
|
+
storedHash,
|
|
480
|
+
remoteHash,
|
|
481
|
+
reason: 'installed optional registration needs merge',
|
|
482
|
+
});
|
|
483
|
+
continue;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
const tier = classify(canonical, localHash, storedHash);
|
|
487
|
+
|
|
326
488
|
if (tier === 'PRESERVE') {
|
|
327
489
|
plan.skipped.push({ file, reason: 'PRESERVE — user data' });
|
|
328
490
|
} else if (tier === 'SAFE') {
|
|
@@ -332,18 +494,22 @@ async function main() {
|
|
|
332
494
|
plan.updated.push({ file, remoteHash });
|
|
333
495
|
}
|
|
334
496
|
} else if (tier === 'CONFLICT') {
|
|
335
|
-
|
|
336
|
-
file,
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
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
|
+
|
|
347
513
|
// 3. Output
|
|
348
514
|
if (jsonOut) {
|
|
349
515
|
const jsonPlan = buildJsonPlan();
|
|
@@ -353,8 +519,10 @@ async function main() {
|
|
|
353
519
|
to: remoteGen,
|
|
354
520
|
sourceBase,
|
|
355
521
|
partialUpdate: localVersion.partialUpdate || null,
|
|
522
|
+
acceptedConflicts: localVersion.acceptedConflicts || {},
|
|
356
523
|
updated: plan.updated.length,
|
|
357
524
|
created: plan.created.length,
|
|
525
|
+
adopted: plan.adopted.length,
|
|
358
526
|
conflict: plan.conflict.length,
|
|
359
527
|
skipped: plan.skipped.length,
|
|
360
528
|
plan: jsonPlan,
|
|
@@ -362,16 +530,16 @@ async function main() {
|
|
|
362
530
|
}, null, 2));
|
|
363
531
|
return;
|
|
364
532
|
}
|
|
365
|
-
|
|
366
|
-
console.log(`\n🔄 Update: v${localGen} → v${remoteGen}`);
|
|
367
|
-
console.log(` ${plan.updated.length} safe update, ${plan.created.length} new, ${plan.conflict.length} conflict, ${plan.skipped.length} skipped\n`);
|
|
368
|
-
|
|
369
|
-
// Show conflicts (these need AI/user decision)
|
|
370
|
-
if (plan.conflict.length > 0) {
|
|
371
|
-
console.log('⚠ CONFLICTS (need your decision):');
|
|
372
|
-
for (const c of plan.conflict) {
|
|
373
|
-
console.log(` 📄 ${c.file} [${c.reason}]`);
|
|
374
|
-
}
|
|
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
|
+
}
|
|
375
543
|
if (plan.updated.length + plan.created.length > 0) {
|
|
376
544
|
console.log(' Tip: run --apply-safe to apply SAFE/NEW files first, then merge conflicts.');
|
|
377
545
|
}
|
|
@@ -379,19 +547,20 @@ async function main() {
|
|
|
379
547
|
}
|
|
380
548
|
|
|
381
549
|
// Show what will be auto-updated
|
|
382
|
-
if (plan.updated.length + plan.created.length > 0) {
|
|
383
|
-
console.log('✅ AUTO (safe to apply):');
|
|
384
|
-
for (const u of plan.updated) console.log(` ↑ ${u.file}`);
|
|
385
|
-
for (const c of plan.created) console.log(` + ${c.file}`);
|
|
386
|
-
console.log('');
|
|
387
|
-
}
|
|
388
|
-
|
|
389
|
-
// 4. Apply if requested
|
|
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
|
|
390
558
|
if (apply || applySafe) {
|
|
391
|
-
// Refuse to apply when conflicts exist — must resolve first
|
|
559
|
+
// Refuse to apply when conflicts exist — must resolve first
|
|
392
560
|
if (apply && !applySafe && plan.conflict.length > 0) {
|
|
393
|
-
console.log(`❌ Cannot apply: ${plan.conflict.length} conflicts must be resolved first.`);
|
|
561
|
+
console.log(`❌ Cannot apply: ${plan.conflict.length} conflicts must be resolved first.`);
|
|
394
562
|
console.log(' Run --apply-safe to apply SAFE/NEW files first, or resolve conflicts manually then re-run --apply.');
|
|
563
|
+
process.exitCode = 1;
|
|
395
564
|
return plan;
|
|
396
565
|
}
|
|
397
566
|
if (applySafe && plan.conflict.length > 0) {
|
|
@@ -399,65 +568,66 @@ async function main() {
|
|
|
399
568
|
}
|
|
400
569
|
|
|
401
570
|
const lexists = existsSync;
|
|
402
|
-
let applied = 0;
|
|
403
|
-
let failed = 0;
|
|
404
|
-
|
|
405
|
-
for (const u of plan.updated) {
|
|
406
|
-
try {
|
|
407
|
-
const dest = safePath(u.file);
|
|
408
|
-
if (!dest) { console.error(` ✗ Traversal rejected: ${u.file}`); failed++; continue; }
|
|
409
|
-
// Symlink rejection — don't follow symlinks
|
|
410
|
-
if (lexists(dest)) {
|
|
411
|
-
try { if (lstatSync(dest).isSymbolicLink()) { console.error(` ✗ Symlink rejected: ${u.file}`); failed++; continue; } } catch (_) {}
|
|
412
|
-
}
|
|
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
|
+
}
|
|
413
582
|
const content = await fetchRemote(sourceBase + remotePath(u.file));
|
|
414
|
-
const normalized = content.replace(/\r\n/g, '\n');
|
|
415
|
-
const fetchedHash = sha256(normalized);
|
|
416
|
-
if (fetchedHash !== u.remoteHash) {
|
|
417
|
-
console.error(` ✗ Hash mismatch: ${u.file}`);
|
|
418
|
-
failed++; continue;
|
|
419
|
-
}
|
|
420
|
-
mkdirSync(dirname(dest), { recursive: true });
|
|
421
|
-
writeFileSync(dest, normalized, 'utf-8');
|
|
422
|
-
applied++;
|
|
423
|
-
} catch (e) {
|
|
424
|
-
console.error(` ✗ Failed: ${u.file} — ${e.message}`);
|
|
425
|
-
failed++;
|
|
426
|
-
}
|
|
427
|
-
}
|
|
428
|
-
|
|
429
|
-
for (const c of plan.created) {
|
|
430
|
-
try {
|
|
431
|
-
const dest = safePath(c.file);
|
|
432
|
-
if (!dest) { console.error(` ✗ Traversal rejected: ${c.file}`); failed++; continue; }
|
|
433
|
-
// TOCTOU: recheck file didn't appear since planning
|
|
434
|
-
if (lexists(dest)) {
|
|
435
|
-
try { if (lstatSync(dest).isSymbolicLink()) { console.error(` ✗ Symlink rejected: ${c.file}`); failed++; continue; } } catch (_) {}
|
|
436
|
-
console.error(` ✗ File created since plan: ${c.file} — treating as CONFLICT`);
|
|
437
|
-
failed++; continue;
|
|
438
|
-
}
|
|
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
|
+
}
|
|
439
608
|
const content = await fetchRemote(sourceBase + remotePath(c.file));
|
|
440
|
-
const normalized = content.replace(/\r\n/g, '\n');
|
|
441
|
-
const fetchedHash = sha256(normalized);
|
|
442
|
-
if (fetchedHash !== c.remoteHash) {
|
|
443
|
-
console.error(` ✗ Hash mismatch: ${c.file}`);
|
|
444
|
-
failed++; continue;
|
|
445
|
-
}
|
|
446
|
-
mkdirSync(dirname(dest), { recursive: true });
|
|
447
|
-
writeFileSync(dest, normalized, 'utf-8');
|
|
448
|
-
applied++;
|
|
449
|
-
} catch (e) {
|
|
450
|
-
console.error(` ✗ Failed: ${c.file} — ${e.message}`);
|
|
451
|
-
failed++;
|
|
452
|
-
}
|
|
453
|
-
}
|
|
454
|
-
|
|
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
|
+
|
|
455
624
|
// Only version-track applied files after complete script success.
|
|
456
625
|
if (failed === 0) {
|
|
457
626
|
const appliedAt = new Date().toISOString();
|
|
458
627
|
localVersion.checksums = localVersion.checksums || {};
|
|
459
628
|
for (const u of plan.updated) localVersion.checksums[u.file] = u.remoteHash;
|
|
460
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;
|
|
461
631
|
if (plan.conflict.length === 0) {
|
|
462
632
|
localVersion.generator = remoteGen;
|
|
463
633
|
localVersion.generated = appliedAt;
|
|
@@ -467,6 +637,7 @@ async function main() {
|
|
|
467
637
|
targetGenerator: remoteGen,
|
|
468
638
|
updatedAt: appliedAt,
|
|
469
639
|
appliedFiles: [...plan.updated, ...plan.created].map(x => x.file),
|
|
640
|
+
adoptedFiles: plan.adopted.map(x => x.file),
|
|
470
641
|
conflicts: plan.conflict.map(x => x.file),
|
|
471
642
|
};
|
|
472
643
|
}
|
|
@@ -477,11 +648,40 @@ async function main() {
|
|
|
477
648
|
console.log(`✅ Applied ${applied} SAFE/NEW files. Version remains ${localGen}; ${plan.conflict.length} conflicts still need merge.`);
|
|
478
649
|
}
|
|
479
650
|
} else {
|
|
480
|
-
console.log(`❌ ${failed} failures. NO files were version-tracked. Fix and re-run.`);
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
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.`);
|
|
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); });
|