create-wxl 0.0.0-stage → 3.0.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.
- package/LICENSE +127 -0
- package/bin/create-wxl.mjs +153 -0
- package/dist/fork-init.mjs +1782 -0
- package/dist/source.json +6 -0
- package/package.json +20 -4
- package/src/archive-rules.mjs +148 -0
- package/src/archive.mjs +109 -0
- package/src/argv.mjs +89 -0
- package/src/fsops.mjs +205 -0
- package/src/git-env.mjs +13 -0
- package/src/git-runner.mjs +107 -0
- package/src/git-steps.mjs +148 -0
- package/src/git-version.mjs +25 -0
- package/src/language.mjs +14 -0
- package/src/messages.mjs +323 -0
- package/src/next-steps.mjs +29 -0
- package/src/node-version.mjs +17 -0
- package/src/numeric-version.mjs +16 -0
- package/src/pristine.mjs +104 -0
- package/src/publish-io.mjs +123 -0
- package/src/publish.mjs +304 -0
- package/src/questions.mjs +274 -0
- package/src/raw-tar.mjs +188 -0
- package/src/run.mjs +537 -0
- package/src/signals.mjs +88 -0
- package/src/text.mjs +80 -0
- package/README.md +0 -3
|
@@ -0,0 +1,1782 @@
|
|
|
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 short-link origin (A8); --host github-pages derives it when --origin is
|
|
22
|
+
* not given (required with --host cloudflare-pages). --site-name sets the site title (A6) and
|
|
23
|
+
* both home pages' hero name (A7). --rebrand cannot be combined with --host, --origin or
|
|
24
|
+
* --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 short-link origin, from --origin or
|
|
32
|
+
* derived), and the deploy workflow (chosen by --host). No tree walk;
|
|
33
|
+
* 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, short-link origin): 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 short-link 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 short-link origin 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 (short-link origin) 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 (short-link origin): --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 short-link origin 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 short-link origin is replaced only when --origin (or a derived GitHub Pages origin) says so.`,
|
|
1488
|
+
)
|
|
1489
|
+
}
|
|
1490
|
+
const nextLines = lines.slice()
|
|
1491
|
+
nextLines[idx] = newLine
|
|
1492
|
+
shortLinkNext = nextLines.join('\n')
|
|
1493
|
+
} // else: already byte-identical to the line that would be written — no write, no warning
|
|
1494
|
+
}
|
|
1495
|
+
|
|
1496
|
+
// The config's final content: read byte-preserving (latin1) (T4) so a byte outside the A6/A9
|
|
1497
|
+
// lines — including one that is not valid UTF-8 on its own — round-trips unchanged; A9's own
|
|
1498
|
+
// edit (`applyA9Edit`, shared with rebrand mode) only ever touches pure-ASCII lines, so
|
|
1499
|
+
// reusing it here on this latin1-decoded text is safe (same line count, same indices as the
|
|
1500
|
+
// utf8-decoded `cfgLines` used for validation above).
|
|
1501
|
+
let cfgNext = null
|
|
1502
|
+
let cfgRawLatin1 = null
|
|
1503
|
+
if (cfgRawShared !== null) {
|
|
1504
|
+
cfgRawLatin1 = readFileSync(abs(CONFIG_REL)).toString('latin1')
|
|
1505
|
+
let cfgLatin1Lines = cfgRawLatin1.split('\n')
|
|
1506
|
+
if (a6Idx !== null) {
|
|
1507
|
+
cfgLatin1Lines = cfgLatin1Lines.slice()
|
|
1508
|
+
cfgLatin1Lines[a6Idx] = a6LineFor(cfgLatin1Lines[a6Idx], utf8AsLatin1(JSON.stringify(args.siteName )))
|
|
1509
|
+
}
|
|
1510
|
+
cfgNext = applyA9Edit(cfgLatin1Lines.join('\n'))
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1513
|
+
// ── write phase: the config (a subdirectory file) first, then the manifest (root) whose
|
|
1514
|
+
// write was deferred above, then both home pages and the short-link module (also
|
|
1515
|
+
// subdirectory files), then the doc anchors (root) — so a root-directory write failure can
|
|
1516
|
+
// still be observed, in a test, to leave an already-written subdirectory file in place
|
|
1517
|
+
// (traceability.md, T55: "files already written stay"). ──
|
|
1518
|
+
if (cfgRawLatin1 !== null) applyEdit(CONFIG_REL, cfgNext , cfgRawLatin1, 'latin1')
|
|
1519
|
+
for (const { file, next, raw } of homeWrites) applyEdit(file, next, raw, 'latin1')
|
|
1520
|
+
if (shortLinkNext !== null) applyEdit(SHORT_LINK_REL, shortLinkNext, shortLinkRaw , 'latin1')
|
|
1521
|
+
applyEdit(PKG_REL, pkgNext, pkgRaw)
|
|
1522
|
+
for (const { file, next, raw } of docWrites) applyEdit(file, next, raw, 'latin1')
|
|
1523
|
+
}
|
|
1524
|
+
|
|
1525
|
+
// The tag-rule warning (traceability.md, "Tag-rule warning"): today's warning on installing
|
|
1526
|
+
// the workflow, pushed whenever this run installs it (absent -> installed), in GH and
|
|
1527
|
+
// no-host runs — never on a replace (GH's "old" branch) and never under --host
|
|
1528
|
+
// cloudflare-pages in the non-rebrand branches below, which never install the workflow there.
|
|
1529
|
+
// Rebrand mode's own install, further below, does not branch on --host at all — but T50
|
|
1530
|
+
// rejects --rebrand together with --host, so args.host is always undefined whenever
|
|
1531
|
+
// args.rebrand is set; this warning is pushed on an absent workflow regardless, since rebrand
|
|
1532
|
+
// mode never reaches a Cloudflare-Pages-shaped run in the first place.
|
|
1533
|
+
const pushTagRuleWarning = () => {
|
|
1534
|
+
warnings.push(
|
|
1535
|
+
`${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.`,
|
|
1536
|
+
)
|
|
1537
|
+
}
|
|
1538
|
+
// `finalDeployRaw` tracks whatever ends up on disk for DEPLOY_REL after this block (its own
|
|
1539
|
+
// write respecting --dry-run below) — read by the no-`--host` T26 check further down, which
|
|
1540
|
+
// needs the workflow's state AFTER this run, including one just installed this same run.
|
|
1541
|
+
let finalDeployRaw = existingDeploy
|
|
1542
|
+
|
|
1543
|
+
if (args.rebrand) {
|
|
1544
|
+
// ── rebrand mode (T51): compared "as today" — raw bytes, no LF normalisation, never
|
|
1545
|
+
// replaced. T50 rejects --rebrand together with --host (among --origin and --site-name),
|
|
1546
|
+
// so args.host is always undefined here; the A9 reset and the template checks above (which
|
|
1547
|
+
// branch on args.host) are therefore exercised, under rebrand, only via their --base path,
|
|
1548
|
+
// never their --host one. This install step itself does not branch on --host either — it
|
|
1549
|
+
// installs the template whenever absent — but since args.host can never be set alongside
|
|
1550
|
+
// args.rebrand, that absence of a branch is never actually exercised against a Cloudflare
|
|
1551
|
+
// Pages run. ──
|
|
1552
|
+
if (tmplRaw) {
|
|
1553
|
+
const { next: deployContent, keysHit: deployKeysHit } = transformText(DEPLOY_REL, tmplRaw)
|
|
1554
|
+
if (existingDeploy === null) {
|
|
1555
|
+
changedFiles.push(DEPLOY_REL)
|
|
1556
|
+
if (!args.dryRun) {
|
|
1557
|
+
mkdirSync(dirname(abs(DEPLOY_REL)), { recursive: true })
|
|
1558
|
+
writeFileSync(abs(DEPLOY_REL), deployContent)
|
|
1559
|
+
}
|
|
1560
|
+
pushTagRuleWarning()
|
|
1561
|
+
for (const k of deployKeysHit) recordSensitive(k, DEPLOY_REL)
|
|
1562
|
+
scanResidual(DEPLOY_REL, deployContent)
|
|
1563
|
+
finalDeployRaw = deployContent
|
|
1564
|
+
} else {
|
|
1565
|
+
if (existingDeploy !== deployContent) {
|
|
1566
|
+
warnings.push(
|
|
1567
|
+
`${DEPLOY_REL} already exists with different content — left untouched; merge the deploy workflow manually if needed.`,
|
|
1568
|
+
)
|
|
1569
|
+
}
|
|
1570
|
+
scanResidual(DEPLOY_REL, existingDeploy)
|
|
1571
|
+
}
|
|
1572
|
+
}
|
|
1573
|
+
} else if (args.host === 'github-pages') {
|
|
1574
|
+
// ── T22/AC-H3: templateLf is non-null and already validated (T24, above). ──
|
|
1575
|
+
const state = classifyDeployWorkflow(existingDeploy, templateLf )
|
|
1576
|
+
if (state === 'absent') {
|
|
1577
|
+
changedFiles.push(DEPLOY_REL)
|
|
1578
|
+
if (!args.dryRun) {
|
|
1579
|
+
try {
|
|
1580
|
+
writeAnchorFile(abs(DEPLOY_REL), templateLf )
|
|
1581
|
+
} catch (err) {
|
|
1582
|
+
throw new AnchorWriteError(DEPLOY_REL, err )
|
|
1583
|
+
}
|
|
1584
|
+
}
|
|
1585
|
+
pushTagRuleWarning()
|
|
1586
|
+
finalDeployRaw = templateLf
|
|
1587
|
+
} else if (state === 'old') {
|
|
1588
|
+
changedFiles.push(DEPLOY_REL)
|
|
1589
|
+
if (!args.dryRun) {
|
|
1590
|
+
try {
|
|
1591
|
+
writeAnchorFile(abs(DEPLOY_REL), templateLf )
|
|
1592
|
+
} catch (err) {
|
|
1593
|
+
throw new AnchorWriteError(DEPLOY_REL, err )
|
|
1594
|
+
}
|
|
1595
|
+
}
|
|
1596
|
+
warnings.push(`${DEPLOY_REL} was an old template output — replaced with the current template.`)
|
|
1597
|
+
finalDeployRaw = templateLf
|
|
1598
|
+
} else if (state === 'other') {
|
|
1599
|
+
if (derivesSiteBase(normalizeLf(existingDeploy ))) {
|
|
1600
|
+
warnings.push(`${DEPLOY_REL} left untouched — it does not match the current template, but it derives SITE_BASE.`)
|
|
1601
|
+
} else {
|
|
1602
|
+
warnings.push(
|
|
1603
|
+
`${DEPLOY_REL} does not derive SITE_BASE — pages under a sub-path 404; install the GitHub Pages workflow ` +
|
|
1604
|
+
`with \`pnpm fork:init --host github-pages\` (or set SITE_BASE yourself).`,
|
|
1605
|
+
)
|
|
1606
|
+
}
|
|
1607
|
+
} // 'current': nothing
|
|
1608
|
+
} else if (args.host === 'cloudflare-pages') {
|
|
1609
|
+
// ── T23/AC-H4: no workflow is ever installed; current/old is removed, other is left. ──
|
|
1610
|
+
const state = classifyDeployWorkflow(existingDeploy, templateLf )
|
|
1611
|
+
if (state === 'current' || state === 'old') {
|
|
1612
|
+
changedFiles.push(DEPLOY_REL)
|
|
1613
|
+
if (!args.dryRun) {
|
|
1614
|
+
try {
|
|
1615
|
+
unlinkSync(abs(DEPLOY_REL))
|
|
1616
|
+
} catch (err) {
|
|
1617
|
+
throw new AnchorWriteError(DEPLOY_REL, err , 'remove')
|
|
1618
|
+
}
|
|
1619
|
+
}
|
|
1620
|
+
warnings.push(`${DEPLOY_REL} removed — Cloudflare Pages ranges get no GitHub Pages workflow.`)
|
|
1621
|
+
finalDeployRaw = null
|
|
1622
|
+
} else if (state === 'other') {
|
|
1623
|
+
warnings.push(`${DEPLOY_REL} left untouched — it does not match the current or a known old template output.`)
|
|
1624
|
+
} // 'absent': nothing
|
|
1625
|
+
} else {
|
|
1626
|
+
// ── T25/AC-H5: no --host — today's behaviour, compared LF-normalised. ──
|
|
1627
|
+
if (tmplRaw === null) {
|
|
1628
|
+
warnings.push(`${DEPLOY_TEMPLATE_REL} is missing — the deploy workflow cannot be installed or verified.`)
|
|
1629
|
+
} else {
|
|
1630
|
+
const templateLfNoHost = normalizeLf(tmplRaw)
|
|
1631
|
+
const state = classifyDeployWorkflow(existingDeploy, templateLfNoHost)
|
|
1632
|
+
if (state === 'absent') {
|
|
1633
|
+
changedFiles.push(DEPLOY_REL)
|
|
1634
|
+
if (!args.dryRun) {
|
|
1635
|
+
try {
|
|
1636
|
+
writeAnchorFile(abs(DEPLOY_REL), templateLfNoHost)
|
|
1637
|
+
} catch (err) {
|
|
1638
|
+
throw new AnchorWriteError(DEPLOY_REL, err )
|
|
1639
|
+
}
|
|
1640
|
+
}
|
|
1641
|
+
pushTagRuleWarning()
|
|
1642
|
+
finalDeployRaw = templateLfNoHost
|
|
1643
|
+
} else if (state === 'old' || state === 'other') {
|
|
1644
|
+
// never replaced without --host (today's behaviour)
|
|
1645
|
+
warnings.push(
|
|
1646
|
+
`${DEPLOY_REL} already exists with different content — left untouched; merge the deploy workflow manually if needed.`,
|
|
1647
|
+
)
|
|
1648
|
+
} // 'current': nothing
|
|
1649
|
+
}
|
|
1650
|
+
}
|
|
1651
|
+
|
|
1652
|
+
// T26/AC-H5: without --host, when A9 ends this run in the upstream form and the workflow
|
|
1653
|
+
// exists (including one just installed this run) but does not derive SITE_BASE, warn naming
|
|
1654
|
+
// the workflow path, SITE_BASE, 404 and --host github-pages.
|
|
1655
|
+
if (args.host === undefined && a9EndsUpstream && finalDeployRaw !== null && !derivesSiteBase(normalizeLf(finalDeployRaw))) {
|
|
1656
|
+
warnings.push(
|
|
1657
|
+
`${DEPLOY_REL} does not derive SITE_BASE — pages under a sub-path 404; install the GitHub Pages workflow ` +
|
|
1658
|
+
`with \`pnpm fork:init --host github-pages\` (or set SITE_BASE yourself).`,
|
|
1659
|
+
)
|
|
1660
|
+
}
|
|
1661
|
+
} catch (err) {
|
|
1662
|
+
// In anchor mode, a write failure partway is a tree error asking the teacher to run the same
|
|
1663
|
+
// command again (T6 then converges); in rebrand mode a write failure still propagates
|
|
1664
|
+
// unwrapped, as today.
|
|
1665
|
+
if (err instanceof AnchorWriteError) {
|
|
1666
|
+
return treeError(`${err.message}; run the same command again`)
|
|
1667
|
+
}
|
|
1668
|
+
throw err
|
|
1669
|
+
}
|
|
1670
|
+
|
|
1671
|
+
// ── B-mode residual summary ──
|
|
1672
|
+
const residualFiles = []
|
|
1673
|
+
if (args.rebrand) {
|
|
1674
|
+
for (const h of residualHits) if (!residualFiles.includes(h.file)) residualFiles.push(h.file)
|
|
1675
|
+
warnings.push(
|
|
1676
|
+
'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.',
|
|
1677
|
+
)
|
|
1678
|
+
if (residualFiles.length) {
|
|
1679
|
+
warnings.push(
|
|
1680
|
+
`${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).`,
|
|
1681
|
+
)
|
|
1682
|
+
}
|
|
1683
|
+
}
|
|
1684
|
+
|
|
1685
|
+
const sensitiveKeys = [...sensitiveMap.entries()].map(([key, files]) => ({
|
|
1686
|
+
key,
|
|
1687
|
+
files: [...files].sort(),
|
|
1688
|
+
}))
|
|
1689
|
+
|
|
1690
|
+
const uniqueChanged = [...new Set(changedFiles)]
|
|
1691
|
+
|
|
1692
|
+
const message = args.dryRun
|
|
1693
|
+
? `dry-run: ${uniqueChanged.length} file(s) would change`
|
|
1694
|
+
: args.rebrand && residualFiles.length
|
|
1695
|
+
? `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)`
|
|
1696
|
+
: `fork:init done — ${uniqueChanged.length} file(s) changed`
|
|
1697
|
+
|
|
1698
|
+
return {
|
|
1699
|
+
exitCode: 0,
|
|
1700
|
+
message,
|
|
1701
|
+
changedFiles: uniqueChanged,
|
|
1702
|
+
sensitiveKeys,
|
|
1703
|
+
residualFiles,
|
|
1704
|
+
residualHits,
|
|
1705
|
+
warnings,
|
|
1706
|
+
}
|
|
1707
|
+
}
|
|
1708
|
+
|
|
1709
|
+
// ─── helpers ─────────────────────────────────────────────────────────────────
|
|
1710
|
+
|
|
1711
|
+
/** True when `childRel` equals an excluded fragment or is a path segment beneath one. */
|
|
1712
|
+
function isExcludedPath(childRel ) {
|
|
1713
|
+
return EXCLUDED_PATH_FRAGMENTS.some(
|
|
1714
|
+
(frag) => childRel === frag || childRel.startsWith(frag + '/'),
|
|
1715
|
+
)
|
|
1716
|
+
}
|
|
1717
|
+
|
|
1718
|
+
/** Recursively list repo-relative text files eligible for the text passes. `rel` is always
|
|
1719
|
+
* `/`-joined (T57); `join(root, rel)` below still resolves correctly on Windows, which accepts
|
|
1720
|
+
* `/` in a path. */
|
|
1721
|
+
function walkTextFiles(root , rel = '') {
|
|
1722
|
+
const out = []
|
|
1723
|
+
for (const entry of readdirSync(join(root, rel), { withFileTypes: true })) {
|
|
1724
|
+
const childRel = rel ? `${rel}/${entry.name}` : entry.name
|
|
1725
|
+
if (entry.isDirectory()) {
|
|
1726
|
+
if (EXCLUDED_DIRS.has(entry.name)) continue
|
|
1727
|
+
if (isExcludedPath(childRel)) continue
|
|
1728
|
+
out.push(...walkTextFiles(root, childRel))
|
|
1729
|
+
} else if (entry.isFile()) {
|
|
1730
|
+
if (EXCLUDED_FILE.has(entry.name)) continue
|
|
1731
|
+
if (SELF_EXCLUDE.has(childRel)) continue
|
|
1732
|
+
if (isExcludedPath(childRel)) continue
|
|
1733
|
+
// skip binary-looking files (any null byte in the file)
|
|
1734
|
+
const buf = readFileSync(join(root, childRel))
|
|
1735
|
+
if (buf.includes(0)) continue
|
|
1736
|
+
out.push(childRel)
|
|
1737
|
+
}
|
|
1738
|
+
}
|
|
1739
|
+
return out
|
|
1740
|
+
}
|
|
1741
|
+
|
|
1742
|
+
// ─── CLI entry ───────────────────────────────────────────────────────────────
|
|
1743
|
+
|
|
1744
|
+
function main() {
|
|
1745
|
+
let args
|
|
1746
|
+
try {
|
|
1747
|
+
args = parseForkInitArgs(process.argv.slice(2))
|
|
1748
|
+
} catch (err) {
|
|
1749
|
+
if (err instanceof ForkInitArgError) {
|
|
1750
|
+
console.error(`✗ ${err.message}`)
|
|
1751
|
+
process.exit(err.exitCode)
|
|
1752
|
+
}
|
|
1753
|
+
throw err
|
|
1754
|
+
}
|
|
1755
|
+
|
|
1756
|
+
const result = runForkInit(args, { projectRoot: PROJECT_ROOT })
|
|
1757
|
+
if (result.exitCode !== 0) {
|
|
1758
|
+
// A tree error: nothing was written, and no warning collected before it is printed.
|
|
1759
|
+
console.error(`✗ ${result.message}`)
|
|
1760
|
+
process.exit(result.exitCode)
|
|
1761
|
+
}
|
|
1762
|
+
console.log(result.message)
|
|
1763
|
+
if (result.changedFiles.length) {
|
|
1764
|
+
console.log('\nChanged files:')
|
|
1765
|
+
for (const f of result.changedFiles) console.log(` ${f}`)
|
|
1766
|
+
}
|
|
1767
|
+
if (result.sensitiveKeys.length) {
|
|
1768
|
+
console.log('\n[!] Runtime-sensitive keys renamed (verify storage/env/release impact):')
|
|
1769
|
+
for (const k of result.sensitiveKeys) console.log(` ${k.key} -> in ${k.files.join(', ')}`)
|
|
1770
|
+
}
|
|
1771
|
+
if (result.residualFiles.length) {
|
|
1772
|
+
console.log(`\n[!] ${result.residualFiles.length} file(s) still contain a case-insensitive "wxl" (NOT auto-renamed — finish manually):`)
|
|
1773
|
+
for (const f of result.residualFiles) console.log(` ${f}`)
|
|
1774
|
+
console.log(' Run `git grep -in wxl` to review each; decide brand vs wxlsh-subsystem vs skill/spec dir per occurrence.')
|
|
1775
|
+
}
|
|
1776
|
+
for (const w of result.warnings) console.log(`\n[i] ${w}`)
|
|
1777
|
+
process.exit(result.exitCode)
|
|
1778
|
+
}
|
|
1779
|
+
|
|
1780
|
+
if (process.argv[1] && resolve(process.argv[1]) === __filename) {
|
|
1781
|
+
main()
|
|
1782
|
+
}
|