create-wxl 3.0.0 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/run.mjs CHANGED
@@ -1,43 +1,18 @@
1
- import { dirname, join, resolve as resolvePath } from 'node:path'
1
+ import { dirname, resolve as resolvePath } from 'node:path'
2
2
  import { createHash } from 'node:crypto'
3
3
  import { messages, cwE12FinishingCommandsList } from './messages.mjs'
4
4
  import { jsonValue } from './text.mjs'
5
5
  import { selectLanguage } from './language.mjs'
6
- import { parseArchive, ArchiveParseError } from './archive.mjs'
7
- import { checkEntryRules, ArchiveRuleError } from './archive-rules.mjs'
8
6
  import { parseArgvStrict, ArgvError, SETUP_FLAG_NAMES } from './argv.mjs'
9
- import { checkTarget, extractEntries, moveIntoPlace, MoveIntoPlaceError } from './fsops.mjs'
10
- import { applyChangelog, applyOpenQuestions, applyPackageJson, writeRangeJson, PristineError } from './pristine.mjs'
7
+ import { checkTarget, moveIntoPlace, MoveIntoPlaceError } from './fsops.mjs'
11
8
  import { isNodeVersionOk, MINIMUM_NODE_VERSION } from './node-version.mjs'
12
9
  import { makeQuestioner, QuestionEofError, buildEngineArgv, missingRequiredFlags, collectInteractiveAnswers } from './questions.mjs'
13
10
  import { runGitSteps, orderedPrintedForms } from './git-steps.mjs'
14
11
  import { buildNextSteps } from './next-steps.mjs'
15
12
  import { createSignalGuard, SignalAbort } from './signals.mjs'
16
-
17
- const VERSION_RE = /^\d+\.\d+\.\d+$/
18
- const COMMIT_RE = /^[0-9a-f]{40}$/
19
-
20
- /** S-20: the one place every "print a failure line, then remove the temporary directory this run
21
- * created, then fail" sequence lives (steps 8-10's CW-E09/CW-E10/CW-E04 branches, and the
22
- * generic bug/signal catch, each used to repeat this pair of calls for itself). */
23
- async function removeTmpDir(deps, tmpDir) {
24
- await deps.fs.rm(tmpDir, { recursive: true, force: true })
25
- }
26
-
27
- async function failWithCleanup(io, deps, tmpDir, line) {
28
- io.stderr.write(line + '\n')
29
- await removeTmpDir(deps, tmpDir)
30
- return 1
31
- }
32
-
33
- /** CW-E09 (steps 8-9) vs. CW-E14 (a bug): a real file-system error carries a string `code` that
34
- * does NOT start with `ERR_` (review finding C-12) -- Node's own argument-validation errors
35
- * (`ERR_INVALID_ARG_TYPE`, `ERR_INVALID_ARG_VALUE`, `ERR_OUT_OF_RANGE`, ...) also carry a string
36
- * `code`, but they are a programming bug in this codebase, not an environment problem to report
37
- * as CW-E09. */
38
- function isFileSystemError(err) {
39
- return typeof err?.code === 'string' && !err.code.startsWith('ERR_')
40
- }
13
+ import { VERSION_RE, COMMIT_RE } from './patterns.mjs'
14
+ import { removeTmpDir, downloadArchive, readArchiveEntries, makeTmpDir, extractInto, applyPristine } from './run-shared.mjs'
15
+ import { runUpgrade } from './upgrade.mjs'
41
16
 
42
17
  /** Step 2's package source record check (D2): `deps.engineFile` is read through `deps.fs` and its
43
18
  * SHA-256 compared with `source.engineSha256`. */
