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/messages.mjs CHANGED
@@ -3,16 +3,31 @@
3
3
  // (AC-18): argument/target/download checks, strict parse and entry rules 1-5 (CW-E08), pristine
4
4
  // rule failures (CW-E10), setup questions (S1-S6's prompts, reasons and summary lines, CW-E05,
5
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).
6
+ // and signal paths (CW-E09, CW-E11, CW-E14, CW-W05). Every UP-* text (0006's upgrade path:
7
+ // UP-E01-E13, UP-I01, UP-A1-A15) likewise begins with its code, in both tables
8
+ // (`upgrade-messages.test.mjs` checks the whole UP-* set).
7
9
 
8
- import { jsonValue, posixCommand, posixQuoteWord, formatDirectoryArg, hasControlChar } from './text.mjs'
10
+ import { jsonValue, posixCommand, posixQuoteWord, formatDirectoryArg, hasControlChar, jsonStringifyEscaped } from './text.mjs'
9
11
  import { printedForms } from './git-steps.mjs'
10
12
  import { MINIMUM_GIT_VERSION } from './git-version.mjs'
13
+ import { MINIMUM_BASE_VERSION_TEXT } from './base-version.mjs'
14
+ import { VERSION_RE } from './patterns.mjs'
15
+ import { restoreCommandLine } from './upgrade-report.mjs'
11
16
 
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. */
17
+ /** CW-W01's and UP-E02's minimum-version text (S-10), one source with the actual check (`isGitVersionOk`'s
18
+ * own default), not a hardcoded version repeated in both language tables. */
14
19
  const MINIMUM_GIT_VERSION_TEXT = MINIMUM_GIT_VERSION.join('.')
15
20
 
