create-wxl 0.0.0-stage → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,107 @@
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 })` 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. */
72
+ export function createSpawnGitRunner({ platform = process.platform } = {}) {
73
+ return function runGit(args, { cwd, env }) {
74
+ return new Promise((resolvePromise, reject) => {
75
+ const gitPath = resolveGitPath(env, cwd, platform)
76
+ if (!gitPath) {
77
+ const err = new Error('spawn git ENOENT')
78
+ err.code = 'ENOENT'
79
+ reject(err)
80
+ return
81
+ }
82
+ let child
83
+ try {
84
+ child = spawn(gitPath, args, { cwd, env, shell: false })
85
+ } catch (err) {
86
+ reject(err)
87
+ return
88
+ }
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')
94
+ child.stderr.setEncoding('utf8')
95
+ let stdout = ''
96
+ let stderr = ''
97
+ child.stdout.on('data', (chunk) => {
98
+ stdout += chunk
99
+ })
100
+ child.stderr.on('data', (chunk) => {
101
+ stderr += chunk
102
+ })
103
+ child.on('error', (err) => reject(err))
104
+ child.on('close', (code) => resolvePromise({ code, stdout, stderr }))
105
+ })
106
+ }
107
+ }
@@ -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,25 @@
1
+ // G0 (traceability.md "Git steps"): `git --version` must report 2.20 or later. `git --version`'s
2
+ // own output is `git version <major>.<minor>.<patch>[<anything>]` (a Homebrew/Xcode suffix like
3
+ // `(Apple Git-154)`, or a Windows suffix like `.windows.1`); only the leading `\d+\.\d+(\.\d+)?` is
4
+ // 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`.
6
+
7
+ import { isAtLeast } from './numeric-version.mjs'
8
+
9
+ export const MINIMUM_GIT_VERSION = [2, 20]
10
+
11
+ const VERSION_PREFIX_RE = /^git version (\d+)\.(\d+)(?:\.(\d+))?/
12
+
13
+ /** Returns `[major, minor, patch]` (patch 0 when absent), or `null` when `stdout` does not start
14
+ * with a recognisable `git version <N>.<N>` prefix. */
15
+ export function parseGitVersion(stdout) {
16
+ const match = VERSION_PREFIX_RE.exec(String(stdout))
17
+ if (!match) return null
18
+ return [Number(match[1]), Number(match[2]), Number(match[3] ?? '0')]
19
+ }
20
+
21
+ /** `parsed`: `parseGitVersion`'s result. `null` (unparsable) is never ok. */
22
+ export function isGitVersionOk(parsed, minimum = MINIMUM_GIT_VERSION) {
23
+ if (parsed === null) return false
24
+ return isAtLeast(parsed, minimum)
25
+ }
@@ -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,323 @@
1
+ // Message tables, one per language, keyed by message id. Every CW-E/CW-W text begins with its
2
+ // code (D3: CW-E/CW-W go to stderr; N-lines go to stdout). Both tables carry the same id set
3
+ // (AC-18): argument/target/download checks, strict parse and entry rules 1-5 (CW-E08), pristine
4
+ // rule failures (CW-E10), setup questions (S1-S6's prompts, reasons and summary lines, CW-E05,
5
+ // CW-E06, CW-E12, CW-E15), git steps (CW-W01-W03), "Next steps" lines (N1-N8), and the failure
6
+ // and signal paths (CW-E09, CW-E11, CW-E14, CW-W05).
7
+
8
+ import { jsonValue, posixCommand, posixQuoteWord, formatDirectoryArg, hasControlChar } from './text.mjs'
9
+ import { printedForms } from './git-steps.mjs'
10
+ import { MINIMUM_GIT_VERSION } from './git-version.mjs'
11
+
12
+ /** CW-W01's minimum-version text (S-10), one source with the actual check (`isGitVersionOk`'s
13
+ * own default), not a hardcoded "2.20" repeated in both language tables. */
14
+ const MINIMUM_GIT_VERSION_TEXT = MINIMUM_GIT_VERSION.join('.')
15
+
16
+ const CONDITION_TEXT = {
17
+ en: {
18
+ file: 'it is not a directory',
19
+ symlink: 'it is a symbolic link',
20
+ 'non-empty': 'it is not an empty directory',
21
+ 'parent-missing': 'its parent does not exist',
22
+ 'parent-not-directory': 'its parent is not a directory',
23
+ 'parent-not-writable': 'its parent is not writable',
24
+ 'cross-device': 'it is on a different device than its parent',
25
+ 'lstat-error': 'it could not be checked',
26
+ },
27
+ 'zh-TW': {
28
+ file: '它不是資料夾',
29
+ symlink: '它是符號連結',
30
+ 'non-empty': '它不是空資料夾',
31
+ 'parent-missing': '上層資料夾不存在',
32
+ 'parent-not-directory': '上層資料夾不是資料夾',
33
+ 'parent-not-writable': '上層資料夾無法寫入',
34
+ 'cross-device': '它與上層資料夾不在同一個磁碟裝置',
35
+ 'lstat-error': '無法檢查它',
36
+ },
37
+ }
38
+
39
+ /** CW-E02's reason text, keyed by `reason` (S-12): what is wrong, not just "invalid argument". */
40
+ const CW_E02_REASON_TEXT = {
41
+ en: {
42
+ unknown: (value) => `${jsonValue(value)} is not a valid argument.`,
43
+ 'missing-value': (value) => `${jsonValue(value)} is missing its value.`,
44
+ repeated: (value) => `${jsonValue(value)} was given more than once.`,
45
+ 'conflicts-with-no-setup': (value) => `${jsonValue(value)} cannot be combined with --no-setup.`,
46
+ // C60's own matrix pins both: the variable's name AND its value (escaped), not the name
47
+ // alone (badValue is the raw override string; undefined when a caller has none to show).
48
+ 'bad-env': (value, badValue) =>
49
+ `the environment variable ${jsonValue(value)} must be 40 lowercase hexadecimal characters` +
50
+ (badValue !== undefined ? `, not ${jsonValue(badValue)}` : '') +
51
+ '.',
52
+ },
53
+ 'zh-TW': {
54
+ unknown: (value) => `${jsonValue(value)} 不是合法的參數。`,
55
+ 'missing-value': (value) => `${jsonValue(value)} 缺少對應的值。`,
56
+ repeated: (value) => `${jsonValue(value)} 出現超過一次。`,
57
+ 'conflicts-with-no-setup': (value) => `${jsonValue(value)} 不能與 --no-setup 一起使用。`,
58
+ 'bad-env': (value, badValue) =>
59
+ `環境變數 ${jsonValue(value)} 必須是 40 位小寫十六進位字元` +
60
+ (badValue !== undefined ? `,而不是 ${jsonValue(badValue)}` : '') +
61
+ '。',
62
+ },
63
+ }
64
+
65
+ /** CW-E05's missing-flags text (traceability.md "Error and warning codes"): each missing flag
66
+ * named, `--no-setup` offered as an escape, and npm's `--` reminder (the Command grammar's own
67
+ * note). The command fragment
68
+ * (`npm create wxl <directory> -- <flags>`) is identical text in both languages (AC-18), without
69
+ * backticks (S-06); zh-TW says 參數 for a command-line flag, not 旗標, consistent with CW-E02
70
+ * (S-05: 旗標 is already taken by two other things in this repo's GLOSSARY). */
71
+ function cwE05Text(lang, missing) {
72
+ const list = missing.map((name) => `--${name}`).join(', ')
73
+ const command = 'npm create wxl <directory> -- <flags>'
74
+ if (lang === 'zh-TW') {
75
+ return (
76
+ `CW-E05:缺少必填的參數:${list}。可以加 --no-setup 跳過設定,` +
77
+ `或是(透過 npm 時)執行 ${command},記得在參數前面加上 --。`
78
+ )
79
+ }
80
+ return (
81
+ `CW-E05: missing required flag(s): ${list}. Use --no-setup to skip setup, ` +
82
+ `or (through npm) run ${command}, remembering the -- before the flags.`
83
+ )
84
+ }
85
+
86
+ /** traceability.md "Error and warning codes": "When a value to be placed in such a command
87
+ * contains a C0, DEL or C1 character, the command is not printed; the message says the value
88
+ * contains control characters and shows it escaped." One wording, shared by every withheld line
89
+ * (N1's `cd`, CW-E12/CW-W05's `cd` and `pnpm fork:init`). */
90
+ function controlCharNotice(lang, value) {
91
+ return lang === 'zh-TW'
92
+ ? `(這個值含控制字元,不印出指令:${jsonValue(value)})`
93
+ : `(this value contains control characters, so the command is not printed: ${jsonValue(value)})`
94
+ }
95
+
96
+ /** The `cd <directory>` line all of N1/CW-E12/CW-W05 print, or the withheld notice. One function
97
+ * so N1 (below) and CW-E12's finishing commands agree. */
98
+ function cdLineOrNotice(lang, directory) {
99
+ if (!hasControlChar(directory)) return posixCommand(['cd', formatDirectoryArg(directory)])
100
+ return controlCharNotice(lang, directory)
101
+ }
102
+
103
+ /** The `pnpm fork:init <argv>` line, or the withheld notice naming the first offending argv item
104
+ * (decision 1): `argv`'s own items are user-given answers/flag values, the same kind of outside
105
+ * value `directory` is, so the same rule applies to them. */
106
+ function forkInitLineOrNotice(lang, argv) {
107
+ const offending = argv.find(hasControlChar)
108
+ if (offending === undefined) return `pnpm fork:init ${argv.map(posixQuoteWord).join(' ')}`
109
+ return controlCharNotice(lang, offending)
110
+ }
111
+
112
+ /** CW-E12's finishing commands (traceability.md "Error and warning codes"): `cd <directory>`
113
+ * (the user's own string, per C60's `cd ./-x`), `pnpm install`, `pnpm fork:init` with the same
114
+ * argv items, then G1, G2 and G4's printed forms (traceability.md "Git steps"), each POSIX-quoted
115
+ * for copying. `directory` is the user-given string (positional or question answer), never the
116
+ * resolved path. `lang` is required (S-21): the caller already knows which table it is building
117
+ * this for, so this never silently falls back to en. */
118
+ export function cwE12FinishingCommandsList({ directory, argv, version, lang }) {
119
+ const forms = printedForms(version)
120
+ return [
121
+ cdLineOrNotice(lang, directory),
122
+ posixCommand(['pnpm', 'install']),
123
+ forkInitLineOrNotice(lang, argv),
124
+ forms.init,
125
+ forms.symbolicRef,
126
+ forms.add,
127
+ forms.commit,
128
+ ]
129
+ }
130
+
131
+ function cwE12Text(lang, { message, directory, argv, version }) {
132
+ const header = lang === 'zh-TW' ? `CW-E12:引擎失敗:${jsonValue(message)}。` : `CW-E12: the engine failed: ${jsonValue(message)}.`
133
+ const intro =
134
+ lang === 'zh-TW'
135
+ ? '靶場與 range.json 都留著;用這些指令完成設定:'
136
+ : 'The range and range.json stay; finish setup with these commands:'
137
+ return `${header} ${intro}\n${cwE12FinishingCommandsList({ directory, argv, version, lang }).join('\n')}`
138
+ }
139
+
140
+ /** CW-E11's text (traceability.md "Error and warning codes"): the target, the error, and -- when
141
+ * rollback itself could not move an entry back -- that entry's name and the kept temporary
142
+ * directory's path (Notes: "CW-E11 names each such entry and the temporary directory's path is
143
+ * kept and named"). */
144
+ function cwE11Text(lang, { target, detail, stranded = [], tmpDir }) {
145
+ const header =
146
+ lang === 'zh-TW'
147
+ ? `CW-E11:搬移 ${jsonValue(target)} 到位失敗:${jsonValue(detail)}。`
148
+ : `CW-E11: moving ${jsonValue(target)} into place failed: ${jsonValue(detail)}.`
149
+ if (stranded.length === 0) return header
150
+ const names = stranded.map(jsonValue).join(', ')
151
+ if (lang === 'zh-TW') {
152
+ return `${header}${names} 無法搬回暫存資料夾,仍留在目標內;暫存資料夾 ${jsonValue(tmpDir)} 因此保留。`
153
+ }
154
+ // S-25: the subject is plural when more than one entry is stranded.
155
+ const subject = stranded.length === 1 ? 'it is' : 'they are'
156
+ return `${header} ${names} could not be moved back; ${subject} still in the target, and the temporary directory ${jsonValue(tmpDir)} was kept.`
157
+ }
158
+
159
+ /** CW-E07's `detail` is structured ({ kind: 'timeout' } / { kind: 'status', status } / { kind:
160
+ * 'error', message }) and rendered by each table; an outside error message is printed through the
161
+ * JSON-literal escaping (S-10, S-11). */
162
+ function cwE07DetailText(lang, detail) {
163
+ if (detail.kind === 'timeout') {
164
+ return lang === 'zh-TW' ? '下載逾時' : 'the download timed out'
165
+ }
166
+ if (detail.kind === 'status') {
167
+ return lang === 'zh-TW' ? `伺服器回應狀態碼 ${detail.status}` : `the server responded with status ${detail.status}`
168
+ }
169
+ return lang === 'zh-TW' ? `下載失敗:${jsonValue(detail.message)}` : `the download failed: ${jsonValue(detail.message)}`
170
+ }
171
+
172
+ export const en = {
173
+ 'CW-E01': ({ running, minimum }) =>
174
+ `CW-E01: the running Node.js ${jsonValue(running)} is older than the minimum ${jsonValue(minimum)}; install a newer Node.js from https://nodejs.org/.`,
175
+ 'CW-E02': ({ value, usage, reason = 'unknown', badValue }) =>
176
+ `CW-E02: ${CW_E02_REASON_TEXT.en[reason](value, badValue)}` + (usage ? ' Usage: create-wxl <directory>' : ''),
177
+ 'CW-E03': () => `CW-E03: no directory was given. Usage: create-wxl <directory>`,
178
+ 'CW-E04': ({ path, conditionId, detail }) =>
179
+ `CW-E04: the target ${jsonValue(path)} cannot be used: ${CONDITION_TEXT.en[conditionId]}` +
180
+ (detail ? ` (${jsonValue(detail)})` : '') +
181
+ '.',
182
+ 'CW-E05': ({ missing }) => cwE05Text('en', missing),
183
+ 'CW-E06': ({ message }) => `CW-E06: the engine rejected the answers: ${jsonValue(message)}.`,
184
+ 'CW-E07': ({ url, detail }) => `CW-E07: downloading ${jsonValue(url)} failed: ${cwE07DetailText('en', detail)}.`,
185
+ 'CW-E08': ({ rule, path, parseError, isCommentMarker }) =>
186
+ parseError !== undefined
187
+ ? `CW-E08: the archive could not be parsed: ${jsonValue(parseError)}.`
188
+ : `CW-E08: the archive broke entry rule ${rule}` +
189
+ (isCommentMarker ? ' (pax comment)' : path !== undefined ? ` (${jsonValue(path)})` : '') +
190
+ '.',
191
+ 'CW-E09': ({ path, detail }) => `CW-E09: a file-system error at ${jsonValue(path)}: ${jsonValue(detail)}.`,
192
+ 'CW-E10': ({ file, rule }) => `CW-E10: pristine rule ${rule} failed for ${jsonValue(file)}.`,
193
+ 'CW-E11': (args) => cwE11Text('en', args),
194
+ 'CW-E12': (args) => cwE12Text('en', args),
195
+ 'CW-E13': () => `CW-E13: the create-wxl package is broken. Retry with npm create wxl@latest.`,
196
+ 'CW-E14': ({ message, remainingCommands }) =>
197
+ `CW-E14: an unexpected error happened: ${jsonValue(message)}.` +
198
+ (remainingCommands ? ` Run these when ready:\n${remainingCommands.join('\n')}` : ''),
199
+ 'CW-E15': () => `CW-E15: input ended while a question was open.`,
200
+ 'CW-W01': ({ remainingCommands }) =>
201
+ `CW-W01: the range is not a git repository yet; git was not found, failed, or is older than ${MINIMUM_GIT_VERSION_TEXT} ` +
202
+ `(install a newer one from https://git-scm.com/downloads). Run these when ready:\n${remainingCommands.join('\n')}`,
203
+ 'CW-W02': ({ remainingCommands }) =>
204
+ `CW-W02: no explicit committer identity was found; set one, then finish the commit:\n${remainingCommands.join('\n')}`,
205
+ 'CW-W03': ({ command, detail, remainingCommands }) =>
206
+ `CW-W03: ${command} failed: ${jsonValue(detail)}. Finish these yourself:\n${remainingCommands.join('\n')}`,
207
+ 'CW-W04': ({ commit }) =>
208
+ `CW-W04: using the override commit ${jsonValue(commit)}; this is not a release and is meant for testing.`,
209
+ 'CW-W05': ({ remainingCommands }) =>
210
+ remainingCommands.length > 0
211
+ ? `CW-W05: a signal stopped the run; the range stays, run these when ready:\n${remainingCommands.join('\n')}`
212
+ : `CW-W05: a signal stopped the run; the range is already complete, nothing is left to run.`,
213
+ 'Q-DIRECTORY': () => `Directory name (default: wxl-range):`,
214
+ 'Q-DIRECTORY-TILDE': () => `A directory beginning with ~ is not expanded here; type the directory itself.`,
215
+ S1: () => `Author:`,
216
+ 'S1-EMPTY': () => `The author name cannot be empty.`,
217
+ S2: () => `GitHub repository (owner/name):`,
218
+ 'S2-EMPTY': () => `The repository cannot be empty.`,
219
+ S3: () => `Host: 1) GitHub Pages 2) Cloudflare Pages:`,
220
+ 'S3-INVALID': ({ value }) =>
221
+ `${jsonValue(value)} is not a valid host; answer 1, 2, github-pages or cloudflare-pages.`,
222
+ S4: () => `Site address (https://...), empty to derive it when possible:`,
223
+ 'S4-REQUIRED': () => `A site address is required for Cloudflare Pages.`,
224
+ S5: () => `Site name (empty keeps the current name):`,
225
+ 'S6-AUTHOR': ({ value }) => ` author: ${jsonValue(value)}`,
226
+ 'S6-REPO': ({ value }) => ` repository: ${jsonValue(value)}`,
227
+ 'S6-HOST': ({ value }) => ` host: ${jsonValue(value)}`,
228
+ 'S6-ORIGIN-ANSWER': ({ value }) => ` site address answered: ${jsonValue(value)}`,
229
+ 'S6-ORIGIN': ({ origin }) => ` short link origin: ${jsonValue(origin)}`,
230
+ 'S6-SITE-NAME': ({ value }) => ` site name: ${jsonValue(value)}`,
231
+ S6: () => `Apply these settings? [Y/n]`,
232
+ 'S6-INVALID': () => `Please answer y, yes, n or no (empty counts as yes).`,
233
+ 'ENGINE-REJECTED': ({ message }) =>
234
+ `The engine rejected the answers: ${jsonValue(message)}. Starting over with the current answers.`,
235
+ N1: ({ directory }) => `Next: ${cdLineOrNotice('en', directory)}`,
236
+ N2: ({ remainingCommands }) => `Next: finish the git steps yourself:\n${remainingCommands.join('\n')}`,
237
+ N3: ({ owner, name }) => `Next: create an empty GitHub repository ${jsonValue(`${owner}/${name}`)}, without a README.`,
238
+ N4: ({ owner, name }) =>
239
+ `Next: ${posixCommand(['git', 'remote', 'add', 'origin', `https://github.com/${owner}/${name}.git`])}, then ${posixCommand(['git', 'push', '-u', 'origin', 'main'])}.`,
240
+ N5: () =>
241
+ 'Next: on GitHub, set Settings → Pages → Source to GitHub Actions, and allow v* tags in the github-pages environment; then ' +
242
+ `${posixCommand(['git', 'tag', 'v0.1.0'])} and ${posixCommand(['git', 'push', 'origin', 'v0.1.0'])}.`,
243
+ N6: () => 'Next: deploying to Cloudflare Pages — follow the README section "Deploying to Cloudflare Pages".',
244
+ N7: () =>
245
+ `Next: ${posixCommand(['pnpm', 'install'])} and Rust with wasm-pack are needed only to run the site locally (README "Prerequisites").`,
246
+ N8: () =>
247
+ `Next: ${posixCommand(['pnpm', 'install'])}, then ${posixCommand(['pnpm', 'fork:init'])} as the README describes.`,
248
+ }
249
+
250
+ export const zhTW = {
251
+ 'CW-E01': ({ running, minimum }) =>
252
+ `CW-E01:目前使用的 Node.js ${jsonValue(running)} 低於最低需求 ${jsonValue(minimum)},請到 https://nodejs.org/ 下載較新版本。`,
253
+ 'CW-E02': ({ value, usage, reason = 'unknown', badValue }) =>
254
+ `CW-E02:${CW_E02_REASON_TEXT['zh-TW'][reason](value, badValue)}` + (usage ? '用法:create-wxl <directory>' : ''),
255
+ 'CW-E03': () => `CW-E03:未提供目標資料夾。用法:create-wxl <directory>`,
256
+ 'CW-E04': ({ path, conditionId, detail }) =>
257
+ `CW-E04:目標 ${jsonValue(path)} 無法使用:${CONDITION_TEXT['zh-TW'][conditionId]}` +
258
+ (detail ? `(${jsonValue(detail)})` : '') +
259
+ '。',
260
+ 'CW-E05': ({ missing }) => cwE05Text('zh-TW', missing),
261
+ 'CW-E06': ({ message }) => `CW-E06:引擎拒絕了這些答案:${jsonValue(message)}。`,
262
+ 'CW-E07': ({ url, detail }) => `CW-E07:下載 ${jsonValue(url)} 失敗:${cwE07DetailText('zh-TW', detail)}。`,
263
+ 'CW-E08': ({ rule, path, parseError, isCommentMarker }) =>
264
+ parseError !== undefined
265
+ ? `CW-E08:壓縮檔無法解析:${jsonValue(parseError)}。`
266
+ : `CW-E08:壓縮檔違反規則 ${rule}` +
267
+ (isCommentMarker ? '(pax comment)' : path !== undefined ? `(${jsonValue(path)})` : '') +
268
+ '。',
269
+ 'CW-E09': ({ path, detail }) => `CW-E09:${jsonValue(path)} 發生檔案系統錯誤:${jsonValue(detail)}。`,
270
+ 'CW-E10': ({ file, rule }) => `CW-E10:原廠靶場處理規則 ${rule} 失敗:${jsonValue(file)}。`,
271
+ 'CW-E11': (args) => cwE11Text('zh-TW', args),
272
+ 'CW-E12': (args) => cwE12Text('zh-TW', args),
273
+ 'CW-E13': () => `CW-E13:套件已損壞,請改用 npm create wxl@latest 重試。`,
274
+ 'CW-E14': ({ message, remainingCommands }) =>
275
+ `CW-E14:發生未預期的錯誤:${jsonValue(message)}。` +
276
+ (remainingCommands ? `準備好後請執行:\n${remainingCommands.join('\n')}` : ''),
277
+ 'CW-E15': () => `CW-E15:問題還開著的時候輸入就結束了。`,
278
+ 'CW-W01': ({ remainingCommands }) =>
279
+ `CW-W01:靶場還不是 git repository;找不到 git、git 失敗,或版本低於 ${MINIMUM_GIT_VERSION_TEXT}(請到 ` +
280
+ `https://git-scm.com/downloads 安裝較新版本)。準備好後請執行:\n${remainingCommands.join('\n')}`,
281
+ 'CW-W02': ({ remainingCommands }) => `CW-W02:找不到明確的 committer 身分;請先設定,再完成 commit:\n${remainingCommands.join('\n')}`,
282
+ 'CW-W03': ({ command, detail, remainingCommands }) =>
283
+ `CW-W03:${command} 失敗:${jsonValue(detail)}。請自行完成剩下的指令:\n${remainingCommands.join('\n')}`,
284
+ 'CW-W04': ({ commit }) => `CW-W04:改用測試用的 commit ${jsonValue(commit)},這不是正式版本,僅供測試。`,
285
+ 'CW-W05': ({ remainingCommands }) =>
286
+ remainingCommands.length > 0
287
+ ? `CW-W05:收到訊號而停止;靶場仍保留,準備好後請執行:\n${remainingCommands.join('\n')}`
288
+ : `CW-W05:收到訊號而停止;靶場已經完成,沒有剩下的指令。`,
289
+ 'Q-DIRECTORY': () => `資料夾名稱(預設:wxl-range):`,
290
+ 'Q-DIRECTORY-TILDE': () => `以 ~ 開頭的答案這裡不會展開,請直接輸入資料夾本身。`,
291
+ S1: () => `作者:`,
292
+ 'S1-EMPTY': () => `作者名稱不能是空的。`,
293
+ S2: () => `GitHub 上的 repository(owner/name):`,
294
+ 'S2-EMPTY': () => `repository 不能是空的。`,
295
+ S3: () => `部署目標:1) GitHub Pages 2) Cloudflare Pages:`,
296
+ 'S3-INVALID': ({ value }) => `${jsonValue(value)} 不是合法的部署目標,請回答 1、2、github-pages 或 cloudflare-pages。`,
297
+ S4: () => `網站網址(https://...),可自動推導時直接按 Enter:`,
298
+ 'S4-REQUIRED': () => `Cloudflare Pages 一定要填網站網址。`,
299
+ S5: () => `網站名稱(留空保留目前的名稱):`,
300
+ 'S6-AUTHOR': ({ value }) => ` 作者:${jsonValue(value)}`,
301
+ 'S6-REPO': ({ value }) => ` GitHub repository:${jsonValue(value)}`,
302
+ 'S6-HOST': ({ value }) => ` 部署目標:${jsonValue(value)}`,
303
+ 'S6-ORIGIN-ANSWER': ({ value }) => ` 回答的網站網址:${jsonValue(value)}`,
304
+ 'S6-ORIGIN': ({ origin }) => ` 短連結網址:${jsonValue(origin)}`,
305
+ 'S6-SITE-NAME': ({ value }) => ` 網站名稱:${jsonValue(value)}`,
306
+ S6: () => `要套用這些設定嗎?[Y/n]`,
307
+ 'S6-INVALID': () => `請回答 y、yes、n 或 no(留空視為 yes)。`,
308
+ 'ENGINE-REJECTED': ({ message }) => `引擎拒絕了這些答案:${jsonValue(message)}。改用目前的答案重新開始。`,
309
+ N1: ({ directory }) => `下一步:${cdLineOrNotice('zh-TW', directory)}`,
310
+ N2: ({ remainingCommands }) => `下一步:請自行完成剩下的 git 步驟:\n${remainingCommands.join('\n')}`,
311
+ N3: ({ owner, name }) => `下一步:在 GitHub 上建立一個空的 ${jsonValue(`${owner}/${name}`)} repository,不要加 README。`,
312
+ N4: ({ owner, name }) =>
313
+ `下一步:${posixCommand(['git', 'remote', 'add', 'origin', `https://github.com/${owner}/${name}.git`])},接著 ${posixCommand(['git', 'push', '-u', 'origin', 'main'])}。`,
314
+ N5: () =>
315
+ '下一步:在 GitHub 上把 Settings → Pages → Source 設成 GitHub Actions,並允許 github-pages 環境接受 v* tag;接著執行 ' +
316
+ `${posixCommand(['git', 'tag', 'v0.1.0'])},再執行 ${posixCommand(['git', 'push', 'origin', 'v0.1.0'])}。`,
317
+ N6: () => '下一步:部署到 Cloudflare Pages——請依照 README「部署到 Cloudflare Pages」/"Deploying to Cloudflare Pages" 一節操作。',
318
+ N7: () =>
319
+ `下一步:只有要在本機執行網站時才需要 ${posixCommand(['pnpm', 'install'])},以及 Rust 與 wasm-pack(README「環境需求」/"Prerequisites")。`,
320
+ N8: () => `下一步:${posixCommand(['pnpm', 'install'])},接著 ${posixCommand(['pnpm', 'fork:init'])},如 README 所述。`,
321
+ }
322
+
323
+ export const messages = { en, 'zh-TW': zhTW }
@@ -0,0 +1,29 @@
1
+ // Step 13's "Next steps" lines (traceability.md "Next steps"), built from the run's directory,
2
+ // whether setup ran, the last accepted setup parse (`parsedArgs`, for N3-N6) and the git steps'
3
+ // result (`gitResult`, for N2).
4
+
5
+ /**
6
+ * Returns the ordered array of already-rendered next-step lines this run should print.
7
+ * `noSetup`: true selects N1 and N8 only. `parsedArgs`: the last accepted `parseForkInitArgs`
8
+ * result (`.repo`, `.host`), required unless `noSetup`. `gitResult`: `runGitSteps`'s own result
9
+ * (S-12, passed whole); a `null` `warningId` omits N2 (CW-W05's signal path is T-0024's).
10
+ */
11
+ export function buildNextSteps({ t, directory, noSetup, parsedArgs, gitResult }) {
12
+ const lines = [t['N1']({ directory })]
13
+
14
+ if (gitResult.warningId !== null) {
15
+ lines.push(t['N2']({ remainingCommands: gitResult.remainingCommands }))
16
+ }
17
+
18
+ if (noSetup) {
19
+ lines.push(t['N8']())
20
+ return lines
21
+ }
22
+
23
+ const [owner, name] = parsedArgs.repo.split('/')
24
+ lines.push(t['N3']({ owner, name }))
25
+ lines.push(t['N4']({ owner, name }))
26
+ lines.push(parsedArgs.host === 'github-pages' ? t['N5']() : t['N6']())
27
+ lines.push(t['N7']())
28
+ return lines
29
+ }