create-harness-vibe-coding 0.8.12 → 0.8.16
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/CHANGELOG.md +165 -0
- package/README-CN.md +52 -8
- package/README.md +50 -8
- package/package.json +3 -1
- package/src/generator.js +601 -472
- package/src/index.js +12 -4
- package/templates/common/.claude/agents/architect-manager.md +1 -0
- package/templates/common/.claude/agents/architect.md +2 -1
- package/templates/common/.claude/agents/codebase-explorer.md +1 -0
- package/templates/common/.claude/agents/context-master.md +2 -1
- package/templates/common/.claude/agents/debugger.md +1 -0
- package/templates/common/.claude/agents/docs-researcher.md +2 -1
- package/templates/common/.claude/agents/explore-manager.md +1 -0
- package/templates/common/.claude/agents/implement-manager.md +1 -0
- package/templates/common/.claude/agents/implementer.md +1 -0
- package/templates/common/.claude/agents/memory-master.md +2 -1
- package/templates/common/.claude/agents/planner.md +3 -2
- package/templates/common/.claude/agents/reflector.md +1 -0
- package/templates/common/.claude/agents/researcher.md +1 -0
- package/templates/common/.claude/agents/review-manager.md +1 -0
- package/templates/common/.claude/agents/reviewer.md +2 -1
- package/templates/common/.claude/agents/task-scribe.md +1 -0
- package/templates/common/.claude/agents/tdd-guide.md +5 -4
- package/templates/common/.claude/agents/test-writer.md +6 -5
- package/templates/common/.claude/agents/verifier.md +1 -0
- package/templates/common/.claude/commands/wf-help.md +1 -0
- package/templates/common/.claude/commands/wf-update.md +73 -9
- package/templates/common/.claude/rules/ecc/common.md +6 -5
- package/templates/common/.claude/skills/subagent-orchestrator/SKILL.md +12 -6
- package/templates/common/.claude/skills/tdd/SKILL.md +5 -5
- package/templates/common/.claude/skills/wf/SKILL.md +13 -5
- package/templates/common/.claude/skills/wf-agents-docs/SKILL.md +119 -0
- package/templates/common/.claude/skills/wf-auto/SKILL.md +27 -7
- package/templates/common/.claude/skills/wf-auto-spark/SKILL.md +12 -5
- package/templates/common/.claude/skills/wf-learn/SKILL.md +6 -0
- package/templates/common/.claude/skills/wf-max/SKILL.md +22 -7
- package/templates/common/.claude/skills/wf-readme/SKILL.md +8 -2
- package/templates/common/.claude/skills/wf-remove/SKILL.md +6 -0
- package/templates/common/.claude/skills/wf-review/SKILL.md +10 -3
- package/templates/common/.claude/skills/wf-update/SKILL.md +52 -5
- package/templates/common/.harness-version +291 -129
- package/templates/common/.opencode/agents/architect-manager.md +1 -0
- package/templates/common/.opencode/agents/architect.md +2 -1
- package/templates/common/.opencode/agents/codebase-explorer.md +1 -0
- package/templates/common/.opencode/agents/context-master.md +2 -1
- package/templates/common/.opencode/agents/debugger.md +1 -0
- package/templates/common/.opencode/agents/docs-researcher.md +2 -1
- package/templates/common/.opencode/agents/explore-manager.md +1 -0
- package/templates/common/.opencode/agents/implement-manager.md +1 -0
- package/templates/common/.opencode/agents/implementer.md +1 -0
- package/templates/common/.opencode/agents/memory-master.md +2 -1
- package/templates/common/.opencode/agents/planner.md +3 -2
- package/templates/common/.opencode/agents/reflector.md +1 -0
- package/templates/common/.opencode/agents/researcher.md +1 -0
- package/templates/common/.opencode/agents/review-manager.md +1 -0
- package/templates/common/.opencode/agents/reviewer.md +2 -1
- package/templates/common/.opencode/agents/task-scribe.md +1 -0
- package/templates/common/.opencode/agents/tdd-guide.md +5 -4
- package/templates/common/.opencode/agents/test-writer.md +6 -5
- package/templates/common/.opencode/agents/verifier.md +1 -0
- package/templates/common/.opencode/commands/wf-auto-spark.md +3 -2
- package/templates/common/.opencode/commands/wf-auto.md +3 -2
- package/templates/common/.opencode/commands/wf-help.md +1 -0
- package/templates/common/.opencode/commands/wf-learn.md +3 -2
- package/templates/common/.opencode/commands/wf-max.md +3 -2
- package/templates/common/.opencode/commands/wf-readme.md +3 -2
- package/templates/common/.opencode/commands/wf-remove.md +3 -2
- package/templates/common/.opencode/commands/wf-review.md +3 -2
- package/templates/common/.opencode/commands/wf-update.md +73 -9
- package/templates/common/.opencode/commands/wf.md +3 -2
- package/templates/common/CLAUDE.md +14 -12
- package/templates/common/Harness/MEMORY.md +21 -18
- package/templates/common/Harness/README.md +41 -39
- package/templates/common/Harness/ownership.manifest.json +815 -0
- package/templates/common/Harness/{architecture.md → project/architecture.md} +1 -1
- package/templates/common/Harness/research/README.md +3 -3
- package/templates/common/Harness/scripts/context-budget.mjs +95 -0
- package/templates/common/Harness/scripts/l2-cache-telemetry.mjs +703 -0
- package/templates/common/Harness/scripts/scan-clean.mjs +86 -4
- package/templates/common/Harness/scripts/validate-harness.mjs +332 -175
- package/templates/common/Harness/scripts/wf-remove.mjs +60 -34
- package/templates/common/Harness/scripts/wf-update-check.mjs +562 -121
- package/templates/common/Harness/settings.json +43 -0
- package/templates/common/Harness/{ECC-GUIDE.md → specs/guides/ECC-GUIDE.md} +4 -4
- package/templates/common/Harness/{SETUP.md → specs/guides/SETUP.md} +32 -35
- package/templates/common/Harness/{extension.md → specs/guides/extension.md} +3 -3
- package/templates/common/Harness/{lifecycle.md → specs/guides/lifecycle.md} +2 -2
- package/templates/common/Harness/{agent-workflow.md → specs/runtime/agent-workflow.md} +6 -6
- package/templates/common/Harness/{context-loading.md → specs/runtime/context-loading.md} +85 -21
- package/templates/common/Harness/{dispatch.md → specs/runtime/dispatch.md} +3 -2
- package/templates/common/Harness/{subagents.md → specs/runtime/subagents.md} +10 -5
- package/templates/common/Harness/{WF-AUTO-SPARK.md → specs/workflows/WF-AUTO-SPARK.md} +2 -2
- package/templates/common/Harness/{WF-AUTO.md → specs/workflows/WF-AUTO.md} +6 -1
- package/templates/common/Harness/{WF-KERNEL.md → specs/workflows/WF-KERNEL.md} +10 -0
- package/templates/common/Harness/{WF-MAX.md → specs/workflows/WF-MAX.md} +10 -5
- package/templates/common/Harness/{WF-STATE.md → specs/workflows/WF-STATE.md} +5 -0
- package/templates/common/Harness/{WF.md → specs/workflows/WF.md} +12 -1
- package/templates/common/README.md +8 -6
- package/templates/common/memory/startup-hints.md +19 -17
- package/templates/optional/skills/browser-e2e/.claude/skills/browser-e2e/SKILL.md +1 -1
- package/templates/optional/skills/browser-e2e/.claude/skills/wf-browser/SKILL.md +7 -0
- package/templates/optional/skills/browser-e2e/.opencode/commands/wf-browser.md +3 -2
- package/templates/optional/skills/browser-e2e/Harness/workflows/browser-e2e.md +4 -4
- package/templates/optional/skills/github-pr-review/.claude/skills/github-pr-review/SKILL.md +1 -1
- package/templates/optional/skills/python-backend/.claude/skills/python-backend/SKILL.md +1 -1
- package/templates/optional/skills/ts-react-frontend/.claude/skills/ts-react-frontend/SKILL.md +1 -1
- package/templates/optional/skills/ui-ux-review/.claude/skills/ui-ux-review/SKILL.md +1 -1
- /package/templates/common/Harness/{ACCEPTANCE_PROTOCOL.md → specs/protocols/ACCEPTANCE_PROTOCOL.md} +0 -0
- /package/templates/common/Harness/{AGENT_ISOLATION.md → specs/protocols/AGENT_ISOLATION.md} +0 -0
- /package/templates/common/Harness/{DEBUG_PROTOCOL.md → specs/protocols/DEBUG_PROTOCOL.md} +0 -0
- /package/templates/common/Harness/{HARNESS_BRIDGE.md → specs/protocols/HARNESS_BRIDGE.md} +0 -0
- /package/templates/common/Harness/{MEMORY_PROTOCOL.md → specs/protocols/MEMORY_PROTOCOL.md} +0 -0
- /package/templates/common/Harness/{TASK_ARCHIVE.md → specs/protocols/TASK_ARCHIVE.md} +0 -0
- /package/templates/common/Harness/{TDD-GUIDE.md → specs/protocols/TDD-GUIDE.md} +0 -0
- /package/templates/common/Harness/{WF-AUTO-ANGLES.md → specs/workflows/WF-AUTO-ANGLES.md} +0 -0
|
@@ -15,19 +15,26 @@
|
|
|
15
15
|
* --full-plan and --verbose are aliases.
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
-
import { readFileSync, writeFileSync, existsSync, mkdirSync, lstatSync } from 'fs';
|
|
18
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, lstatSync, unlinkSync } from 'fs';
|
|
19
19
|
import { createHash } from 'crypto';
|
|
20
20
|
import { resolve, dirname, sep } from 'path';
|
|
21
21
|
import { fileURLToPath } from 'url';
|
|
22
|
+
import { gunzipSync } from 'zlib';
|
|
22
23
|
|
|
23
24
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
24
25
|
const ROOT = process.env.WF_ROOT ? resolve(process.env.WF_ROOT) : resolve(__dirname, '..', '..');
|
|
25
26
|
const VERSION_FILE = resolve(ROOT, 'Harness', '.harness-version');
|
|
27
|
+
const NPM_PACKAGE = 'create-harness-vibe-coding';
|
|
28
|
+
const NPM_REGISTRY = (process.env.NPM_CONFIG_REGISTRY || 'https://registry.npmjs.org').replace(/\/+$/, '');
|
|
26
29
|
const GITHUB_REPO = 'LiWeny16/create-harness-vibe-coding';
|
|
30
|
+
const LEGACY_GITHUB_REPO = 'zingspark/create-harness-vibe-coding';
|
|
27
31
|
const GITHUB_LATEST_STABLE = `https://api.github.com/repos/${GITHUB_REPO}/releases/latest`;
|
|
28
32
|
const RAW_GITHUB = `https://raw.githubusercontent.com/${GITHUB_REPO}`;
|
|
33
|
+
const LEGACY_RAW_GITHUB = `https://raw.githubusercontent.com/${LEGACY_GITHUB_REPO}`;
|
|
29
34
|
const TEMPLATE_SUBPATH = 'templates/common/';
|
|
30
35
|
const DEFAULT_SOURCE_BASE = `${RAW_GITHUB}/main/${TEMPLATE_SUBPATH}`;
|
|
36
|
+
const LEGACY_SOURCE_BASE = `${LEGACY_RAW_GITHUB}/main/${TEMPLATE_SUBPATH}`;
|
|
37
|
+
const npmPackageCache = new Map();
|
|
31
38
|
|
|
32
39
|
// Tier classification
|
|
33
40
|
|
|
@@ -39,6 +46,7 @@ const PRESERVE_PATTERNS = [
|
|
|
39
46
|
/^Harness\/research\/PRD\.md$/,
|
|
40
47
|
/^Harness\/research\/research-results\.md$/,
|
|
41
48
|
/^Harness\/architecture\.md$/,
|
|
49
|
+
/^Harness\/project\/architecture\.md$/,
|
|
42
50
|
/^README\.md$/,
|
|
43
51
|
/^\.gitignore$/,
|
|
44
52
|
/^package\.json$/,
|
|
@@ -52,6 +60,7 @@ const MERGE_PATTERNS = [
|
|
|
52
60
|
/^MEMORY\.md$/,
|
|
53
61
|
/^Harness\/MEMORY\.md$/,
|
|
54
62
|
/^Harness\/README\.md$/,
|
|
63
|
+
/^Harness\/settings\.json$/,
|
|
55
64
|
];
|
|
56
65
|
|
|
57
66
|
const OPTIONAL_REGISTRATION_FILES = new Set([
|
|
@@ -60,9 +69,25 @@ const OPTIONAL_REGISTRATION_FILES = new Set([
|
|
|
60
69
|
'Harness/README.md',
|
|
61
70
|
]);
|
|
62
71
|
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
]
|
|
72
|
+
const HARNESS_OWNED_CANDIDATE_PATTERNS = [
|
|
73
|
+
/^\.claude\/agents\/[^/]+\.md$/,
|
|
74
|
+
/^\.opencode\/agents\/[^/]+\.md$/,
|
|
75
|
+
/^\.claude\/commands\/wf(?:-[^/]+)?\.md$/,
|
|
76
|
+
/^\.opencode\/commands\/wf(?:-[^/]+)?\.md$/,
|
|
77
|
+
/^\.claude\/skills\/(?:wf|wf-[^/]+|subagent-orchestrator|tdd)\/SKILL\.md$/,
|
|
78
|
+
/^\.agents\/skills\/(?:wf|wf-[^/]+|subagent-orchestrator|tdd)\/SKILL\.md$/,
|
|
79
|
+
/^\.codex\//,
|
|
80
|
+
/^\.opencode\/plugins\/harness-/,
|
|
81
|
+
/^Harness\/scripts\//,
|
|
82
|
+
];
|
|
83
|
+
|
|
84
|
+
const HARNESS_OWNED_CONTENT_MARKERS = [
|
|
85
|
+
/^harness:\s*(?:wf-agent|wf-framework|create-harness-vibe-coding)\b/im,
|
|
86
|
+
/\bcreate-harness-vibe-coding\b/i,
|
|
87
|
+
/\bproject harness\b/i,
|
|
88
|
+
/\bHarness\/(?:specs|WF|MEMORY|tasks|scripts|subagents|dispatch|context-loading|lifecycle|SETUP)\b/,
|
|
89
|
+
/\bWF-(?:MAX|AUTO|KERNEL|STATE)\b/,
|
|
90
|
+
];
|
|
66
91
|
|
|
67
92
|
// Helpers
|
|
68
93
|
|
|
@@ -150,18 +175,146 @@ function sha256File(path) {
|
|
|
150
175
|
return sha256(content);
|
|
151
176
|
}
|
|
152
177
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
178
|
+
// ── Ownership manifest (manifest-first classification) ─────────────────────
|
|
179
|
+
//
|
|
180
|
+
// Harness/ownership.manifest.json is the machine-readable source of truth for
|
|
181
|
+
// file classification. When present it drives PRESERVE/MERGE/frameworkOwned/
|
|
182
|
+
// optionalOwned decisions; the path-regex + content-marker logic below remains
|
|
183
|
+
// as a FALLBACK for old installs that ship no manifest. The updater cannot
|
|
184
|
+
// import from scripts/lib (different package layer), so a small local glob
|
|
185
|
+
// matcher mirrors the one in scripts/lib/ownership-manifest.mjs.
|
|
186
|
+
|
|
187
|
+
function globToRegex(pattern) {
|
|
188
|
+
let re = '';
|
|
189
|
+
let i = 0;
|
|
190
|
+
while (i < pattern.length) {
|
|
191
|
+
const c = pattern[i];
|
|
192
|
+
if (c === '*' && pattern[i + 1] === '*') {
|
|
193
|
+
re += '.*';
|
|
194
|
+
i += 2;
|
|
195
|
+
if (pattern[i] === '/') i += 1; // swallow a trailing slash after **
|
|
196
|
+
} else if (c === '*') {
|
|
197
|
+
re += '[^/]*';
|
|
198
|
+
i += 1;
|
|
199
|
+
} else if ('.+?^${}()|[]\\'.includes(c)) {
|
|
200
|
+
re += '\\' + c;
|
|
201
|
+
i += 1;
|
|
202
|
+
} else {
|
|
203
|
+
re += c;
|
|
204
|
+
i += 1;
|
|
205
|
+
}
|
|
157
206
|
}
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
207
|
+
return new RegExp('^' + re + '$');
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function matchesGlob(dest, pattern) {
|
|
211
|
+
if (!pattern.includes('*')) return dest === pattern;
|
|
212
|
+
return globToRegex(pattern).test(dest);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function matchesAnyGlob(dest, patterns) {
|
|
216
|
+
return (patterns || []).some(p => matchesGlob(dest, p));
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function buildOptionalOwnedMap(optionalOwned) {
|
|
220
|
+
const map = new Map();
|
|
221
|
+
for (const entry of optionalOwned || []) {
|
|
222
|
+
if (!entry || typeof entry.option !== 'string') continue;
|
|
223
|
+
map.set(entry.option, new Set(entry.paths || []));
|
|
224
|
+
}
|
|
225
|
+
return map;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Load + validate the ownership manifest. Missing/unreadable/invalid -> null (fallback mode). */
|
|
229
|
+
function loadOwnershipManifest(root) {
|
|
230
|
+
const manifestPath = resolve(root, 'Harness', 'ownership.manifest.json');
|
|
231
|
+
try {
|
|
232
|
+
if (!existsSync(manifestPath)) return null;
|
|
233
|
+
const parsed = JSON.parse(readFileSync(manifestPath, 'utf-8'));
|
|
234
|
+
if (!parsed || typeof parsed !== 'object' || !Array.isArray(parsed.preserve)) return null;
|
|
235
|
+
return parsed;
|
|
236
|
+
} catch {
|
|
237
|
+
return null;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function buildManifestCtx(manifest) {
|
|
242
|
+
if (!manifest) {
|
|
243
|
+
return { present: false, preserveGlobs: [], mergeSet: new Set(), frameworkOwnedSet: new Set(), optionalOwned: new Map() };
|
|
244
|
+
}
|
|
245
|
+
return {
|
|
246
|
+
present: true,
|
|
247
|
+
preserveGlobs: manifest.preserve || [],
|
|
248
|
+
mergeSet: new Set(manifest.merge || []),
|
|
249
|
+
frameworkOwnedSet: new Set((manifest.frameworkOwned || []).map(e => e && e.path).filter(Boolean)),
|
|
250
|
+
optionalOwned: buildOptionalOwnedMap(manifest.optionalOwned),
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function isPreserveFile(file, ctx) {
|
|
255
|
+
if (ctx.present) return matchesAnyGlob(file, ctx.preserveGlobs);
|
|
256
|
+
return PRESERVE_PATTERNS.some(p => p.test(file));
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function isMergeFile(file, ctx) {
|
|
260
|
+
if (ctx.present) return ctx.mergeSet.has(file);
|
|
261
|
+
return MERGE_PATTERNS.some(p => p.test(file));
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** Manifest-first declared ownership: frameworkOwned, or optionalOwned whose option is selected. */
|
|
265
|
+
function isDeclaredHarnessOwned(file, ctx, selectedOptions) {
|
|
266
|
+
if (!ctx.present) return false;
|
|
267
|
+
if (ctx.frameworkOwnedSet.has(file)) return true;
|
|
268
|
+
for (const [option, paths] of ctx.optionalOwned) {
|
|
269
|
+
if (paths.has(file) && selectedOptions.has(option)) return true;
|
|
270
|
+
}
|
|
271
|
+
return false;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** An optional-owned file whose owning option is NOT selected for this install. */
|
|
275
|
+
function isOptionalOwnedUnselected(file, ctx, selectedOptions) {
|
|
276
|
+
if (!ctx.present) return false;
|
|
277
|
+
let owned = false;
|
|
278
|
+
for (const [option, paths] of ctx.optionalOwned) {
|
|
279
|
+
if (paths.has(file)) {
|
|
280
|
+
owned = true;
|
|
281
|
+
if (selectedOptions.has(option)) return false;
|
|
163
282
|
}
|
|
164
283
|
}
|
|
284
|
+
return owned;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Manifest-first untracked-existing-file ownership. When a remote file is NEW
|
|
289
|
+
* (not in local checksums) but exists on disk, decide Harness-owned vs
|
|
290
|
+
* user-owned. The MARKER decides instance ownership: a prior Harness install
|
|
291
|
+
* leaves a marker, a user-authored file at a Harness-looking path does not.
|
|
292
|
+
* Manifest declaration (frameworkOwned / installed-optional) and the candidate
|
|
293
|
+
* regex only build the Harness-interest candidate set; they never by themselves
|
|
294
|
+
* authorize overwriting an untracked file. A user file at a Harness-interest
|
|
295
|
+
* path with NO marker stays a conflict (protected) in BOTH manifest and
|
|
296
|
+
* fallback modes. This matches the installer and AC #5: a user's same-name
|
|
297
|
+
* agent/command/skill must never be overwritten.
|
|
298
|
+
*/
|
|
299
|
+
function isHarnessOwnedExistingFileManifestFirst(file, diskPath, ctx, selectedOptions) {
|
|
300
|
+
// A manifest-declared path joins the candidate set, but — like a regex
|
|
301
|
+
// candidate — still requires a Harness content marker to be adopted. The
|
|
302
|
+
// marker (not the declaration) decides adopt-vs-conflict for an untracked
|
|
303
|
+
// existing file, in both manifest and fallback modes.
|
|
304
|
+
if (isDeclaredHarnessOwned(file, ctx, selectedOptions)) {
|
|
305
|
+
return fileHasHarnessOwnedMarker(diskPath);
|
|
306
|
+
}
|
|
307
|
+
return isHarnessOwnedExistingFile(file, diskPath);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function classify(file, localHash, storedHash, ctx) {
|
|
311
|
+
// PRESERVE
|
|
312
|
+
if (isPreserveFile(file, ctx)) return 'PRESERVE';
|
|
313
|
+
// MERGE: dual-purpose, check if user modified.
|
|
314
|
+
if (isMergeFile(file, ctx)) {
|
|
315
|
+
if (localHash === storedHash) return 'SAFE'; // unmodified, safe
|
|
316
|
+
return 'CONFLICT'; // user modified, needs decision
|
|
317
|
+
}
|
|
165
318
|
// Everything else is SAFE runtime file: always overwrite.
|
|
166
319
|
// Harness system files (scripts, skills, agents, commands, WF docs) are
|
|
167
320
|
// not user data; the template is authoritative. Only PRESERVE and MERGE
|
|
@@ -169,6 +322,25 @@ function classify(file, localHash, storedHash) {
|
|
|
169
322
|
return 'SAFE';
|
|
170
323
|
}
|
|
171
324
|
|
|
325
|
+
function isHarnessOwnedCandidate(file) {
|
|
326
|
+
return HARNESS_OWNED_CANDIDATE_PATTERNS.some(pattern => pattern.test(file));
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** Read on-disk content and test for a Harness ownership marker. */
|
|
330
|
+
function fileHasHarnessOwnedMarker(diskPath) {
|
|
331
|
+
try {
|
|
332
|
+
const content = readFileSync(diskPath, 'utf-8');
|
|
333
|
+
return HARNESS_OWNED_CONTENT_MARKERS.some(pattern => pattern.test(content));
|
|
334
|
+
} catch {
|
|
335
|
+
return false;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function isHarnessOwnedExistingFile(file, diskPath) {
|
|
340
|
+
if (!isHarnessOwnedCandidate(file)) return false;
|
|
341
|
+
return fileHasHarnessOwnedMarker(diskPath);
|
|
342
|
+
}
|
|
343
|
+
|
|
172
344
|
async function fetchRemote(url, timeoutMs = 30000) {
|
|
173
345
|
if (url.startsWith('file://')) {
|
|
174
346
|
return readFileSync(fileURLToPath(url), 'utf-8');
|
|
@@ -185,6 +357,47 @@ async function fetchRemote(url, timeoutMs = 30000) {
|
|
|
185
357
|
}
|
|
186
358
|
}
|
|
187
359
|
|
|
360
|
+
async function fetchBytes(url, timeoutMs = 30000) {
|
|
361
|
+
const controller = new AbortController();
|
|
362
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
363
|
+
try {
|
|
364
|
+
const res = await fetch(url, { signal: controller.signal, headers: { 'User-Agent': 'harness-wf-update-check' } });
|
|
365
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}: ${url}`);
|
|
366
|
+
return Buffer.from(await res.arrayBuffer());
|
|
367
|
+
} finally {
|
|
368
|
+
clearTimeout(timer);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function tarString(header, start, length) {
|
|
373
|
+
const slice = header.subarray(start, start + length);
|
|
374
|
+
const zero = slice.indexOf(0);
|
|
375
|
+
return Buffer.from(zero === -1 ? slice : slice.subarray(0, zero)).toString('utf8').trim();
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function parseTarFiles(buffer) {
|
|
379
|
+
const files = new Map();
|
|
380
|
+
for (let offset = 0; offset + 512 <= buffer.length;) {
|
|
381
|
+
const header = buffer.subarray(offset, offset + 512);
|
|
382
|
+
if (header.every(byte => byte === 0)) break;
|
|
383
|
+
|
|
384
|
+
const name = tarString(header, 0, 100);
|
|
385
|
+
const prefix = tarString(header, 345, 155);
|
|
386
|
+
const sizeText = tarString(header, 124, 12);
|
|
387
|
+
const size = Number.parseInt(sizeText || '0', 8) || 0;
|
|
388
|
+
const type = String.fromCharCode(header[156] || 0);
|
|
389
|
+
const fullName = prefix ? `${prefix}/${name}` : name;
|
|
390
|
+
const dataStart = offset + 512;
|
|
391
|
+
|
|
392
|
+
if ((type === '\0' || type === '0' || type === '') && fullName) {
|
|
393
|
+
files.set(fullName, buffer.subarray(dataStart, dataStart + size));
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
offset = dataStart + Math.ceil(size / 512) * 512;
|
|
397
|
+
}
|
|
398
|
+
return files;
|
|
399
|
+
}
|
|
400
|
+
|
|
188
401
|
// Main
|
|
189
402
|
|
|
190
403
|
function isPrerelease(v) {
|
|
@@ -192,7 +405,74 @@ function isPrerelease(v) {
|
|
|
192
405
|
return /-[0-9A-Za-z.-]+/.test(v.replace(/^[^0-9]*/, ''));
|
|
193
406
|
}
|
|
194
407
|
|
|
195
|
-
|
|
408
|
+
function parseSemver(v) {
|
|
409
|
+
if (!v || typeof v !== 'string') return [0, 0, 0];
|
|
410
|
+
return v.replace(/^[^0-9]*/, '').split('.').slice(0, 3).map(n => Number(n) || 0);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function cmpSemver(a, b) {
|
|
414
|
+
const va = parseSemver(a), vb = parseSemver(b);
|
|
415
|
+
for (let i = 0; i < 3; i++) {
|
|
416
|
+
if ((va[i] || 0) > (vb[i] || 0)) return 1;
|
|
417
|
+
if ((va[i] || 0) < (vb[i] || 0)) return -1;
|
|
418
|
+
}
|
|
419
|
+
return 0;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
async function loadNpmPackage(version = 'latest') {
|
|
423
|
+
const cacheKey = `${NPM_REGISTRY}:${version}`;
|
|
424
|
+
if (npmPackageCache.has(cacheKey)) return npmPackageCache.get(cacheKey);
|
|
425
|
+
|
|
426
|
+
const metaUrl = `${NPM_REGISTRY}/${NPM_PACKAGE}/${version}`;
|
|
427
|
+
const meta = JSON.parse(await fetchRemote(metaUrl, 15000));
|
|
428
|
+
const packageVersion = meta.version;
|
|
429
|
+
const tarball = meta?.dist?.tarball;
|
|
430
|
+
if (!packageVersion || !tarball) throw new Error(`npm metadata missing dist.tarball for ${NPM_PACKAGE}@${version}`);
|
|
431
|
+
|
|
432
|
+
const archive = gunzipSync(await fetchBytes(tarball, 30000));
|
|
433
|
+
const files = parseTarFiles(archive);
|
|
434
|
+
const loaded = { version: packageVersion, tarball, files };
|
|
435
|
+
npmPackageCache.set(cacheKey, loaded);
|
|
436
|
+
npmPackageCache.set(`${NPM_REGISTRY}:${packageVersion}`, loaded);
|
|
437
|
+
return loaded;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function readNpmFile(pkg, templateRelPath) {
|
|
441
|
+
const key = `package/${TEMPLATE_SUBPATH}${templateRelPath}`;
|
|
442
|
+
const content = pkg.files.get(key);
|
|
443
|
+
if (!content) throw new Error(`npm package missing ${key}`);
|
|
444
|
+
return Buffer.from(content).toString('utf8');
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
async function createNpmSource(version = 'latest') {
|
|
448
|
+
const pkg = await loadNpmPackage(version);
|
|
449
|
+
const raw = readNpmFile(pkg, '.harness-version');
|
|
450
|
+
if (isTemplate(raw)) throw new Error('npm .harness-version has not been generated yet');
|
|
451
|
+
const manifest = JSON.parse(raw);
|
|
452
|
+
return {
|
|
453
|
+
kind: 'npm',
|
|
454
|
+
stable: true,
|
|
455
|
+
label: `npm:${NPM_PACKAGE}@${pkg.version}/${TEMPLATE_SUBPATH}`,
|
|
456
|
+
version: pkg.version,
|
|
457
|
+
manifest,
|
|
458
|
+
package: pkg,
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
async function createUrlSource(sourceBase, { stable = false } = {}) {
|
|
463
|
+
const base = normalizeSourceBase(sourceBase);
|
|
464
|
+
const raw = await fetchRemote(base + '.harness-version');
|
|
465
|
+
if (isTemplate(raw)) throw new Error('remote .harness-version has not been generated yet');
|
|
466
|
+
return {
|
|
467
|
+
kind: 'url',
|
|
468
|
+
stable,
|
|
469
|
+
label: base,
|
|
470
|
+
base,
|
|
471
|
+
manifest: JSON.parse(raw),
|
|
472
|
+
};
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
async function resolveGithubReleaseSource() {
|
|
196
476
|
try {
|
|
197
477
|
const controller = new AbortController();
|
|
198
478
|
const timer = setTimeout(() => controller.abort(), 15000);
|
|
@@ -209,26 +489,45 @@ async function resolveStableSourceBase() {
|
|
|
209
489
|
const data = JSON.parse(await res.text());
|
|
210
490
|
const tag = data && data.tag_name;
|
|
211
491
|
if (!tag || data.prerelease) return null;
|
|
212
|
-
|
|
213
|
-
const versionController = new AbortController();
|
|
214
|
-
const versionTimer = setTimeout(() => versionController.abort(), 15000);
|
|
215
|
-
try {
|
|
216
|
-
const versionRes = await fetch(source + '.harness-version', {
|
|
217
|
-
signal: versionController.signal,
|
|
218
|
-
headers: { 'User-Agent': 'harness-wf-update-check' },
|
|
219
|
-
});
|
|
220
|
-
if (!versionRes.ok) return null;
|
|
221
|
-
const raw = await versionRes.text();
|
|
222
|
-
if (isTemplate(raw)) return null;
|
|
223
|
-
} finally {
|
|
224
|
-
clearTimeout(versionTimer);
|
|
225
|
-
}
|
|
226
|
-
return source;
|
|
492
|
+
return await createUrlSource(`${RAW_GITHUB}/${tag}/${TEMPLATE_SUBPATH}`, { stable: true });
|
|
227
493
|
} catch {
|
|
228
494
|
return null;
|
|
229
495
|
}
|
|
230
496
|
}
|
|
231
497
|
|
|
498
|
+
async function resolveUpdateSource(explicitSource) {
|
|
499
|
+
if (explicitSource) {
|
|
500
|
+
return createUrlSource(explicitSource, { stable: false });
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
const errors = [];
|
|
504
|
+
try {
|
|
505
|
+
return await createNpmSource('latest');
|
|
506
|
+
} catch (e) {
|
|
507
|
+
errors.push(`npm latest: ${e.message}`);
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
const releaseSource = await resolveGithubReleaseSource();
|
|
511
|
+
if (releaseSource) return releaseSource;
|
|
512
|
+
errors.push('GitHub latest release: unavailable');
|
|
513
|
+
|
|
514
|
+
try {
|
|
515
|
+
return await createUrlSource(DEFAULT_SOURCE_BASE, { stable: false });
|
|
516
|
+
} catch (e) {
|
|
517
|
+
errors.push(`GitHub canonical main: ${e.message}`);
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
try {
|
|
521
|
+
return await createUrlSource(LEGACY_SOURCE_BASE, { stable: false });
|
|
522
|
+
} catch (e) {
|
|
523
|
+
errors.push(`GitHub legacy mirror: ${e.message}`);
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
const err = new Error('Cannot reach npm, canonical GitHub, or legacy mirror update sources.');
|
|
527
|
+
err.sourceErrors = errors;
|
|
528
|
+
throw err;
|
|
529
|
+
}
|
|
530
|
+
|
|
232
531
|
async function main() {
|
|
233
532
|
const args = process.argv.slice(2);
|
|
234
533
|
const apply = args.includes('--apply');
|
|
@@ -240,14 +539,16 @@ async function main() {
|
|
|
240
539
|
const acceptTemplate = readRepeatedFlagValues(args, '--accept-template');
|
|
241
540
|
const ignoreVersion = args.includes('--ignore-version') || args.includes('--force-check');
|
|
242
541
|
const explicitSource = readFlagValue(args, '--source-base') || process.env.WF_SOURCE_BASE;
|
|
243
|
-
let
|
|
244
|
-
let
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
542
|
+
let source;
|
|
543
|
+
let sourceBase;
|
|
544
|
+
let sourceStable = false;
|
|
545
|
+
let sourceErrors = [];
|
|
546
|
+
try {
|
|
547
|
+
source = await resolveUpdateSource(explicitSource);
|
|
548
|
+
sourceBase = source.label;
|
|
549
|
+
sourceStable = Boolean(source.stable);
|
|
550
|
+
} catch (e) {
|
|
551
|
+
sourceErrors = e.sourceErrors || [e.message];
|
|
251
552
|
}
|
|
252
553
|
|
|
253
554
|
// 1. Read local state
|
|
@@ -302,44 +603,29 @@ async function main() {
|
|
|
302
603
|
|
|
303
604
|
const localChecksums = localVersion.checksums || {};
|
|
304
605
|
|
|
305
|
-
//
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
console.log(' The generate step has not been run on the remote repo. No update possible.');
|
|
315
|
-
console.log(' This is expected during development; the update mechanism works once the remote is live.');
|
|
316
|
-
}
|
|
317
|
-
process.exitCode = 1;
|
|
318
|
-
return;
|
|
319
|
-
}
|
|
320
|
-
remoteVersion = JSON.parse(raw);
|
|
321
|
-
} catch (e) {
|
|
606
|
+
// Ownership manifest: manifest-first classification with regex/marker fallback
|
|
607
|
+
// for old installs that ship no manifest. manifest=null -> fallback mode.
|
|
608
|
+
const manifest = loadOwnershipManifest(ROOT);
|
|
609
|
+
const manifestCtx = buildManifestCtx(manifest);
|
|
610
|
+
const selectedOptions = selectedOptionIds(localVersion);
|
|
611
|
+
|
|
612
|
+
// 2. Read remote version metadata from the selected update source.
|
|
613
|
+
let remoteVersion = source?.manifest;
|
|
614
|
+
if (!remoteVersion) {
|
|
322
615
|
if (jsonOut) {
|
|
323
|
-
console.log(JSON.stringify({
|
|
616
|
+
console.log(JSON.stringify({
|
|
617
|
+
status: 'offline',
|
|
618
|
+
message: 'Cannot reach npm, canonical GitHub, or legacy mirror update sources.',
|
|
619
|
+
sourceErrors,
|
|
620
|
+
}));
|
|
324
621
|
} else {
|
|
325
|
-
console.error('ERROR: Cannot reach GitHub or
|
|
326
|
-
console.error(
|
|
622
|
+
console.error('ERROR: Cannot reach npm, canonical GitHub, or legacy mirror update sources. Offline?');
|
|
623
|
+
for (const err of sourceErrors) console.error(` ${err}`);
|
|
327
624
|
}
|
|
328
625
|
process.exitCode = 1;
|
|
329
626
|
return;
|
|
330
627
|
}
|
|
331
628
|
|
|
332
|
-
// Compare versions and prevent downgrades from explicit custom sources.
|
|
333
|
-
function parseSemver(v) {
|
|
334
|
-
if (!v || typeof v !== 'string') return [0, 0, 0];
|
|
335
|
-
return v.replace(/^[^0-9]*/, '').split('.').slice(0, 3).map(n => Number(n) || 0);
|
|
336
|
-
}
|
|
337
|
-
function cmpSemver(a, b) {
|
|
338
|
-
const va = parseSemver(a), vb = parseSemver(b);
|
|
339
|
-
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; }
|
|
340
|
-
return 0;
|
|
341
|
-
}
|
|
342
|
-
|
|
343
629
|
const localGen = localVersion.generator || '0.0.0';
|
|
344
630
|
const remoteGen = remoteVersion.generator || '0.0.0';
|
|
345
631
|
|
|
@@ -355,7 +641,7 @@ async function main() {
|
|
|
355
641
|
const versionCmp = cmpSemver(remoteGen, localGen);
|
|
356
642
|
|
|
357
643
|
if (!ignoreVersion && versionCmp <= 0) {
|
|
358
|
-
const reportDowngrade = !
|
|
644
|
+
const reportDowngrade = !sourceStable;
|
|
359
645
|
if (jsonOut) {
|
|
360
646
|
console.log(JSON.stringify({
|
|
361
647
|
status: (versionCmp < 0 && reportDowngrade) ? 'downgrade-refused' : 'up-to-date',
|
|
@@ -378,6 +664,18 @@ async function main() {
|
|
|
378
664
|
|
|
379
665
|
const remoteChecksums = remoteVersion.checksums || {};
|
|
380
666
|
const remoteSources = remoteVersion.sources || {};
|
|
667
|
+
const remoteMoves = Array.isArray(remoteVersion.moves)
|
|
668
|
+
? remoteVersion.moves
|
|
669
|
+
.map(move => ({
|
|
670
|
+
from: canonicalPath(move?.from || ''),
|
|
671
|
+
to: canonicalPath(move?.to || ''),
|
|
672
|
+
deleteOldIfChecksumMatches: move?.deleteOldIfChecksumMatches !== false,
|
|
673
|
+
preserveOldIfModified: move?.preserveOldIfModified !== false,
|
|
674
|
+
}))
|
|
675
|
+
.filter(move => move.from && move.to && remoteChecksums[move.to])
|
|
676
|
+
: [];
|
|
677
|
+
const moveByFrom = new Map(remoteMoves.map(move => [move.from, move]));
|
|
678
|
+
const movedToFiles = new Set();
|
|
381
679
|
localVersion.acceptedConflicts = localVersion.acceptedConflicts || {};
|
|
382
680
|
|
|
383
681
|
/** Resolve the remote template-relative path for a dest-keyed file. Falls back to the key itself for back-compat. */
|
|
@@ -385,13 +683,37 @@ async function main() {
|
|
|
385
683
|
return remoteSources[file] || file;
|
|
386
684
|
}
|
|
387
685
|
|
|
686
|
+
async function fetchSourceFile(templateRelPath) {
|
|
687
|
+
if (source.kind === 'npm') return readNpmFile(source.package, templateRelPath);
|
|
688
|
+
return fetchRemote(source.base + templateRelPath);
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
function sourceUrl(templateRelPath) {
|
|
692
|
+
if (source.kind === 'npm') return `${source.label}${templateRelPath}`;
|
|
693
|
+
return source.base + templateRelPath;
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
function releaseHighlights(versionObj) {
|
|
697
|
+
const highlights = versionObj?.releaseNotes?.highlights;
|
|
698
|
+
return Array.isArray(highlights)
|
|
699
|
+
? highlights.map(String).map(s => s.trim()).filter(Boolean)
|
|
700
|
+
: [];
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
function printReleaseHighlights(versionObj) {
|
|
704
|
+
const highlights = releaseHighlights(versionObj).slice(0, 6);
|
|
705
|
+
if (highlights.length === 0) return;
|
|
706
|
+
console.log('\nRelease highlights:');
|
|
707
|
+
for (const item of highlights) console.log(` - ${item}`);
|
|
708
|
+
}
|
|
709
|
+
|
|
388
710
|
function withRemoteMeta(entry) {
|
|
389
711
|
if (!entry || !entry.file || !remoteChecksums[entry.file]) return entry;
|
|
390
712
|
const templateHint = remotePath(entry.file);
|
|
391
713
|
return {
|
|
392
714
|
...entry,
|
|
393
715
|
templateHint,
|
|
394
|
-
remoteUrl:
|
|
716
|
+
remoteUrl: sourceUrl(templateHint),
|
|
395
717
|
};
|
|
396
718
|
}
|
|
397
719
|
|
|
@@ -419,6 +741,7 @@ async function main() {
|
|
|
419
741
|
adopted: plan.adopted.map(withRemoteMeta),
|
|
420
742
|
conflict: verbose ? plan.conflict.map(withConflictActions) : plan.conflict.map(withConflictActions).slice(0, 5),
|
|
421
743
|
conflictTruncated: !verbose && plan.conflict.length > 5 ? plan.conflict.length - 5 : undefined,
|
|
744
|
+
moved: plan.moved.map(withRemoteMeta),
|
|
422
745
|
skipped: plan.skipped.map(withRemoteMeta),
|
|
423
746
|
};
|
|
424
747
|
}
|
|
@@ -438,8 +761,12 @@ async function main() {
|
|
|
438
761
|
safeApplyCommand: 'node Harness/scripts/wf-update-check.mjs --apply-safe',
|
|
439
762
|
strictApplyCommand: 'node Harness/scripts/wf-update-check.mjs --apply',
|
|
440
763
|
finalizeCommand: 'node Harness/scripts/wf-update-check.mjs --finalize',
|
|
764
|
+
pathMoves: remoteMoves,
|
|
441
765
|
partialUpdate: localVersion.partialUpdate || null,
|
|
442
766
|
acceptedConflicts: localVersion.acceptedConflicts || {},
|
|
767
|
+
releaseNotes: remoteVersion.releaseNotes || null,
|
|
768
|
+
releaseHighlights: releaseHighlights(remoteVersion),
|
|
769
|
+
updateReportRequired: 'After apply/finalize, tell the user the version, safe/new/conflict counts, validation results, and the core releaseHighlights from this update.',
|
|
443
770
|
// aiMergeRequired array is attached only in verbose mode to keep default output token-safe.
|
|
444
771
|
...(verbose ? { aiMergeRequired: conflicts } : {}),
|
|
445
772
|
aiMergeRequiredCount: totalConflicts,
|
|
@@ -459,7 +786,7 @@ async function main() {
|
|
|
459
786
|
const dest = safePath(file);
|
|
460
787
|
if (!dest) throw new Error(`Traversal rejected: ${file}`);
|
|
461
788
|
if (existsSync(dest) && lstatSync(dest).isSymbolicLink()) throw new Error(`Symlink rejected: ${file}`);
|
|
462
|
-
const content = await
|
|
789
|
+
const content = await fetchSourceFile(remotePath(file));
|
|
463
790
|
const normalized = content.replace(/\r\n/g, '\n');
|
|
464
791
|
const fetchedHash = sha256(normalized);
|
|
465
792
|
if (fetchedHash !== remoteHash) throw new Error(`Hash mismatch: ${file}`);
|
|
@@ -490,12 +817,18 @@ async function main() {
|
|
|
490
817
|
const decision = localVersion.acceptedConflicts?.[file];
|
|
491
818
|
return Boolean(
|
|
492
819
|
decision
|
|
493
|
-
&& decision.targetGenerator === remoteGen
|
|
494
820
|
&& decision.localHash === localHash
|
|
495
821
|
&& decision.remoteHash === remoteHash
|
|
496
822
|
);
|
|
497
823
|
}
|
|
498
824
|
|
|
825
|
+
function acceptedDecisionReason(file) {
|
|
826
|
+
const decision = localVersion.acceptedConflicts?.[file];
|
|
827
|
+
if (!decision) return 'accepted conflict decision';
|
|
828
|
+
if (decision.targetGenerator === remoteGen) return `accepted conflict decision: ${decision.decision}`;
|
|
829
|
+
return `accepted conflict decision carried forward from ${decision.targetGenerator}: ${decision.decision}`;
|
|
830
|
+
}
|
|
831
|
+
|
|
499
832
|
let decisionMetadataDirty = false;
|
|
500
833
|
if (acceptLocal.length || acceptMerged.length || acceptTemplate.length) {
|
|
501
834
|
const appliedAt = new Date().toISOString();
|
|
@@ -517,10 +850,11 @@ async function main() {
|
|
|
517
850
|
|
|
518
851
|
const allFiles = new Set([...Object.keys(localChecksums), ...Object.keys(remoteChecksums)]);
|
|
519
852
|
|
|
520
|
-
const plan = { updated: [], created: [], adopted: [], conflict: [], skipped: [] };
|
|
853
|
+
const plan = { updated: [], created: [], adopted: [], moved: [], conflict: [], skipped: [] };
|
|
521
854
|
|
|
522
855
|
for (const file of [...allFiles].sort()) {
|
|
523
856
|
const canonical = canonicalPath(file);
|
|
857
|
+
if (movedToFiles.has(canonical)) continue;
|
|
524
858
|
|
|
525
859
|
// Reject paths that escape ROOT before any file access
|
|
526
860
|
const diskPath = safePath(file);
|
|
@@ -533,6 +867,47 @@ async function main() {
|
|
|
533
867
|
const storedHash = localChecksums[file];
|
|
534
868
|
const remoteHash = remoteChecksums[file];
|
|
535
869
|
|
|
870
|
+
const move = moveByFrom.get(canonical);
|
|
871
|
+
if (move && storedHash && !remoteHash) {
|
|
872
|
+
const toDiskPath = safePath(move.to);
|
|
873
|
+
if (!toDiskPath) {
|
|
874
|
+
plan.skipped.push({ file, movedTo: move.to, reason: 'path move target traversal rejected' });
|
|
875
|
+
continue;
|
|
876
|
+
}
|
|
877
|
+
const toLocalHash = sha256File(toDiskPath);
|
|
878
|
+
const toRemoteHash = remoteChecksums[move.to];
|
|
879
|
+
if (localHash && localHash !== storedHash) {
|
|
880
|
+
plan.skipped.push({
|
|
881
|
+
file,
|
|
882
|
+
movedTo: move.to,
|
|
883
|
+
reason: 'legacy moved file modified; preserved for manual review',
|
|
884
|
+
});
|
|
885
|
+
continue;
|
|
886
|
+
}
|
|
887
|
+
if (toLocalHash && toLocalHash !== toRemoteHash) {
|
|
888
|
+
plan.skipped.push({
|
|
889
|
+
file,
|
|
890
|
+
movedTo: move.to,
|
|
891
|
+
reason: 'legacy moved file preserved until canonical path conflict is resolved',
|
|
892
|
+
});
|
|
893
|
+
continue;
|
|
894
|
+
}
|
|
895
|
+
plan.moved.push({
|
|
896
|
+
file: move.to,
|
|
897
|
+
from: file,
|
|
898
|
+
to: move.to,
|
|
899
|
+
localHash,
|
|
900
|
+
storedHash,
|
|
901
|
+
remoteHash: toRemoteHash,
|
|
902
|
+
write: toLocalHash !== toRemoteHash,
|
|
903
|
+
reason: toLocalHash === toRemoteHash
|
|
904
|
+
? 'legacy framework path cleanup; canonical file already current'
|
|
905
|
+
: 'safe framework path move',
|
|
906
|
+
});
|
|
907
|
+
movedToFiles.add(move.to);
|
|
908
|
+
continue;
|
|
909
|
+
}
|
|
910
|
+
|
|
536
911
|
if (!remoteHash) {
|
|
537
912
|
if (storedHash && isInstalledOptionalFile(file, localVersion)) {
|
|
538
913
|
plan.skipped.push({ file, reason: 'installed optional workflow file' });
|
|
@@ -542,8 +917,11 @@ async function main() {
|
|
|
542
917
|
continue;
|
|
543
918
|
}
|
|
544
919
|
|
|
545
|
-
|
|
546
|
-
|
|
920
|
+
// Manifest-first: an optional-owned file whose option is NOT selected for
|
|
921
|
+
// this install is not in this install's plan; skip it rather than
|
|
922
|
+
// force-applying an optional workflow the user never opted into.
|
|
923
|
+
if (isOptionalOwnedUnselected(canonical, manifestCtx, selectedOptions)) {
|
|
924
|
+
plan.skipped.push({ file, reason: 'optional workflow not selected for this install' });
|
|
547
925
|
continue;
|
|
548
926
|
}
|
|
549
927
|
|
|
@@ -555,16 +933,22 @@ async function main() {
|
|
|
555
933
|
continue;
|
|
556
934
|
}
|
|
557
935
|
if (acceptedDecisionMatches(file, localHash, remoteHash)) {
|
|
558
|
-
plan.skipped.push({ file, reason:
|
|
936
|
+
plan.skipped.push({ file, reason: acceptedDecisionReason(file) });
|
|
937
|
+
continue;
|
|
938
|
+
}
|
|
939
|
+
if (isHarnessOwnedExistingFileManifestFirst(canonical, diskPath, manifestCtx, selectedOptions)) {
|
|
940
|
+
plan.updated.push({ file, localHash, remoteHash, reason: 'existing untracked Harness-owned file' });
|
|
559
941
|
continue;
|
|
560
942
|
}
|
|
561
943
|
plan.conflict.push({ file, localHash, storedHash: 'none', remoteHash, reason: 'new remote file conflicts with existing local file' });
|
|
562
944
|
continue;
|
|
563
945
|
}
|
|
564
|
-
// New file: still respect PRESERVE classification
|
|
565
|
-
|
|
946
|
+
// New file: still respect PRESERVE classification for existing user data,
|
|
947
|
+
// but create missing scaffold starter files. PRESERVE means "do not
|
|
948
|
+
// overwrite local user data", not "leave required new docs absent".
|
|
949
|
+
const tier = classify(canonical, null, null, manifestCtx);
|
|
566
950
|
if (tier === 'PRESERVE') {
|
|
567
|
-
plan.
|
|
951
|
+
plan.created.push({ file, remoteHash, reason: 'PRESERVE: missing scaffold starter; no local user data to overwrite' });
|
|
568
952
|
} else {
|
|
569
953
|
plan.created.push({ file, remoteHash });
|
|
570
954
|
}
|
|
@@ -579,7 +963,7 @@ async function main() {
|
|
|
579
963
|
&& localHash !== remoteHash
|
|
580
964
|
) {
|
|
581
965
|
if (acceptedDecisionMatches(file, localHash, remoteHash)) {
|
|
582
|
-
plan.skipped.push({ file, reason:
|
|
966
|
+
plan.skipped.push({ file, reason: acceptedDecisionReason(file) });
|
|
583
967
|
continue;
|
|
584
968
|
}
|
|
585
969
|
plan.conflict.push({
|
|
@@ -592,7 +976,7 @@ async function main() {
|
|
|
592
976
|
continue;
|
|
593
977
|
}
|
|
594
978
|
|
|
595
|
-
const tier = classify(canonical, localHash, storedHash);
|
|
979
|
+
const tier = classify(canonical, localHash, storedHash, manifestCtx);
|
|
596
980
|
|
|
597
981
|
if (tier === 'PRESERVE') {
|
|
598
982
|
plan.skipped.push({ file, reason: 'PRESERVE: user data' });
|
|
@@ -604,7 +988,7 @@ async function main() {
|
|
|
604
988
|
}
|
|
605
989
|
} else if (tier === 'CONFLICT') {
|
|
606
990
|
if (acceptedDecisionMatches(file, localHash, remoteHash)) {
|
|
607
|
-
plan.skipped.push({ file, reason:
|
|
991
|
+
plan.skipped.push({ file, reason: acceptedDecisionReason(file) });
|
|
608
992
|
continue;
|
|
609
993
|
}
|
|
610
994
|
plan.conflict.push({
|
|
@@ -612,7 +996,7 @@ async function main() {
|
|
|
612
996
|
localHash,
|
|
613
997
|
storedHash,
|
|
614
998
|
remoteHash,
|
|
615
|
-
reason:
|
|
999
|
+
reason: isMergeFile(canonical, manifestCtx)
|
|
616
1000
|
? 'user modified MERGE file'
|
|
617
1001
|
: 'user modified runtime file',
|
|
618
1002
|
});
|
|
@@ -628,11 +1012,13 @@ async function main() {
|
|
|
628
1012
|
from: localGen,
|
|
629
1013
|
to: remoteGen,
|
|
630
1014
|
sourceBase,
|
|
1015
|
+
releaseNotes: remoteVersion.releaseNotes || null,
|
|
631
1016
|
partialUpdate: localVersion.partialUpdate || null,
|
|
632
1017
|
acceptedConflicts: localVersion.acceptedConflicts || {},
|
|
633
1018
|
updated: plan.updated.length,
|
|
634
1019
|
created: plan.created.length,
|
|
635
1020
|
adopted: plan.adopted.length,
|
|
1021
|
+
moved: plan.moved.length,
|
|
636
1022
|
conflict: plan.conflict.length,
|
|
637
1023
|
skipped: plan.skipped.length,
|
|
638
1024
|
// Token-safe by default: attach the full plan only when --verbose / --full-plan is passed.
|
|
@@ -643,7 +1029,8 @@ async function main() {
|
|
|
643
1029
|
}
|
|
644
1030
|
|
|
645
1031
|
console.log(`\nUpdate: v${localGen} -> v${remoteGen}`);
|
|
646
|
-
console.log(` ${plan.updated.length} safe update, ${plan.created.length} new, ${plan.conflict.length} conflict, ${plan.skipped.length} skipped\n`);
|
|
1032
|
+
console.log(` ${plan.updated.length} safe update, ${plan.created.length} new, ${plan.moved.length} moved, ${plan.conflict.length} conflict, ${plan.skipped.length} skipped\n`);
|
|
1033
|
+
printReleaseHighlights(remoteVersion);
|
|
647
1034
|
|
|
648
1035
|
// Show conflicts (these need AI/user decision)
|
|
649
1036
|
if (plan.conflict.length > 0) {
|
|
@@ -658,10 +1045,11 @@ async function main() {
|
|
|
658
1045
|
}
|
|
659
1046
|
|
|
660
1047
|
// Show what will be auto-updated
|
|
661
|
-
if (plan.updated.length + plan.created.length > 0) {
|
|
1048
|
+
if (plan.updated.length + plan.created.length + plan.moved.length > 0) {
|
|
662
1049
|
console.log('AUTO (safe to apply):');
|
|
663
1050
|
for (const u of plan.updated) console.log(` ^ ${u.file}`);
|
|
664
1051
|
for (const c of plan.created) console.log(` + ${c.file}`);
|
|
1052
|
+
for (const m of plan.moved) console.log(` > ${m.from} -> ${m.to}`);
|
|
665
1053
|
console.log('');
|
|
666
1054
|
}
|
|
667
1055
|
|
|
@@ -678,56 +1066,87 @@ async function main() {
|
|
|
678
1066
|
console.log(`Applying SAFE/NEW files only; ${plan.conflict.length} conflicts will remain for AI/user merge.`);
|
|
679
1067
|
}
|
|
680
1068
|
|
|
681
|
-
const
|
|
682
|
-
let applied = 0;
|
|
1069
|
+
const preparedWrites = [];
|
|
683
1070
|
let failed = 0;
|
|
684
1071
|
|
|
685
|
-
|
|
1072
|
+
async function prepareWrite(entry, { mustNotExist = false } = {}) {
|
|
686
1073
|
try {
|
|
687
|
-
const dest = safePath(
|
|
688
|
-
if (!dest) {
|
|
1074
|
+
const dest = safePath(entry.file);
|
|
1075
|
+
if (!dest) return { ok: false, message: ` x Traversal rejected: ${entry.file}` };
|
|
689
1076
|
// Symlink rejection: do not follow symlinks.
|
|
690
|
-
if (
|
|
691
|
-
try { if (lstatSync(dest).isSymbolicLink()) {
|
|
1077
|
+
if (existsSync(dest)) {
|
|
1078
|
+
try { if (lstatSync(dest).isSymbolicLink()) return { ok: false, message: ` x Symlink rejected: ${entry.file}` }; } catch (_) {}
|
|
1079
|
+
if (mustNotExist) {
|
|
1080
|
+
return { ok: false, message: ` x File created since plan: ${entry.file} - treating as CONFLICT` };
|
|
1081
|
+
}
|
|
692
1082
|
}
|
|
693
|
-
const content = await
|
|
1083
|
+
const content = await fetchSourceFile(remotePath(entry.file));
|
|
694
1084
|
const normalized = content.replace(/\r\n/g, '\n');
|
|
695
1085
|
const fetchedHash = sha256(normalized);
|
|
696
|
-
if (fetchedHash !==
|
|
697
|
-
|
|
698
|
-
failed++; continue;
|
|
1086
|
+
if (fetchedHash !== entry.remoteHash) {
|
|
1087
|
+
return { ok: false, message: ` x Hash mismatch: ${entry.file}` };
|
|
699
1088
|
}
|
|
700
|
-
|
|
701
|
-
writeFileSync(dest, normalized, 'utf-8');
|
|
702
|
-
applied++;
|
|
1089
|
+
return { ok: true, prepared: { file: entry.file, dest, content: normalized, mustNotExist } };
|
|
703
1090
|
} catch (e) {
|
|
704
|
-
|
|
1091
|
+
return { ok: false, message: ` x Failed: ${entry.file} - ${e.message}` };
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
const prepareJobs = [
|
|
1096
|
+
...plan.updated.map(entry => ({ entry, options: {} })),
|
|
1097
|
+
...plan.created.map(entry => ({ entry, options: { mustNotExist: true } })),
|
|
1098
|
+
...plan.moved
|
|
1099
|
+
.filter(entry => entry.write !== false)
|
|
1100
|
+
.map(entry => ({ entry, options: { mustNotExist: !existsSync(safePath(entry.file)) } })),
|
|
1101
|
+
];
|
|
1102
|
+
const prepareResults = await Promise.all(
|
|
1103
|
+
prepareJobs.map(job => prepareWrite(job.entry, job.options)),
|
|
1104
|
+
);
|
|
1105
|
+
for (const result of prepareResults) {
|
|
1106
|
+
if (result.ok) {
|
|
1107
|
+
preparedWrites.push(result.prepared);
|
|
1108
|
+
} else {
|
|
1109
|
+
console.error(result.message);
|
|
705
1110
|
failed++;
|
|
706
1111
|
}
|
|
707
1112
|
}
|
|
708
1113
|
|
|
709
|
-
|
|
1114
|
+
if (failed === 0) {
|
|
1115
|
+
for (const prepared of preparedWrites) {
|
|
1116
|
+
if (prepared.mustNotExist && existsSync(prepared.dest)) {
|
|
1117
|
+
console.error(` x File created since plan: ${prepared.file} - treating as CONFLICT`);
|
|
1118
|
+
failed++;
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
// All-or-nothing apply invariant: every prepared file above was hash-validated
|
|
1124
|
+
// against its manifest checksum before ANY writeFileSync below. The version
|
|
1125
|
+
// file is advanced only when failed === 0, so a failed apply leaves version
|
|
1126
|
+
// tracking unchanged and is safe to re-run with --apply-safe. A mid-loop
|
|
1127
|
+
// writeFileSync throw (disk failure) is the only partial-disk edge case;
|
|
1128
|
+
// re-running --apply-safe reconciles it.
|
|
1129
|
+
let applied = 0;
|
|
1130
|
+
if (failed === 0) {
|
|
710
1131
|
try {
|
|
711
|
-
const
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
try { if (lstatSync(dest).isSymbolicLink()) { console.error(` x Symlink rejected: ${c.file}`); failed++; continue; } } catch (_) {}
|
|
716
|
-
console.error(` x File created since plan: ${c.file} - treating as CONFLICT`);
|
|
717
|
-
failed++; continue;
|
|
1132
|
+
for (const prepared of preparedWrites) {
|
|
1133
|
+
mkdirSync(dirname(prepared.dest), { recursive: true });
|
|
1134
|
+
writeFileSync(prepared.dest, prepared.content, 'utf-8');
|
|
1135
|
+
applied++;
|
|
718
1136
|
}
|
|
719
|
-
const
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
1137
|
+
for (const moved of plan.moved) {
|
|
1138
|
+
const from = safePath(moved.from);
|
|
1139
|
+
if (!from) throw new Error(`Traversal rejected: ${moved.from}`);
|
|
1140
|
+
if (!existsSync(from)) continue;
|
|
1141
|
+
if (lstatSync(from).isSymbolicLink()) throw new Error(`Symlink rejected: ${moved.from}`);
|
|
1142
|
+
const currentHash = sha256File(from);
|
|
1143
|
+
if (currentHash && currentHash !== moved.storedHash) {
|
|
1144
|
+
throw new Error(`Legacy moved file changed before cleanup: ${moved.from}`);
|
|
1145
|
+
}
|
|
1146
|
+
unlinkSync(from);
|
|
725
1147
|
}
|
|
726
|
-
mkdirSync(dirname(dest), { recursive: true });
|
|
727
|
-
writeFileSync(dest, normalized, 'utf-8');
|
|
728
|
-
applied++;
|
|
729
1148
|
} catch (e) {
|
|
730
|
-
console.error(` x Failed
|
|
1149
|
+
console.error(` x Failed while writing prepared files - ${e.message}`);
|
|
731
1150
|
failed++;
|
|
732
1151
|
}
|
|
733
1152
|
}
|
|
@@ -739,24 +1158,43 @@ async function main() {
|
|
|
739
1158
|
for (const u of plan.updated) localVersion.checksums[u.file] = u.remoteHash;
|
|
740
1159
|
for (const c of plan.created) localVersion.checksums[c.file] = c.remoteHash;
|
|
741
1160
|
for (const a of plan.adopted) localVersion.checksums[a.file] = a.remoteHash;
|
|
1161
|
+
localVersion.sources = localVersion.sources || {};
|
|
1162
|
+
for (const u of plan.updated) localVersion.sources[u.file] = remoteSources[u.file] || u.file;
|
|
1163
|
+
for (const c of plan.created) localVersion.sources[c.file] = remoteSources[c.file] || c.file;
|
|
1164
|
+
for (const a of plan.adopted) localVersion.sources[a.file] = remoteSources[a.file] || a.file;
|
|
1165
|
+
for (const m of plan.moved) {
|
|
1166
|
+
localVersion.checksums[m.file] = m.remoteHash;
|
|
1167
|
+
localVersion.sources[m.file] = remoteSources[m.file] || m.file;
|
|
1168
|
+
delete localVersion.checksums[m.from];
|
|
1169
|
+
delete localVersion.sources[m.from];
|
|
1170
|
+
delete localVersion.acceptedConflicts[m.from];
|
|
1171
|
+
}
|
|
1172
|
+
if (Array.isArray(remoteVersion.moves)) localVersion.moves = remoteVersion.moves;
|
|
1173
|
+
else delete localVersion.moves;
|
|
742
1174
|
if (plan.conflict.length === 0) {
|
|
743
1175
|
localVersion.generator = remoteGen;
|
|
744
1176
|
localVersion.generated = appliedAt;
|
|
1177
|
+
if (remoteVersion.releaseNotes) localVersion.releaseNotes = remoteVersion.releaseNotes;
|
|
1178
|
+
else delete localVersion.releaseNotes;
|
|
745
1179
|
delete localVersion.partialUpdate;
|
|
746
1180
|
} else {
|
|
747
1181
|
localVersion.partialUpdate = {
|
|
748
1182
|
targetGenerator: remoteGen,
|
|
749
1183
|
updatedAt: appliedAt,
|
|
750
|
-
appliedFiles: [...plan.updated, ...plan.created].map(x => x.file),
|
|
1184
|
+
appliedFiles: [...plan.updated, ...plan.created, ...plan.moved].map(x => x.file),
|
|
751
1185
|
adoptedFiles: plan.adopted.map(x => x.file),
|
|
1186
|
+
movedFiles: plan.moved.map(x => ({ from: x.from, to: x.to })),
|
|
752
1187
|
conflicts: plan.conflict.map(x => x.file),
|
|
1188
|
+
releaseNotes: remoteVersion.releaseNotes || null,
|
|
753
1189
|
};
|
|
754
1190
|
}
|
|
755
1191
|
writeFileSync(VERSION_FILE, JSON.stringify(localVersion, null, 2) + '\n', 'utf-8');
|
|
756
1192
|
if (plan.conflict.length === 0) {
|
|
757
1193
|
console.log(`Applied ${applied} files. Version updated to ${remoteVersion.generator}.`);
|
|
1194
|
+
printReleaseHighlights(remoteVersion);
|
|
758
1195
|
} else {
|
|
759
1196
|
console.log(`Applied ${applied} SAFE/NEW files. Version remains ${localGen}; ${plan.conflict.length} conflicts still need merge.`);
|
|
1197
|
+
printReleaseHighlights(remoteVersion);
|
|
760
1198
|
}
|
|
761
1199
|
} else {
|
|
762
1200
|
console.log(`${failed} failures. NO files were version-tracked. Fix and re-run.`);
|
|
@@ -765,8 +1203,8 @@ async function main() {
|
|
|
765
1203
|
}
|
|
766
1204
|
|
|
767
1205
|
if (finalize) {
|
|
768
|
-
if (plan.updated.length > 0 || plan.created.length > 0 || plan.adopted.length > 0) {
|
|
769
|
-
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.`);
|
|
1206
|
+
if (plan.updated.length > 0 || plan.created.length > 0 || plan.adopted.length > 0 || plan.moved.length > 0) {
|
|
1207
|
+
console.log(`Cannot finalize: ${plan.updated.length} safe updates, ${plan.created.length} new files, ${plan.moved.length} path moves, and ${plan.adopted.length} adopted metadata entries still need --apply-safe.`);
|
|
770
1208
|
if (decisionMetadataDirty) {
|
|
771
1209
|
writeFileSync(VERSION_FILE, JSON.stringify(localVersion, null, 2) + '\n', 'utf-8');
|
|
772
1210
|
}
|
|
@@ -784,9 +1222,12 @@ async function main() {
|
|
|
784
1222
|
const finalizedAt = new Date().toISOString();
|
|
785
1223
|
localVersion.generator = remoteGen;
|
|
786
1224
|
localVersion.generated = finalizedAt;
|
|
1225
|
+
if (remoteVersion.releaseNotes) localVersion.releaseNotes = remoteVersion.releaseNotes;
|
|
1226
|
+
else delete localVersion.releaseNotes;
|
|
787
1227
|
delete localVersion.partialUpdate;
|
|
788
1228
|
writeFileSync(VERSION_FILE, JSON.stringify(localVersion, null, 2) + '\n', 'utf-8');
|
|
789
1229
|
console.log(`Finalized Harness update to ${remoteGen}.`);
|
|
1230
|
+
printReleaseHighlights(remoteVersion);
|
|
790
1231
|
} else if (decisionMetadataDirty) {
|
|
791
1232
|
writeFileSync(VERSION_FILE, JSON.stringify(localVersion, null, 2) + '\n', 'utf-8');
|
|
792
1233
|
console.log('Recorded conflict decision metadata.');
|