create-wxl 0.0.0-stage → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1783 @@
1
+ /**
2
+ * fork-init.ts
3
+ *
4
+ * Deterministic edits to fork this template repo into a new project.
5
+ * The wxl-fork-init skill calls this CLI instead of hand-editing files.
6
+ * create-wxl (the npm package, 0005-create-wxl) carries a type-stripped copy of this file: it
7
+ * calls parseForkInitArgs and runForkInit on a range it just created, and also uses
8
+ * deriveGithubPagesOrigin, ForkInitArgError and UPSTREAM_SLUG.
9
+ *
10
+ * Usage:
11
+ * pnpm fork:init --author <name> --repo <owner/repo>
12
+ * [--base <path> | --base none | --host github-pages|cloudflare-pages]
13
+ * [--origin <https://host[/path]>] [--site-name <text>]
14
+ * [--name <pkg-name>] [--description <text>]
15
+ * [--rebrand <newshortname>] [--dry-run]
16
+ *
17
+ * --host and --base are mutually exclusive: --host decides the workflow and resets A9
18
+ * (the base definition) to its upstream form from deployment; --base only applies without
19
+ * --host, and writes A9 as a literal.
20
+ *
21
+ * --origin sets the range address, the origin of every copied link (A8); --host github-pages
22
+ * derives it when --origin is not given (required with --host cloudflare-pages). --site-name
23
+ * sets the site title (A6) and both home pages' hero name (A7). --rebrand cannot be combined
24
+ * with --host, --origin or --site-name (A mode's new flags are rejected in B mode).
25
+ *
26
+ * Modes:
27
+ * A (default) rewrites only the identity anchors (traceability.md's A1-A9):
28
+ * the manifest (structured), the doc-anchor forms in README.md,
29
+ * README.en.md and CONTRIBUTE.md — wherever they occur, whatever
30
+ * repository slug they held — A9 (the base definition), A6/A7 (the site
31
+ * name, from --site-name), A8 (the range address, the origin of every
32
+ * copied link, from --origin or derived), and the deploy workflow (chosen
33
+ * by --host). No tree walk; every other file is byte-identical.
34
+ * B (--rebrand <name>) today's path: walks the whole tree doing the upstream-slug swap and
35
+ * also renames the four runtime-sensitive keys (deterministic,
36
+ * structure-aware), plus an HONEST residual inventory of every
37
+ * remaining case-insensitive `wxl` (which is NOT auto-renamed — see
38
+ * "Why not rename every wxl?" below). Unlike the rest of the walk, a root
39
+ * range.json (create-wxl's setup record) is never rewritten in B mode —
40
+ * it still goes through the residual scan, so it keeps appearing in the
41
+ * inventory when it carries the upstream slug.
42
+ *
43
+ * Why not rename every `wxl`?
44
+ * In this repo the substring `wxl` spans several unrelated identifier families that a
45
+ * deterministic text tool cannot tell apart: the brand short-name, the `wxlsh` challenge
46
+ * subsystem / `X-Wxlsh-*` wire headers, the four runtime-sensitive keys, the upstream repo
47
+ * slug, and path-referenced skill/spec directory names. A blind `wxl`->`x` replace corrupts
48
+ * the ones that must survive (e.g. `wxlsh`->`xsh`, or a dead `.../x-template` link) and does
49
+ * so silently. So B-mode renames ONLY what it can prove is a full, self-contained token (the
50
+ * upstream slug + the 4 keys) and reports everything else as a manual, namespace-aware to-do,
51
+ * computed from the bytes actually written. It never claims the rebrand is complete while a
52
+ * `wxl` remains.
53
+ *
54
+ * Exit codes:
55
+ * 0 success (A mode complete; B mode: deterministic edits done — a residual inventory may remain)
56
+ * 1 user input error or tree error (invalid/missing flag, or a problem found in the tree — see
57
+ * ForkInitArgError and the tree-error returns in runForkInit)
58
+ */
59
+
60
+ import { parseArgs } from 'node:util'
61
+ import {
62
+ existsSync,
63
+ readFileSync,
64
+ writeFileSync,
65
+ mkdirSync,
66
+ readdirSync,
67
+ statSync,
68
+ lstatSync,
69
+ chmodSync,
70
+ renameSync,
71
+ unlinkSync,
72
+ } from 'node:fs'
73
+ import { dirname, resolve, join } from 'node:path'
74
+ import { fileURLToPath } from 'node:url'
75
+ import { createHash } from 'node:crypto'
76
+ import { parse as parseYaml, parseDocument as parseYamlDocument, isScalar, isMap, isPair } from 'yaml'
77
+
78
+ const __filename = fileURLToPath(import.meta.url)
79
+ const __dirname = dirname(__filename)
80
+ export const PROJECT_ROOT = resolve(__dirname, '..')
81
+
82
+ // ─── args ────────────────────────────────────────────────────────────────────
83
+
84
+
85
+
86
+
87
+
88
+
89
+
90
+
91
+
92
+
93
+
94
+
95
+
96
+
97
+
98
+
99
+
100
+
101
+
102
+
103
+
104
+
105
+
106
+ export class ForkInitArgError extends Error {
107
+ exitCode
108
+
109
+ constructor(message , exitCode = 1) {
110
+ super(message)
111
+ this.name = 'ForkInitArgError'
112
+ this.exitCode = exitCode
113
+ }
114
+ }
115
+
116
+ // Repository-slug grammar (traceability.md, "Repository-slug grammar" / T45): owner is
117
+ // alphanumerics and `-`, 1-39 characters, no leading or trailing `-`; name is
118
+ // `[A-Za-z0-9._-]{1,100}`, not `.` or `..`, and not ending in `.git` (any case).
119
+ const REPO_OWNER_RE = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?$/
120
+ const REPO_NAME_RE = /^[A-Za-z0-9._-]{1,100}$/
121
+ // Base grammar (traceability.md, "Base grammar" / T21): `/`, or `/` followed by one or more
122
+ // segments each ending in `/`; a segment is URL-path-safe and is never `.` or `..` — the same
123
+ // grammar `.vitepress/config.mts` checks on the resolved `siteBase` (T31), unified here in one
124
+ // place for `--base`. Rejecting quotes and `$` also keeps the value from breaking out of the A9
125
+ // string literal it is written into (.vitepress/config.mts is an executable TS module).
126
+ const BASE_RE = /^\/(?:(?!\.\.?\/)[A-Za-z0-9._~-]+\/)*$/
127
+
128
+ // Origin grammar (traceability.md, "Origin grammar" / T32-T34): `https://` + hostname + path.
129
+ // A hostname label and a path segment share the same base alphabet; the label additionally bounds
130
+ // its own length (1-63) and forbids a leading/trailing `-`.
131
+ const ORIGIN_LABEL_RE = /^[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?$/
132
+ const ORIGIN_PATH_SEGMENT_RE = /^[A-Za-z0-9._~-]+$/
133
+
134
+ /** T32-T34/AC-O1: validates `raw` against the origin grammar and returns it with the hostname
135
+ * lowercased (the path's case is untouched). Throws `ForkInitArgError` naming `--origin` and the
136
+ * JSON-quoted value for any grammar violation (T33), and separately for a path given together
137
+ * with `--host cloudflare-pages` (T34, which needs to know whether the host is Cloudflare —
138
+ * passed in by the caller, since this function only knows the origin's own shape). */
139
+ function validateOriginValue(raw , host ) {
140
+ const fail = (reason ) => {
141
+ throw new ForkInitArgError(
142
+ `--origin must be https://<hostname>[/path] (no userinfo, port, query, fragment or trailing slash)${
143
+ reason ? ` (${reason})` : ''
144
+ }, got: ${JSON.stringify(raw)}`,
145
+ )
146
+ }
147
+ if (!raw.startsWith('https://')) fail()
148
+ const rest = raw.slice('https://'.length)
149
+ const slashIdx = rest.indexOf('/')
150
+ const hostPart = slashIdx === -1 ? rest : rest.slice(0, slashIdx)
151
+ const pathPart = slashIdx === -1 ? '' : rest.slice(slashIdx) // includes the leading '/', if any
152
+ if (hostPart === '' || hostPart.length > 253) fail()
153
+ const labels = hostPart.split('.')
154
+ if (labels.length < 2) fail()
155
+ for (const label of labels) {
156
+ if (!ORIGIN_LABEL_RE.test(label)) fail()
157
+ }
158
+ if (/^[0-9]+$/.test(labels[labels.length - 1])) fail('the last label is all digits')
159
+ if (pathPart !== '') {
160
+ // pathPart starts with '/', so splitting on '/' always yields a leading '' element.
161
+ const segments = pathPart.split('/').slice(1)
162
+ for (const segment of segments) {
163
+ if (segment === '' || segment === '.' || segment === '..' || !ORIGIN_PATH_SEGMENT_RE.test(segment)) fail()
164
+ }
165
+ }
166
+ if (host === 'cloudflare-pages' && pathPart !== '') {
167
+ throw new ForkInitArgError(`--origin must have no path when --host is cloudflare-pages, got: ${JSON.stringify(raw)}`)
168
+ }
169
+ return `https://${hostPart.toLowerCase()}${pathPart}`
170
+ }
171
+
172
+ /** T70/AC-C1: whether `raw` matches the origin grammar (traceability.md, "Origin grammar"),
173
+ * ignoring the `--host cloudflare-pages` path restriction (a caller-supplied condition, not part
174
+ * of the grammar itself — see `validateOriginValue`). Exported so S6's component tests reuse this
175
+ * exact grammar instead of re-implementing it (Note 8, T-0011). */
176
+ export function matchesOriginGrammar(raw ) {
177
+ try {
178
+ validateOriginValue(raw, undefined)
179
+ return true
180
+ } catch (e) {
181
+ if (e instanceof ForkInitArgError) return false
182
+ throw e
183
+ }
184
+ }
185
+
186
+ /** T45: `--repo` is a single `owner/name` slug; the value itself is never trimmed. */
187
+ function validateRepoValue(value ) {
188
+ const firstSlash = value.indexOf('/')
189
+ const lastSlash = value.lastIndexOf('/')
190
+ const owner = firstSlash === -1 ? '' : value.slice(0, firstSlash)
191
+ const name = firstSlash === -1 ? '' : value.slice(firstSlash + 1)
192
+ const ok =
193
+ firstSlash !== -1 &&
194
+ firstSlash === lastSlash &&
195
+ REPO_OWNER_RE.test(owner) &&
196
+ REPO_NAME_RE.test(name) &&
197
+ name !== '.' &&
198
+ name !== '..' &&
199
+ !/\.git$/i.test(name)
200
+ if (!ok) {
201
+ throw new ForkInitArgError(`--repo must be in owner/repo form, got: ${JSON.stringify(value)}`)
202
+ }
203
+ }
204
+
205
+ /** The 66 Unicode noncharacters: U+FDD0-U+FDEF, and every code point ending in FFFE or FFFF. */
206
+ function isNoncharacter(codePoint ) {
207
+ if (codePoint >= 0xfdd0 && codePoint <= 0xfdef) return true
208
+ return (codePoint & 0xfffe) === 0xfffe
209
+ }
210
+
211
+ /**
212
+ * The character rule shared by `--author` and (later) `--site-name` (traceability.md,
213
+ * "Character rules"): length is counted in Unicode code points, without normalisation; the
214
+ * trimmed value must hold at least one code point other than U+200C/U+200D; Cc, Cs, Zl, Zp, Cf
215
+ * (except U+200C/U+200D) and the noncharacters are rejected.
216
+ */
217
+ function validateCharacterRuleValue(trimmed , flag , rawForMessage , maxLength = 100) {
218
+ // Written as escapes, not the literal invisible characters, so the source stays readable.
219
+ const ZWNJ = '\u200C'
220
+ const ZWJ = '\u200D'
221
+ const codePoints = Array.from(trimmed)
222
+ // `reason` says what was wrong (too long, which character, or only joiners) so the message is
223
+ // actionable, not just a bare rejection.
224
+ const fail = (reason ) => {
225
+ throw new ForkInitArgError(`${flag} rejected (${reason}), got: ${JSON.stringify(rawForMessage)}`)
226
+ }
227
+ if (codePoints.length < 1) fail('empty')
228
+ if (codePoints.length > maxLength) fail('too long')
229
+ let hasOther = false
230
+ for (const ch of codePoints) {
231
+ if (ch !== ZWNJ && ch !== ZWJ) hasOther = true
232
+ const codePoint = ch.codePointAt(0)
233
+ const disallowed =
234
+ /\p{Cc}|\p{Cs}|\p{Zl}|\p{Zp}/u.test(ch) ||
235
+ (/\p{Cf}/u.test(ch) && ch !== ZWNJ && ch !== ZWJ) ||
236
+ isNoncharacter(codePoint)
237
+ if (disallowed) {
238
+ fail(`disallowed character U+${codePoint.toString(16).toUpperCase().padStart(4, '0')}`)
239
+ }
240
+ }
241
+ if (!hasOther) fail('only joiners')
242
+ }
243
+
244
+ /** T39-T41/AC-N1/AC-N2: --site-name is trimmed, then validated against the shared character rule
245
+ * (1-60 code points), then against the characters that would break the home page's HTML (`<`,
246
+ * `>`, `&`), each refused by name. Returns the trimmed value to write. */
247
+ function validateSiteNameValue(raw ) {
248
+ const trimmed = raw.trim()
249
+ validateCharacterRuleValue(trimmed, '--site-name', raw, 60)
250
+ for (const ch of Array.from(trimmed)) {
251
+ if (ch === '<' || ch === '>' || ch === '&') {
252
+ throw new ForkInitArgError(`--site-name rejected (disallowed character ${JSON.stringify(ch)}), got: ${JSON.stringify(raw)}`)
253
+ }
254
+ }
255
+ return trimmed
256
+ }
257
+
258
+ /** Extract the flag Node names in an `ERR_PARSE_ARGS_INVALID_OPTION_VALUE` message, stopping at
259
+ * the first space or quote — handles both the `'--author <value>'` and the plain `'--repo'`
260
+ * shape Node uses depending on which sub-case raised the error. Every message this function
261
+ * receives (argument missing, argument is ambiguous, does not take an argument — confirmed for
262
+ * every flag this CLI declares, on every Node version this code has run against) begins with
263
+ * `Option '...'`. The branch below exists only for a future Node message format this code has not
264
+ * seen, so a non-match throws a `ForkInitArgError` instead of crashing on a null regex match. */
265
+ function flagFromParseArgsMessage(message ) {
266
+ const m = /^Option '([^ ']+)/.exec(message)
267
+ if (!m) throw new ForkInitArgError(`invalid arguments: ${JSON.stringify(message)}`)
268
+ return m[1]
269
+ }
270
+
271
+ /** The `=value` an argv token gave for `flag` (e.g. `--dry-run=yes` -> `yes`) — Node's own "does
272
+ * not take an argument" message for a boolean flag never echoes the value, so this reads it back
273
+ * from the original argv instead. */
274
+ function inlineValueFor(argv , flag ) {
275
+ const prefix = `${flag}=`
276
+ const token = argv.find((a) => a.startsWith(prefix))
277
+ return token?.slice(prefix.length)
278
+ }
279
+
280
+ /**
281
+ * Rebuild one of Node's `util.parseArgs` errors into a `ForkInitArgError` with our own message:
282
+ * Node's own text for an unknown option appends a "place it at the end after '--'" hint that does
283
+ * not apply here (this CLI takes no positionals), and never runs an echoed flag/value through
284
+ * `JSON.stringify`. Every message here names the flag; every echoed value is
285
+ * `JSON.stringify`-quoted.
286
+ */
287
+ function rewrapParseArgsError(err , argv ) {
288
+ const message = err.message
289
+ if (err.code === 'ERR_PARSE_ARGS_UNKNOWN_OPTION') {
290
+ const m = /^Unknown option '([^]*?)'\. To specify/.exec(message)
291
+ // Every current Node message for an unknown option matches; this branch exists only for a
292
+ // future Node message format this code has not seen.
293
+ if (!m) throw new ForkInitArgError(`invalid arguments: ${JSON.stringify(message)}`)
294
+ return new ForkInitArgError(`unknown flag: ${JSON.stringify(m[1])}`)
295
+ }
296
+ const flag = flagFromParseArgsMessage(message)
297
+ if (/does not take an argument/.test(message)) {
298
+ return new ForkInitArgError(`${flag} takes no value, got: ${JSON.stringify(inlineValueFor(argv, flag))}`)
299
+ }
300
+ // "argument missing" (the flag is the last token) and "argument is ambiguous" (the next known
301
+ // flag ate the value) are both, from the caller's point of view, this flag not getting a value.
302
+ return new ForkInitArgError(`${flag} needs a value`)
303
+ }
304
+
305
+ export function parseForkInitArgs(argv ) {
306
+ const options = {
307
+ author: { type: 'string' },
308
+ repo: { type: 'string' },
309
+ base: { type: 'string' },
310
+ host: { type: 'string' },
311
+ origin: { type: 'string' },
312
+ 'site-name': { type: 'string' },
313
+ name: { type: 'string' },
314
+ description: { type: 'string' },
315
+ rebrand: { type: 'string' },
316
+ 'dry-run': { type: 'boolean', default: false },
317
+ }
318
+
319
+ try {
320
+ const { tokens, values } = parseArgs({ args: argv, options, tokens: true, allowPositionals: true })
321
+
322
+ // This CLI takes no positional arguments; the first one found is itself the rejection.
323
+ const positional = tokens.find((t) => t.kind === 'positional')
324
+ if (positional) {
325
+ throw new ForkInitArgError(`unexpected positional argument: ${JSON.stringify(positional.value)}`)
326
+ }
327
+
328
+ // parseArgs silently keeps only the last value of a repeated flag; detect it ourselves.
329
+ const seenFlags = new Set ()
330
+ for (const t of tokens) {
331
+ if (t.kind !== 'option') continue
332
+ if (seenFlags.has(t.name)) {
333
+ throw new ForkInitArgError(`flag given more than once: --${t.name}`)
334
+ }
335
+ seenFlags.add(t.name)
336
+ }
337
+
338
+ const authorRaw = values.author
339
+ const repoRaw = values.repo
340
+ if (authorRaw === undefined) {
341
+ throw new ForkInitArgError('missing required flag: --author <name>')
342
+ }
343
+ if (authorRaw.trim() === '') {
344
+ throw new ForkInitArgError(`--author must not be empty, got: ${JSON.stringify(authorRaw)}`)
345
+ }
346
+ if (repoRaw === undefined) {
347
+ throw new ForkInitArgError('missing required flag: --repo <owner/repo>')
348
+ }
349
+ if (repoRaw.trim() === '') {
350
+ throw new ForkInitArgError(`--repo must not be empty, got: ${JSON.stringify(repoRaw)}`)
351
+ }
352
+
353
+ const hostRaw = values.host
354
+ const baseRaw = values.base
355
+ const rebrandRaw = values.rebrand
356
+
357
+ // T50 (before T17/T16/T35, traceability.md "Argument check order"): --rebrand together with
358
+ // any of --host, --origin, --site-name is an argument error naming --rebrand and every one of
359
+ // those flags given — checked by presence alone, ahead of every value check (including
360
+ // --rebrand's own syntax and --host's own value), so an invalid value on either side still
361
+ // surfaces this message.
362
+ if (rebrandRaw !== undefined) {
363
+ const conflicting = [
364
+ hostRaw !== undefined ? '--host' : null,
365
+ values.origin !== undefined ? '--origin' : null,
366
+ values['site-name'] !== undefined ? '--site-name' : null,
367
+ ].filter((f) => f !== null)
368
+ if (conflicting.length > 0) {
369
+ throw new ForkInitArgError(`--rebrand cannot be combined with ${conflicting.join(', ')}`)
370
+ }
371
+ }
372
+
373
+ // T17 (before T16 and every value check): --host together with --base is an argument error.
374
+ // Read here, ahead of any value validation, so a combination of the two flags is rejected
375
+ // before either value is judged on its own.
376
+ if (hostRaw !== undefined && baseRaw !== undefined) {
377
+ throw new ForkInitArgError(
378
+ `--host decides the workflow and cannot be combined with --base (which only applies without --host), got --base: ${JSON.stringify(baseRaw)}`,
379
+ )
380
+ }
381
+ // T16: --host must be one of the two known values.
382
+ let host
383
+ if (hostRaw !== undefined) {
384
+ if (hostRaw !== 'github-pages' && hostRaw !== 'cloudflare-pages') {
385
+ throw new ForkInitArgError(
386
+ `--host must be "github-pages" or "cloudflare-pages", got: ${JSON.stringify(hostRaw)}`,
387
+ )
388
+ }
389
+ host = hostRaw
390
+ }
391
+ // T35/AC-O2: --host cloudflare-pages needs --origin — the engine cannot derive a Cloudflare
392
+ // Pages project's own pages.dev subdomain the way it derives a GitHub Pages one.
393
+ if (host === 'cloudflare-pages' && values.origin === undefined) {
394
+ throw new ForkInitArgError('--origin is required with --host cloudflare-pages')
395
+ }
396
+
397
+ validateRepoValue(repoRaw)
398
+ const authorTrimmed = authorRaw.trim()
399
+ validateCharacterRuleValue(authorTrimmed, '--author', authorRaw)
400
+
401
+ if (rebrandRaw !== undefined) {
402
+ // --rebrand is substituted verbatim into the sensitive-key replacements, which land in
403
+ // executable TS modules and CI YAML, so it must be a plain identifier — reject
404
+ // quotes/$/backtick/whitespace that could break out of a string literal at build time.
405
+ // It MUST start with a letter: the uppercased form seeds an env-var / JS-identifier
406
+ // (`<UPPER>_VERIFY_RUNTIME`), which cannot begin with a digit. (Interior `-`/`.` are
407
+ // allowed for the brand short-name and sanitized to `_` when building that identifier.)
408
+ if (!/^[A-Za-z][A-Za-z0-9._-]*$/.test(rebrandRaw)) {
409
+ throw new ForkInitArgError(
410
+ `--rebrand must start with a letter and contain only [A-Za-z0-9._-] (no spaces/quotes/$), got: ${JSON.stringify(rebrandRaw)}`,
411
+ )
412
+ }
413
+ if (rebrandRaw.toLowerCase().includes('wxl')) {
414
+ throw new ForkInitArgError(
415
+ `--rebrand name must not contain "wxl" (case-insensitive; would reintroduce the token and break idempotent rename), got: ${JSON.stringify(rebrandRaw)}`,
416
+ )
417
+ }
418
+ }
419
+
420
+ let base
421
+ if (baseRaw === undefined) base = undefined
422
+ else if (baseRaw === 'none') base = null
423
+ else {
424
+ if (!BASE_RE.test(baseRaw)) {
425
+ throw new ForkInitArgError(
426
+ `--base must be an absolute URL path like /myfork/ (letters, digits, . _ ~ - and slashes), or "none"; got: ${JSON.stringify(baseRaw)}`,
427
+ )
428
+ }
429
+ base = baseRaw
430
+ }
431
+
432
+ // --origin (T33, then T34): validated after --base, before --site-name, per the argument check
433
+ // order (traceability.md).
434
+ const originRaw = values.origin
435
+ const origin = originRaw !== undefined ? validateOriginValue(originRaw, host) : undefined
436
+
437
+ // --site-name (T40, then T41): validated last, after --origin, per the argument check order.
438
+ const siteNameRaw = values['site-name']
439
+ const siteName = siteNameRaw !== undefined ? validateSiteNameValue(siteNameRaw) : undefined
440
+
441
+ return {
442
+ author: authorTrimmed,
443
+ repo: repoRaw,
444
+ base,
445
+ host,
446
+ origin,
447
+ siteName,
448
+ name: values.name,
449
+ description: values.description,
450
+ rebrand: rebrandRaw,
451
+ dryRun: values['dry-run'],
452
+ }
453
+ } catch (err) {
454
+ if (err instanceof ForkInitArgError) throw err
455
+ const code = (err ).code
456
+ // Only Node's own argument-parser errors are rebuilt with our own message; anything else is a
457
+ // real failure (a bug, a resource error, ...) and must propagate, not be mislabelled as a
458
+ // user input error.
459
+ if (!code || !code.startsWith('ERR_PARSE_ARGS_')) throw err
460
+ throw rewrapParseArgsError(err , argv)
461
+ }
462
+ }
463
+
464
+ // ─── result ──────────────────────────────────────────────────────────────────
465
+
466
+
467
+
468
+
469
+
470
+
471
+
472
+
473
+
474
+
475
+
476
+
477
+
478
+
479
+
480
+
481
+
482
+
483
+
484
+
485
+
486
+
487
+
488
+
489
+
490
+ // T44/AC-G1: exported so the guard and pristine tests (tests/unit/scripts/upstream-guard.test.ts)
491
+ // build their fixtures from it instead of holding a second copy of the literal, and so the
492
+ // upstream-only `if:`/`env` of T71 can be cross-checked against it (they themselves spell it
493
+ // through `format`, never importing this directly, since a workflow YAML cannot import a TS
494
+ // module).
495
+ export const UPSTREAM_SLUG = 'BrowserLaboratory/wxl-platform'
496
+
497
+ /**
498
+ * A tree error (traceability.md: `exitCode` 1, nothing written) as a returned result, not a
499
+ * throw — only `parseForkInitArgs` throws (`ForkInitArgError`), for argument errors. The manifest
500
+ * check (A1) below and, in anchor mode, a write failure partway through (T55) both return one of
501
+ * these. Every exitCode 1 `ForkInitResult` — every value this function returns — carries empty
502
+ * changedFiles/sensitiveKeys/residualFiles/residualHits/warnings arrays: nothing is collected
503
+ * before the error surfaces.
504
+ *
505
+ * Every CHECKED tree error — a condition this function itself tests for and deliberately returns
506
+ * one of these for (the manifest check (A1) above all of them) — is returned before any write
507
+ * happens in the same run. A write failure, by contrast, can only happen once the write phase has
508
+ * begun (T55): its own tree error still carries these same empty arrays, but any file that phase
509
+ * had already written stays on disk (T6's convergence then applies on a re-run) — "nothing
510
+ * written" describes this function's own bookkeeping, not an undo of earlier writes in the same
511
+ * run.
512
+ */
513
+ function treeError(message ) {
514
+ return {
515
+ exitCode: 1,
516
+ message,
517
+ changedFiles: [],
518
+ sensitiveKeys: [],
519
+ residualFiles: [],
520
+ residualHits: [],
521
+ warnings: [],
522
+ }
523
+ }
524
+
525
+ /** `value` if it is a plain JSON object (not null, not an array), else an empty base to spread
526
+ * from — so a non-object `repository`/`bugs` (string, array, number, boolean, null, absent) is
527
+ * replaced wholesale instead of producing spread-indexed keys (T13). */
528
+ function asPlainObject(value ) {
529
+ return typeof value === 'object' && value !== null && !Array.isArray(value) ? (value ) : {}
530
+ }
531
+
532
+ /** T4/T11: the manifest's line ending, decided by its FIRST line break only (a file with no line
533
+ * break at all has no first line ending, so it is treated as LF). */
534
+ function firstLineEnding(raw ) {
535
+ const nl = raw.indexOf('\n')
536
+ return nl > 0 && raw[nl - 1] === '\r' ? '\r\n' : '\n'
537
+ }
538
+
539
+ // ─── doc anchors (A2–A5, anchor mode only) ──────────────────────────────────
540
+
541
+ // traceability.md, "Repository-slug grammar", the wildcard forms used to recognise a doc anchor
542
+ // (distinct from T45's stricter grammar for a --repo VALUE): owner has no leading/trailing `-`;
543
+ // name allows `.`, `_` and `-`.
544
+ const OWNER_WILDCARD = `[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?`
545
+ const NAME_WILDCARD = `[A-Za-z0-9._-]+`
546
+
547
+ function escapeForRegExp(literal ) {
548
+ return literal.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
549
+ }
550
+
551
+ /** A doc-anchor form: `prefix` + any repository slug + `suffix`, built as a global regex so every
552
+ * occurrence is found and replaced in one pass. `requireEol` adds the "end of the line" lookahead
553
+ * A3/A4 need (traceability.md: before LF or CRLF, or at the end of the file (optionally after a
554
+ * CR); trailing white space after the form is NOT an end of line, so it is not matched) — it never
555
+ * consumes the line ending itself, so CRLF files keep their line endings untouched (T11). An
556
+ * anchor form may be preceded by anything — approved by the user on 2026-10-14 (recorded in the
557
+ * T-0013 ticket): there is no left-hand boundary check.
558
+ *
559
+ * The slug sits in a capture group — never relied on by `runForkInit` itself (which only ever
560
+ * checks match COUNT via `.match()` and performs a whole-match `.replace()`), but `DOC_ANCHORS`
561
+ * (below, which carries every regex this function builds) is exported so S5's pristine check
562
+ * (upstream-guard.test.ts) can read back exactly the slug fork-init itself recognised, instead of
563
+ * re-deriving it with a second, boundary-unaware regex (Note 8, T-0011). */
564
+ function docAnchorRegex(prefix , suffix , requireEol ) {
565
+ const body = `${escapeForRegExp(prefix)}(${OWNER_WILDCARD}/${NAME_WILDCARD})${escapeForRegExp(suffix)}`
566
+ return new RegExp(requireEol ? `${body}(?=\\r?\\n|\\r?$)` : body, 'g')
567
+ }
568
+
569
+
570
+
571
+
572
+
573
+
574
+
575
+
576
+ /** A2–A5 (traceability.md, "Identity anchors"): every occurrence of its form, in its file(s),
577
+ * carries the new repository slug — whatever repository slug it held. Declared once, in file
578
+ * order, so `runForkInit` below processes each anchor file's anchors together. */
579
+ // Exported so the guard and pristine tests recognise A2-A5 exactly as fork-init itself does
580
+ // (Note 8, T-0011), instead of re-implementing the recognition rule.
581
+ export const DOC_ANCHORS = [
582
+ {
583
+ id: 'A2',
584
+ file: 'README.md',
585
+ regex: docAnchorRegex('https://github.com/', '/actions/workflows/quality-gates.yml', false),
586
+ replacement: (repo) => `https://github.com/${repo}/actions/workflows/quality-gates.yml`,
587
+ },
588
+ {
589
+ id: 'A3',
590
+ file: 'README.md',
591
+ regex: docAnchorRegex('git clone https://github.com/', '.git', true),
592
+ replacement: (repo) => `git clone https://github.com/${repo}.git`,
593
+ },
594
+ {
595
+ id: 'A2',
596
+ file: 'README.en.md',
597
+ regex: docAnchorRegex('https://github.com/', '/actions/workflows/quality-gates.yml', false),
598
+ replacement: (repo) => `https://github.com/${repo}/actions/workflows/quality-gates.yml`,
599
+ },
600
+ {
601
+ id: 'A3',
602
+ file: 'README.en.md',
603
+ regex: docAnchorRegex('git clone https://github.com/', '.git', true),
604
+ replacement: (repo) => `git clone https://github.com/${repo}.git`,
605
+ },
606
+ {
607
+ id: 'A4',
608
+ file: 'CONTRIBUTE.md',
609
+ regex: docAnchorRegex('git remote add upstream https://github.com/', '.git', true),
610
+ replacement: (repo) => `git remote add upstream https://github.com/${repo}.git`,
611
+ },
612
+ {
613
+ id: 'A5',
614
+ file: 'CONTRIBUTE.md',
615
+ regex: docAnchorRegex('(https://github.com/', '/issues)', false),
616
+ replacement: (repo) => `(https://github.com/${repo}/issues)`,
617
+ },
618
+ ]
619
+
620
+ /** Write `content` to `rel` through `<file>.fork-init-tmp` + rename (traceability.md, T55; the
621
+ * root `.gitignore` ignores the temporary name). A temp file left over from an earlier,
622
+ * interrupted run is removed first — `lstatSync`, not `statSync`, so a symbolic link at that name
623
+ * is detected without being followed, and unlinked rather than having its target touched; if that
624
+ * `lstatSync` itself throws (no leftover temp file to begin with), this is the ordinary case and
625
+ * is swallowed, but if the leftover DOES exist and its `unlinkSync` fails, that failure is NOT
626
+ * swallowed — it propagates (becoming an `AnchorWriteError` at the call site) rather than falling
627
+ * through to write the fresh content through whatever that leftover name still points at (e.g. a
628
+ * symlink). The fresh temp file is then written and renamed over the original, and only THEN does
629
+ * the original's permission bits get re-applied to the now-renamed target (best-effort keeps them
630
+ * when the target already existed; not observed by any test). If anything fails after the temp
631
+ * file is (or may be) written, that temp file is removed, best-effort, before the failure
632
+ * propagates to the caller, which turns it into a tree error naming the file. */
633
+ function writeAnchorFile(absPath , content , encoding = 'utf8') {
634
+ mkdirSync(dirname(absPath), { recursive: true })
635
+ const tmpPath = `${absPath}.fork-init-tmp`
636
+ let leftoverTmpExists = false
637
+ try {
638
+ lstatSync(tmpPath)
639
+ leftoverTmpExists = true
640
+ } catch {
641
+ // no leftover temp file to remove before this run's own write
642
+ }
643
+ if (leftoverTmpExists) {
644
+ // Do NOT catch this: a leftover temp file that cannot be removed must not be written through.
645
+ unlinkSync(tmpPath)
646
+ }
647
+ let mode
648
+ try {
649
+ mode = statSync(absPath).mode
650
+ } catch {
651
+ mode = undefined
652
+ }
653
+ try {
654
+ writeFileSync(tmpPath, content, encoding)
655
+ renameSync(tmpPath, absPath)
656
+ if (mode !== undefined) chmodSync(absPath, mode)
657
+ } catch (err) {
658
+ try {
659
+ unlinkSync(tmpPath)
660
+ } catch {
661
+ // best-effort only; the temp file may never have been created, or may already be gone
662
+ }
663
+ throw err
664
+ }
665
+ }
666
+
667
+ /** Carries the repo-relative path a `writeAnchorFile` or `unlinkSync` failure was for, so the
668
+ * single `try`/`catch` around anchor mode's writes (T55) can name it in the returned tree error
669
+ * without every call site repeating that plumbing. The message names only the relative path, the
670
+ * operation (`write` or `remove`, so a `--host cloudflare-pages` removal failure is not reported
671
+ * as a write) and the error code — never Node's own message (which would embed the absolute
672
+ * `projectRoot` path and, inside it, the `.fork-init-tmp` temp-file name) — and keeps the original
673
+ * error as `cause` for anyone inspecting the exception directly. Rebrand mode's writes never throw
674
+ * this (they call `writeFileSync` directly), so an exception there still propagates unwrapped, as
675
+ * today. */
676
+ class AnchorWriteError extends Error {
677
+ rel
678
+ constructor(rel , cause , op = 'write') {
679
+ super(`failed to ${op} ${rel} (${(cause ).code ?? 'unknown'})`, { cause })
680
+ this.rel = rel
681
+ }
682
+ }
683
+
684
+ // Directories/files never touched by any rewrite.
685
+ // The skill trees `.claude`/`.agents` and the Codex config dir `.codex` are excluded because
686
+ // skill identifiers (e.g. `wxl-create`) are structural: they name the skill directories, so a
687
+ // content-only rename would break them without a coordinated path rename (out of scope; see
688
+ // inventory).
689
+ const EXCLUDED_DIRS = new Set(['.git', 'node_modules', '.claude', '.agents', '.codex'])
690
+ const EXCLUDED_FILE = new Set(['pnpm-lock.yaml', 'pnpm-lock.yml'])
691
+ // Repo-relative paths below are always `/`-joined (T57: every path in the result uses `/`, on
692
+ // every OS) — `join`/`sep` would produce `\` on Windows. Node's fs functions accept `/` in a
693
+ // relative path on every OS, so this never needs an OS-specific join for file access either.
694
+ //
695
+ // Path prefixes (matched at a path-segment boundary, so `dist-notes.md` is NOT excluded by
696
+ // `.vitepress/dist`): regenerated build output/cache.
697
+ const EXCLUDED_PATH_FRAGMENTS = ['.vitepress/dist', '.vitepress/cache']
698
+ // This tool's own source + test must not be rewritten — they need the literal `wxl`/`WXL`
699
+ // probes, fixtures, and UPSTREAM_SLUG to keep working on subsequent runs.
700
+ const SELF_EXCLUDE = new Set(['scripts/fork-init.ts', 'tests/unit/scripts/fork-init.test.ts'])
701
+ // package.json is edited structurally, never via text passes.
702
+ const PKG_REL = 'package.json'
703
+ const CONFIG_REL = '.vitepress/config.mts'
704
+ // create-wxl's setup record (0005-create-wxl override of 0004 T51): the tree root's own
705
+ // range.json is never a text-pattern edit in rebrand mode — see the walk below.
706
+ const RANGE_JSON_REL = 'range.json'
707
+ const DEPLOY_TEMPLATE_REL = 'scripts/templates/deploy.yml.template'
708
+ const UPSTREAM_URL = `https://github.com/${UPSTREAM_SLUG}`
709
+
710
+ // ─── A9 (base definition) and the deploy workflow (--host, spec.en.md "Host, base and
711
+ // origin" and traceability.md "Workflow states") ────────────────────────────────────────────
712
+
713
+ /** A9's upstream form, exactly (traceability.md, "Identity anchors"). `--host` resets A9 to this
714
+ * line; the config's required ` base: siteBase,` line is never itself rewritten — only A9 is. */
715
+ export const A9_UPSTREAM_LINE = `const siteBase = process.env.SITE_BASE || '/'`
716
+ const BASE_SITEBASE_LINE = ' base: siteBase,'
717
+ // A9 candidate line (traceability.md: `^const siteBase = .+$`), tolerant of a trailing CRLF `\r`
718
+ // the file's own line ending may have left on this line (T11: anchors are recognised in CRLF
719
+ // files as in LF ones).
720
+ export const A9_CANDIDATE_RE = /^const siteBase = .+?\r?$/
721
+ export const BASE_SITEBASE_RE = /^ {2}base: siteBase,\r?$/
722
+
723
+ /** Every known old `.github/workflows/deploy.yml` output, as the LF-normalised file's SHA-256
724
+ * (traceability.md, "Workflow states"): initially one value, the template at `72327e2`, which the
725
+ * engine wrote verbatim for every argument combination. T27 (0004-S5, an upstream-only test) pins
726
+ * the current template's hash separately and fails until a changed template's old hash has moved
727
+ * into this list. */
728
+ export const KNOWN_OLD_DEPLOY_SHA256 = [
729
+ '0cf52f116082bed48b19462d02b6827123a26cf882a428f841cd6646f73f7276',
730
+ ]
731
+
732
+ /** CRLF normalised to LF — the comparison traceability.md's "Workflow states" and T25 require for
733
+ * every deploy-workflow byte comparison and hash in anchor mode. */
734
+ export function normalizeLf(s ) {
735
+ return s.replace(/\r\n/g, '\n')
736
+ }
737
+
738
+ export function sha256Hex(s ) {
739
+ return createHash('sha256').update(s, 'utf8').digest('hex')
740
+ }
741
+
742
+
743
+
744
+
745
+
746
+
747
+
748
+
749
+
750
+ /** traceability.md, "Workflow states": the file parses with the `yaml` package and the step whose
751
+ * `run` is exactly `pnpm build` has `env.SITE_BASE` exactly
752
+ * `${{ steps.pages.outputs.base_path }}/`; a file that does not parse does not derive it.
753
+ * `contentLf` is expected already LF-normalised (YAML parses either line ending identically, but
754
+ * every caller here already holds the LF-normalised form for the hash/compare above it). */
755
+ function derivesSiteBase(contentLf ) {
756
+ let doc
757
+ try {
758
+ doc = parseYaml(contentLf, { logLevel: 'error' })
759
+ } catch {
760
+ return false
761
+ }
762
+ if (typeof doc !== 'object' || doc === null) return false
763
+ const jobs = (doc ).jobs
764
+ // A parseable but malformed workflow (a null job, a non-array `steps`, a null step) must be
765
+ // judged "does not derive SITE_BASE" rather than throw — every layer below is type-guarded
766
+ // instead of trusting `DeployWorkflowDoc`'s shape.
767
+ if (typeof jobs !== 'object' || jobs === null || Array.isArray(jobs)) return false
768
+ for (const job of Object.values(jobs)) {
769
+ if (typeof job !== 'object' || job === null || Array.isArray(job)) continue
770
+ const steps = (job ).steps
771
+ if (!Array.isArray(steps)) continue
772
+ for (const step of steps) {
773
+ if (typeof step !== 'object' || step === null || Array.isArray(step)) continue
774
+ const typedStep = step
775
+ if (typedStep.run === 'pnpm build') {
776
+ return typedStep.env?.SITE_BASE === '${{ steps.pages.outputs.base_path }}/'
777
+ }
778
+ }
779
+ }
780
+ return false
781
+ }
782
+
783
+ /** T18/AC-H2: the message `--host` or `--base` returns when the config does not have exactly one
784
+ * A9 line and exactly one ` base: siteBase,` line — naming A9, the file, both exact lines to
785
+ * bring over, and upstream's URL. */
786
+ function a9MissingMessage() {
787
+ return (
788
+ `A9: ${CONFIG_REL} must have exactly one line matching \`const siteBase = ...\` and exactly one ` +
789
+ `\`${BASE_SITEBASE_LINE.trim()}\` line — bring both over from upstream (${UPSTREAM_URL}): ` +
790
+ `\`${A9_UPSTREAM_LINE}\` and \`${BASE_SITEBASE_LINE.trim()}\``
791
+ )
792
+ }
793
+
794
+ /** T24/AC-H3: the message `--host` returns when its tree's template cannot be used as given. */
795
+ function templateUnusableMessage(reason ) {
796
+ return `${DEPLOY_TEMPLATE_REL} ${reason} — bring the current template over from upstream (${UPSTREAM_URL})`
797
+ }
798
+
799
+ /** Finds A9's and `base: siteBase`'s candidate lines in a config already split on `\n` (so each
800
+ * entry may still carry a trailing `\r` from a CRLF file, T11). Returns every matching line
801
+ * index — the caller decides what "not exactly one" means for its own message. */
802
+ function findA9Candidates(lines ) {
803
+ const a9Idx = []
804
+ const baseIdx = []
805
+ lines.forEach((line, i) => {
806
+ if (A9_CANDIDATE_RE.test(line)) a9Idx.push(i)
807
+ if (BASE_SITEBASE_RE.test(line)) baseIdx.push(i)
808
+ })
809
+ return { a9Idx, baseIdx }
810
+ }
811
+
812
+ /** The exact A9 line (traceability.md's stated exception) for a resolved base-definition target:
813
+ * `--base <path>` (a validated T21 value), `--base none` (`null`), or `--host` (resetting to the
814
+ * upstream form). Never parses the existing value. */
815
+ function a9LineFor(target ) {
816
+ if ('host' in target) return A9_UPSTREAM_LINE
817
+ const value = target.base === null ? '/' : target.base
818
+ return `const siteBase = '${value}'`
819
+ }
820
+
821
+
822
+
823
+ /** traceability.md, "Workflow states": the existing deploy.yml, classified against the tree's
824
+ * current template output (both LF-normalised). `templateLf` is already LF-normalised; `existingRaw`
825
+ * is the file's raw (possibly CRLF) bytes, or `null` when it does not exist. */
826
+ function classifyDeployWorkflow(existingRaw , templateLf ) {
827
+ if (existingRaw === null) return 'absent'
828
+ const existingLf = normalizeLf(existingRaw)
829
+ if (existingLf === templateLf) return 'current'
830
+ if (KNOWN_OLD_DEPLOY_SHA256.includes(sha256Hex(existingLf))) return 'old'
831
+ return 'other'
832
+ }
833
+
834
+ /**
835
+ * The four runtime-sensitive keys, as EXACT full-token rename rules (structure-aware, so
836
+ * `wxlsh` and other `wxl`-containing tokens are never touched). Applied to original template
837
+ * content BEFORE the slug swap writes user identity, so a `--repo`/`--author` that happens to
838
+ * contain a key token can never be corrupted.
839
+ */
840
+ function sensitiveRenames(lower , upper ) {
841
+ return [
842
+ { key: 'wxl-locale', from: 'wxl-locale', to: `${lower}-locale` },
843
+ { key: 'WXL_VERIFY_RUNTIME', from: 'WXL_VERIFY_RUNTIME', to: `${upper}_VERIFY_RUNTIME` },
844
+ { key: 'tmp/wxl-verify', from: 'tmp/wxl-verify', to: `tmp/${lower}-verify` },
845
+ // release-asset prefix in release.yml, e.g. `wxl-${GITHUB_REF_NAME}.zip`. Built with
846
+ // concatenation so the literal `${` is never parsed as an interpolation here.
847
+ { key: 'release-asset', from: 'wxl-' + '${', to: lower + '-' + '${' },
848
+ ]
849
+ }
850
+
851
+ // ─── A6-A8 (site name, range address): anchor mode only — never reached in rebrand mode,
852
+ // since --rebrand rejects --host/--origin/--site-name (T50) ───────────────────────────────────
853
+
854
+ const PLATFORM_HOME_RELS = ['platform/index.md', 'platform/zh-TW/index.md']
855
+ const SHORT_LINK_REL = '.vitepress/challenge/shortLink.ts'
856
+ /** The upstream range address, SITE_ORIGIN (traceability.md, T81): replacing a value that is not this one
857
+ * warns; a run that starts from this one never does. */
858
+ const UPSTREAM_SITE_ORIGIN = 'https://wxl.browserlab.online'
859
+
860
+ /** A single- or double-quoted JS string literal containing only STANDARD ECMAScript escape
861
+ * sequences, with nothing after the closing quote (traceability.md, "Identity anchors": "single-
862
+ * or double-quoted with standard escapes") — the shape every new A6/A7/A8 value is written as,
863
+ * and the shape an existing A6/A8 value must already be in to be replaced (T9). Returns the
864
+ * decoded value on success, `null` for anything else — including a quote character that is not
865
+ * the literal's own closing quote, an escape outside the nine single-character ones (`' " \ b f
866
+ * n r t v`), `\0` followed by a digit (a legacy octal escape, not the standalone NUL this accepts),
867
+ * `\xHH`/`\uHHHH` with the wrong number of hex digits, an unterminated or empty `\u{...}`, and a
868
+ * `\u{...}` whose value exceeds U+10FFFF. Never throws: every malformed escape — including one
869
+ * `String.fromCodePoint` would otherwise reject with an uncaught `RangeError`, and an unterminated
870
+ * `\u{` that would otherwise loop forever looking for a `}` that is not there — is caught by this
871
+ * function's own bounds/format checks before it ever reaches `String.fromCodePoint`/
872
+ * `String.fromCharCode`, so a malformed anchor value always falls through to the ordinary T9 tree
873
+ * error instead of crashing or hanging the whole run. A line continuation can never occur: every
874
+ * caller matches this against one single line (T9's own candidate-line regexes never span more
875
+ * than one). */
876
+ function parseJsStringLiteral(s ) {
877
+ if (s.length < 2) return null
878
+ const quote = s[0]
879
+ if ((quote !== "'" && quote !== '"') || s[s.length - 1] !== quote) return null
880
+ const inner = s.slice(1, -1)
881
+ let result = ''
882
+ for (let i = 0; i < inner.length; i++) {
883
+ const ch = inner[i]
884
+ if (ch === quote) return null // the literal's own quote character, unescaped: ends it early
885
+ if (ch !== '\\') {
886
+ result += ch
887
+ continue
888
+ }
889
+ i++
890
+ const esc = inner[i]
891
+ switch (esc) {
892
+ case "'":
893
+ case '"':
894
+ case '\\':
895
+ result += esc
896
+ break
897
+ case 'b':
898
+ result += '\b'
899
+ break
900
+ case 'f':
901
+ result += '\f'
902
+ break
903
+ case 'n':
904
+ result += '\n'
905
+ break
906
+ case 'r':
907
+ result += '\r'
908
+ break
909
+ case 't':
910
+ result += '\t'
911
+ break
912
+ case 'v':
913
+ result += '\v'
914
+ break
915
+ case '0':
916
+ if (/[0-9]/.test(inner[i + 1] ?? '')) return null // \0 followed by a digit: a legacy octal escape, not standard
917
+ result += '\0'
918
+ break
919
+ case 'x': {
920
+ const hex = inner.slice(i + 1, i + 3)
921
+ if (!/^[0-9A-Fa-f]{2}$/.test(hex)) return null
922
+ result += String.fromCharCode(parseInt(hex, 16))
923
+ i += 2
924
+ break
925
+ }
926
+ case 'u': {
927
+ if (inner[i + 1] === '{') {
928
+ const end = inner.indexOf('}', i + 2)
929
+ if (end === -1) return null // unterminated \u{...: no closing } to be found at all
930
+ const hex = inner.slice(i + 2, end)
931
+ if (!/^[0-9A-Fa-f]{1,6}$/.test(hex)) return null // empty, or not 1-6 hex digits
932
+ const codePoint = parseInt(hex, 16)
933
+ if (codePoint > 0x10ffff) return null
934
+ result += String.fromCodePoint(codePoint)
935
+ i = end
936
+ } else {
937
+ const hex = inner.slice(i + 1, i + 5)
938
+ if (!/^[0-9A-Fa-f]{4}$/.test(hex)) return null
939
+ result += String.fromCharCode(parseInt(hex, 16))
940
+ i += 4
941
+ }
942
+ break
943
+ }
944
+ default:
945
+ return null // not one of the standard escapes above
946
+ }
947
+ }
948
+ return result
949
+ }
950
+
951
+ export function isJsStringLiteral(s ) {
952
+ return parseJsStringLiteral(s) !== null
953
+ }
954
+
955
+ /** Decodes a JS string literal already known (by its caller, via `isJsStringLiteral`) to parse —
956
+ * used only to compare an EXISTING A8 value against the upstream origin and the new one (T81). */
957
+ export function evalJsStringLiteral(literal ) {
958
+ const result = parseJsStringLiteral(literal)
959
+ if (result === null) {
960
+ // Unreachable in practice: every caller only reaches this after `isJsStringLiteral` (the same
961
+ // parser) has already confirmed `literal` parses.
962
+ throw new Error(`evalJsStringLiteral: not a valid JS string literal: ${JSON.stringify(literal)}`)
963
+ }
964
+ return result
965
+ }
966
+
967
+ /** Encodes `text` (an arbitrary Unicode string — a trimmed `--site-name`, or a validated
968
+ * `--origin`) as the sequence of latin1 "characters" whose codes are its UTF-8 bytes. Splicing
969
+ * the result into a file already decoded with `.toString('latin1')`, then writing the whole
970
+ * result back with the `latin1` encoding, reproduces the correct UTF-8 bytes for `text` while
971
+ * leaving every other byte in the file — including one that is not valid UTF-8 on its own —
972
+ * unchanged (T4). */
973
+ function utf8AsLatin1(text ) {
974
+ return Buffer.from(text, 'utf8').toString('latin1')
975
+ }
976
+
977
+ /** Finds every line index matching `re` — shared by every A6-A8 candidate search below (each
978
+ * anchor needs "exactly one", T9). */
979
+ function findMatchingLines(lines , re ) {
980
+ const idx = []
981
+ lines.forEach((line, i) => {
982
+ if (re.test(line)) idx.push(i)
983
+ })
984
+ return idx
985
+ }
986
+
987
+ // A6 (VitePress config site title, traceability.md "Identity anchors"): candidate lines
988
+ // `^ title: .+,$` (exactly two spaces), CRLF-tolerant; its value is a JS string literal.
989
+ export const A6_CANDIDATE_RE = /^ {2}title: .+,\r?$/
990
+ export const A6_VALUE_RE = /^ {2}title: (.+?),\r?$/
991
+
992
+ /** A6's new line, given the already-JSON-stringified new title literal; keeps the line's own CRLF. */
993
+ function a6LineFor(line , newLiteral ) {
994
+ return ` title: ${newLiteral},${line.endsWith('\r') ? '\r' : ''}`
995
+ }
996
+
997
+ // A8 (short-link module, traceability.md "Identity anchors"): candidate lines
998
+ // `^export const SITE_ORIGIN = .+$`; its value is a JS string literal (a trailing `;` makes the
999
+ // value fail to parse, per the stated exception in traceability.md).
1000
+ export const A8_CANDIDATE_RE = /^export const SITE_ORIGIN = .+\r?$/
1001
+ export const A8_VALUE_RE = /^export const SITE_ORIGIN = (.+?)\r?$/
1002
+
1003
+ function a8LineFor(line , newLiteral ) {
1004
+ return `export const SITE_ORIGIN = ${newLiteral}${line.endsWith('\r') ? '\r' : ''}`
1005
+ }
1006
+
1007
+ // A7 (home-page hero name, traceability.md "Identity anchors"): candidate lines `^ name: .+$`
1008
+ // (exactly two spaces), inside the first `---` frontmatter block, after the column-0 `hero:` line
1009
+ // and before the next column-0 key.
1010
+ // Exported so S5's pristine check (upstream-guard.test.ts) can find A7's candidate lines the same
1011
+ // way fork-init itself does, instead of re-implementing the pattern (Note 8, T-0011).
1012
+ export const A7_CANDIDATE_RE = /^ {2}name: .+\r?$/
1013
+
1014
+ /** The indices of A7's candidate lines in `lines` (traceability.md's scoping rule), or `[]` when
1015
+ * there is no frontmatter, no column-0 `hero:` line inside it, or no candidate under it. The
1016
+ * frontmatter block must start at the file's own first line — a `---`/`---` pair that merely
1017
+ * looks like frontmatter further down the file (after other prose) is not one (T9): unlike a
1018
+ * Markdown parser, this scan never backtracks to "the first `---` anywhere", so a body divider
1019
+ * can never be mistaken for the real frontmatter fence. */
1020
+ export function findA7Candidates(lines ) {
1021
+ const stripped = lines.map((l) => l.replace(/\r$/, ''))
1022
+ if (stripped[0] !== '---') return []
1023
+ const isDashes = (s ) => s === '---'
1024
+ const fmEnd = stripped.findIndex((s, i) => i > 0 && isDashes(s))
1025
+ if (fmEnd === -1) return []
1026
+ let heroIdx = -1
1027
+ for (let i = 1; i < fmEnd; i++) {
1028
+ if (stripped[i] === 'hero:') {
1029
+ heroIdx = i
1030
+ break
1031
+ }
1032
+ }
1033
+ if (heroIdx === -1) return []
1034
+ const candidates = []
1035
+ for (let i = heroIdx + 1; i < fmEnd; i++) {
1036
+ if (/^\S/.test(stripped[i])) break // the next column-0 key ends hero's scope
1037
+ if (A7_CANDIDATE_RE.test(lines[i])) candidates.push(i)
1038
+ }
1039
+ return candidates
1040
+ }
1041
+
1042
+ /** Parses `line` as one YAML scalar shaped the way A7's existing value must already be in to be
1043
+ * replaced (traceability.md: parses as a one-key map whose value is a string, not a block scalar,
1044
+ * with nothing after it on the line — a trailing `# comment` is unparsable, caught here via the
1045
+ * `yaml` package's own comment tracking) (T9), returning the decoded string value, or `null` when
1046
+ * `line` does not have this shape. Exported so S5's pristine check (upstream-guard.test.ts)
1047
+ * compares A7's actual decoded value against the YAML scalar rule the engine uses, instead of
1048
+ * decoding it with the unrelated JS-string-literal rule A6/A8 use (Note 8, T-0011). */
1049
+ export function parseA7ScalarValue(line ) {
1050
+ const content = line.replace(/\r$/, '').trim()
1051
+ let doc
1052
+ try {
1053
+ doc = parseYamlDocument(content, { logLevel: 'silent' })
1054
+ } catch {
1055
+ return null
1056
+ }
1057
+ if (doc.errors.length > 0) return null
1058
+ const map = doc.contents
1059
+ if (!isMap(map) || map.items.length !== 1) return null
1060
+ const pair = map.items[0]
1061
+ if (!isPair(pair) || !isScalar(pair.key) || pair.key.value !== 'name') return null
1062
+ const val = pair.value
1063
+ if (!isScalar(val)) return null
1064
+ if (val.type === 'BLOCK_LITERAL' || val.type === 'BLOCK_FOLDED') return null
1065
+ if (val.comment !== undefined && val.comment !== null) return null
1066
+ return typeof val.value === 'string' ? val.value : null
1067
+ }
1068
+
1069
+ /** Whether `line` is one YAML scalar shaped the way A7's existing value must already be in to be
1070
+ * replaced (T9). The decoded value itself is never used here (A7's new value always comes from
1071
+ * `--site-name`, never from the old line) — only whether `line` has this shape at all. */
1072
+ function isA7ScalarValue(line ) {
1073
+ return parseA7ScalarValue(line) !== null
1074
+ }
1075
+
1076
+ /** A7's new line, given the already-JSON-stringified new name literal; keeps the line's own CRLF. */
1077
+ function a7LineFor(line , newLiteral ) {
1078
+ return ` name: ${newLiteral}${line.endsWith('\r') ? '\r' : ''}`
1079
+ }
1080
+
1081
+ /** `https://<owner, lowercased>.github.io[/<name as given>]` (traceability.md, T36-T37): the
1082
+ * derived range address for `--host github-pages` without `--origin`, omitting the path for
1083
+ * the user site `<owner>.github.io` (compared case-insensitively). `repo` must already have passed
1084
+ * parseForkInitArgs's `owner/name` check (T45); create-wxl calls it only after a successful parse. */
1085
+ export function deriveGithubPagesOrigin(repo ) {
1086
+ const [owner, name] = repo.split('/')
1087
+ const ownerLower = owner.toLowerCase()
1088
+ if (name.toLowerCase() === `${ownerLower}.github.io`) return `https://${ownerLower}.github.io`
1089
+ return `https://${ownerLower}.github.io/${name}`
1090
+ }
1091
+
1092
+ // ─── core ────────────────────────────────────────────────────────────────────
1093
+
1094
+ export function runForkInit(
1095
+ args ,
1096
+ opts ,
1097
+ ) {
1098
+ const root = opts.projectRoot
1099
+ const changedFiles = []
1100
+ const warnings = []
1101
+ const sensitiveMap = new Map ()
1102
+ const residualHits = []
1103
+
1104
+ const abs = (rel ) => join(root, rel)
1105
+ const readIf = (rel ) =>
1106
+ existsSync(abs(rel)) ? readFileSync(abs(rel), 'utf8') : null
1107
+
1108
+ // Rebrand mode writes in place and fails as today; anchor mode writes through
1109
+ // `<file>.fork-init-tmp` + rename (T55), so a write failure partway through is a tree error
1110
+ // (caught by the caller below), never a half-written file. `encoding` defaults to `utf8` (the
1111
+ // manifest and the config are both always-valid-UTF-8 sources); doc-anchor files pass `latin1`
1112
+ // so an invalid UTF-8 byte outside any anchor form round-trips unchanged (T1).
1113
+ const applyEdit = (rel , next , prev , encoding = 'utf8') => {
1114
+ if (next === prev) return
1115
+ changedFiles.push(rel)
1116
+ if (args.dryRun) return
1117
+ if (args.rebrand) {
1118
+ writeFileSync(abs(rel), next, encoding)
1119
+ return
1120
+ }
1121
+ try {
1122
+ writeAnchorFile(abs(rel), next, encoding)
1123
+ } catch (err) {
1124
+ throw new AnchorWriteError(rel, err )
1125
+ }
1126
+ }
1127
+
1128
+ // The env-var / JS-identifier seed for the WXL_VERIFY_RUNTIME rename: uppercased, with any
1129
+ // `-`/`.` (legal in the brand short-name) collapsed to `_` so `<UPPER>_VERIFY_RUNTIME` is
1130
+ // always a valid identifier (e.g. `--rebrand my-ctf` -> `MY_CTF_VERIFY_RUNTIME`, not the
1131
+ // syntax-breaking `MY-CTF_VERIFY_RUNTIME`). --rebrand is required to start with a letter.
1132
+ const upperEnv = args.rebrand ? args.rebrand.toUpperCase().replace(/[^A-Z0-9_]/g, '_') : ''
1133
+ const renames = args.rebrand ? sensitiveRenames(args.rebrand, upperEnv) : []
1134
+ const recordSensitive = (key , rel ) => {
1135
+ if (!sensitiveMap.has(key)) sensitiveMap.set(key, new Set())
1136
+ sensitiveMap.get(key) .add(rel)
1137
+ }
1138
+ // Honest residual inventory: scan the ACTUAL final content for any case-insensitive `wxl`,
1139
+ // with NO masking/stripping. Because the tool no longer renames the general brand token,
1140
+ // every remaining `wxl` is genuinely un-handled and must be surfaced — brand prose, the
1141
+ // `wxlsh` subsystem, skill/spec dir refs, and even the user's own `--repo`/`--author` slug
1142
+ // (intentional identity, called out in the warning). Never subtracting anything is what
1143
+ // guarantees a bare `--author wxl`, or a slug that is a substring of another token, can never
1144
+ // mask real residual work (a subtraction-based mask silently hid such cases).
1145
+ const scanResidual = (rel , finalContent ) => {
1146
+ const lines = finalContent.split('\n')
1147
+ for (let i = 0; i < lines.length; i++) {
1148
+ if (/wxl/i.test(lines[i])) {
1149
+ residualHits.push({ file: rel, line: i + 1, text: lines[i].trim().slice(0, 200) })
1150
+ }
1151
+ }
1152
+ }
1153
+ // Rebrand-mode-only text transform for one file (anchor mode never calls this): sensitive-key
1154
+ // rename (on original content) -> upstream-slug swap. Returns the keys hit so the caller
1155
+ // records them ONLY when it actually commits this content.
1156
+ const transformText = (rel , raw ) => {
1157
+ let next = raw
1158
+ const keysHit = []
1159
+ for (const { key, from, to } of renames) {
1160
+ if (next.includes(from)) {
1161
+ next = next.split(from).join(to)
1162
+ keysHit.push(key)
1163
+ }
1164
+ }
1165
+ next = next.split(UPSTREAM_SLUG).join(args.repo)
1166
+ // CONFIG_REL's A9 line is handled separately, below (T-0014): it is an exact-line replacement,
1167
+ // never a text-pattern edit, and needs the same validation (T18) in both modes.
1168
+ return { next, keysHit }
1169
+ }
1170
+
1171
+ const DEPLOY_REL = '.github/workflows/deploy.yml'
1172
+
1173
+ // ── A1: the manifest, a tree error (T12) when missing, BOM-prefixed, unparsable, or not a
1174
+ // JSON object. Checked before anything is written, in both modes (T53, T54). ──
1175
+ const pkgRaw = readIf(PKG_REL)
1176
+ if (pkgRaw === null) {
1177
+ return treeError(`A1: ${PKG_REL} is missing`)
1178
+ }
1179
+ // A leading byte-order mark is not valid JSON syntax, so JSON.parse below would always fail on
1180
+ // it too — checked first so the message names the actual cause instead of the generic one.
1181
+ if (pkgRaw.charCodeAt(0) === 0xfeff) {
1182
+ return treeError(`A1: ${PKG_REL} starts with a byte-order mark`)
1183
+ }
1184
+ let pkg
1185
+ try {
1186
+ pkg = JSON.parse(pkgRaw)
1187
+ } catch {
1188
+ return treeError(`A1: ${PKG_REL} does not parse as JSON`)
1189
+ }
1190
+ if (typeof pkg !== 'object' || pkg === null || Array.isArray(pkg)) {
1191
+ return treeError(`A1: ${PKG_REL} must be a JSON object`)
1192
+ }
1193
+
1194
+ try {
1195
+ // ── A1: package.json identity (structured), both modes ──
1196
+ const pkgObj = pkg
1197
+ const existingRepository = pkgObj.repository
1198
+ // T15 "first conversion" (traceability.md): before any edit, `repository` is an object whose
1199
+ // `url` is EXACTLY upstream's `git+https://...` URL (case-sensitive) — checked on the
1200
+ // original value, before the T13 replacement below turns a non-object `repository` into one.
1201
+ const firstConversion =
1202
+ typeof existingRepository === 'object' &&
1203
+ existingRepository !== null &&
1204
+ !Array.isArray(existingRepository) &&
1205
+ (existingRepository ).url === `git+https://github.com/${UPSTREAM_SLUG}.git`
1206
+
1207
+ pkgObj.author = args.author
1208
+ // T13: `repository`/`bugs` that is not a plain object is replaced wholesale; a plain object
1209
+ // keeps its other keys (only `repository.type`/`url` and `bugs.url` are set).
1210
+ pkgObj.repository = { ...asPlainObject(pkgObj.repository), type: 'git', url: `git+https://github.com/${args.repo}.git` }
1211
+ pkgObj.bugs = { ...asPlainObject(pkgObj.bugs), url: `https://github.com/${args.repo}/issues` }
1212
+ pkgObj.homepage = `https://github.com/${args.repo}#readme`
1213
+ // T15: rebrand mode resets `version`/`license` unconditionally (today's behaviour); anchor
1214
+ // mode only on first conversion, so a teacher's later manual version bump survives every
1215
+ // subsequent run.
1216
+ if (args.rebrand || firstConversion) {
1217
+ pkgObj.version = '0.1.0'
1218
+ pkgObj.license = 'ECL-2.0'
1219
+ }
1220
+ if (args.rebrand) {
1221
+ pkgObj.name = args.name ?? args.rebrand
1222
+ if (args.description) pkgObj.description = args.description
1223
+ } else {
1224
+ // T14: anchor mode trims `--name`/`--description`; empty after trim, or not given, leaves
1225
+ // the manifest's current value alone (rebrand mode keeps today's untrimmed handling above).
1226
+ if (args.name !== undefined) {
1227
+ const trimmedName = args.name.trim()
1228
+ if (trimmedName !== '') pkgObj.name = trimmedName
1229
+ }
1230
+ if (args.description !== undefined) {
1231
+ const trimmedDescription = args.description.trim()
1232
+ if (trimmedDescription !== '') pkgObj.description = trimmedDescription
1233
+ }
1234
+ }
1235
+ const pkgNextLf = JSON.stringify(pkgObj, null, 2) + '\n'
1236
+ // T4/T11: re-serialised in the manifest's own line-ending style — CRLF when its FIRST line
1237
+ // ending is CRLF, LF otherwise (a file with no newline at all has no first line ending, so it
1238
+ // gets LF) — but only in anchor mode; rebrand mode keeps writing LF, as today.
1239
+ const pkgNext = !args.rebrand && firstLineEnding(pkgRaw) === '\r\n' ? pkgNextLf.replace(/\n/g, '\r\n') : pkgNextLf
1240
+ // Rebrand mode's own write of this (as today, immediate) is deferred to below the A9/template
1241
+ // checks (T18/T24), which must see nothing written yet (T54) — see "compute before write"
1242
+ // below. Anchor mode already deferred its own write to the end of the A-mode branch.
1243
+
1244
+ // Read the deploy template up front: it decides whether the deploy target is handled by the
1245
+ // dedicated block below. If the template is absent, an existing deploy.yml in rebrand mode's
1246
+ // walk is still inventoried like any active file (never silently omitted -> no false "clean").
1247
+ const tmplRaw = readIf(DEPLOY_TEMPLATE_REL)
1248
+ // Read any existing deploy.yml here too, alongside the template — before either mode's write
1249
+ // phase begins — so the compute-before-write property (anchor mode's own goal below) also
1250
+ // covers the one read the deploy block further down used to make on its own, after other
1251
+ // files had already been written.
1252
+ const existingDeploy = readIf(DEPLOY_REL)
1253
+
1254
+ // ── A9 (base definition), both modes: read and validated here, before any write in this run
1255
+ // (T54), whenever --base or --host is given (T18). The read is shared by the A/B mode
1256
+ // branches below (which only ever apply the already-computed edit) and by the no-`--host`
1257
+ // T26 check at the end of this function (which needs the ORIGINAL line, before this run's
1258
+ // own edit, when neither --base nor --host touches it). ──
1259
+ const cfgRawShared = readIf(CONFIG_REL)
1260
+ const cfgLines = cfgRawShared !== null ? cfgRawShared.split('\n') : null
1261
+ const cfgCandidates = cfgLines ? findA9Candidates(cfgLines) : { a9Idx: [] , baseIdx: [] }
1262
+ const originalA9Line =
1263
+ cfgLines && cfgCandidates.a9Idx.length === 1 ? cfgLines[cfgCandidates.a9Idx[0]].replace(/\r$/, '') : null
1264
+
1265
+ // idx/hadCR/newA9Line, when set, describe the single edit A9 needs — applied, below, to
1266
+ // whichever text (anchor mode's untouched read, or rebrand mode's already slug-swapped text)
1267
+ // ends up being written for CONFIG_REL. Neither A9's own line nor ` base: siteBase,` ever
1268
+ // contains the upstream repository slug or a rebrand key, so this index and content stay valid
1269
+ // whichever of those two texts they are applied to (rebrand's slug/key swap never changes the
1270
+ // line count: none of its `from`/`to` values contain a newline).
1271
+ let a9Idx = -1
1272
+ let a9HadCR = false
1273
+ let newA9Line = null
1274
+ // Whether A9 ends this run in the upstream form (T26, below): true under --host (which always
1275
+ // resets it there); under --base it is always a literal, never upstream's form; without
1276
+ // either, A9 is untouched, so it ends upstream iff it already started there.
1277
+ const a9EndsUpstream =
1278
+ args.host !== undefined ? true : args.base !== undefined ? false : originalA9Line === A9_UPSTREAM_LINE
1279
+
1280
+ if (args.base !== undefined || args.host !== undefined) {
1281
+ if (cfgLines === null || cfgCandidates.a9Idx.length !== 1 || cfgCandidates.baseIdx.length !== 1) {
1282
+ return treeError(a9MissingMessage())
1283
+ }
1284
+ a9Idx = cfgCandidates.a9Idx[0]
1285
+ a9HadCR = cfgLines[a9Idx].endsWith('\r')
1286
+ newA9Line = args.host !== undefined ? a9LineFor({ host: true }) : a9LineFor({ base: args.base })
1287
+ if (originalA9Line !== newA9Line) {
1288
+ if (args.host !== undefined && originalA9Line !== A9_UPSTREAM_LINE) {
1289
+ // T82/AC-H2: resetting a literal A9 under --host warns with the old line and the
1290
+ // upstream form it is about to become.
1291
+ warnings.push(
1292
+ `A9: ${CONFIG_REL} line \`${originalA9Line}\` reset to the upstream form \`${A9_UPSTREAM_LINE}\` — ` +
1293
+ `--host decides the base from deployment, not from source.`,
1294
+ )
1295
+ }
1296
+ } else {
1297
+ newA9Line = null // already the target form: no edit, no T82 warning
1298
+ }
1299
+ }
1300
+
1301
+ /** Applies the A9 edit computed above (if any) to `text`, which must have the same line count
1302
+ * and the same content at `a9Idx` as `cfgRawShared` (true of both anchor mode's untouched read
1303
+ * and rebrand mode's slug/key-swapped text — see the comment above). */
1304
+ const applyA9Edit = (text ) => {
1305
+ if (newA9Line === null) return text
1306
+ const lines = text.split('\n')
1307
+ lines[a9Idx] = newA9Line + (a9HadCR ? '\r' : '')
1308
+ return lines.join('\n')
1309
+ }
1310
+
1311
+ // ── the deploy template, both modes, whenever --host is given (T24/AC-H3): the tree's
1312
+ // template must exist, must not itself be a known old output, and (GitHub Pages only) must
1313
+ // derive SITE_BASE — checked here, before any write in this run (T54). ──
1314
+ let templateLf = null
1315
+ if (args.host !== undefined) {
1316
+ if (tmplRaw === null) return treeError(templateUnusableMessage('is missing'))
1317
+ templateLf = normalizeLf(tmplRaw)
1318
+ if (KNOWN_OLD_DEPLOY_SHA256.includes(sha256Hex(templateLf))) {
1319
+ return treeError(templateUnusableMessage('is an old, already-superseded version'))
1320
+ }
1321
+ if (args.host === 'github-pages' && !derivesSiteBase(templateLf)) {
1322
+ return treeError(templateUnusableMessage('does not derive SITE_BASE (needed for --host github-pages)'))
1323
+ }
1324
+ }
1325
+
1326
+ if (args.rebrand) {
1327
+ // Rebrand mode writes the manifest immediately, as today — deferred to here (past the
1328
+ // A9/template checks above) so compute-before-write (T54) holds for them too.
1329
+ applyEdit(PKG_REL, pkgNext, pkgRaw)
1330
+ scanResidual(PKG_REL, pkgNext)
1331
+ // ── B mode: today's tree-wide swap, kept exactly as before (0004 T51), except CONFIG_REL,
1332
+ // whose A9 line is never a text-pattern edit (handled explicitly, below, with the same A9
1333
+ // logic anchor mode uses), and the tree root's own range.json (0005-create-wxl override of
1334
+ // 0004 T51, C61), which is only scanned, never rewritten, below. ──
1335
+ for (const rel of walkTextFiles(root)) {
1336
+ if (rel === PKG_REL) continue // structured above
1337
+ if (rel === CONFIG_REL) continue // handled explicitly, below
1338
+ if (rel === DEPLOY_REL && tmplRaw !== null) continue // handled with the deploy copy below
1339
+ const raw = readIf(rel)
1340
+ if (raw === null) continue
1341
+ if (rel === RANGE_JSON_REL) {
1342
+ // 0005-create-wxl override (0004 T51, C61): the tree root's own setup record is never
1343
+ // rewritten here — only scanned, so the honest inventory still reports it when it
1344
+ // carries the upstream slug. A nested `sub/range.json` is not this exact root-relative
1345
+ // path, so it falls through to the ordinary swap below, unaffected.
1346
+ scanResidual(rel, raw)
1347
+ continue
1348
+ }
1349
+ const { next, keysHit } = transformText(rel, raw)
1350
+ applyEdit(rel, next, raw)
1351
+ for (const k of keysHit) recordSensitive(k, rel)
1352
+ scanResidual(rel, next)
1353
+ }
1354
+ if (cfgRawShared !== null) {
1355
+ const { next: cfgSwapped, keysHit } = transformText(CONFIG_REL, cfgRawShared)
1356
+ const cfgNext = applyA9Edit(cfgSwapped)
1357
+ applyEdit(CONFIG_REL, cfgNext, cfgRawShared)
1358
+ for (const k of keysHit) recordSensitive(k, CONFIG_REL)
1359
+ scanResidual(CONFIG_REL, cfgNext)
1360
+ }
1361
+ } else {
1362
+ // ── A mode: only the identity anchors (A1–A9, traceability.md) and the workflow — the doc
1363
+ // anchors (A2–A5), the config's A9 line, A6 (site title) and A7 (home-page hero name) when
1364
+ // --site-name is given, and A8 (range address) when --origin is given or derived. No
1365
+ // tree walk — every file outside A1–A9 and the workflow is byte-identical (T3).
1366
+ //
1367
+ // Every CHECKED read/content computation below happens before anything is written —
1368
+ // including the manifest, whose own write was deferred above, and the deploy workflow's
1369
+ // own existing-file read, moved up above this branch — so the write phase that follows is
1370
+ // the only place a write failure (T55) can still occur. A read failure that is not one of
1371
+ // this function's own checks (e.g. a doc-anchor path that is a directory) is not caught
1372
+ // here: it propagates as an uncaught exception, same as it did before this restructuring
1373
+ // — T2 governs only "the file does not exist", never any other read failure. ──
1374
+ const anchorsByFile = new Map ()
1375
+ for (const anchor of DOC_ANCHORS) {
1376
+ if (!anchorsByFile.has(anchor.file)) anchorsByFile.set(anchor.file, [])
1377
+ anchorsByFile.get(anchor.file) .push(anchor)
1378
+ }
1379
+
1380
+ // ── compute phase ──
1381
+ const docWrites = []
1382
+ for (const [file, anchors] of anchorsByFile) {
1383
+ // Read as a Buffer, decoded latin1 (not utf8): a byte-for-byte reversible mapping, so any
1384
+ // invalid UTF-8 outside the anchor forms round-trips unchanged — the forms themselves are
1385
+ // ASCII, which latin1 decodes identically to utf8, so the matching below is unaffected. A
1386
+ // read failure other than "the file does not exist" (e.g. the path is a directory) is NOT
1387
+ // caught here (T2): it throws, uncaught, before any write in this run — same as every
1388
+ // other anchor file's read failure, which this ticket leaves untouched.
1389
+ const raw = existsSync(abs(file)) ? readFileSync(abs(file)).toString('latin1') : null
1390
+ if (raw === null) {
1391
+ for (const anchor of anchors) warnings.push(`${anchor.id}: ${file} is missing`)
1392
+ continue
1393
+ }
1394
+ let next = raw
1395
+ for (const anchor of anchors) {
1396
+ const matches = next.match(anchor.regex)
1397
+ if (!matches || matches.length === 0) {
1398
+ warnings.push(`${anchor.id}: no occurrence of its form in ${file}`)
1399
+ } else {
1400
+ next = next.replace(anchor.regex, () => anchor.replacement(args.repo))
1401
+ }
1402
+ }
1403
+ docWrites.push({ file, next, raw })
1404
+ }
1405
+
1406
+ // ── A6 (site title), whenever --site-name is given (T39-T43/AC-N1-AC-N3): validated here
1407
+ // (candidate count, value shape), before any write in this run (T54), using the shared
1408
+ // utf8 read already in hand for A9 — ASCII-only structure, so utf8's possible replacement
1409
+ // characters for an invalid byte elsewhere in the file never affect this check. The actual
1410
+ // new content is built below, from a byte-preserving (latin1) read, so a byte outside this
1411
+ // line — including one that is not valid UTF-8 on its own — round-trips unchanged (T4);
1412
+ // only `a6Idx`, the validated line INDEX (identical under either decoding, since neither
1413
+ // adds nor removes a line), crosses from this check into that later construction. ──
1414
+ let a6Idx = null
1415
+ if (args.siteName !== undefined) {
1416
+ if (cfgLines === null) return treeError(`A6: ${CONFIG_REL} is missing`)
1417
+ const a6Candidates = findMatchingLines(cfgLines, A6_CANDIDATE_RE)
1418
+ if (a6Candidates.length !== 1) {
1419
+ return treeError(`A6: ${CONFIG_REL} must have exactly one line matching \` title: ...,\``)
1420
+ }
1421
+ a6Idx = a6Candidates[0]
1422
+ const a6ValueMatch = A6_VALUE_RE.exec(cfgLines[a6Idx])
1423
+ if (!a6ValueMatch || !isJsStringLiteral(a6ValueMatch[1])) {
1424
+ return treeError(`A6: ${CONFIG_REL} existing title does not parse as a JS string literal`)
1425
+ }
1426
+ }
1427
+
1428
+ // ── A7 (home-page hero name), whenever --site-name is given: read byte-preserving (latin1)
1429
+ // from the start (T4) — unlike config.mts, a home page is never shared with A9/rebrand, so
1430
+ // there is no separate utf8 read to keep in step with. ──
1431
+ const homeWrites = []
1432
+ if (args.siteName !== undefined) {
1433
+ const newNameLiteral = utf8AsLatin1(JSON.stringify(args.siteName))
1434
+ for (const file of PLATFORM_HOME_RELS) {
1435
+ const raw = existsSync(abs(file)) ? readFileSync(abs(file)).toString('latin1') : null
1436
+ if (raw === null) return treeError(`A7: ${file} is missing`)
1437
+ const lines = raw.split('\n')
1438
+ const candidates = findA7Candidates(lines)
1439
+ if (candidates.length !== 1) {
1440
+ return treeError(`A7: ${file} must have exactly one \` name: ...\` line under \`hero:\``)
1441
+ }
1442
+ const idx = candidates[0]
1443
+ const line = lines[idx]
1444
+ if (!isA7ScalarValue(line)) {
1445
+ return treeError(`A7: ${file} existing hero name does not parse as one YAML scalar`)
1446
+ }
1447
+ const nextLines = lines.slice()
1448
+ nextLines[idx] = a7LineFor(line, newNameLiteral)
1449
+ homeWrites.push({ file, next: nextLines.join('\n'), raw })
1450
+ }
1451
+ }
1452
+
1453
+ // ── A8 (range address): --origin, or derived from --host github-pages without
1454
+ // --origin (T32-T38/T81/AC-O1-AC-O2). Read byte-preserving (latin1) (T4); the write
1455
+ // decision compares the WHOLE LINE about to be written against the existing one, not just
1456
+ // the decoded value (T8: a same-VALUE line in a different quote style is still rewritten
1457
+ // into the canonical JSON-string-literal form) — the T81 warning, separately, still
1458
+ // compares the decoded values (an equal value never warns, regardless of quoting). ──
1459
+ const finalOrigin =
1460
+ args.origin !== undefined ? args.origin : args.host === 'github-pages' ? deriveGithubPagesOrigin(args.repo) : undefined
1461
+ let shortLinkNext = null
1462
+ let shortLinkRaw = null
1463
+ if (finalOrigin !== undefined) {
1464
+ shortLinkRaw = existsSync(abs(SHORT_LINK_REL)) ? readFileSync(abs(SHORT_LINK_REL)).toString('latin1') : null
1465
+ if (shortLinkRaw === null) return treeError(`A8: ${SHORT_LINK_REL} is missing`)
1466
+ const lines = shortLinkRaw.split('\n')
1467
+ const candidates = findMatchingLines(lines, A8_CANDIDATE_RE)
1468
+ if (candidates.length !== 1) {
1469
+ return treeError(`A8: ${SHORT_LINK_REL} must have exactly one line matching \`export const SITE_ORIGIN = ...\``)
1470
+ }
1471
+ const idx = candidates[0]
1472
+ const line = lines[idx]
1473
+ const valueMatch = A8_VALUE_RE.exec(line)
1474
+ if (!valueMatch || !isJsStringLiteral(valueMatch[1])) {
1475
+ return treeError(`A8: ${SHORT_LINK_REL} existing value does not parse as a JS string literal`)
1476
+ }
1477
+ const newLine = a8LineFor(line, utf8AsLatin1(JSON.stringify(finalOrigin)))
1478
+ if (line !== newLine) {
1479
+ const oldValue = evalJsStringLiteral(valueMatch[1])
1480
+ if (oldValue !== finalOrigin && oldValue !== UPSTREAM_SITE_ORIGIN) {
1481
+ // T81/AC-O2: replacing a range address that is not upstream's warns — the engine
1482
+ // keeps no state, so it cannot otherwise tell a fork's own custom domain from one that
1483
+ // should be silently kept. Compares the DECODED value: a line rewritten only to
1484
+ // normalise its quoting (the same value, differently quoted) never warns.
1485
+ warnings.push(
1486
+ `A8: ${SHORT_LINK_REL} changes from ${JSON.stringify(oldValue)} to ${JSON.stringify(finalOrigin)} — ` +
1487
+ `a custom range address (SITE_ORIGIN, the origin of every copied link) is replaced only when ` +
1488
+ `--origin (or a derived GitHub Pages origin) says so.`,
1489
+ )
1490
+ }
1491
+ const nextLines = lines.slice()
1492
+ nextLines[idx] = newLine
1493
+ shortLinkNext = nextLines.join('\n')
1494
+ } // else: already byte-identical to the line that would be written — no write, no warning
1495
+ }
1496
+
1497
+ // The config's final content: read byte-preserving (latin1) (T4) so a byte outside the A6/A9
1498
+ // lines — including one that is not valid UTF-8 on its own — round-trips unchanged; A9's own
1499
+ // edit (`applyA9Edit`, shared with rebrand mode) only ever touches pure-ASCII lines, so
1500
+ // reusing it here on this latin1-decoded text is safe (same line count, same indices as the
1501
+ // utf8-decoded `cfgLines` used for validation above).
1502
+ let cfgNext = null
1503
+ let cfgRawLatin1 = null
1504
+ if (cfgRawShared !== null) {
1505
+ cfgRawLatin1 = readFileSync(abs(CONFIG_REL)).toString('latin1')
1506
+ let cfgLatin1Lines = cfgRawLatin1.split('\n')
1507
+ if (a6Idx !== null) {
1508
+ cfgLatin1Lines = cfgLatin1Lines.slice()
1509
+ cfgLatin1Lines[a6Idx] = a6LineFor(cfgLatin1Lines[a6Idx], utf8AsLatin1(JSON.stringify(args.siteName )))
1510
+ }
1511
+ cfgNext = applyA9Edit(cfgLatin1Lines.join('\n'))
1512
+ }
1513
+
1514
+ // ── write phase: the config (a subdirectory file) first, then the manifest (root) whose
1515
+ // write was deferred above, then both home pages and the short-link module (also
1516
+ // subdirectory files), then the doc anchors (root) — so a root-directory write failure can
1517
+ // still be observed, in a test, to leave an already-written subdirectory file in place
1518
+ // (traceability.md, T55: "files already written stay"). ──
1519
+ if (cfgRawLatin1 !== null) applyEdit(CONFIG_REL, cfgNext , cfgRawLatin1, 'latin1')
1520
+ for (const { file, next, raw } of homeWrites) applyEdit(file, next, raw, 'latin1')
1521
+ if (shortLinkNext !== null) applyEdit(SHORT_LINK_REL, shortLinkNext, shortLinkRaw , 'latin1')
1522
+ applyEdit(PKG_REL, pkgNext, pkgRaw)
1523
+ for (const { file, next, raw } of docWrites) applyEdit(file, next, raw, 'latin1')
1524
+ }
1525
+
1526
+ // The tag-rule warning (traceability.md, "Tag-rule warning"): today's warning on installing
1527
+ // the workflow, pushed whenever this run installs it (absent -> installed), in GH and
1528
+ // no-host runs — never on a replace (GH's "old" branch) and never under --host
1529
+ // cloudflare-pages in the non-rebrand branches below, which never install the workflow there.
1530
+ // Rebrand mode's own install, further below, does not branch on --host at all — but T50
1531
+ // rejects --rebrand together with --host, so args.host is always undefined whenever
1532
+ // args.rebrand is set; this warning is pushed on an absent workflow regardless, since rebrand
1533
+ // mode never reaches a Cloudflare-Pages-shaped run in the first place.
1534
+ const pushTagRuleWarning = () => {
1535
+ warnings.push(
1536
+ `${DEPLOY_REL} deploys on a \`v*\` tag push. GitHub's \`github-pages\` environment authorises only the default branch by default, so add a Tag rule \`v*\` under Settings > Environments > github-pages > "Deployment branches and tags" (keep the existing \`main\` rule — workflow_dispatch needs it) before cutting a release.`,
1537
+ )
1538
+ }
1539
+ // `finalDeployRaw` tracks whatever ends up on disk for DEPLOY_REL after this block (its own
1540
+ // write respecting --dry-run below) — read by the no-`--host` T26 check further down, which
1541
+ // needs the workflow's state AFTER this run, including one just installed this same run.
1542
+ let finalDeployRaw = existingDeploy
1543
+
1544
+ if (args.rebrand) {
1545
+ // ── rebrand mode (T51): compared "as today" — raw bytes, no LF normalisation, never
1546
+ // replaced. T50 rejects --rebrand together with --host (among --origin and --site-name),
1547
+ // so args.host is always undefined here; the A9 reset and the template checks above (which
1548
+ // branch on args.host) are therefore exercised, under rebrand, only via their --base path,
1549
+ // never their --host one. This install step itself does not branch on --host either — it
1550
+ // installs the template whenever absent — but since args.host can never be set alongside
1551
+ // args.rebrand, that absence of a branch is never actually exercised against a Cloudflare
1552
+ // Pages run. ──
1553
+ if (tmplRaw) {
1554
+ const { next: deployContent, keysHit: deployKeysHit } = transformText(DEPLOY_REL, tmplRaw)
1555
+ if (existingDeploy === null) {
1556
+ changedFiles.push(DEPLOY_REL)
1557
+ if (!args.dryRun) {
1558
+ mkdirSync(dirname(abs(DEPLOY_REL)), { recursive: true })
1559
+ writeFileSync(abs(DEPLOY_REL), deployContent)
1560
+ }
1561
+ pushTagRuleWarning()
1562
+ for (const k of deployKeysHit) recordSensitive(k, DEPLOY_REL)
1563
+ scanResidual(DEPLOY_REL, deployContent)
1564
+ finalDeployRaw = deployContent
1565
+ } else {
1566
+ if (existingDeploy !== deployContent) {
1567
+ warnings.push(
1568
+ `${DEPLOY_REL} already exists with different content — left untouched; merge the deploy workflow manually if needed.`,
1569
+ )
1570
+ }
1571
+ scanResidual(DEPLOY_REL, existingDeploy)
1572
+ }
1573
+ }
1574
+ } else if (args.host === 'github-pages') {
1575
+ // ── T22/AC-H3: templateLf is non-null and already validated (T24, above). ──
1576
+ const state = classifyDeployWorkflow(existingDeploy, templateLf )
1577
+ if (state === 'absent') {
1578
+ changedFiles.push(DEPLOY_REL)
1579
+ if (!args.dryRun) {
1580
+ try {
1581
+ writeAnchorFile(abs(DEPLOY_REL), templateLf )
1582
+ } catch (err) {
1583
+ throw new AnchorWriteError(DEPLOY_REL, err )
1584
+ }
1585
+ }
1586
+ pushTagRuleWarning()
1587
+ finalDeployRaw = templateLf
1588
+ } else if (state === 'old') {
1589
+ changedFiles.push(DEPLOY_REL)
1590
+ if (!args.dryRun) {
1591
+ try {
1592
+ writeAnchorFile(abs(DEPLOY_REL), templateLf )
1593
+ } catch (err) {
1594
+ throw new AnchorWriteError(DEPLOY_REL, err )
1595
+ }
1596
+ }
1597
+ warnings.push(`${DEPLOY_REL} was an old template output — replaced with the current template.`)
1598
+ finalDeployRaw = templateLf
1599
+ } else if (state === 'other') {
1600
+ if (derivesSiteBase(normalizeLf(existingDeploy ))) {
1601
+ warnings.push(`${DEPLOY_REL} left untouched — it does not match the current template, but it derives SITE_BASE.`)
1602
+ } else {
1603
+ warnings.push(
1604
+ `${DEPLOY_REL} does not derive SITE_BASE — pages under a sub-path 404; install the GitHub Pages workflow ` +
1605
+ `with \`pnpm fork:init --host github-pages\` (or set SITE_BASE yourself).`,
1606
+ )
1607
+ }
1608
+ } // 'current': nothing
1609
+ } else if (args.host === 'cloudflare-pages') {
1610
+ // ── T23/AC-H4: no workflow is ever installed; current/old is removed, other is left. ──
1611
+ const state = classifyDeployWorkflow(existingDeploy, templateLf )
1612
+ if (state === 'current' || state === 'old') {
1613
+ changedFiles.push(DEPLOY_REL)
1614
+ if (!args.dryRun) {
1615
+ try {
1616
+ unlinkSync(abs(DEPLOY_REL))
1617
+ } catch (err) {
1618
+ throw new AnchorWriteError(DEPLOY_REL, err , 'remove')
1619
+ }
1620
+ }
1621
+ warnings.push(`${DEPLOY_REL} removed — Cloudflare Pages ranges get no GitHub Pages workflow.`)
1622
+ finalDeployRaw = null
1623
+ } else if (state === 'other') {
1624
+ warnings.push(`${DEPLOY_REL} left untouched — it does not match the current or a known old template output.`)
1625
+ } // 'absent': nothing
1626
+ } else {
1627
+ // ── T25/AC-H5: no --host — today's behaviour, compared LF-normalised. ──
1628
+ if (tmplRaw === null) {
1629
+ warnings.push(`${DEPLOY_TEMPLATE_REL} is missing — the deploy workflow cannot be installed or verified.`)
1630
+ } else {
1631
+ const templateLfNoHost = normalizeLf(tmplRaw)
1632
+ const state = classifyDeployWorkflow(existingDeploy, templateLfNoHost)
1633
+ if (state === 'absent') {
1634
+ changedFiles.push(DEPLOY_REL)
1635
+ if (!args.dryRun) {
1636
+ try {
1637
+ writeAnchorFile(abs(DEPLOY_REL), templateLfNoHost)
1638
+ } catch (err) {
1639
+ throw new AnchorWriteError(DEPLOY_REL, err )
1640
+ }
1641
+ }
1642
+ pushTagRuleWarning()
1643
+ finalDeployRaw = templateLfNoHost
1644
+ } else if (state === 'old' || state === 'other') {
1645
+ // never replaced without --host (today's behaviour)
1646
+ warnings.push(
1647
+ `${DEPLOY_REL} already exists with different content — left untouched; merge the deploy workflow manually if needed.`,
1648
+ )
1649
+ } // 'current': nothing
1650
+ }
1651
+ }
1652
+
1653
+ // T26/AC-H5: without --host, when A9 ends this run in the upstream form and the workflow
1654
+ // exists (including one just installed this run) but does not derive SITE_BASE, warn naming
1655
+ // the workflow path, SITE_BASE, 404 and --host github-pages.
1656
+ if (args.host === undefined && a9EndsUpstream && finalDeployRaw !== null && !derivesSiteBase(normalizeLf(finalDeployRaw))) {
1657
+ warnings.push(
1658
+ `${DEPLOY_REL} does not derive SITE_BASE — pages under a sub-path 404; install the GitHub Pages workflow ` +
1659
+ `with \`pnpm fork:init --host github-pages\` (or set SITE_BASE yourself).`,
1660
+ )
1661
+ }
1662
+ } catch (err) {
1663
+ // In anchor mode, a write failure partway is a tree error asking the teacher to run the same
1664
+ // command again (T6 then converges); in rebrand mode a write failure still propagates
1665
+ // unwrapped, as today.
1666
+ if (err instanceof AnchorWriteError) {
1667
+ return treeError(`${err.message}; run the same command again`)
1668
+ }
1669
+ throw err
1670
+ }
1671
+
1672
+ // ── B-mode residual summary ──
1673
+ const residualFiles = []
1674
+ if (args.rebrand) {
1675
+ for (const h of residualHits) if (!residualFiles.includes(h.file)) residualFiles.push(h.file)
1676
+ warnings.push(
1677
+ 'Rebrand renamed only the upstream slug and the 4 runtime-sensitive keys — the tokens it can prove are self-contained. Every other `wxl` is left as a deliberate, namespace-aware decision: the brand short-name vs the `wxlsh` subsystem / `X-Wxlsh-*` wire headers vs path-referenced skill/spec directory names cannot be told apart automatically. Directory names (e.g. scripts/wxl-solver) are NOT auto-renamed. `pnpm build` does not touch them, so it keeps working; what breaks is the path references into them — scripts/challenge-verify.ts statically imports ./wxl-solver/spawn-runtime.ts, and the skill docs and .mcp.json name those paths as text. Rename a dir and you must update every reference by hand.',
1678
+ )
1679
+ if (residualFiles.length) {
1680
+ warnings.push(
1681
+ `${residualFiles.length} file(s) still contain a case-insensitive "wxl" — the rebrand is NOT complete; run \`git grep -in wxl\` and finish it manually (occurrences of your own --repo/--author are intentional).`,
1682
+ )
1683
+ }
1684
+ }
1685
+
1686
+ const sensitiveKeys = [...sensitiveMap.entries()].map(([key, files]) => ({
1687
+ key,
1688
+ files: [...files].sort(),
1689
+ }))
1690
+
1691
+ const uniqueChanged = [...new Set(changedFiles)]
1692
+
1693
+ const message = args.dryRun
1694
+ ? `dry-run: ${uniqueChanged.length} file(s) would change`
1695
+ : args.rebrand && residualFiles.length
1696
+ ? `fork:init done (rebrand) — ${uniqueChanged.length} file(s) changed; ${residualFiles.length} file(s) still contain "wxl" and need manual, namespace-aware handling (rebrand NOT complete)`
1697
+ : `fork:init done — ${uniqueChanged.length} file(s) changed`
1698
+
1699
+ return {
1700
+ exitCode: 0,
1701
+ message,
1702
+ changedFiles: uniqueChanged,
1703
+ sensitiveKeys,
1704
+ residualFiles,
1705
+ residualHits,
1706
+ warnings,
1707
+ }
1708
+ }
1709
+
1710
+ // ─── helpers ─────────────────────────────────────────────────────────────────
1711
+
1712
+ /** True when `childRel` equals an excluded fragment or is a path segment beneath one. */
1713
+ function isExcludedPath(childRel ) {
1714
+ return EXCLUDED_PATH_FRAGMENTS.some(
1715
+ (frag) => childRel === frag || childRel.startsWith(frag + '/'),
1716
+ )
1717
+ }
1718
+
1719
+ /** Recursively list repo-relative text files eligible for the text passes. `rel` is always
1720
+ * `/`-joined (T57); `join(root, rel)` below still resolves correctly on Windows, which accepts
1721
+ * `/` in a path. */
1722
+ function walkTextFiles(root , rel = '') {
1723
+ const out = []
1724
+ for (const entry of readdirSync(join(root, rel), { withFileTypes: true })) {
1725
+ const childRel = rel ? `${rel}/${entry.name}` : entry.name
1726
+ if (entry.isDirectory()) {
1727
+ if (EXCLUDED_DIRS.has(entry.name)) continue
1728
+ if (isExcludedPath(childRel)) continue
1729
+ out.push(...walkTextFiles(root, childRel))
1730
+ } else if (entry.isFile()) {
1731
+ if (EXCLUDED_FILE.has(entry.name)) continue
1732
+ if (SELF_EXCLUDE.has(childRel)) continue
1733
+ if (isExcludedPath(childRel)) continue
1734
+ // skip binary-looking files (any null byte in the file)
1735
+ const buf = readFileSync(join(root, childRel))
1736
+ if (buf.includes(0)) continue
1737
+ out.push(childRel)
1738
+ }
1739
+ }
1740
+ return out
1741
+ }
1742
+
1743
+ // ─── CLI entry ───────────────────────────────────────────────────────────────
1744
+
1745
+ function main() {
1746
+ let args
1747
+ try {
1748
+ args = parseForkInitArgs(process.argv.slice(2))
1749
+ } catch (err) {
1750
+ if (err instanceof ForkInitArgError) {
1751
+ console.error(`✗ ${err.message}`)
1752
+ process.exit(err.exitCode)
1753
+ }
1754
+ throw err
1755
+ }
1756
+
1757
+ const result = runForkInit(args, { projectRoot: PROJECT_ROOT })
1758
+ if (result.exitCode !== 0) {
1759
+ // A tree error: nothing was written, and no warning collected before it is printed.
1760
+ console.error(`✗ ${result.message}`)
1761
+ process.exit(result.exitCode)
1762
+ }
1763
+ console.log(result.message)
1764
+ if (result.changedFiles.length) {
1765
+ console.log('\nChanged files:')
1766
+ for (const f of result.changedFiles) console.log(` ${f}`)
1767
+ }
1768
+ if (result.sensitiveKeys.length) {
1769
+ console.log('\n[!] Runtime-sensitive keys renamed (verify storage/env/release impact):')
1770
+ for (const k of result.sensitiveKeys) console.log(` ${k.key} -> in ${k.files.join(', ')}`)
1771
+ }
1772
+ if (result.residualFiles.length) {
1773
+ console.log(`\n[!] ${result.residualFiles.length} file(s) still contain a case-insensitive "wxl" (NOT auto-renamed — finish manually):`)
1774
+ for (const f of result.residualFiles) console.log(` ${f}`)
1775
+ console.log(' Run `git grep -in wxl` to review each; decide brand vs wxlsh-subsystem vs skill/spec dir per occurrence.')
1776
+ }
1777
+ for (const w of result.warnings) console.log(`\n[i] ${w}`)
1778
+ process.exit(result.exitCode)
1779
+ }
1780
+
1781
+ if (process.argv[1] && resolve(process.argv[1]) === __filename) {
1782
+ main()
1783
+ }