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.
- package/LICENSE +127 -0
- package/bin/create-wxl.mjs +155 -0
- package/dist/fork-init.mjs +1783 -0
- package/dist/source.json +6 -0
- package/package.json +20 -4
- package/src/archive-rules.mjs +164 -0
- package/src/archive.mjs +109 -0
- package/src/argv.mjs +146 -0
- package/src/base-version.mjs +10 -0
- package/src/fsops.mjs +205 -0
- package/src/git-env.mjs +13 -0
- package/src/git-named.mjs +43 -0
- package/src/git-runner.mjs +120 -0
- package/src/git-steps.mjs +148 -0
- package/src/git-version.mjs +27 -0
- package/src/language.mjs +14 -0
- package/src/merge-apply.mjs +156 -0
- package/src/merge-plan.mjs +660 -0
- package/src/messages.mjs +547 -0
- package/src/next-steps.mjs +29 -0
- package/src/node-version.mjs +17 -0
- package/src/numeric-version.mjs +16 -0
- package/src/patterns.mjs +6 -0
- package/src/pristine.mjs +104 -0
- package/src/publish-io.mjs +123 -0
- package/src/publish.mjs +304 -0
- package/src/questions.mjs +274 -0
- package/src/raw-tar.mjs +188 -0
- package/src/run-shared.mjs +207 -0
- package/src/run.mjs +437 -0
- package/src/signals.mjs +88 -0
- package/src/text.mjs +127 -0
- package/src/upgrade-report.mjs +134 -0
- package/src/upgrade.mjs +582 -0
- package/README.md +0 -3
package/src/git-env.mjs
ADDED
|
@@ -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
|
+
}
|
package/src/language.mjs
ADDED
|
@@ -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
|
+
}
|