create-wxl 0.0.0-stage → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,13 @@
1
+ // "Git steps" (traceability.md): every git command runs with `deps.env` minus every variable
2
+ // whose name starts with `GIT_`, plus exactly `GIT_TERMINAL_PROMPT=0` and `GIT_CONFIG_NOSYSTEM=1`.
3
+ // The filter compares the name exactly, as the spec says (decision 5): Windows' case-insensitive
4
+ // environment names are a recorded known limit, not handled here.
5
+
6
+ export function buildGitEnv(env) {
7
+ const filtered = {}
8
+ for (const [key, value] of Object.entries(env)) {
9
+ if (key.startsWith('GIT_')) continue
10
+ filtered[key] = value
11
+ }
12
+ return { ...filtered, GIT_TERMINAL_PROMPT: '0', GIT_CONFIG_NOSYSTEM: '1' }
13
+ }
@@ -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
+ }
@@ -0,0 +1,120 @@
1
+ // The real `runGit` implementation ("Git steps", traceability.md): spawns `git` without a shell,
2
+ // using only the env the caller gives (never merged with `process.env` -- running under a git hook
3
+ // during this repository's own tests would otherwise leak `GIT_DIR`/`GIT_INDEX_FILE` into the
4
+ // isolated run). `git` is resolved from the absolute directories of `PATH` only: a relative entry
5
+ // is ignored, and so is an entry whose realpath equals the current call's own `cwd` (the target)
6
+ // or lies under it (decision 2, "never from the target directory"); a `PATH` entry that does not
7
+ // exist is simply skipped. `bin`'s own wiring is T-0025's.
8
+
9
+ import { spawn } from 'node:child_process'
10
+ import { accessSync, constants as fsConstants, realpathSync, statSync } from 'node:fs'
11
+ import { delimiter, isAbsolute, join, relative, resolve as resolvePath, sep } from 'node:path'
12
+
13
+ function candidateNames(platform) {
14
+ return platform === 'win32' ? ['git.exe'] : ['git']
15
+ }
16
+
17
+ /** Windows' environment variable names are case-insensitive (`Path`, not just `PATH`); find the
18
+ * first key that matches `PATH` case-insensitively on win32, and only `PATH` exactly elsewhere. */
19
+ function findPathValue(env, platform) {
20
+ if (platform !== 'win32') return env.PATH ?? ''
21
+ for (const [key, value] of Object.entries(env)) {
22
+ if (key.toUpperCase() === 'PATH') return value ?? ''
23
+ }
24
+ return ''
25
+ }
26
+
27
+ /** True when `dir` (already realpath'd) is `base` itself or lies under it -- a plain prefix check
28
+ * on the two realpaths, which also makes this case-insensitive-filesystem-safe (macOS APFS,
29
+ * Windows): `realpathSync.native` returns the on-disk casing regardless of the casing given. */
30
+ function isSameOrUnder(dir, base) {
31
+ if (dir === base) return true
32
+ const rel = relative(base, dir)
33
+ return rel !== '' && rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel)
34
+ }
35
+
36
+ function resolveGitPath(env, cwd, platform) {
37
+ const pathValue = findPathValue(env, platform)
38
+ const dirs = pathValue.split(delimiter).filter((entry) => entry !== '')
39
+ let excludedDir
40
+ try {
41
+ excludedDir = realpathSync.native(cwd)
42
+ } catch {
43
+ excludedDir = resolvePath(cwd)
44
+ }
45
+ for (const dir of dirs) {
46
+ if (!isAbsolute(dir)) continue
47
+ let resolvedDir
48
+ try {
49
+ resolvedDir = realpathSync.native(dir)
50
+ } catch {
51
+ continue
52
+ }
53
+ if (isSameOrUnder(resolvedDir, excludedDir)) continue
54
+ for (const name of candidateNames(platform)) {
55
+ const candidate = join(resolvedDir, name)
56
+ try {
57
+ if (!statSync(candidate).isFile()) continue
58
+ accessSync(candidate, fsConstants.X_OK)
59
+ return candidate
60
+ } catch {
61
+ continue
62
+ }
63
+ }
64
+ }
65
+ return null
66
+ }
67
+
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. */
76
+ export function createSpawnGitRunner({ platform = process.platform } = {}) {
77
+ return function runGit(args, { cwd, env, binary = false }) {
78
+ return new Promise((resolvePromise, reject) => {
79
+ const gitPath = resolveGitPath(env, cwd, platform)
80
+ if (!gitPath) {
81
+ const err = new Error('spawn git ENOENT')
82
+ err.code = 'ENOENT'
83
+ reject(err)
84
+ return
85
+ }
86
+ let child
87
+ try {
88
+ child = spawn(gitPath, args, { cwd, env, shell: false })
89
+ } catch (err) {
90
+ reject(err)
91
+ return
92
+ }
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.
99
+ child.stderr.setEncoding('utf8')
100
+ let stderr = ''
101
+ child.stderr.on('data', (chunk) => {
102
+ stderr += chunk
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
+ })
116
+ child.on('error', (err) => reject(err))
117
+ child.on('close', (code) => resolvePromise({ code, stdout, stderr }))
118
+ })
119
+ }
120
+ }
@@ -0,0 +1,148 @@
1
+ // Git steps (traceability.md "Git steps", G0-G4): turns the configured range into a git
2
+ // repository on `main` with exactly one commit, isolated from the teacher's global git
3
+ // configuration. `runGitSteps` runs G0-G4 in order and classifies any failure into one of
4
+ // CW-W01 (G0), CW-W02 (G3: no identity) or CW-W03 (any other command failure).
5
+
6
+ import { buildGitEnv } from './git-env.mjs'
7
+ import { parseGitVersion, isGitVersionOk } from './git-version.mjs'
8
+
9
+ const CONFIG_GLOBAL_NAME = 'git config --global user.name "<your name>"'
10
+ const CONFIG_GLOBAL_EMAIL = 'git config --global user.email "<you@example.com>"'
11
+
12
+ /** The printed forms of G1, G2 and G4 (traceability.md "Git steps", "Printed form" column), the
13
+ * one source `cwE12FinishingCommands` (messages.mjs) also builds from (S-09). */
14
+ export function printedForms(version) {
15
+ return {
16
+ init: 'git init',
17
+ symbolicRef: 'git symbolic-ref HEAD refs/heads/main',
18
+ add: 'git add -A',
19
+ commit: `git commit -m "Create range from WXL ${version}"`,
20
+ }
21
+ }
22
+
23
+ /** S-20: `printedForms`' own G1/G2/G4 lines, in Step order (the one array this module's own
24
+ * `orderedForms` and CW-W05's step-12 window -- run.mjs -- both need; a signal before step 12's
25
+ * G0 ever ran means none of them have). */
26
+ export function orderedPrintedForms(version) {
27
+ const forms = printedForms(version)
28
+ return [forms.init, forms.symbolicRef, forms.add, forms.commit]
29
+ }
30
+
31
+ /** Runs `run(args)`, turning a rejection into the same shape a resolved call has (`threw: true`
32
+ * instead of a `code`), so every call site classifies both the same way. */
33
+ async function attempt(run, args) {
34
+ try {
35
+ const result = await run(args)
36
+ return { threw: false, code: result.code, stdout: result.stdout ?? '', stderr: result.stderr ?? '' }
37
+ } catch (err) {
38
+ return { threw: true, error: err }
39
+ }
40
+ }
41
+
42
+ /** The text CW-W03 and N2 print for a command's failure: its stderr when it ran (even empty), or
43
+ * the rejection's own message when it never returned at all. */
44
+ function detailOf(result) {
45
+ return result.threw ? (result.error?.message ?? String(result.error)) : result.stderr
46
+ }
47
+
48
+ /** G3's own rule (traceability.md "Git steps" and the revision 2.1 self-check "G3's exit 1 is
49
+ * CW-W02, not CW-W03"): exit 1, or exit 0 with empty output, is "no identity" (CW-W02); a
50
+ * rejection or any other exit code (e.g. 128, "fatal: bad config") is CW-W03 ("error"). */
51
+ function classifyConfigGet(result) {
52
+ if (result.threw) return 'error'
53
+ if (result.code === 1) return 'missing'
54
+ if (result.code === 0 && result.stdout.trim() === '') return 'missing'
55
+ if (result.code === 0) return 'ok'
56
+ return 'error'
57
+ }
58
+
59
+ /**
60
+ * Runs G0-G4 in order through `runGit(args, { cwd, env })` with `buildGitEnv(env)`. Returns
61
+ * `{ warningId: null }` on success, or one of:
62
+ * - `{ warningId: 'CW-W01', remainingCommands }` (G0 rejected, exited non-zero, or reported an
63
+ * unparsable or too-old version)
64
+ * - `{ warningId: 'CW-W02', remainingCommands }` (G3: no explicit committer identity)
65
+ * - `{ warningId: 'CW-W03', command, detail, remainingCommands }` (any other git command failure,
66
+ * decision 4: a G1-G4 rejection is always CW-W03, never CW-W01)
67
+ * `remainingCommands` are the printed forms (traceability.md "Git steps") still to run, in order;
68
+ * for CW-W02 the two `git config --global` commands come first.
69
+ */
70
+ export async function runGitSteps({ runGit, cwd, env: rawEnv, version, shouldStop }) {
71
+ const env = buildGitEnv(rawEnv)
72
+ const run = (args) => runGit(args, { cwd, env })
73
+ const forms = printedForms(version)
74
+ const orderedForms = orderedPrintedForms(version)
75
+ const isSignaled = shouldStop ?? (() => false)
76
+
77
+ const versionResult = await attempt(run, ['--version'])
78
+ if (isSignaled()) return { signal: true, remainingCommands: orderedForms }
79
+ if (versionResult.threw || versionResult.code !== 0 || !isGitVersionOk(parseGitVersion(versionResult.stdout))) {
80
+ return { warningId: 'CW-W01', remainingCommands: orderedForms }
81
+ }
82
+
83
+ const mainArgs = [
84
+ ['init', '--template='],
85
+ ['symbolic-ref', 'HEAD', 'refs/heads/main'],
86
+ ['-c', 'core.excludesFile=', 'add', '-A', '--force'],
87
+ ]
88
+ for (let i = 0; i < mainArgs.length; i++) {
89
+ const result = await attempt(run, mainArgs[i])
90
+ // C-13/P-22: a command that already succeeded is not "not run", even when a signal arrives
91
+ // right after it -- checked first, so CW-W05 never lists a command this run already did.
92
+ if (!result.threw && result.code === 0) {
93
+ if (isSignaled()) return { signal: true, remainingCommands: orderedForms.slice(i + 1) }
94
+ continue
95
+ }
96
+ // traceability.md "Signals": "a git command that exits non-zero after a signal counts as the
97
+ // signal, not as CW-W03" -- checked before the failure classification below, so a command this
98
+ // run's own injected signal made fail is never misreported as a `runGit` failure.
99
+ if (isSignaled()) return { signal: true, remainingCommands: orderedForms.slice(i) }
100
+ return { warningId: 'CW-W03', command: orderedForms[i], detail: detailOf(result), remainingCommands: orderedForms.slice(i) }
101
+ }
102
+
103
+ const nameResult = await attempt(run, ['config', '--get', 'user.name'])
104
+ if (isSignaled()) return { signal: true, remainingCommands: [forms.commit] }
105
+ const nameClass = classifyConfigGet(nameResult)
106
+ if (nameClass === 'error') {
107
+ return {
108
+ warningId: 'CW-W03',
109
+ command: 'git config --get user.name',
110
+ detail: detailOf(nameResult),
111
+ remainingCommands: [forms.commit],
112
+ }
113
+ }
114
+ const emailResult = await attempt(run, ['config', '--get', 'user.email'])
115
+ if (isSignaled()) return { signal: true, remainingCommands: [forms.commit] }
116
+ const emailClass = classifyConfigGet(emailResult)
117
+ if (emailClass === 'error') {
118
+ return {
119
+ warningId: 'CW-W03',
120
+ command: 'git config --get user.email',
121
+ detail: detailOf(emailResult),
122
+ remainingCommands: [forms.commit],
123
+ }
124
+ }
125
+ if (nameClass === 'missing' || emailClass === 'missing') {
126
+ return { warningId: 'CW-W02', remainingCommands: [CONFIG_GLOBAL_NAME, CONFIG_GLOBAL_EMAIL, forms.commit] }
127
+ }
128
+
129
+ const commitResult = await attempt(run, [
130
+ '-c',
131
+ 'user.useConfigOnly=true',
132
+ '-c',
133
+ 'commit.gpgsign=false',
134
+ '-c',
135
+ 'core.hooksPath=',
136
+ 'commit',
137
+ '--no-verify',
138
+ '-m',
139
+ `Create range from WXL ${version}`,
140
+ ])
141
+ // C-13/P-22: G4 already succeeded is not "not run" either -- the range is complete.
142
+ if (!commitResult.threw && commitResult.code === 0) {
143
+ if (isSignaled()) return { signal: true, remainingCommands: [] }
144
+ return { warningId: null }
145
+ }
146
+ if (isSignaled()) return { signal: true, remainingCommands: [forms.commit] }
147
+ return { warningId: 'CW-W03', command: forms.commit, detail: detailOf(commitResult), remainingCommands: [forms.commit] }
148
+ }
@@ -0,0 +1,27 @@
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
4
+ // own output is `git version <major>.<minor>.<patch>[<anything>]` (a Homebrew/Xcode suffix like
5
+ // `(Apple Git-154)`, or a Windows suffix like `.windows.1`); only the leading `\d+\.\d+(\.\d+)?` is
6
+ // read, compared with `numeric-version.mjs`'s shared helper (S-11) so `2.9.5` is not mistaken
7
+ // for newer than `2.26.0`.
8
+
9
+ import { isAtLeast } from './numeric-version.mjs'
10
+
11
+ export const MINIMUM_GIT_VERSION = [2, 26]
12
+
13
+ const VERSION_PREFIX_RE = /^git version (\d+)\.(\d+)(?:\.(\d+))?/
14
+
15
+ /** Returns `[major, minor, patch]` (patch 0 when absent), or `null` when `stdout` does not start
16
+ * with a recognisable `git version <N>.<N>` prefix. */
17
+ export function parseGitVersion(stdout) {
18
+ const match = VERSION_PREFIX_RE.exec(String(stdout))
19
+ if (!match) return null
20
+ return [Number(match[1]), Number(match[2]), Number(match[3] ?? '0')]
21
+ }
22
+
23
+ /** `parsed`: `parseGitVersion`'s result. `null` (unparsable) is never ok. */
24
+ export function isGitVersionOk(parsed, minimum = MINIMUM_GIT_VERSION) {
25
+ if (parsed === null) return false
26
+ return isAtLeast(parsed, minimum)
27
+ }
@@ -0,0 +1,14 @@
1
+ // "Language" (traceability.md): `--lang` if given and valid; otherwise the first of `LC_ALL`,
2
+ // `LC_MESSAGES`, `LANG` in `deps.env` that is set and non-empty; otherwise `deps.locale`. CW-E01
3
+ // and CW-E02 use the environment rule only (they happen before or during parsing): callers pass no
4
+ // `explicitLang` for those. Every message after argv has parsed passes `parsed.lang` here.
5
+
6
+ export function selectLanguage(deps, explicitLang) {
7
+ if (explicitLang === 'en' || explicitLang === 'zh-TW') return explicitLang
8
+ const candidates = [deps.env?.LC_ALL, deps.env?.LC_MESSAGES, deps.env?.LANG]
9
+ for (const candidate of candidates) {
10
+ if (candidate) return /^zh/i.test(candidate) ? 'zh-TW' : 'en'
11
+ }
12
+ const locale = deps.locale ?? ''
13
+ return /^zh/i.test(locale) ? 'zh-TW' : 'en'
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
+ }