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.
@@ -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
@@ -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 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).
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 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.
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, short-link origin): anchor mode only — never reached in rebrand mode,
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 short-link origin (traceability.md, T81): replacing a value that is not this one
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 short-link origin for `--host github-pages` without `--origin`, omitting the path for
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 (short-link origin) when --origin is given or derived. No
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 (short-link origin): --origin, or derived from --host github-pages without
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 short-link origin that is not upstream's warns — the engine
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 short-link origin is replaced only when --origin (or a derived GitHub Pages origin) says so.`,
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.0.0",
4
- "commit": "52192fa85fe7c6c7bf4cecb4c287d6f2876387a4",
5
- "engineSha256": "691937dc55f28cc78e581d66b77f3eb16a34e2bc2ea56b1a83df9bba95f3a035"
3
+ "version": "3.1.0",
4
+ "commit": "b4d3fd0e0c46f5c872e7f680e5401f8a8d8c5f60",
5
+ "engineSha256": "d67a3403bada3d3c8ec9a22d8c43e3e07a99018ab2662cd9e740dd2c7772d61a"
6
6
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-wxl",
3
- "version": "3.0.0",
3
+ "version": "3.1.0",
4
4
  "description": "Create a Web eXploitation Laboratory range",
5
5
  "type": "module",
6
6
  "bin": { "create-wxl": "bin/create-wxl.mjs" },
@@ -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.toLowerCase() === '.git') throw new ArchiveRuleError(3, headerPath)
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 requested (create-wxl always downloads by commit, so rule 2's comment
117
- * check always applies).
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
- if (arg === 'upgrade') throw new ArgvError(arg)
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
+ }
@@ -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: resolves to
69
- * `{ code, stdout, stderr }`, or rejects (ENOENT when `git` cannot be resolved from `PATH`, or
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
- // A multibyte UTF-8 character can land split across two `data` events; decoding each
90
- // chunk on its own (`stdout += chunk`) would mangle it, so each stream gets its own
91
- // stateful decoder instead (`setEncoding` buffers an incomplete trailing sequence until
92
- // the rest arrives).
93
- child.stdout.setEncoding('utf8')
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
  })
@@ -1,12 +1,14 @@
1
- // G0 (traceability.md "Git steps"): `git --version` must report 2.20 or later. `git --version`'s
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.20.0`.
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, 20]
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
+ }