@@ -62,7 +37,8 @@ async function validateSourceRecord(deps) {
62
37
  /**
63
38
  * create-wxl's one exported seam (0005-S1): the Step order table's steps 1-13, every signal
64
39
  * window (traceability.md "Signals"), and the full set of failure-path messages (CW-E09,
65
- * CW-E11, CW-E14, CW-W05).
40
+ * CW-E11, CW-E14, CW-W05). An argv whose first positional is `upgrade` is dispatched to
41
+ * 0006's upgrade path instead (0006-S1, `runUpgrade` in upgrade.mjs).
66
42
  */
67
43
  export async function run(argv, io, deps) {
68
44
  // traceability.md "Signals": "errors writing the messages are ignored". Without a listener, a
@@ -143,6 +119,9 @@ async function runBody(argv, io, deps) {
143
119
  const guard = createSignalGuard(deps.signals, ['SIGINT', 'SIGTERM', 'SIGHUP'])
144
120
  const questioner = makeQuestioner(io, deps, guard)
145
121
  try {
122
+ if (parsed.mode === 'upgrade') {
123
+ return await runUpgrade(parsed, io, deps, t2, lang2, guard, { hasOverride, overrideRaw })
124
+ }
146
125
  return await runSteps(parsed, io, deps, t2, lang2, questioner, guard, { hasOverride, overrideRaw })
147
126
  } catch (err) {
148
127
  if (err instanceof QuestionEofError) {
@@ -289,121 +268,42 @@ async function runStepsBody(
289
268
  if (hasOverride) {
290
269
  io.stderr.write(t2['CW-W04']({ commit }) + '\n')
291
270
  }
292
- const url = `https://codeload.github.com/${deps.source.repository}/tar.gz/${commit}`
293
- const controller = new AbortController()
294
- // decision 6: a signal during the download aborts it through this same controller, instead of
295
- // waiting out the full `downloadTimeoutMs` -- traceability.md "Signals": "after a signal no new
296
- // file-system operation starts" (the download itself is not a file-system operation, but the
297
- // rule not to keep running applies the same way).
298
- const unsubscribeAbort = guard.onRaise(() => controller.abort())
299
- let timedOut = false
300
- let timer
301
- const timeoutPromise = new Promise((_resolveTimeout, rejectTimeout) => {
302
- timer = setTimeout(() => {
303
- timedOut = true
304
- controller.abort()
305
- rejectTimeout(new Error())
306
- }, deps.downloadTimeoutMs)
307
- })
308
-
309
- let status, body
310
- try {
311
- ;({ status, body } = await Promise.race([
312
- deps.fetchArchive(deps.source.repository, commit, { signal: controller.signal }),
313
- timeoutPromise,
314
- ]))
315
- } catch (err) {
316
- if (guard.raised()) throw new SignalAbort(guard.raised())
317
- const detail = timedOut ? { kind: 'timeout' } : { kind: 'error', message: err?.message ?? String(err) }
318
- io.stderr.write(t2['CW-E07']({ url, detail }) + '\n')
319
- return 1
320
- } finally {
321
- clearTimeout(timer)
322
- // S-17: step 6's own window has closed either way; this `AbortController` is no longer
323
- // useful to abort, so the callback is removed instead of lingering for the rest of the run.
324
- unsubscribeAbort()
325
- }
326
-
327
- guard.throwIfRaised() // P-17/C-14: a signal already received wins over a non-200 status
328
- if (status !== 200) {
329
- io.stderr.write(t2['CW-E07']({ url, detail: { kind: 'status', status } }) + '\n')
330
- return 1
331
- }
271
+ // The URL/AbortController/timeout/race/CW-E07 window lives in `run-shared.mjs`'s
272
+ // `downloadArchive`, shared with the upgrade path (traceability.md "Step order" row 7); this
273
+ // call site's own checkpoints and CW-W04 are unchanged.
274
+ const downloaded = await downloadArchive({ io, deps, t2, guard, repository: deps.source.repository, ref: commit })
275
+ if (!downloaded) return 1
276
+ const { body } = downloaded
332
277
 
333
278
  deps.checkpoint('step-7')
334
279
  guard.throwIfRaised()
335
- let parsedArchive
336
- try {
337
- parsedArchive = await parseArchive(body)
338
- } catch (err) {
339
- if (err instanceof ArchiveParseError) {
340
- io.stderr.write(t2['CW-E08']({ parseError: err.message }) + '\n')
341
- return 1
342
- }
343
- throw err
344
- }
345
- let validatedEntries
346
- try {
347
- validatedEntries = checkEntryRules(parsedArchive, { commit })
348
- } catch (err) {
349
- if (err instanceof ArchiveRuleError) {
350
- io.stderr.write(t2['CW-E08']({ rule: err.rule, path: err.path, isCommentMarker: err.isCommentMarker }) + '\n')
351
- return 1
352
- }
353
- throw err
354
- }
280
+ // Parsing, entry-rule validation, mkdtemp, extraction and P1-P4 live in `run-shared.mjs`,
281
+ // shared with the upgrade path; this call site's own checkpoints, `setTmpDir` and argument
282
+ // shape are unchanged.
283
+ const validatedEntries = await readArchiveEntries({ io, t2, body, commit })
284
+ if (!validatedEntries) return 1
355
285
 
356
286
  deps.checkpoint('step-8')
357
287
  guard.throwIfRaised()
358
288
  const parentDir = dirname(target)
359
- let tmpDir
360
- try {
361
- tmpDir = await deps.fs.mkdtemp(join(parentDir, '.create-wxl-'))
362
- setTmpDir(tmpDir)
363
- } catch (err) {
364
- if (isFileSystemError(err)) {
365
- io.stderr.write(t2['CW-E09']({ path: parentDir, detail: err.message }) + '\n')
366
- return 1 // mkdtemp itself failed: nothing was created, nothing to clean up
367
- }
368
- throw err
369
- }
370
- try {
371
- // S-19: `{ stopped: true }` (never a silent early return) is the one signal `extractEntries`
372
- // gives for "a signal arrived mid-extraction"; turned into the same `SignalAbort` every other
373
- // window throws, right here, instead of a separate check the caller could forget.
374
- const { stopped } = await extractEntries(deps, validatedEntries, tmpDir, { shouldStop: () => guard.isRaised() })
375
- if (stopped) throw new SignalAbort(guard.raised())
376
- } catch (err) {
377
- if (err instanceof SignalAbort) throw err
378
- if (isFileSystemError(err)) {
379
- return failWithCleanup(io, deps, tmpDir, t2['CW-E09']({ path: err.reportPath ?? tmpDir, detail: err.message }))
380
- }
381
- throw err
382
- }
289
+ const tmpDir = await makeTmpDir({ io, deps, t2, parent: parentDir, prefix: '.create-wxl-' })
290
+ if (!tmpDir) return 1 // mkdtemp itself failed: nothing was created, nothing to clean up
291
+ setTmpDir(tmpDir)
292
+ if (!(await extractInto({ io, deps, t2, guard, entries: validatedEntries, tmpDir }))) return 1
383
293
 
384
294
  deps.checkpoint('step-9')
385
295
  guard.throwIfRaised()
386
- try {
387
- await applyChangelog(deps.fs, tmpDir)
388
- guard.throwIfRaised() // decision 6: a signal mid-step-9 stops before the next P-rule's write
389
- await applyOpenQuestions(deps.fs, tmpDir)
390
- guard.throwIfRaised()
391
- await applyPackageJson(deps.fs, tmpDir)
392
- guard.throwIfRaised()
393
- await writeRangeJson(deps.fs, tmpDir, {
394
- repository: deps.source.repository,
395
- version: deps.source.version,
396
- commit,
397
- })
398
- guard.throwIfRaised() // P-02/C-01: a signal during step 9's last write starts no step-10 work
399
- } catch (err) {
400
- if (err instanceof PristineError) {
401
- return failWithCleanup(io, deps, tmpDir, t2['CW-E10']({ file: err.file, rule: err.rule }))
402
- }
403
- if (isFileSystemError(err)) {
404
- return failWithCleanup(io, deps, tmpDir, t2['CW-E09']({ path: err.reportPath ?? tmpDir, detail: err.message }))
405
- }
406
- throw err
296
+ if (
297
+ !(await applyPristine({
298
+ io,
299
+ deps,
300
+ t2,
301
+ guard,
302
+ tmpDir,
303
+ record: { repository: deps.source.repository, version: deps.source.version, commit },
304
+ }))
305
+ ) {
306
+ return 1
407
307
  }
408
308
 
409
309
  deps.checkpoint('step-10')
package/src/text.mjs CHANGED
@@ -45,6 +45,38 @@ export function jsonValue(value) {
45
45
  return out + '"'
46
46
  }
47
47
 
48
+ /** Candidate 22 (stage 7, security-T-0026): prints `value` through `JSON.stringify` -- unlike
49
+ * `jsonValue`, without wrapping the result in its own extra quotes, so an array or object prints
50
+ * as itself (`["3.1.0"]`, `{"toString":1}`), not through `String(value)` (which calls `toString`,
51
+ * throwing for a value whose own `toString` property is not callable, and collapsing an array
52
+ * like `["3.1.0"]` into the indistinguishable-from-a-real-version `"3.1.0"`). Any DEL/C1
53
+ * character or lone surrogate `JSON.stringify`'s own algorithm leaves unescaped (it already
54
+ * escapes every C0 character and lone surrogate itself, ES2019) is escaped the same way
55
+ * `escapeChar` would (D10). Returns `undefined` when `JSON.stringify` throws (a `BigInt`, a
56
+ * circular structure) or itself returns `undefined` (a bare `function` or `undefined` value) --
57
+ * the caller falls back to a type name in that case. */
58
+ const SHORT_ESCAPE_CODES = { b: '0008', f: '000c', r: '000d', t: '0009' }
59
+
60
+ export function jsonStringifyEscaped(value) {
61
+ let str
62
+ try {
63
+ str = JSON.stringify(value)
64
+ } catch {
65
+ return undefined
66
+ }
67
+ if (typeof str !== 'string') return undefined
68
+ // D10: JSON.stringify's own short escapes for backspace, form feed, CR and tab become `\uXXXX`
69
+ // (a line feed keeps `\n`); matching escape pairs left to right leaves `\\` followed by a
70
+ // letter untouched.
71
+ str = str.replace(/\\(.)/g, (pair, c) => (c in SHORT_ESCAPE_CODES ? `\\u${SHORT_ESCAPE_CODES[c]}` : pair))
72
+ let out = ''
73
+ for (const ch of str) {
74
+ const code = ch.codePointAt(0)
75
+ out += isControlCodePoint(code) || isLoneSurrogate(code) ? `\\u${code.toString(16).padStart(4, '0')}` : ch
76
+ }
77
+ return out
78
+ }
79
+
48
80
  const BARE_RE = /^[A-Za-z0-9._\-/@%+=:,]+$/
49
81
 
50
82
  export function posixQuoteWord(word) {
@@ -78,3 +110,18 @@ export function hasControlChar(str) {
78
110
  }
79
111
  return false
80
112
  }
113
+
114
+ /** `\` is Windows' own path separator; comparing after turning `\` into `/` (traceability.md
115
+ * "Step order" row 3, Vocabulary "name on disk") makes a path comparison platform-neutral. Shared
116
+ * by step 3 and P-9 since T-0062. */
117
+ export function toSlash(value) {
118
+ return String(value).replace(/\\/g, '/')
119
+ }
120
+
121
+ /** ASCII-only case folding (`[A-Z]` to lowercase), the way git's own `tolower` and J5's repository
122
+ * comparison fold -- never JavaScript's Unicode-aware lowercasing. Shared by J5 and entry rule 3
123
+ * since T-0062. */
124
+ export function foldAsciiCase(value) {
125
+ return value.replace(/[A-Z]/g, (c) => c.toLowerCase())
126
+ }
127
+
@@ -0,0 +1,134 @@
1
+ // Step 10 (traceability.md "Step order" row 10, "Report"): prints R1-R5, one per line, each
2
+ // starting with its own id, in the run's language (`t2`). R3 renders each attention item through the
3
+ // language table's own UP-A* text. R2 lists every path M6 deleted after the counts (D16). R4
4
+ // (traceability.md "Report" R4) is a fixed body of next-steps advice plus three conditional bullets:
5
+ // D16's `|||||||` line only when R3 holds UP-A1, `pnpm install` only when the run wrote
6
+ // `package.json` or `pnpm-lock.yaml`, and the deploy-tag push only for a range with
7
+ // `.github/workflows/deploy.yml`.
8
+
9
+ import { jsonValue, posixCommand } from './text.mjs'
10
+
11
+ /** D14 (T-0063): the restore commands, in order -- `checkout` first, so a missing or changed
12
+ * ignore file is back before `clean` runs, and `clean` never deletes a file `HEAD`'s rules ignore.
13
+ * R5 and UP-E09 print them, and U70 runs them, from this one list. */
14
+ export const RESTORE_COMMANDS = Object.freeze([
15
+ Object.freeze(['git', 'checkout', '--', '.']),
16
+ Object.freeze(['git', 'clean', '-fd']),
17
+ ])
18
+
19
+ /** D23 (T-0066): the restore commands as the one line R5 and UP-E09 print, joined with `&&`, so a
20
+ * `git checkout -- .` that fails never lets `git clean -fd` run (a shell without `&&`, such as
21
+ * Windows PowerShell 5.1, refuses the whole line, and nothing runs). */
22
+ export function restoreCommandLine() {
23
+ return RESTORE_COMMANDS.map((command) => posixCommand(command)).join(' && ')
24
+ }
25
+
26
+ /** `ops` carrying a `report` code (M10, or an M12 conflict/UP-A11 outcome) are written, but not
27
+ * counted here (traceability.md "Report" R2's own "M10 is not counted as updated"). */
28
+ function countsFromOps(ops) {
29
+ const counts = { updated: 0, added: 0, deleted: 0 }
30
+ for (const op of ops) {
31
+ if (op.report !== undefined) continue
32
+ if (op.action === 'delete') counts.deleted++
33
+ else if (op.action === 'write') counts[op.existedBefore ? 'updated' : 'added']++
34
+ }
35
+ return counts
36
+ }
37
+
38
+ /** UP-A12 sorts last among attention lines (traceability.md "Report" R3); every other code sorts
39
+ * by path. */
40
+ function sortAttention(attention) {
41
+ const rest = attention.filter((item) => item.code !== 'UP-A12').sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0))
42
+ const anchor = attention.filter((item) => item.code === 'UP-A12')
43
+ return [...rest, ...anchor]
44
+ }
45
+
46
+ /** T-0027 increment 21 (U26), traceability.md "Report" R2's own "M10 is not counted as updated":
47
+ * unlike `countsFromOps`, above, this does not look at `op.report` at all -- a written
48
+ * `package.json`/`pnpm-lock.yaml` still needs `pnpm install` advice whether or not it was an M10
49
+ * lockfile-conflict write or a plain M5/M12 write. */
50
+ function wroteDependencyFile(ops) {
51
+ return ops.some((op) => op.action === 'write' && (op.path === 'package.json' || op.path === 'pnpm-lock.yaml'))
52
+ }
53
+
54
+ /** R4 (traceability.md "Report"): a fixed body of next-steps advice, plus three conditional
55
+ * bullets (D16's `|||||||` line, `pnpm install`, the `v*` tag), each its own `R4:` line (mirrors
56
+ * R3's own one-line-per-item convention). */
57
+ function buildR4Lines(lang2, { ops, attention, hasDeployWorkflow }) {
58
+ const zh = lang2 === 'zh-TW'
59
+ const lines = [
60
+ zh ? 'R4: 執行 git status 與 git diff' : 'R4: run git status and git diff',
61
+ // Named generically here (never the literal code "UP-A1"), so this fixed bullet never
62
+ // collides with a path-specific assertion elsewhere checking a particular attention code's
63
+ // ABSENCE from the whole run's stdout (e.g. U56, increment 17's own `not.toContain('UP-A1')`).
64
+ zh
65
+ ? 'R4: 處理上面的衝突標記(搜尋 <<<<<<<)與其他注意項目'
66
+ : 'R4: resolve the conflict markers above (search for <<<<<<<) and the other attention items',
67
+ ]
68
+ // D16 (T-0063): when R3 holds UP-A1, say whose text the `|||||||` section of a conflict block is.
69
+ // Like the bullet above, it never names the code itself.
70
+ if (attention.some((item) => item.code === 'UP-A1')) {
71
+ lines.push(
72
+ zh
73
+ ? 'R4: 衝突區塊裡 ||||||| 之後的段落是 R1 所列 base 版本的內容;如果這個 base 不是靶場原本所依據的版本,請先執行 R5 的指令'
74
+ : 'R4: in a conflict block, the section after ||||||| is the text of the base version in R1; ' +
75
+ 'if that base is not the version the range came from, run the R5 commands first',
76
+ )
77
+ }
78
+ if (wroteDependencyFile(ops)) lines.push(zh ? 'R4: 執行 pnpm install' : 'R4: run pnpm install')
79
+ lines.push(zh ? 'R4: build 並測試' : 'R4: build and test')
80
+ lines.push('R4: commit')
81
+ if (hasDeployWorkflow) lines.push(zh ? 'R4: 推送新的 v* tag 以觸發部署' : 'R4: push a new v* tag to deploy')
82
+ return lines
83
+ }
84
+
85
+ export function buildReport({ lang2, toolVersion, baseVersion, baseCommit, targetVersion, targetCommit, ops, attention, hasDeployWorkflow, t2, baseUnchecked = false }) {
86
+ const zh = lang2 === 'zh-TW'
87
+ const counts = countsFromOps(ops)
88
+ const lines = []
89
+ // D17 (traceability.md "Report" R1; T-0061): a `--from <commit>` base is whatever that commit
90
+ // holds -- R1 says it was not checked against an upstream tag.
91
+ const uncheckedNote = baseUnchecked
92
+ ? zh
93
+ ? ';base 由 --from <commit> 指定,沒有比對上游的 tag'
94
+ : '; the base was given by --from <commit> and not checked against an upstream tag'
95
+ : ''
96
+ lines.push(
97
+ zh
98
+ ? `R1: create-wxl ${jsonValue(toolVersion)};base 版本 ${jsonValue(baseVersion)}(commit ${jsonValue(baseCommit)});` +
99
+ `target 版本 ${jsonValue(targetVersion)}(commit ${jsonValue(targetCommit)})` +
100
+ uncheckedNote
101
+ // Names the commit the same way the zh-TW text above already does.
102
+ : `R1: create-wxl ${jsonValue(toolVersion)}; base ${jsonValue(baseVersion)} (commit ${jsonValue(baseCommit)}); ` +
103
+ `target ${jsonValue(targetVersion)} (commit ${jsonValue(targetCommit)})` +
104
+ uncheckedNote,
105
+ )
106
+ lines.push(
107
+ zh
108
+ ? `R2: 更新 ${counts.updated} 新增 ${counts.added} 刪除 ${counts.deleted}`
109
+ : `R2: updated ${counts.updated} added ${counts.added} deleted ${counts.deleted}`,
110
+ )
111
+ // D16 (T-0063): then every path M6 deleted (the only deleting row), sorted by UTF-16 code units,
112
+ // without a code.
113
+ const deletedPaths = ops.filter((op) => op.action === 'delete').map((op) => op.path)
114
+ for (const path of deletedPaths.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0))) lines.push(`R2: ${jsonValue(path)}`)
115
+ // T-0027 verification follow-up (item 1/2): each attention line is rendered through `t2`
116
+ // (traceability.md:206 "in the run's language"), the same en/zh-TW UP-A* message table every
117
+ // other code already uses -- UP-A12 (T-0027 increment 20, U59) is not tied to any one path
118
+ // (traceability.md "Fork:init-derived content"), so it prints without one, unlike every other
119
+ // UP-A* line.
120
+ for (const item of sortAttention(attention)) {
121
+ lines.push(item.code === 'UP-A12' ? `R3: ${t2[item.code]()}` : `R3: ${t2[item.code]()} ${jsonValue(item.path)}`)
122
+ }
123
+ lines.push(...buildR4Lines(lang2, { ops, attention, hasDeployWorkflow }))
124
+ // traceability.md "Report" R5; spec.en.md B4: the restore commands undo
125
+ // the WHOLE upgrade, so they are only safe BEFORE any other change -- the fixed bare commands
126
+ // alone, without that warning, let a teacher who edits first lose that edit too.
127
+ // The same one line UP-E09 prints (messages.mjs, D23).
128
+ lines.push(
129
+ zh
130
+ ? `R5: 如果要撤銷整個升級,請在做其他變更之前執行 ${restoreCommandLine()}`
131
+ : `R5: to undo the whole upgrade before making other changes, run ${restoreCommandLine()}`,
132
+ )
133
+ return lines.join('\n') + '\n'
134
+ }