21
+ /** Candidate 22 (stage 7): the fallback UP-E10 prints when `jsonStringifyEscaped` cannot (a
22
+ * `JSON.stringify` throw, or a `function`/`undefined` value it reflects back as `undefined`) --
23
+ * only `array`/`object`/`null`/`number`/`boolean` are reachable from the one call site this
24
+ * exists for (a non-string `version` read from parsed JSON). */
25
+ function typeNameOf(value) {
26
+ if (value === null) return 'null'
27
+ if (Array.isArray(value)) return 'array'
28
+ return typeof value
29
+ }
30
+
16
31
  const CONDITION_TEXT = {
17
32
  en: {
18
33
  file: 'it is not a directory',
@@ -169,7 +184,146 @@ function cwE07DetailText(lang, detail) {
169
184
  return lang === 'zh-TW' ? `下載失敗:${jsonValue(detail.message)}` : `the download failed: ${jsonValue(detail.message)}`
170
185
  }
171
186
 
187
+ /** D18 (traceability.md revision 3.2; T-0061): the remedies for a base read from `range.json`
188
+ * that the upgrade refused, in this order -- `--from <version>` first (checked through upstream's
189
+ * tag), the recorded commit only when the teacher knows the range was made from it, then how to see
190
+ * who changed the setup record. `reason` says why the recorded commit is in doubt: `'not-release'`
191
+ * for UP-E12 (the tag names another commit), `'unchecked'` for UP-E08 and a 404 on the tag
192
+ * download (the commit could not be checked against its tag; the tag may only have been deleted). */
193
+ function recordedBaseRemediesText(lang, reason) {
194
+ if (lang === 'zh-TW') {
195
+ const doubt = reason === 'not-release' ? '記錄的 commit 不是發布版' : '記錄的 commit 沒辦法經由它的 tag 驗證'
196
+ return (
197
+ '如果你知道這個靶場是從哪一版上游建立的,請用 --from <version> 重新執行(會經由上游的 tag 驗證)。' +
198
+ `${doubt}:只有確定這個靶場就是從它建立時,才用 --from <commit> 指定它。` +
199
+ 'git log -p -- range.json 可以查出是誰改了這份設定紀錄。'
200
+ )
201
+ }
202
+ const doubt = reason === 'not-release' ? 'The recorded commit is not a release' : 'The recorded commit could not be checked against its tag'
203
+ return (
204
+ "If you know the release this range came from, run the upgrade again with --from <version> (checked through upstream's tag). " +
205
+ `${doubt}: name it with --from <commit> only if you know this range was made from it. ` +
206
+ 'git log -p -- range.json shows who changed the setup record.'
207
+ )
208
+ }
209
+
172
210
  export const en = {
211
+ 'UP-E01': ({ path }) =>
212
+ `UP-E01: ${jsonValue(path)} is not the top level of a git work tree; run upgrade in the range's own root.`,
213
+ 'UP-I01': ({ version, commit }) =>
214
+ `UP-I01: nothing to do; the range is already at version ${jsonValue(version)} (commit ${jsonValue(commit)}).`,
215
+ 'UP-E02': ({ version }) =>
216
+ `UP-E02: git could not be run, or is older than ${MINIMUM_GIT_VERSION_TEXT}` +
217
+ (version !== undefined ? ` (found ${jsonValue(version)})` : '') +
218
+ `; install a newer git from https://git-scm.com/downloads.`,
219
+ 'UP-E03': ({ reason }) => `UP-E03: the range is not clean (${jsonValue(reason)}); run git status to see what changed.`,
220
+ 'UP-E04': () =>
221
+ `UP-E04: no range.json was found and no --from was given; pass --from <version> or --from <commit>, ` +
222
+ `or run this in a range made with create-wxl, which always has a range.json.`,
223
+ 'UP-E05': ({ rangeRepository, sourceRepository }) =>
224
+ `UP-E05: range.json's upstream.repository ${jsonValue(rangeRepository)} does not match the upstream repository ${jsonValue(sourceRepository)}.`,
225
+ 'UP-E06': ({ reason }) => `UP-E06: range.json is invalid (${jsonValue(reason)}).`,
226
+ 'UP-E07': ({ baseVersion, targetVersion }) =>
227
+ `UP-E07: the range's base version ${jsonValue(baseVersion)} is higher than the target version ${jsonValue(targetVersion)}; downgrading is not supported.`,
228
+ 'UP-E10': ({ version }) => {
229
+ if (version === undefined) {
230
+ return `UP-E10: the base version could not be read; a range from before ${MINIMUM_BASE_VERSION_TEXT} cannot be upgraded by this tool.`
231
+ }
232
+ if (typeof version !== 'string') {
233
+ const printed = jsonStringifyEscaped(version) ?? typeNameOf(version)
234
+ return `UP-E10: the base version ${printed} is not valid; a range from before ${MINIMUM_BASE_VERSION_TEXT} cannot be upgraded by this tool.`
235
+ }
236
+ if (!VERSION_RE.test(version)) {
237
+ return `UP-E10: the base version ${jsonValue(version)} is not valid; a range from before ${MINIMUM_BASE_VERSION_TEXT} cannot be upgraded by this tool.`
238
+ }
239
+ return `UP-E10: the base version ${jsonValue(version)} is lower than ${MINIMUM_BASE_VERSION_TEXT}; a range from before ${MINIMUM_BASE_VERSION_TEXT} cannot be upgraded by this tool.`
240
+ },
241
+ // D18 (T-0061): `remedies` is set for a base read from range.json.
242
+ 'UP-E08': ({ tag, remedies }) =>
243
+ `UP-E08: the tag ${jsonValue(tag)}'s archive comment is not 40 lowercase hexadecimal characters; the commit the tag names could not be read.` +
244
+ (remedies ? ` ${recordedBaseRemediesText('en', 'unchecked')}` : ''),
245
+ // T-0027 increment 1 (traceability.md "Codes", attention items UP-A1-UP-A12): a fixed
246
+ // description per code, never per-instance (the path itself is printed by R3, next to the
247
+ // code, not inside this text) -- so every one of these takes no fields at all, trivially safe
248
+ // under `language.test.mjs`'s shared `ARGS` object: a function that never destructures anything
249
+ // can never throw on a missing field.
250
+ 'UP-A1': () => `UP-A1: a conflict was written, marked with three labels (range, the base version, the target version).`,
251
+ 'UP-A2': () => `UP-A2: a binary file changed on both sides; the range's own version was kept.`,
252
+ 'UP-A3': () => `UP-A3: upstream deleted a file the range changed; the range's own version was kept.`,
253
+ 'UP-A4': () => `UP-A4: upstream added a file where the range already has a different one; the range's own version was kept.`,
254
+ 'UP-A5': () => `UP-A5: the range deleted a file upstream changed; it stays deleted.`,
255
+ 'UP-A6': () => `UP-A6: the lockfile changed on both sides; upstream's was taken. Run pnpm install.`,
256
+ // T-0027 increment 4 (traceability.md "Codes", P-3): the path is or passes through a symbolic
257
+ // link in the range; untouched.
258
+ 'UP-A7': () => `UP-A7: the path is or passes through a symbolic link in the range; it was left untouched.`,
259
+ // T-0027 increment 6 (traceability.md "Codes", P-5): the path is ignored by the range's own
260
+ // rules; untouched.
261
+ 'UP-A9': () => `UP-A9: the path is ignored by the range's own rules; it was left untouched.`,
262
+ // T-0027 increment 13 (traceability.md "Codes", P-4): another path differs only by
263
+ // `path.normalize('NFC')` and `toLowerCase()` (`merge-plan.mjs`'s `hasCaseCollision`) --
264
+ // lowercasing, not full Unicode case folding (recorded, not changed), so the text names the
265
+ // Unicode form too, not only letter case; untouched.
266
+ 'UP-A8': () =>
267
+ `UP-A8: another path in the range differs from this one only by letter case or Unicode form; it was left untouched.`,
268
+ // T-0027 increment 15 (traceability.md "Codes", P-6 and the parent check): the range has a
269
+ // directory or other non-file where upstream has a file, or a planned write's parent is kept as
270
+ // a non-directory; either way, untouched.
271
+ 'UP-A10': () => `UP-A10: the range has a directory or other non-file where upstream has a file (or the reverse); it was left untouched.`,
272
+ // T-0027 increment 19 (traceability.md "Codes", M12): a clean merge of a `.json` file produced
273
+ // output that does not parse as JSON; the range's own version was kept instead.
274
+ 'UP-A11': () => `UP-A11: a clean JSON merge did not parse; the range's own version was kept.`,
275
+ // T-0027 increment 20 (traceability.md "Fork:init-derived content", "Codes" UP-A12): any of the
276
+ // nine anchor files differs between base and target; this adds the single UP-A12 line, never
277
+ // naming the files themselves -- the one fixed text below covers both the
278
+ // rerun-with-original-arguments clause and the deploy-workflow clause.
279
+ 'UP-A12': () =>
280
+ `UP-A12: upstream changed files pnpm fork:init writes. After resolving every attention item, rerun pnpm fork:init with the range's original arguments; for a range with a deploy workflow, this also refreshes it.`,
281
+ // D9 (traceability.md "Pre-checks" P-7, "Codes" UP-A13): an ignore file (at any level) that
282
+ // upstream changed is never written, so the restore commands and every ignored file stay
283
+ // safe; the teacher merges the ignore rules by hand.
284
+ 'UP-A13': () =>
285
+ `UP-A13: upstream changed an ignore file; it was left untouched so ignored files and the restore commands stay safe. Merge the ignore rules by hand.`,
286
+ // D10 (traceability.md "Pre-checks" P-8, "Codes" UP-A14): the path is a submodule (a gitlink in
287
+ // the index) or lies under one; left untouched, since the restore commands cannot undo a change
288
+ // inside a submodule's own working tree.
289
+ 'UP-A14': () =>
290
+ `UP-A14: the path is a submodule, or is inside one; it was left untouched, since the restore commands cannot undo a change there.`,
291
+ // P-9 (D12, T-0062).
292
+ 'UP-A15': () =>
293
+ `UP-A15: the path's name on disk differs from upstream's (a short name, ignored characters, or letter case, in the path or a parent); it was left untouched.`,
294
+ // T-0027 increment 10 (traceability.md "Codes" UP-E09): a write failed, or a signal arrived,
295
+ // during step 9 (apply); names the path and the file-system error, or (for a signal) the signal
296
+ // name, plus the restore commands as one line in D14's order (`restoreCommandLine`: `git checkout
297
+ // -- . && git clean -fd`; D14 T-0063, D23 T-0066).
298
+ 'UP-E09': ({ path, signal, detail }) =>
299
+ `UP-E09: ` +
300
+ (signal !== undefined
301
+ ? `a signal (${jsonValue(signal)}) stopped the upgrade while applying the plan`
302
+ : `applying the plan failed at ${jsonValue(path)}: ${jsonValue(detail)}`) +
303
+ `; the range may be partially changed. Run ${restoreCommandLine()} to restore it to HEAD.`,
304
+ // T-0027 increment 18 (traceability.md "Codes" UP-E11): planning (step 8) failed -- `git
305
+ // merge-file` exited outside 0-127 (or was ended by a signal, `code` `null`), or its output was
306
+ // not read in full (traceability.md's own wording for this code; no code path exercises that
307
+ // last clause -- see the ticket's coverage report). Nothing is written in the range before
308
+ // step 9, so there is nothing to restore (unlike UP-E09).
309
+ // `detail` is the underlying error's own text (a read error of o/b/t, or a `runGit` rejection
310
+ // for `merge-file`) -- absent for `MergeFileError` (U57's own "exited outside 0-127" case),
311
+ // which keeps its fixed wording instead.
312
+ 'UP-E11': ({ path, detail }) =>
313
+ `UP-E11: planning the merge failed at ${jsonValue(path)}: ` +
314
+ (detail !== undefined ? jsonValue(detail) : `git merge-file did not finish normally.`),
315
+ // J6, D11, D18 (T-0061): the base read from range.json is not the commit upstream's tag names.
316
+ 'UP-E12': ({ version, recordedCommit, tagCommit }) =>
317
+ `UP-E12: range.json records version ${jsonValue(version)} with commit ${jsonValue(recordedCommit)}, ` +
318
+ `but upstream's tag ${jsonValue(`v${version}`)} names commit ${jsonValue(tagCommit)}, so the recorded base is not an upstream release. ` +
319
+ `range.json may have been written with CREATE_WXL_COMMIT or by a package packed at a commit other than its version's tag, the tag may have moved since, or someone may have changed the setup record. ` +
320
+ recordedBaseRemediesText('en', 'not-release'),
321
+ // D15 (OQ-026; T-0061): an untracked range.json that the range's rules ignore.
322
+ 'UP-E13': () =>
323
+ `UP-E13: range.json is not tracked and the range's ignore rules ignore it, so the restore commands could not undo the upgrade's change to it. ` +
324
+ `The rule may be in a .gitignore, in .git/info/exclude or in your core.excludesFile. ` +
325
+ `Remove the rule and commit that change, together with range.json when it exists; or, when range.json exists, track it with git add -f -- range.json and commit it. ` +
326
+ `If a tracked file's name differs from range.json only in letter case (such as Range.json), rename it to range.json with git mv and commit.`,
173
327
  'CW-E01': ({ running, minimum }) =>
174
328
  `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
329
  'CW-E02': ({ value, usage, reason = 'unknown', badValue }) =>
@@ -181,7 +335,9 @@ export const en = {
181
335
  '.',
182
336
  'CW-E05': ({ missing }) => cwE05Text('en', missing),
183
337
  '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)}.`,
338
+ // D18 (T-0061): `remedies` is set only for a 404 on the tag download of a base read from range.json.
339
+ 'CW-E07': ({ url, detail, remedies }) =>
340
+ `CW-E07: downloading ${jsonValue(url)} failed: ${cwE07DetailText('en', detail)}.` + (remedies ? ` ${recordedBaseRemediesText('en', 'unchecked')}` : ''),
185
341
  'CW-E08': ({ rule, path, parseError, isCommentMarker }) =>
186
342
  parseError !== undefined
187
343
  ? `CW-E08: the archive could not be parsed: ${jsonValue(parseError)}.`
@@ -226,7 +382,7 @@ export const en = {
226
382
  'S6-REPO': ({ value }) => ` repository: ${jsonValue(value)}`,
227
383
  'S6-HOST': ({ value }) => ` host: ${jsonValue(value)}`,
228
384
  'S6-ORIGIN-ANSWER': ({ value }) => ` site address answered: ${jsonValue(value)}`,
229
- 'S6-ORIGIN': ({ origin }) => ` short link origin: ${jsonValue(origin)}`,
385
+ 'S6-ORIGIN': ({ origin }) => ` range address, the origin of every copied link: ${jsonValue(origin)}`,
230
386
  'S6-SITE-NAME': ({ value }) => ` site name: ${jsonValue(value)}`,
231
387
  S6: () => `Apply these settings? [Y/n]`,
232
388
  'S6-INVALID': () => `Please answer y, yes, n or no (empty counts as yes).`,
@@ -248,6 +404,73 @@ export const en = {
248
404
  }
249
405
 
250
406
  export const zhTW = {
407
+ 'UP-E01': ({ path }) => `UP-E01:${jsonValue(path)} 不是 git 工作目錄的頂層;請在靶場自己的根目錄執行 upgrade。`,
408
+ 'UP-I01': ({ version, commit }) => `UP-I01:不需要升級;靶場目前已經是版本 ${jsonValue(version)}(commit ${jsonValue(commit)})。`,
409
+ 'UP-E02': ({ version }) =>
410
+ `UP-E02:git 無法執行,或版本低於 ${MINIMUM_GIT_VERSION_TEXT}` +
411
+ (version !== undefined ? `(偵測到 ${jsonValue(version)})` : '') +
412
+ `;請到 https://git-scm.com/downloads 安裝較新版本。`,
413
+ 'UP-E03': ({ reason }) => `UP-E03:靶場不乾淨(${jsonValue(reason)});請執行 git status 查看變更內容。`,
414
+ 'UP-E04': () =>
415
+ `UP-E04:找不到 range.json,也沒有帶 --from;請帶上 --from <version> 或 --from <commit>,` +
416
+ `或是在用 create-wxl 建出來的靶場(一定會有 range.json)裡執行。`,
417
+ 'UP-E05': ({ rangeRepository, sourceRepository }) =>
418
+ `UP-E05:range.json 的 upstream.repository ${jsonValue(rangeRepository)} 與上游的 repository ${jsonValue(sourceRepository)} 不一致。`,
419
+ 'UP-E06': ({ reason }) => `UP-E06:range.json 不合法(${jsonValue(reason)})。`,
420
+ 'UP-E07': ({ baseVersion, targetVersion }) =>
421
+ `UP-E07:靶場目前的 base 版本 ${jsonValue(baseVersion)} 比 target 版本 ${jsonValue(targetVersion)} 還新,不支援降版。`,
422
+ 'UP-E10': ({ version }) => {
423
+ if (version === undefined) {
424
+ return `UP-E10:無法讀出 base 版本;${MINIMUM_BASE_VERSION_TEXT} 之前的靶場無法用這個工具升級。`
425
+ }
426
+ if (typeof version !== 'string') {
427
+ const printed = jsonStringifyEscaped(version) ?? typeNameOf(version)
428
+ return `UP-E10:base 版本 ${printed} 格式不正確;${MINIMUM_BASE_VERSION_TEXT} 之前的靶場無法用這個工具升級。`
429
+ }
430
+ if (!VERSION_RE.test(version)) {
431
+ return `UP-E10:base 版本 ${jsonValue(version)} 格式不正確;${MINIMUM_BASE_VERSION_TEXT} 之前的靶場無法用這個工具升級。`
432
+ }
433
+ return `UP-E10:base 版本 ${jsonValue(version)} 低於 ${MINIMUM_BASE_VERSION_TEXT};${MINIMUM_BASE_VERSION_TEXT} 之前的靶場無法用這個工具升級。`
434
+ },
435
+ 'UP-E08': ({ tag, remedies }) =>
436
+ `UP-E08:tag ${jsonValue(tag)} 的 pax comment 不是 40 位小寫十六進位字元,讀不到 tag 指向的 commit。` +
437
+ (remedies ? recordedBaseRemediesText('zh-TW', 'unchecked') : ''),
438
+ // These UP-A* texts use an ASCII colon after the code, like `R1:`/`R2:`
439
+ // etc. elsewhere in the zh-TW report, instead of this table's own full-width ":" -- a R3 line
440
+ // is `R3: ` (ASCII) followed by one of these, so the two would otherwise mix colon styles in
441
+ // the same line.
442
+ 'UP-A1': () => `UP-A1: 寫入了衝突標記,共三個標籤(range、base 版本、target 版本)。`,
443
+ 'UP-A2': () => `UP-A2: 有二進位檔案兩邊都改了;保留靶場自己的版本。`,
444
+ 'UP-A3': () => `UP-A3: 上游刪除了靶場改過的檔案;保留靶場自己的版本。`,
445
+ 'UP-A4': () => `UP-A4: 上游新增的檔案在靶場裡已經是另一個不同的檔案;保留靶場自己的版本。`,
446
+ 'UP-A5': () => `UP-A5: 靶場刪除了上游改過的檔案;維持刪除狀態。`,
447
+ 'UP-A6': () => `UP-A6: lockfile 兩邊都改了;採用上游的版本,請執行 pnpm install。`,
448
+ 'UP-A7': () => `UP-A7: 這個路徑本身或途經的目錄是靶場裡的符號連結;維持不動。`,
449
+ 'UP-A9': () => `UP-A9: 這個路徑被靶場自己的規則忽略;維持不動。`,
450
+ 'UP-A8': () => `UP-A8: 靶場裡另有一個路徑只差在大小寫或 Unicode 形式;維持不動。`,
451
+ 'UP-A10': () => `UP-A10: 靶場這個路徑是目錄或其他非檔案的東西,但上游是檔案(或反過來);維持不動。`,
452
+ 'UP-A11': () => `UP-A11: JSON 乾淨合併後無法解析;保留靶場自己的版本。`,
453
+ 'UP-A12': () =>
454
+ `UP-A12: 上游改了 pnpm fork:init 會寫入的檔案;請在解決所有注意項目後,用靶場原本的參數重新執行 pnpm fork:init;若靶場有部署用的 workflow,這個步驟也會一併更新它。`,
455
+ 'UP-A13': () => `UP-A13: 上游改了一個忽略規則檔;維持不動,讓忽略的檔案與還原指令都安全。請自行手動合併忽略規則。`,
456
+ 'UP-A14': () => `UP-A14: 這個路徑是子模組本身或在子模組裡面;維持不動,因為還原指令無法復原子模組內部的變更。`,
457
+ 'UP-A15': () => `UP-A15: 這個路徑在磁碟上的實際名稱和上游的不同(短檔名、檔案系統會略過的字元或大小寫,在路徑本身或父目錄);維持不動。`,
458
+ 'UP-E09': ({ path, signal, detail }) =>
459
+ `UP-E09:` +
460
+ (signal !== undefined ? `收到訊號(${jsonValue(signal)})後停止套用合併結果` : `套用合併結果時在 ${jsonValue(path)} 失敗:${jsonValue(detail)}`) +
461
+ `;靶場可能已經被部分改動。請執行 ${restoreCommandLine()} 還原到 HEAD。`,
462
+ 'UP-E11': ({ path, detail }) =>
463
+ `UP-E11:規劃合併時在 ${jsonValue(path)} 失敗:` + (detail !== undefined ? jsonValue(detail) : `git merge-file 沒有正常結束。`),
464
+ 'UP-E12': ({ version, recordedCommit, tagCommit }) =>
465
+ `UP-E12:range.json 記錄的版本是 ${jsonValue(version)}、commit 是 ${jsonValue(recordedCommit)},` +
466
+ `但上游的 tag ${jsonValue(`v${version}`)} 指向 commit ${jsonValue(tagCommit)},所以記錄的 base 不是上游的發布版。` +
467
+ `可能的原因:range.json 是用 CREATE_WXL_COMMIT,或由在不是該版本 tag 的 commit 打包的套件寫入的、這個 tag 之後被移動過,或是有人改了這份設定紀錄。` +
468
+ recordedBaseRemediesText('zh-TW', 'not-release'),
469
+ 'UP-E13': () =>
470
+ `UP-E13:range.json 沒有被 git 追蹤,又被靶場的 ignore 規則排除,還原指令沒辦法撤銷升級對它的改動。` +
471
+ `這條規則可能在某個 .gitignore、.git/info/exclude,或你的 core.excludesFile 裡。` +
472
+ `請移除這條規則並 commit,range.json 存在時一起 commit;或者在 range.json 存在時,用 git add -f -- range.json 把它納入追蹤並 commit。` +
473
+ `如果被追蹤的檔名和 range.json 只差在大小寫(例如 Range.json),請用 git mv 改名為 range.json 並 commit。`,
251
474
  'CW-E01': ({ running, minimum }) =>
252
475
  `CW-E01:目前使用的 Node.js ${jsonValue(running)} 低於最低需求 ${jsonValue(minimum)},請到 https://nodejs.org/ 下載較新版本。`,
253
476
  'CW-E02': ({ value, usage, reason = 'unknown', badValue }) =>
@@ -259,7 +482,8 @@ export const zhTW = {
259
482
  '。',
260
483
  'CW-E05': ({ missing }) => cwE05Text('zh-TW', missing),
261
484
  'CW-E06': ({ message }) => `CW-E06:引擎拒絕了這些答案:${jsonValue(message)}。`,
262
- 'CW-E07': ({ url, detail }) => `CW-E07:下載 ${jsonValue(url)} 失敗:${cwE07DetailText('zh-TW', detail)}。`,
485
+ 'CW-E07': ({ url, detail, remedies }) =>
486
+ `CW-E07:下載 ${jsonValue(url)} 失敗:${cwE07DetailText('zh-TW', detail)}。` + (remedies ? recordedBaseRemediesText('zh-TW', 'unchecked') : ''),
263
487
  'CW-E08': ({ rule, path, parseError, isCommentMarker }) =>
264
488
  parseError !== undefined
265
489
  ? `CW-E08:壓縮檔無法解析:${jsonValue(parseError)}。`
@@ -301,7 +525,7 @@ export const zhTW = {
301
525
  'S6-REPO': ({ value }) => ` GitHub repository:${jsonValue(value)}`,
302
526
  'S6-HOST': ({ value }) => ` 部署目標:${jsonValue(value)}`,
303
527
  'S6-ORIGIN-ANSWER': ({ value }) => ` 回答的網站網址:${jsonValue(value)}`,
304
- 'S6-ORIGIN': ({ origin }) => ` 短連結網址:${jsonValue(origin)}`,
528
+ 'S6-ORIGIN': ({ origin }) => ` 靶場網址(複製出來的每一條連結都從它開始):${jsonValue(origin)}`,
305
529
  'S6-SITE-NAME': ({ value }) => ` 網站名稱:${jsonValue(value)}`,
306
530
  S6: () => `要套用這些設定嗎?[Y/n]`,
307
531
  'S6-INVALID': () => `請回答 y、yes、n 或 no(留空視為 yes)。`,
@@ -0,0 +1,6 @@
1
+ // Shared patterns (moved unchanged from run.mjs): the package source record's version and
2
+ // commit shapes, reused by the upgrade path (traceability.md "Command grammar", "Base from
3
+ // range.json").
4
+
5
+ export const VERSION_RE = /^\d+\.\d+\.\d+$/
6
+ export const COMMIT_RE = /^[0-9a-f]{40}$/
package/src/questions.mjs CHANGED
@@ -197,9 +197,9 @@ async function askField(io, t, questioner, field, current) {
197
197
  }
198
198
  }
199
199
 
200
- /** S6: each answer, and the short-link origin the engine will write (traceability.md "Setup
201
- * questions"): the parsed args' `origin` when present, else `deriveGithubPagesOrigin(repo)` for
202
- * GitHub Pages. */
200
+ /** S6: each answer, and the range address (the origin of every copied link) the engine will
201
+ * write (traceability.md "Setup questions"): the parsed args' `origin` when present, else
202
+ * `deriveGithubPagesOrigin(repo)` for GitHub Pages. */
203
203
  function printSummary(io, t, deps, current, parsedArgs) {
204
204
  io.stdout.write(t['S6-AUTHOR']({ value: current.author }) + '\n')
205
205
  io.stdout.write(t['S6-REPO']({ value: current.repo }) + '\n')
@@ -0,0 +1,207 @@
1
+ // Shared run helpers, moved out of run.mjs so steps 6-9 (0005) and the upgrade path's step 7
2
+ // (0006) can call the same functions: the one place every "print a failure line, then remove the
3
+ // temporary directory this run created, then fail" sequence lives, the file-system-vs-bug error
4
+ // classifier, the download window (`downloadArchive`, now taking the repository/ref to download
5
+ // instead of being tied to `deps.source`/`commit`), and the archive-read/mkdtemp/extract/
6
+ // pristine-apply window (`readArchiveEntries` also gained the optional `tag` parameter here, for
7
+ // 0006's tag-mode bases, and, with T-0061, `expectedCommit`/`tagVersion`/`remedies` for J6 and
8
+ // D18; `downloadArchive` gained `remediesOn404`). The create path passes none of these.
9
+
10
+ import { join } from 'node:path'
11
+ import { SignalAbort } from './signals.mjs'
12
+ import { parseArchive, ArchiveParseError } from './archive.mjs'
13
+ import { checkEntryRules, ArchiveRuleError } from './archive-rules.mjs'
14
+ import { extractEntries } from './fsops.mjs'
15
+ import { applyChangelog, applyOpenQuestions, applyPackageJson, writeRangeJson, PristineError } from './pristine.mjs'
16
+ import { COMMIT_RE } from './patterns.mjs'
17
+
18
+ /** S-20: the one place every "print a failure line, then remove the temporary directory this run
19
+ * created, then fail" sequence lives (steps 8-10's CW-E09/CW-E10/CW-E04 branches, and the
20
+ * generic bug/signal catch, each used to repeat this pair of calls for itself). */
21
+ export async function removeTmpDir(deps, tmpDir) {
22
+ await deps.fs.rm(tmpDir, { recursive: true, force: true })
23
+ }
24
+
25
+ export async function failWithCleanup(io, deps, tmpDir, line) {
26
+ io.stderr.write(line + '\n')
27
+ await removeTmpDir(deps, tmpDir)
28
+ return 1
29
+ }
30
+
31
+ /** CW-E09 (steps 8-9) vs. CW-E14 (a bug): a real file-system error carries a string `code` that
32
+ * does NOT start with `ERR_` (review finding C-12) -- Node's own argument-validation errors
33
+ * (`ERR_INVALID_ARG_TYPE`, `ERR_INVALID_ARG_VALUE`, `ERR_OUT_OF_RANGE`, ...) also carry a string
34
+ * `code`, but they are a programming bug in this codebase, not an environment problem to report
35
+ * as CW-E09. */
36
+ export function isFileSystemError(err) {
37
+ return typeof err?.code === 'string' && !err.code.startsWith('ERR_')
38
+ }
39
+
40
+ /** The URL, `AbortController`, timeout, the `fetchArchive` race and CW-E07
41
+ * for a rejection, a timeout or a non-200 status -- the whole window 0005's step 6 (`runSteps`)
42
+ * and 0006's step 7 (`upgrade.mjs`) share, now taking the repository/ref to download instead of
43
+ * being tied to `deps.source`/`commit`. Returns `{ body }`, or `null` after CW-E07 is already
44
+ * printed; the caller's own `checkpoint('step-6')`/`guard.throwIfRaised()`/CW-W04 are unchanged by
45
+ * this move, and so is `checkpoint('step-7')` (both stay at the call site). `remediesOn404` (D18,
46
+ * T-0061) adds the recorded-base remedies to CW-E07 for a 404 status only -- never for a rejection,
47
+ * a timeout or another status; the upgrade sets it for the tag download of a base read from
48
+ * `range.json`. */
49
+ export async function downloadArchive({ io, deps, t2, guard, repository, ref, remediesOn404 = false }) {
50
+ const url = `https://codeload.github.com/${repository}/tar.gz/${ref}`
51
+ const controller = new AbortController()
52
+ // decision 6: a signal during the download aborts it through this same controller, instead of
53
+ // waiting out the full `downloadTimeoutMs` -- traceability.md "Signals": "after a signal no new
54
+ // file-system operation starts" (the download itself is not a file-system operation, but the
55
+ // rule not to keep running applies the same way).
56
+ const unsubscribeAbort = guard.onRaise(() => controller.abort())
57
+ let timedOut = false
58
+ let timer
59
+ const timeoutPromise = new Promise((_resolveTimeout, rejectTimeout) => {
60
+ timer = setTimeout(() => {
61
+ timedOut = true
62
+ controller.abort()
63
+ rejectTimeout(new Error())
64
+ }, deps.downloadTimeoutMs)
65
+ })
66
+
67
+ let status, body
68
+ try {
69
+ ;({ status, body } = await Promise.race([deps.fetchArchive(repository, ref, { signal: controller.signal }), timeoutPromise]))
70
+ } catch (err) {
71
+ if (guard.raised()) throw new SignalAbort(guard.raised())
72
+ const detail = timedOut ? { kind: 'timeout' } : { kind: 'error', message: err?.message ?? String(err) }
73
+ io.stderr.write(t2['CW-E07']({ url, detail }) + '\n')
74
+ return null
75
+ } finally {
76
+ clearTimeout(timer)
77
+ // S-17: this window has closed either way; this `AbortController` is no longer useful to
78
+ // abort, so the callback is removed instead of lingering for the rest of the run.
79
+ unsubscribeAbort()
80
+ }
81
+
82
+ guard.throwIfRaised() // P-17/C-14: a signal already received wins over a non-200 status
83
+ if (status !== 200) {
84
+ io.stderr.write(t2['CW-E07']({ url, detail: { kind: 'status', status }, remedies: remediesOn404 && status === 404 }) + '\n')
85
+ return null
86
+ }
87
+
88
+ return { body }
89
+ }
90
+
91
+ /** With an optional `tag` (U16): parses `body` (CW-E08 on a parse
92
+ * failure) and validates its entries against the archive entry rules (CW-E08 for a broken rule),
93
+ * returning the enriched entry list, or `null` after the message is already printed -- never
94
+ * throwing for either of those two conditions; anything else (a bug) is rethrown. Without `tag`,
95
+ * `commit` is compared against the archive's own pax comment exactly as rule 2 always has
96
+ * (0005's own call site never passes `tag`, so its behaviour is unchanged, and the return value
97
+ * stays the bare entry list). With `tag` (a `--from <version>` base, or a base read from
98
+ * `range.json`, whose tag is `v<tagVersion>`), the comment is resolved
99
+ * first: when it is not a string matching `COMMIT_RE`, UP-E08 is printed instead of CW-E08 and
100
+ * `commit` is never consulted; otherwise the comment itself becomes the `commit` rule 2 compares
101
+ * against (unchanged `checkEntryRules`), and the resolved commit is also returned (U52, inc 22:
102
+ * `{ entries, commit: comment }`) so the caller can learn the base commit a tag download alone
103
+ * never names. `expectedCommit` (J6, T-0061: a base read from `range.json`) must then equal the
104
+ * comment, checked before every entry rule; otherwise UP-E12 names `tagVersion` and both commits.
105
+ * `remedies` (D18) adds the recorded-base remedies to UP-E08. */
106
+ export async function readArchiveEntries({ io, t2, body, commit, tag, expectedCommit, tagVersion, remedies = false }) {
107
+ let parsedArchive
108
+ try {
109
+ parsedArchive = await parseArchive(body)
110
+ } catch (err) {
111
+ if (err instanceof ArchiveParseError) {
112
+ io.stderr.write(t2['CW-E08']({ parseError: err.message }) + '\n')
113
+ return null
114
+ }
115
+ throw err
116
+ }
117
+ let effectiveCommit = commit
118
+ if (tag !== undefined) {
119
+ const { comment } = parsedArchive
120
+ if (typeof comment !== 'string' || !COMMIT_RE.test(comment)) {
121
+ io.stderr.write(t2['UP-E08']({ tag, remedies }) + '\n')
122
+ return null
123
+ }
124
+ effectiveCommit = comment
125
+ // J6 (D11, T-0061): a base read from range.json must be the commit the tag names. Checked
126
+ // right after the comment's own shape and before every entry rule, so a mismatched archive is
127
+ // UP-E12 whatever else it holds.
128
+ if (expectedCommit !== undefined && comment !== expectedCommit) {
129
+ io.stderr.write(t2['UP-E12']({ version: tagVersion, recordedCommit: expectedCommit, tagCommit: comment }) + '\n')
130
+ return null
131
+ }
132
+ }
133
+ try {
134
+ const entries = checkEntryRules(parsedArchive, { commit: effectiveCommit })
135
+ return tag !== undefined ? { entries, commit: effectiveCommit } : entries
136
+ } catch (err) {
137
+ if (err instanceof ArchiveRuleError) {
138
+ io.stderr.write(t2['CW-E08']({ rule: err.rule, path: err.path, isCommentMarker: err.isCommentMarker }) + '\n')
139
+ return null
140
+ }
141
+ throw err
142
+ }
143
+ }
144
+
145
+ /** `deps.fs.mkdtemp(join(parent, prefix))`, reporting CW-E09 naming `parent` for a
146
+ * file-system error (mkdtemp itself failed: nothing was created, nothing to clean up), or
147
+ * rethrowing a bug. Returns the new directory's path, or `null` after the message is printed. */
148
+ export async function makeTmpDir({ io, deps, t2, parent, prefix }) {
149
+ try {
150
+ return await deps.fs.mkdtemp(join(parent, prefix))
151
+ } catch (err) {
152
+ if (isFileSystemError(err)) {
153
+ io.stderr.write(t2['CW-E09']({ path: parent, detail: err.message }) + '\n')
154
+ return null
155
+ }
156
+ throw err
157
+ }
158
+ }
159
+
160
+ /** Extracts `entries` into `tmpDir` (S-19's `{ stopped: true }` turned into the same
161
+ * `SignalAbort` every other window throws), reporting CW-E09 through `failWithCleanup` for a
162
+ * file-system error. Returns `true` on success, `false` after `failWithCleanup` already printed
163
+ * the message and removed `tmpDir`; a `SignalAbort` or a bug is rethrown, uncaught. */
164
+ export async function extractInto({ io, deps, t2, guard, entries, tmpDir }) {
165
+ try {
166
+ const { stopped } = await extractEntries(deps, entries, tmpDir, { shouldStop: () => guard.isRaised() })
167
+ if (stopped) throw new SignalAbort(guard.raised())
168
+ } catch (err) {
169
+ if (err instanceof SignalAbort) throw err
170
+ if (isFileSystemError(err)) {
171
+ await failWithCleanup(io, deps, tmpDir, t2['CW-E09']({ path: err.reportPath ?? tmpDir, detail: err.message }))
172
+ return false
173
+ }
174
+ throw err
175
+ }
176
+ return true
177
+ }
178
+
179
+ /** P1-P4 against `tmpDir`, with the guard checked in between each write (decision 6: a signal
180
+ * mid-step-9 stops before the next P-rule's write); `record` is P4's `{ repository, version,
181
+ * commit }`. CW-E10 for a `PristineError`, CW-E09 for a file-system error, each through
182
+ * `failWithCleanup`; anything else (including a `SignalAbort` from `guard.throwIfRaised()`) is
183
+ * rethrown, uncaught. Returns `true` on success, `false` after the message is printed and
184
+ * `tmpDir` removed. */
185
+ export async function applyPristine({ io, deps, t2, guard, tmpDir, record }) {
186
+ try {
187
+ await applyChangelog(deps.fs, tmpDir)
188
+ guard.throwIfRaised()
189
+ await applyOpenQuestions(deps.fs, tmpDir)
190
+ guard.throwIfRaised()
191
+ await applyPackageJson(deps.fs, tmpDir)
192
+ guard.throwIfRaised()
193
+ await writeRangeJson(deps.fs, tmpDir, record)
194
+ guard.throwIfRaised()
195
+ } catch (err) {
196
+ if (err instanceof PristineError) {
197
+ await failWithCleanup(io, deps, tmpDir, t2['CW-E10']({ file: err.file, rule: err.rule }))
198
+ return false
199
+ }
200
+ if (isFileSystemError(err)) {
201
+ await failWithCleanup(io, deps, tmpDir, t2['CW-E09']({ path: err.reportPath ?? tmpDir, detail: err.message }))
202
+ return false
203
+ }
204
+ throw err
205
+ }
206
+ return true
207
+ }