create-wxl 3.0.0 → 3.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/create-wxl.mjs +2 -0
- package/dist/fork-init.mjs +15 -14
- package/dist/source.json +3 -3
- package/package.json +1 -1
- package/src/archive-rules.mjs +19 -3
- package/src/argv.mjs +59 -2
- package/src/base-version.mjs +10 -0
- package/src/git-named.mjs +43 -0
- package/src/git-runner.mjs +27 -14
- package/src/git-version.mjs +5 -3
- package/src/merge-apply.mjs +156 -0
- package/src/merge-plan.mjs +660 -0
- package/src/messages.mjs +232 -8
- package/src/patterns.mjs +6 -0
- package/src/questions.mjs +3 -3
- package/src/run-shared.mjs +207 -0
- package/src/run.mjs +36 -136
- package/src/text.mjs +47 -0
- package/src/upgrade-report.mjs +134 -0
- package/src/upgrade.mjs +582 -0
package/bin/create-wxl.mjs
CHANGED
|
@@ -67,6 +67,7 @@ async function main() {
|
|
|
67
67
|
const { dirname, join } = await import('node:path')
|
|
68
68
|
const fsp = await import('node:fs/promises')
|
|
69
69
|
const { readFile } = fsp
|
|
70
|
+
const { tmpdir } = await import('node:os')
|
|
70
71
|
|
|
71
72
|
const { run } = await import('../src/run.mjs')
|
|
72
73
|
const { createSpawnGitRunner } = await import('../src/git-runner.mjs')
|
|
@@ -106,6 +107,7 @@ async function main() {
|
|
|
106
107
|
return { status: response.status, body }
|
|
107
108
|
},
|
|
108
109
|
downloadTimeoutMs: 120000,
|
|
110
|
+
tmpRoot: tmpdir(),
|
|
109
111
|
runGit: createSpawnGitRunner(),
|
|
110
112
|
fs: fsp,
|
|
111
113
|
// `process` itself is `deps.signals` (the seam's own dependency list, traceability.md
|
package/dist/fork-init.mjs
CHANGED
|
@@ -18,19 +18,19 @@
|
|
|
18
18
|
* (the base definition) to its upstream form from deployment; --base only applies without
|
|
19
19
|
* --host, and writes A9 as a literal.
|
|
20
20
|
*
|
|
21
|
-
* --origin sets the
|
|
22
|
-
* not given (required with --host cloudflare-pages). --site-name
|
|
23
|
-
* both home pages' hero name (A7). --rebrand cannot be combined
|
|
24
|
-
* --site-name (A mode's new flags are rejected in B mode).
|
|
21
|
+
* --origin sets the range address, the origin of every copied link (A8); --host github-pages
|
|
22
|
+
* derives it when --origin is not given (required with --host cloudflare-pages). --site-name
|
|
23
|
+
* sets the site title (A6) and both home pages' hero name (A7). --rebrand cannot be combined
|
|
24
|
+
* with --host, --origin or --site-name (A mode's new flags are rejected in B mode).
|
|
25
25
|
*
|
|
26
26
|
* Modes:
|
|
27
27
|
* A (default) rewrites only the identity anchors (traceability.md's A1-A9):
|
|
28
28
|
* the manifest (structured), the doc-anchor forms in README.md,
|
|
29
29
|
* README.en.md and CONTRIBUTE.md — wherever they occur, whatever
|
|
30
30
|
* repository slug they held — A9 (the base definition), A6/A7 (the site
|
|
31
|
-
* name, from --site-name), A8 (the
|
|
32
|
-
* derived), and the deploy workflow (chosen
|
|
33
|
-
* every other file is byte-identical.
|
|
31
|
+
* name, from --site-name), A8 (the range address, the origin of every
|
|
32
|
+
* copied link, from --origin or derived), and the deploy workflow (chosen
|
|
33
|
+
* by --host). No tree walk; every other file is byte-identical.
|
|
34
34
|
* B (--rebrand <name>) today's path: walks the whole tree doing the upstream-slug swap and
|
|
35
35
|
* also renames the four runtime-sensitive keys (deterministic,
|
|
36
36
|
* structure-aware), plus an HONEST residual inventory of every
|
|
@@ -848,12 +848,12 @@ function sensitiveRenames(lower , upper )
|
|
|
848
848
|
]
|
|
849
849
|
}
|
|
850
850
|
|
|
851
|
-
// ─── A6-A8 (site name,
|
|
851
|
+
// ─── A6-A8 (site name, range address): anchor mode only — never reached in rebrand mode,
|
|
852
852
|
// since --rebrand rejects --host/--origin/--site-name (T50) ───────────────────────────────────
|
|
853
853
|
|
|
854
854
|
const PLATFORM_HOME_RELS = ['platform/index.md', 'platform/zh-TW/index.md']
|
|
855
855
|
const SHORT_LINK_REL = '.vitepress/challenge/shortLink.ts'
|
|
856
|
-
/** The upstream
|
|
856
|
+
/** The upstream range address, SITE_ORIGIN (traceability.md, T81): replacing a value that is not this one
|
|
857
857
|
* warns; a run that starts from this one never does. */
|
|
858
858
|
const UPSTREAM_SITE_ORIGIN = 'https://wxl.browserlab.online'
|
|
859
859
|
|
|
@@ -1079,7 +1079,7 @@ function a7LineFor(line , newLiteral ) {
|
|
|
1079
1079
|
}
|
|
1080
1080
|
|
|
1081
1081
|
/** `https://<owner, lowercased>.github.io[/<name as given>]` (traceability.md, T36-T37): the
|
|
1082
|
-
* derived
|
|
1082
|
+
* derived range address for `--host github-pages` without `--origin`, omitting the path for
|
|
1083
1083
|
* the user site `<owner>.github.io` (compared case-insensitively). `repo` must already have passed
|
|
1084
1084
|
* parseForkInitArgs's `owner/name` check (T45); create-wxl calls it only after a successful parse. */
|
|
1085
1085
|
export function deriveGithubPagesOrigin(repo ) {
|
|
@@ -1361,7 +1361,7 @@ export function runForkInit(
|
|
|
1361
1361
|
} else {
|
|
1362
1362
|
// ── A mode: only the identity anchors (A1–A9, traceability.md) and the workflow — the doc
|
|
1363
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 (
|
|
1364
|
+
// --site-name is given, and A8 (range address) when --origin is given or derived. No
|
|
1365
1365
|
// tree walk — every file outside A1–A9 and the workflow is byte-identical (T3).
|
|
1366
1366
|
//
|
|
1367
1367
|
// Every CHECKED read/content computation below happens before anything is written —
|
|
@@ -1450,7 +1450,7 @@ export function runForkInit(
|
|
|
1450
1450
|
}
|
|
1451
1451
|
}
|
|
1452
1452
|
|
|
1453
|
-
// ── A8 (
|
|
1453
|
+
// ── A8 (range address): --origin, or derived from --host github-pages without
|
|
1454
1454
|
// --origin (T32-T38/T81/AC-O1-AC-O2). Read byte-preserving (latin1) (T4); the write
|
|
1455
1455
|
// decision compares the WHOLE LINE about to be written against the existing one, not just
|
|
1456
1456
|
// the decoded value (T8: a same-VALUE line in a different quote style is still rewritten
|
|
@@ -1478,13 +1478,14 @@ export function runForkInit(
|
|
|
1478
1478
|
if (line !== newLine) {
|
|
1479
1479
|
const oldValue = evalJsStringLiteral(valueMatch[1])
|
|
1480
1480
|
if (oldValue !== finalOrigin && oldValue !== UPSTREAM_SITE_ORIGIN) {
|
|
1481
|
-
// T81/AC-O2: replacing a
|
|
1481
|
+
// T81/AC-O2: replacing a range address that is not upstream's warns — the engine
|
|
1482
1482
|
// keeps no state, so it cannot otherwise tell a fork's own custom domain from one that
|
|
1483
1483
|
// should be silently kept. Compares the DECODED value: a line rewritten only to
|
|
1484
1484
|
// normalise its quoting (the same value, differently quoted) never warns.
|
|
1485
1485
|
warnings.push(
|
|
1486
1486
|
`A8: ${SHORT_LINK_REL} changes from ${JSON.stringify(oldValue)} to ${JSON.stringify(finalOrigin)} — ` +
|
|
1487
|
-
`a custom
|
|
1487
|
+
`a custom range address (SITE_ORIGIN, the origin of every copied link) is replaced only when ` +
|
|
1488
|
+
`--origin (or a derived GitHub Pages origin) says so.`,
|
|
1488
1489
|
)
|
|
1489
1490
|
}
|
|
1490
1491
|
const nextLines = lines.slice()
|
package/dist/source.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"repository": "BrowserLaboratory/wxl-platform",
|
|
3
|
-
"version": "3.
|
|
4
|
-
"commit": "
|
|
5
|
-
"engineSha256": "
|
|
3
|
+
"version": "3.1.0",
|
|
4
|
+
"commit": "b4d3fd0e0c46f5c872e7f680e5401f8a8d8c5f60",
|
|
5
|
+
"engineSha256": "d67a3403bada3d3c8ec9a22d8c43e3e07a99018ab2662cd9e740dd2c7772d61a"
|
|
6
6
|
}
|
package/package.json
CHANGED
package/src/archive-rules.mjs
CHANGED
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
// list (first component stripped, classified kind) so step 8 writes from the SAME validated data,
|
|
10
10
|
// never re-deriving a path or kind from `tar`'s own, un-stripped fields (C-01).
|
|
11
11
|
|
|
12
|
+
import { foldAsciiCase } from './text.mjs'
|
|
13
|
+
|
|
12
14
|
export class ArchiveRuleError extends Error {
|
|
13
15
|
constructor(rule, path, { isCommentMarker = false } = {}) {
|
|
14
16
|
super(`archive entry rule ${rule} broken${path === undefined ? '' : ` at ${path}`}`)
|
|
@@ -49,12 +51,23 @@ function checkFirstComponent(component, headerPath) {
|
|
|
49
51
|
}
|
|
50
52
|
}
|
|
51
53
|
|
|
54
|
+
/** 0005 revision 2.2 (D13, T-0062): a component git itself treats as `.git`. Compared ignoring
|
|
55
|
+
* ASCII case only (as git's own `tolower` does -- not JavaScript's Unicode-aware `/i`): either
|
|
56
|
+
* `.git` or `git~1`, then any run of `.` and spaces, then nothing or `:` and anything (git's
|
|
57
|
+
* `is_ntfs_dotgit`); or `.git` once every HFS+ ignorable code point is removed, wherever it occurs
|
|
58
|
+
* (git's `is_hfs_dotgit`). Other short names (`PLATFO~1`, `GITIGN~1`) are not `.git`. */
|
|
59
|
+
const HFS_IGNORABLE_RE = /[\u200c-\u200f\u202a-\u202e\u206a-\u206f\ufeff]/g
|
|
60
|
+
const NTFS_DOTGIT_RE = /^(?:\.git|git~1)[. ]*(?::.*)?$/s
|
|
61
|
+
function isGitAlias(component) {
|
|
62
|
+
return NTFS_DOTGIT_RE.test(foldAsciiCase(component)) || foldAsciiCase(component.replace(HFS_IGNORABLE_RE, '')) === '.git'
|
|
63
|
+
}
|
|
64
|
+
|
|
52
65
|
function checkComponents(relPath, headerPath) {
|
|
53
66
|
if (relPath.includes('\\')) throw new ArchiveRuleError(3, headerPath)
|
|
54
67
|
for (const component of relPath.split('/')) {
|
|
55
68
|
if (component === '') throw new ArchiveRuleError(3, headerPath)
|
|
56
69
|
if (component === '.' || component === '..') throw new ArchiveRuleError(3, headerPath)
|
|
57
|
-
if (component
|
|
70
|
+
if (isGitAlias(component)) throw new ArchiveRuleError(3, headerPath)
|
|
58
71
|
if (component.endsWith('.') || component.endsWith(' ')) throw new ArchiveRuleError(3, headerPath)
|
|
59
72
|
if (CONTROL_CHAR_RE.test(component)) throw new ArchiveRuleError(3, headerPath)
|
|
60
73
|
}
|
|
@@ -113,8 +126,11 @@ function checkCollisionAgainstSeen(entry, seen) {
|
|
|
113
126
|
* breaking entry in archive order (D4: the path named is the entry's header path exactly as it
|
|
114
127
|
* appears in the archive, including the first component), and returning the enriched list (each
|
|
115
128
|
* entry's own fields plus `firstComponent`, `rest`, `kind`) for step 8 to write from directly.
|
|
116
|
-
* `commit` is the commit
|
|
117
|
-
*
|
|
129
|
+
* `commit` is the commit rule 2's comment check compares against: the commit requested for a
|
|
130
|
+
* download by commit (create, a `--from <commit>` base, the upgrade's target), or, for a tag
|
|
131
|
+
* download (a `--from <version>` base, a base read from `range.json`), the tag archive's own
|
|
132
|
+
* comment once `readArchiveEntries` has checked it (UP-E08, and J6's UP-E12); for publishing's own
|
|
133
|
+
* check of the local archive (`publish.mjs`, X7), the commit at `HEAD`.
|
|
118
134
|
*/
|
|
119
135
|
export function checkEntryRules({ entries, comment }, { commit }) {
|
|
120
136
|
// S-04: rule 5 (no file in the list) is checked after rule 2's comment, and the comment
|
package/src/argv.mjs
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
// Command grammar (traceability.md "Command grammar"): `parseArgvStrict` is the full grammar.
|
|
2
2
|
// The `--no-setup` + setup-flag rule (C03) is checked separately by `run.mjs`.
|
|
3
3
|
|
|
4
|
+
import { VERSION_RE, COMMIT_RE } from './patterns.mjs'
|
|
5
|
+
|
|
4
6
|
export class ArgvError extends Error {
|
|
5
7
|
constructor(token, { usage = false, reason = 'unknown' } = {}) {
|
|
6
8
|
super(`invalid argument: ${token}`)
|
|
@@ -15,10 +17,13 @@ const VALUE_FLAGS = new Set(['--author', '--repo', '--host', '--origin', '--site
|
|
|
15
17
|
export const SETUP_FLAG_NAMES = ['author', 'repo', 'host', 'origin', 'site-name']
|
|
16
18
|
|
|
17
19
|
export function parseArgvStrict(argv) {
|
|
18
|
-
const result = { directory: undefined, noSetup: false, lang: undefined, flags: {} }
|
|
20
|
+
const result = { directory: undefined, noSetup: false, lang: undefined, flags: {}, mode: undefined }
|
|
19
21
|
const seen = new Set()
|
|
20
22
|
let positionalCount = 0
|
|
21
23
|
let endOfFlags = false
|
|
24
|
+
// U02: every create-only flag seen, in argv order, whether before or after `upgrade` -- checked
|
|
25
|
+
// once the loop ends (below), once `mode` is known for certain.
|
|
26
|
+
const createOnlyFlagsSeen = []
|
|
22
27
|
|
|
23
28
|
for (let i = 0; i < argv.length; i++) {
|
|
24
29
|
const arg = argv[i]
|
|
@@ -45,10 +50,42 @@ export function parseArgvStrict(argv) {
|
|
|
45
50
|
if (eq !== -1) throw new ArgvError(arg)
|
|
46
51
|
if (seen.has('--no-setup')) throw new ArgvError(arg, { reason: 'repeated' })
|
|
47
52
|
seen.add('--no-setup')
|
|
53
|
+
createOnlyFlagsSeen.push('--no-setup')
|
|
48
54
|
result.noSetup = true
|
|
49
55
|
continue
|
|
50
56
|
}
|
|
51
57
|
|
|
58
|
+
// Command grammar: "`--from` after `upgrade` only" (decision (2)) -- recognised only once
|
|
59
|
+
// `upgrade` has already been read as the mode, using the same value-flag shape every other
|
|
60
|
+
// VALUE_FLAGS member uses (repeated/missing-value/inline `=`); its value goes to
|
|
61
|
+
// `result.from`, never `result.flags`, so it can never reach `buildEngineArgv`. Anywhere
|
|
62
|
+
// else (before `upgrade`, or on the create path) it falls through to the unknown-flag throw
|
|
63
|
+
// below, unchanged.
|
|
64
|
+
if (name === '--from' && result.mode === 'upgrade') {
|
|
65
|
+
if (seen.has('--from')) throw new ArgvError('--from', { reason: 'repeated' })
|
|
66
|
+
seen.add('--from')
|
|
67
|
+
|
|
68
|
+
let fromValue
|
|
69
|
+
if (eq !== -1) {
|
|
70
|
+
if (inlineValue === '') throw new ArgvError('--from', { reason: 'missing-value' })
|
|
71
|
+
fromValue = inlineValue
|
|
72
|
+
} else {
|
|
73
|
+
const next = argv[i + 1]
|
|
74
|
+
if (next === undefined || next.startsWith('-')) {
|
|
75
|
+
throw new ArgvError('--from', { reason: 'missing-value' })
|
|
76
|
+
}
|
|
77
|
+
fromValue = next
|
|
78
|
+
i++
|
|
79
|
+
}
|
|
80
|
+
// Command grammar: "`<version>` matches ... `<commit>` matches ...; any other `--from`
|
|
81
|
+
// value ... -> CW-E02" -- named by the value itself, not by "--from" (U02).
|
|
82
|
+
if (!VERSION_RE.test(fromValue) && !COMMIT_RE.test(fromValue)) {
|
|
83
|
+
throw new ArgvError(fromValue)
|
|
84
|
+
}
|
|
85
|
+
result.from = fromValue
|
|
86
|
+
continue
|
|
87
|
+
}
|
|
88
|
+
|
|
52
89
|
if (!VALUE_FLAGS.has(name)) throw new ArgvError(arg)
|
|
53
90
|
if (seen.has(name)) throw new ArgvError(name, { reason: 'repeated' })
|
|
54
91
|
seen.add(name)
|
|
@@ -73,17 +110,37 @@ export function parseArgvStrict(argv) {
|
|
|
73
110
|
result.lang = value
|
|
74
111
|
} else {
|
|
75
112
|
result.flags[name.slice(2)] = value
|
|
113
|
+
createOnlyFlagsSeen.push(name)
|
|
76
114
|
}
|
|
77
115
|
continue
|
|
78
116
|
}
|
|
79
117
|
|
|
80
118
|
// positional
|
|
81
119
|
if (arg === '') throw new ArgvError(arg)
|
|
82
|
-
|
|
120
|
+
// U02: once `upgrade` has been read, any further positional (another directory-shaped
|
|
121
|
+
// argument, or `upgrade` again) is rejected by its own literal token -- the upgrade path never
|
|
122
|
+
// takes a directory argument.
|
|
123
|
+
if (result.mode === 'upgrade') throw new ArgvError(arg)
|
|
124
|
+
// Command grammar: "chosen when the first positional argument is `upgrade`" -- Q2's reading
|
|
125
|
+
// (the first positional's literal covers one after `--` too) makes this check come before the
|
|
126
|
+
// positional count, not folded into it: a later `upgrade` (positionalCount > 0) falls through
|
|
127
|
+
// to the ordinary positional handling below, so `['range', 'upgrade']` still fails on the
|
|
128
|
+
// second-positional check with token "upgrade".
|
|
129
|
+
if (arg === 'upgrade' && positionalCount === 0) {
|
|
130
|
+
result.mode = 'upgrade'
|
|
131
|
+
continue
|
|
132
|
+
}
|
|
83
133
|
positionalCount++
|
|
84
134
|
if (positionalCount > 1) throw new ArgvError(arg)
|
|
85
135
|
result.directory = arg
|
|
86
136
|
}
|
|
87
137
|
|
|
138
|
+
// Command grammar: "a create-only flag (...) -> CW-E02" -- checked once `mode` is known for
|
|
139
|
+
// certain, so a create-only flag given BEFORE `upgrade` is caught too (U02's 2026-11-07
|
|
140
|
+
// sub-case), named by whichever such flag argv gave first.
|
|
141
|
+
if (result.mode === 'upgrade' && createOnlyFlagsSeen.length > 0) {
|
|
142
|
+
throw new ArgvError(createOnlyFlagsSeen[0])
|
|
143
|
+
}
|
|
144
|
+
|
|
88
145
|
return result
|
|
89
146
|
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// D2 (10): the oldest base version this tool upgrades (traceability.md "Decisions", "Step order"
|
|
2
|
+
// row 6). `MINIMUM_BASE_VERSION` is the numeric form `upgrade.mjs`'s own comparison uses;
|
|
3
|
+
// `MINIMUM_BASE_VERSION_TEXT` is the one string both message tables print for UP-E10 (S-10: one
|
|
4
|
+
// source with the actual check, not a literal "3.0.0" repeated in both tables) -- mirroring
|
|
5
|
+
// `git-version.mjs`'s `MINIMUM_GIT_VERSION`/`messages.mjs`'s own `MINIMUM_GIT_VERSION_TEXT` pair.
|
|
6
|
+
// Lives in its own module, not in `upgrade.mjs`, so `messages.mjs` can import this constant
|
|
7
|
+
// without a cycle.
|
|
8
|
+
|
|
9
|
+
export const MINIMUM_BASE_VERSION = [3, 0, 0]
|
|
10
|
+
export const MINIMUM_BASE_VERSION_TEXT = MINIMUM_BASE_VERSION.join('.')
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// D19 (traceability.md revision 3.1; T-0061, moved here in T-0062 so step 8 shares it): runs one
|
|
2
|
+
// git command and, when `runGit` rejects (not an exit code -- the spawn itself failed), rethrows it
|
|
3
|
+
// as a bug whose message names the command, so CW-E14 says which git command failed. A signal
|
|
4
|
+
// already received, or a `SignalAbort`, passes through unchanged so the run still exits 130.
|
|
5
|
+
|
|
6
|
+
import { SignalAbort } from './signals.mjs'
|
|
7
|
+
import { buildGitEnv } from './git-env.mjs'
|
|
8
|
+
|
|
9
|
+
export async function runGitNamed(deps, guard, args, opts, name) {
|
|
10
|
+
try {
|
|
11
|
+
return await deps.runGit(args, opts)
|
|
12
|
+
} catch (err) {
|
|
13
|
+
if (err instanceof SignalAbort) throw err
|
|
14
|
+
guard.throwIfRaised()
|
|
15
|
+
throw new Error(`git ${name} failed: ${err?.message ?? String(err)}`, { cause: err })
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** P-5 and D15 (traceability.md "Pre-checks", step 5; Vocabulary's own "ignored path" -- "a path
|
|
20
|
+
* that is not tracked and that `git check-ignore -q --no-index -- ./<path>` reports as ignored by
|
|
21
|
+
* the range's rules"); shared by both since T-0062 (S1). `check-ignore`'s own exit codes are 0
|
|
22
|
+
* (ignored) and 1 (not ignored); any other exit code, or a `runGit` rejection, is a bug naming the
|
|
23
|
+
* command -- fail closed (D6, D19), never silently read as either answer. Called only for a path
|
|
24
|
+
* the caller has already found untracked (a force-added, tracked file that also matches an ignore
|
|
25
|
+
* rule is never P-5, and never D15). */
|
|
26
|
+
export async function isIgnoredPath(deps, guard, rangeRoot, path) {
|
|
27
|
+
// `./` keeps the path literal: `check-ignore` refuses every pathspec magic (`--literal-pathspecs`
|
|
28
|
+
// included), and a path starting with `:` would otherwise be read as magic and check another path.
|
|
29
|
+
const name = `check-ignore -- ./${path}`
|
|
30
|
+
const result = await runGitNamed(
|
|
31
|
+
deps,
|
|
32
|
+
guard,
|
|
33
|
+
['check-ignore', '-q', '--no-index', '--', `./${path}`],
|
|
34
|
+
{ cwd: rangeRoot, env: buildGitEnv(deps.env) },
|
|
35
|
+
name,
|
|
36
|
+
)
|
|
37
|
+
// A signal this process already received while that `runGit` call was in flight wins over
|
|
38
|
+
// interpreting its result, the same way `runMergeFile` already does.
|
|
39
|
+
guard.throwIfRaised()
|
|
40
|
+
if (result.code === 0) return true
|
|
41
|
+
if (result.code === 1) return false
|
|
42
|
+
throw new Error(`git ${name} failed (exit code ${result.code}): ${result.stderr}`)
|
|
43
|
+
}
|
package/src/git-runner.mjs
CHANGED
|
@@ -65,12 +65,16 @@ function resolveGitPath(env, cwd, platform) {
|
|
|
65
65
|
return null
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
-
/** Returns a `runGit(args, { cwd, env })` function matching `deps.runGit`'s contract:
|
|
69
|
-
* `{ code, stdout, stderr }`, or rejects (ENOENT when `git` cannot be resolved from
|
|
70
|
-
* whatever `child_process.spawn`'s own `error` event reports). `platform` (default
|
|
71
|
-
* `process.platform`) is injectable so tests can exercise the win32 lookup on any host.
|
|
68
|
+
/** Returns a `runGit(args, { cwd, env, binary })` function matching `deps.runGit`'s contract:
|
|
69
|
+
* resolves to `{ code, stdout, stderr }`, or rejects (ENOENT when `git` cannot be resolved from
|
|
70
|
+
* `PATH`, or whatever `child_process.spawn`'s own `error` event reports). `platform` (default
|
|
71
|
+
* `process.platform`) is injectable so tests can exercise the win32 lookup on any host. T-0027
|
|
72
|
+
* (D4, increment 25): `binary` (default falsy; every existing call site is unaffected) keeps
|
|
73
|
+
* `stdout` as a `Buffer` -- `git merge-file -p`'s own output is the one caller (`merge-plan.mjs`)
|
|
74
|
+
* that cannot afford the string-decoding path below silently replacing an invalid UTF-8 byte
|
|
75
|
+
* sequence (a non-UTF-8 text file, e.g. Big5) with U+FFFD before it is ever written back. */
|
|
72
76
|
export function createSpawnGitRunner({ platform = process.platform } = {}) {
|
|
73
|
-
return function runGit(args, { cwd, env }) {
|
|
77
|
+
return function runGit(args, { cwd, env, binary = false }) {
|
|
74
78
|
return new Promise((resolvePromise, reject) => {
|
|
75
79
|
const gitPath = resolveGitPath(env, cwd, platform)
|
|
76
80
|
if (!gitPath) {
|
|
@@ -86,20 +90,29 @@ export function createSpawnGitRunner({ platform = process.platform } = {}) {
|
|
|
86
90
|
reject(err)
|
|
87
91
|
return
|
|
88
92
|
}
|
|
89
|
-
//
|
|
90
|
-
//
|
|
91
|
-
//
|
|
92
|
-
// the
|
|
93
|
-
|
|
93
|
+
// stderr is always decoded as text (a multibyte UTF-8 character can land split across two
|
|
94
|
+
// `data` events; `setEncoding` buffers an incomplete trailing sequence until the rest
|
|
95
|
+
// arrives, decoding each chunk on its own would mangle it). stdout does the same UNLESS
|
|
96
|
+
// `binary` asks for the raw bytes instead, accumulated as `Buffer` chunks and concatenated
|
|
97
|
+
// once on `close` -- never `setEncoding('utf8')`, which would already have thrown away the
|
|
98
|
+
// original bytes by the time `close` fires.
|
|
94
99
|
child.stderr.setEncoding('utf8')
|
|
95
|
-
let stdout = ''
|
|
96
100
|
let stderr = ''
|
|
97
|
-
child.stdout.on('data', (chunk) => {
|
|
98
|
-
stdout += chunk
|
|
99
|
-
})
|
|
100
101
|
child.stderr.on('data', (chunk) => {
|
|
101
102
|
stderr += chunk
|
|
102
103
|
})
|
|
104
|
+
if (binary) {
|
|
105
|
+
const stdoutChunks = []
|
|
106
|
+
child.stdout.on('data', (chunk) => stdoutChunks.push(chunk))
|
|
107
|
+
child.on('error', (err) => reject(err))
|
|
108
|
+
child.on('close', (code) => resolvePromise({ code, stdout: Buffer.concat(stdoutChunks), stderr }))
|
|
109
|
+
return
|
|
110
|
+
}
|
|
111
|
+
child.stdout.setEncoding('utf8')
|
|
112
|
+
let stdout = ''
|
|
113
|
+
child.stdout.on('data', (chunk) => {
|
|
114
|
+
stdout += chunk
|
|
115
|
+
})
|
|
103
116
|
child.on('error', (err) => reject(err))
|
|
104
117
|
child.on('close', (code) => resolvePromise({ code, stdout, stderr }))
|
|
105
118
|
})
|
package/src/git-version.mjs
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
|
-
// G0 (traceability.md "Git steps"): `git --version` must report 2.
|
|
1
|
+
// G0 (traceability.md "Git steps"): `git --version` must report 2.26 or later (0006's D21, 0005
|
|
2
|
+
// revision 2.3: before 2.26 git runs `core.fsmonitor=true` as a hook and may report no changes, and
|
|
3
|
+
// before 2.25.2 `git check-ignore` ignores `!` re-inclusions). `git --version`'s
|
|
2
4
|
// own output is `git version <major>.<minor>.<patch>[<anything>]` (a Homebrew/Xcode suffix like
|
|
3
5
|
// `(Apple Git-154)`, or a Windows suffix like `.windows.1`); only the leading `\d+\.\d+(\.\d+)?` is
|
|
4
6
|
// read, compared with `numeric-version.mjs`'s shared helper (S-11) so `2.9.5` is not mistaken
|
|
5
|
-
// for newer than `2.
|
|
7
|
+
// for newer than `2.26.0`.
|
|
6
8
|
|
|
7
9
|
import { isAtLeast } from './numeric-version.mjs'
|
|
8
10
|
|
|
9
|
-
export const MINIMUM_GIT_VERSION = [2,
|
|
11
|
+
export const MINIMUM_GIT_VERSION = [2, 26]
|
|
10
12
|
|
|
11
13
|
const VERSION_PREFIX_RE = /^git version (\d+)\.(\d+)(?:\.(\d+))?/
|
|
12
14
|
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
// Step 9 (traceability.md "Step order" row 9): deletions first, then writes, in sorted path
|
|
2
|
+
// order; each write goes to a new file in the destination directory, then renamed over the
|
|
3
|
+
// destination, creating any missing parent directory first. traceability.md "Merge table" M5: a
|
|
4
|
+
// brand-new path takes `op.newFileExecutable`'s own bit (under the process's own umask, through
|
|
5
|
+
// the ordinary `writeFile` mode option); an existing path keeps ITS OWN current mode --
|
|
6
|
+
// write-then-rename always creates a new inode, so that mode is never carried forward for free,
|
|
7
|
+
// hence the explicit `lstat`-then-`chmod` here (T-0027 increment 7). T-0027 increment 10 (U27):
|
|
8
|
+
// a real file-system error on any delete or write is reported as `{ ok: false, path, detail }`
|
|
9
|
+
// instead of propagating as a bug (CW-E14); the half-written temp file, if any, is unlinked
|
|
10
|
+
// first. T-0027 increment 11 (U28): `guard` is checked after every successful operation (delete
|
|
11
|
+
// or write) -- traceability.md "Codes" UP-E09: a signal already raised there stops the loop
|
|
12
|
+
// before the next operation starts and before `range.json` is ever rewritten, reported as
|
|
13
|
+
// `{ ok: false, signal }`.
|
|
14
|
+
|
|
15
|
+
import { randomBytes } from 'node:crypto'
|
|
16
|
+
import { dirname, join } from 'node:path'
|
|
17
|
+
|
|
18
|
+
/** A dotfile alongside the destination, inside the same directory (so the final `rename` is
|
|
19
|
+
* same-device): `.create-wxl-upgrade-tmp-<16 random hex chars>`, a FIXED LENGTH never growing
|
|
20
|
+
* with the destination's own basename -- a brand-new path whose own basename is already close to
|
|
21
|
+
* `NAME_MAX` must not fail with `ENAMETOOLONG` only because of space this temporary name added on
|
|
22
|
+
* top of it. The random part alone still keeps every concurrent write
|
|
23
|
+
* (in the same destination directory, in the same run) its own temporary name; a FIXED name with
|
|
24
|
+
* no random part at all could already exist -- an ignored file the teacher has, or a symbolic
|
|
25
|
+
* link -- and the write loop's own `writeFile`, below, uses `flag: 'wx'` (never the default
|
|
26
|
+
* `'w'`) so it refuses to create over whatever that existing name turns out to be, instead of
|
|
27
|
+
* silently truncating it or following a symlink through it. */
|
|
28
|
+
function tempPathFor(rangeRoot, path) {
|
|
29
|
+
const parts = path.split('/')
|
|
30
|
+
const last = parts.pop()
|
|
31
|
+
const dest = join(rangeRoot, ...parts, last)
|
|
32
|
+
const tmp = join(rangeRoot, ...parts, `.create-wxl-upgrade-tmp-${randomBytes(8).toString('hex')}`)
|
|
33
|
+
return { dest, tmp }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** `range.json` after the plan (traceability.md "`range.json` after the plan", T-0027 increment
|
|
37
|
+
* 8): read the current file (or start from `{}`), replace `upstream`'s value with `upstream`
|
|
38
|
+
* (keeping every other top-level key and the order of keys -- `upstream` keeps its own position,
|
|
39
|
+
* or is first when added), two-space indent, one trailing LF. */
|
|
40
|
+
function buildRangeJson(current, upstream) {
|
|
41
|
+
if (Object.prototype.hasOwnProperty.call(current, 'upstream')) {
|
|
42
|
+
const next = {}
|
|
43
|
+
// R-6 (T-0063): define each key as the object's own, as `JSON.parse` did -- a plain assignment
|
|
44
|
+
// of a `__proto__` key would set `next`'s prototype instead, and the key would be lost.
|
|
45
|
+
for (const key of Object.keys(current)) {
|
|
46
|
+
Object.defineProperty(next, key, {
|
|
47
|
+
value: key === 'upstream' ? upstream : current[key],
|
|
48
|
+
enumerable: true,
|
|
49
|
+
writable: true,
|
|
50
|
+
configurable: true,
|
|
51
|
+
})
|
|
52
|
+
}
|
|
53
|
+
return next
|
|
54
|
+
}
|
|
55
|
+
return { upstream, ...current }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** `range.json`'s own write (the last of step 9) goes through the
|
|
59
|
+
* same write-then-rename, file-system-error-vs-bug and signal handling as every other write in
|
|
60
|
+
* the loop below -- a failure here has already deleted/written every other op, so the range may
|
|
61
|
+
* be partially changed exactly the same way, and needs the same `UP-E09` + restore commands,
|
|
62
|
+
* not `CW-E14`. Returns `{ ok: true }`, or `{ ok: false, path: 'range.json', detail }` / `{ ok:
|
|
63
|
+
* false, signal }`, mirroring the write loop's own return shape. */
|
|
64
|
+
async function writeRangeJson(deps, guard, rangeRoot, upstream) {
|
|
65
|
+
const { dest: path, tmp } = tempPathFor(rangeRoot, 'range.json')
|
|
66
|
+
try {
|
|
67
|
+
let current = {}
|
|
68
|
+
try {
|
|
69
|
+
current = JSON.parse(await deps.fs.readFile(path, 'utf8'))
|
|
70
|
+
} catch (err) {
|
|
71
|
+
if (err?.code !== 'ENOENT') throw err
|
|
72
|
+
}
|
|
73
|
+
await deps.fs.writeFile(tmp, JSON.stringify(buildRangeJson(current, upstream), null, 2) + '\n', { flag: 'wx' })
|
|
74
|
+
if (guard.isRaised()) {
|
|
75
|
+
try {
|
|
76
|
+
await deps.fs.unlink(tmp)
|
|
77
|
+
} catch {
|
|
78
|
+
// Never created, or already gone -- either way, nothing left to clean up.
|
|
79
|
+
}
|
|
80
|
+
return { ok: false, signal: guard.raised() }
|
|
81
|
+
}
|
|
82
|
+
await deps.fs.rename(tmp, path)
|
|
83
|
+
} catch (err) {
|
|
84
|
+
try {
|
|
85
|
+
await deps.fs.unlink(tmp)
|
|
86
|
+
} catch {
|
|
87
|
+
// Never created, or already gone -- either way, nothing left to clean up.
|
|
88
|
+
}
|
|
89
|
+
return { ok: false, path: 'range.json', detail: err.message }
|
|
90
|
+
}
|
|
91
|
+
if (guard.isRaised()) return { ok: false, signal: guard.raised() }
|
|
92
|
+
return { ok: true }
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** `applyMerge({ deps, guard, rangeRoot, ops, upstream })` -> `{ ok: true }` on success, or
|
|
96
|
+
* `{ ok: false, path, detail }` (a file-system error at that path) / `{ ok: false, signal }` (a
|
|
97
|
+
* signal raised mid-apply, U28) otherwise -- the caller (`upgrade.mjs`) turns either into
|
|
98
|
+
* `UP-E09` and exit 1 / 130. `ops` is `planMerge`'s own sorted array; this function re-sorts
|
|
99
|
+
* defensively so a caller's own ordering is never relied on by accident. `upstream` (T-0027
|
|
100
|
+
* increment 8) is `range.json`'s own `upstream` value after the run: `{ repository, version,
|
|
101
|
+
* commit }` of the target, written last (after every deletion/write), the same write-then-rename
|
|
102
|
+
* path -- never reached when a delete/write already failed or a signal already stopped the loop,
|
|
103
|
+
* so an incomplete apply is never recorded as done (U24's deferred sub-case). */
|
|
104
|
+
export async function applyMerge({ deps, guard, rangeRoot, ops, upstream }) {
|
|
105
|
+
const deletes = ops.filter((op) => op.action === 'delete').sort((a, b) => (a.path < b.path ? -1 : 1))
|
|
106
|
+
const writes = ops.filter((op) => op.action === 'write').sort((a, b) => (a.path < b.path ? -1 : 1))
|
|
107
|
+
|
|
108
|
+
for (const op of deletes) {
|
|
109
|
+
const { dest } = tempPathFor(rangeRoot, op.path)
|
|
110
|
+
try {
|
|
111
|
+
await deps.fs.unlink(dest)
|
|
112
|
+
} catch (err) {
|
|
113
|
+
return { ok: false, path: op.path, detail: err.message }
|
|
114
|
+
}
|
|
115
|
+
if (guard.isRaised()) return { ok: false, signal: guard.raised() }
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
for (const op of writes) {
|
|
119
|
+
const { dest, tmp } = tempPathFor(rangeRoot, op.path)
|
|
120
|
+
try {
|
|
121
|
+
await deps.fs.mkdir(dirname(dest), { recursive: true })
|
|
122
|
+
|
|
123
|
+
let existingMode
|
|
124
|
+
if (op.existedBefore) {
|
|
125
|
+
try {
|
|
126
|
+
existingMode = (await deps.fs.lstat(dest)).mode & 0o777
|
|
127
|
+
} catch (err) {
|
|
128
|
+
// Only ENOENT (gone by some other means -- nothing to preserve) is read as absent;
|
|
129
|
+
// any other error (e.g. EACCES) is a planning failure, not evidence of absence --
|
|
130
|
+
// fail closed, the same convention every other pre-check's own `lstat` already uses.
|
|
131
|
+
// Rethrown here, this reaches the write loop's own catch, below, which reports it as
|
|
132
|
+
// `UP-E09` with the restore commands, the same as any other failed write.
|
|
133
|
+
if (err?.code !== 'ENOENT') throw err
|
|
134
|
+
existingMode = undefined
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const writeOptions = { flag: 'wx' }
|
|
139
|
+
if (!op.existedBefore && op.newFileExecutable) writeOptions.mode = 0o755
|
|
140
|
+
await deps.fs.writeFile(tmp, op.content, writeOptions)
|
|
141
|
+
if (existingMode !== undefined) await deps.fs.chmod(tmp, existingMode)
|
|
142
|
+
await deps.fs.rename(tmp, dest)
|
|
143
|
+
} catch (err) {
|
|
144
|
+
// The half-written temp file never survives a failed write.
|
|
145
|
+
try {
|
|
146
|
+
await deps.fs.unlink(tmp)
|
|
147
|
+
} catch {
|
|
148
|
+
// Never created, or already gone -- either way, nothing left to clean up.
|
|
149
|
+
}
|
|
150
|
+
return { ok: false, path: op.path, detail: err.message }
|
|
151
|
+
}
|
|
152
|
+
if (guard.isRaised()) return { ok: false, signal: guard.raised() }
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return await writeRangeJson(deps, guard, rangeRoot, upstream)
|
|
156
|
+
}
|