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/dist/source.json
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,22 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-wxl",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
}
|
|
3
|
+
"version": "3.1.0",
|
|
4
|
+
"description": "Create a Web eXploitation Laboratory range",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": { "create-wxl": "bin/create-wxl.mjs" },
|
|
7
|
+
"files": ["bin", "src", "dist", "LICENSE"],
|
|
8
|
+
"engines": { "node": ">=22.6.0" },
|
|
9
|
+
"license": "LicenseRef-LICENSE",
|
|
10
|
+
"dependencies": {
|
|
11
|
+
"tar": "7.5.22",
|
|
12
|
+
"yaml": "2.9.0"
|
|
13
|
+
},
|
|
14
|
+
"devDependencies": {
|
|
15
|
+
"vitest": "4.1.6"
|
|
16
|
+
},
|
|
17
|
+
"scripts": {
|
|
18
|
+
"prepack": "node scripts/prepare-dist.mjs",
|
|
19
|
+
"prepublishOnly": "node scripts/prepare-dist.mjs && node scripts/check-publish.mjs",
|
|
20
|
+
"test": "vitest run"
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
// The archive entry rules (traceability.md "Archive entry rules", rules 1-5), checked against the
|
|
2
|
+
// already-parsed entry list `archive.mjs`'s `parseArchive` returns (entries and ignored entries,
|
|
3
|
+
// in archive order, each carrying the raw typeflag `raw-tar.mjs` read off the decompressed
|
|
4
|
+
// bytes, cross-checked against `tar`'s own decode). The whole list is checked before step 8
|
|
5
|
+
// writes anything (step order table, step 7). Entries are walked ONCE, in archive order, checking
|
|
6
|
+
// every rule against each entry before moving to the next (S-03/P-03), so CW-E08 names the first
|
|
7
|
+
// breaking entry in archive order, not the first one a particular rule happens to reach; rule 4
|
|
8
|
+
// compares each entry only against the ones already seen. `checkEntryRules` returns the enriched
|
|
9
|
+
// list (first component stripped, classified kind) so step 8 writes from the SAME validated data,
|
|
10
|
+
// never re-deriving a path or kind from `tar`'s own, un-stripped fields (C-01).
|
|
11
|
+
|
|
12
|
+
import { foldAsciiCase } from './text.mjs'
|
|
13
|
+
|
|
14
|
+
export class ArchiveRuleError extends Error {
|
|
15
|
+
constructor(rule, path, { isCommentMarker = false } = {}) {
|
|
16
|
+
super(`archive entry rule ${rule} broken${path === undefined ? '' : ` at ${path}`}`)
|
|
17
|
+
this.name = 'ArchiveRuleError'
|
|
18
|
+
this.rule = rule
|
|
19
|
+
this.path = path
|
|
20
|
+
// S-25: rule 2's comment violation has no entry to name; `path` is the FIXED marker text
|
|
21
|
+
// "pax comment", not a value read from the archive, so it must not be printed as a quoted
|
|
22
|
+
// JSON literal the way a real entry path is (messages.mjs).
|
|
23
|
+
this.isCommentMarker = isCommentMarker
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const CONTROL_CHAR_RE = /[\u0000-\u001f\u007f\u0080-\u009f]/
|
|
28
|
+
const FIRST_COMPONENT_RE = /^[^\u0000-\u001f\u007f\u0080-\u009f\\]+$/
|
|
29
|
+
|
|
30
|
+
/** Regular file (typeflag `0`, NUL or `7`) or directory (`5`); anything else (symlink, hardlink,
|
|
31
|
+
* character/block device, FIFO, GNU sparse, unknown) breaks rule 1. Classified from the RAW
|
|
32
|
+
* typeflag byte, not `tar`'s own reclassified `entry.type` (raw-tar.mjs's header comment). */
|
|
33
|
+
function classify(rawTypeflag) {
|
|
34
|
+
if (rawTypeflag === '0' || rawTypeflag === '\u0000' || rawTypeflag === '7') return 'file'
|
|
35
|
+
if (rawTypeflag === '5') return 'dir'
|
|
36
|
+
return 'other'
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function firstComponentAndRest(headerPath) {
|
|
40
|
+
const idx = headerPath.indexOf('/')
|
|
41
|
+
if (idx === -1) return { firstComponent: headerPath, rest: '' }
|
|
42
|
+
return { firstComponent: headerPath.slice(0, idx), rest: headerPath.slice(idx + 1) }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The shared first component itself must be a sane, relative, single name (C-01/C-11): step 8
|
|
46
|
+
* joins `rest` under it, so a `\`, a control character, an empty value or `.`/`..` here would be
|
|
47
|
+
* the same path-shape problem rule 3 already rejects for everything after it. */
|
|
48
|
+
function checkFirstComponent(component, headerPath) {
|
|
49
|
+
if (component === '' || component === '.' || component === '..' || !FIRST_COMPONENT_RE.test(component)) {
|
|
50
|
+
throw new ArchiveRuleError(2, headerPath)
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** 0005 revision 2.2 (D13, T-0062): a component git itself treats as `.git`. Compared ignoring
|
|
55
|
+
* ASCII case only (as git's own `tolower` does -- not JavaScript's Unicode-aware `/i`): either
|
|
56
|
+
* `.git` or `git~1`, then any run of `.` and spaces, then nothing or `:` and anything (git's
|
|
57
|
+
* `is_ntfs_dotgit`); or `.git` once every HFS+ ignorable code point is removed, wherever it occurs
|
|
58
|
+
* (git's `is_hfs_dotgit`). Other short names (`PLATFO~1`, `GITIGN~1`) are not `.git`. */
|
|
59
|
+
const HFS_IGNORABLE_RE = /[\u200c-\u200f\u202a-\u202e\u206a-\u206f\ufeff]/g
|
|
60
|
+
const NTFS_DOTGIT_RE = /^(?:\.git|git~1)[. ]*(?::.*)?$/s
|
|
61
|
+
function isGitAlias(component) {
|
|
62
|
+
return NTFS_DOTGIT_RE.test(foldAsciiCase(component)) || foldAsciiCase(component.replace(HFS_IGNORABLE_RE, '')) === '.git'
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function checkComponents(relPath, headerPath) {
|
|
66
|
+
if (relPath.includes('\\')) throw new ArchiveRuleError(3, headerPath)
|
|
67
|
+
for (const component of relPath.split('/')) {
|
|
68
|
+
if (component === '') throw new ArchiveRuleError(3, headerPath)
|
|
69
|
+
if (component === '.' || component === '..') throw new ArchiveRuleError(3, headerPath)
|
|
70
|
+
if (isGitAlias(component)) throw new ArchiveRuleError(3, headerPath)
|
|
71
|
+
if (component.endsWith('.') || component.endsWith(' ')) throw new ArchiveRuleError(3, headerPath)
|
|
72
|
+
if (CONTROL_CHAR_RE.test(component)) throw new ArchiveRuleError(3, headerPath)
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Rule 3: after dropping the first component, a file's path is non-empty and does not end in
|
|
77
|
+
* `/`; a directory's path may or may not end in a single trailing `/` (both are accepted, P-04 --
|
|
78
|
+
* the spec only excepts a directory's trailing `/` from the "no empty component" rule, it does
|
|
79
|
+
* not require one); the prefix directory itself has an empty `rest` here and is always accepted. */
|
|
80
|
+
function checkRule3(entry) {
|
|
81
|
+
const { rest, kind, headerPath } = entry
|
|
82
|
+
if (kind === 'dir') {
|
|
83
|
+
if (rest === '') return
|
|
84
|
+
const trimmed = rest.endsWith('/') ? rest.slice(0, -1) : rest
|
|
85
|
+
checkComponents(trimmed, headerPath)
|
|
86
|
+
return
|
|
87
|
+
}
|
|
88
|
+
if (rest === '' || rest.endsWith('/')) throw new ArchiveRuleError(3, headerPath)
|
|
89
|
+
checkComponents(rest, headerPath)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function relForCompareOf(entry) {
|
|
93
|
+
return entry.kind === 'dir' && entry.rest.endsWith('/') ? entry.rest.slice(0, -1) : entry.rest
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function isProperPrefix(shortRel, longRel) {
|
|
97
|
+
return longRel.length > shortRel.length && longRel.startsWith(`${shortRel}/`)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Rule 4, checked against only the entries already seen (archive order, S-03/P-03): no two
|
|
101
|
+
* entries share a path, or a path equal after NFC then `toLowerCase()`; and no file's path is a
|
|
102
|
+
* proper prefix of another entry's path, whatever their order. A collision names the entry being
|
|
103
|
+
* checked now (always the later of the two, since `seen` holds only earlier ones); the
|
|
104
|
+
* proper-prefix check compares the raw (non-folded) relative paths, by decision (no case folding
|
|
105
|
+
* beyond the spec's literal text). Mutates `entry` with the comparison keys it just computed, and
|
|
106
|
+
* pushes it onto `seen` for the entries after it. */
|
|
107
|
+
function checkCollisionAgainstSeen(entry, seen) {
|
|
108
|
+
const relForCompare = relForCompareOf(entry)
|
|
109
|
+
const normalized = relForCompare.normalize('NFC').toLowerCase()
|
|
110
|
+
for (const prior of seen) {
|
|
111
|
+
if (prior.normalized === normalized) throw new ArchiveRuleError(4, entry.headerPath)
|
|
112
|
+
if (prior.kind === 'file' && isProperPrefix(prior.relForCompare, relForCompare)) {
|
|
113
|
+
throw new ArchiveRuleError(4, entry.headerPath)
|
|
114
|
+
}
|
|
115
|
+
if (entry.kind === 'file' && isProperPrefix(relForCompare, prior.relForCompare)) {
|
|
116
|
+
throw new ArchiveRuleError(4, entry.headerPath)
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
entry.relForCompare = relForCompare
|
|
120
|
+
entry.normalized = normalized
|
|
121
|
+
seen.push(entry)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Validates the whole entry list against rules 1-5, throwing `ArchiveRuleError` on the first
|
|
126
|
+
* breaking entry in archive order (D4: the path named is the entry's header path exactly as it
|
|
127
|
+
* appears in the archive, including the first component), and returning the enriched list (each
|
|
128
|
+
* entry's own fields plus `firstComponent`, `rest`, `kind`) for step 8 to write from directly.
|
|
129
|
+
* `commit` is the commit rule 2's comment check compares against: the commit requested for a
|
|
130
|
+
* download by commit (create, a `--from <commit>` base, the upgrade's target), or, for a tag
|
|
131
|
+
* download (a `--from <version>` base, a base read from `range.json`), the tag archive's own
|
|
132
|
+
* comment once `readArchiveEntries` has checked it (UP-E08, and J6's UP-E12); for publishing's own
|
|
133
|
+
* check of the local archive (`publish.mjs`, X7), the commit at `HEAD`.
|
|
134
|
+
*/
|
|
135
|
+
export function checkEntryRules({ entries, comment }, { commit }) {
|
|
136
|
+
// S-04: rule 5 (no file in the list) is checked after rule 2's comment, and the comment
|
|
137
|
+
// condition no longer repeats "undefined" as a special case: `commit` is always a non-empty
|
|
138
|
+
// string, so `comment !== commit` already covers a missing comment.
|
|
139
|
+
if (comment !== commit) throw new ArchiveRuleError(2, undefined, { isCommentMarker: true })
|
|
140
|
+
if (entries.length === 0) throw new ArchiveRuleError(5)
|
|
141
|
+
|
|
142
|
+
const firstEntryComponent = firstComponentAndRest(entries[0].headerPath).firstComponent
|
|
143
|
+
checkFirstComponent(firstEntryComponent, entries[0].headerPath)
|
|
144
|
+
|
|
145
|
+
let hasFile = false
|
|
146
|
+
const seen = []
|
|
147
|
+
const enriched = []
|
|
148
|
+
for (const entry of entries) {
|
|
149
|
+
const { firstComponent, rest } = firstComponentAndRest(entry.headerPath)
|
|
150
|
+
const kind = classify(entry.rawType)
|
|
151
|
+
const enrichedEntry = { ...entry, firstComponent, rest, kind }
|
|
152
|
+
|
|
153
|
+
if (firstComponent !== firstEntryComponent) throw new ArchiveRuleError(2, entry.headerPath)
|
|
154
|
+
if (kind === 'other') throw new ArchiveRuleError(1, entry.headerPath)
|
|
155
|
+
checkRule3(enrichedEntry)
|
|
156
|
+
checkCollisionAgainstSeen(enrichedEntry, seen)
|
|
157
|
+
if (kind === 'file') hasFile = true
|
|
158
|
+
enriched.push(enrichedEntry)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (!hasFile) throw new ArchiveRuleError(5)
|
|
162
|
+
|
|
163
|
+
return enriched
|
|
164
|
+
}
|
package/src/archive.mjs
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// Parses a downloaded archive body (gzip + tar) into an in-memory entry list, using the `tar`
|
|
2
|
+
// package's low-level `Parser` (strict mode turns every parse warning into an 'error' event,
|
|
3
|
+
// rows 6/C55) alongside `raw-tar.mjs`'s own walker, which recovers the raw typeflag byte `tar`'s
|
|
4
|
+
// `entry.type` cannot (archive-rules.mjs needs it for rules 1 and 3). T-0021's `archive-rules.mjs`
|
|
5
|
+
// validates the returned list against rules 1-5; this module only extracts it, in archive order.
|
|
6
|
+
//
|
|
7
|
+
// Review finding P-01/C-02 (desync): two independent parsers walking the same bytes can disagree
|
|
8
|
+
// about where one header ends and the next begins (a pax `size` override, or a directory's
|
|
9
|
+
// decoded size being forced to 0, are the two `tar` applies that `raw-tar.mjs` must mirror). A
|
|
10
|
+
// mirrored rule closes the KNOWN divergences, but as a second line of defence this module also
|
|
11
|
+
// cross-checks every pairing's path and size against `tar`'s own decoded header, and the raw
|
|
12
|
+
// walker's own entry count against `tar`'s: any mismatch is `ArchiveParseError` (CW-E08), not a
|
|
13
|
+
// silent, wrong pairing. Finding P-11/C-07: the body must be gzip (`tar`'s own zstd
|
|
14
|
+
// auto-detection is explicitly turned off, closing another source of divergence); a plain tar
|
|
15
|
+
// body is rejected here even though `raw-tar.mjs`'s own export still accepts one directly.
|
|
16
|
+
|
|
17
|
+
import { Parser } from 'tar'
|
|
18
|
+
import { walkRawEntries } from './raw-tar.mjs'
|
|
19
|
+
|
|
20
|
+
const GZIP_MAGIC_0 = 0x1f
|
|
21
|
+
const GZIP_MAGIC_1 = 0x8b
|
|
22
|
+
|
|
23
|
+
/** Wraps every failure this module itself detects: a `tar`/zlib parse error, or a cross-check
|
|
24
|
+
* mismatch between the raw walker and `tar`'s own decode. `run.mjs` prints CW-E08 only for this
|
|
25
|
+
* class and rethrows anything else (a bug is not an archive problem, S-02/P-08/C-08). */
|
|
26
|
+
export class ArchiveParseError extends Error {}
|
|
27
|
+
|
|
28
|
+
export function parseArchive(bytes) {
|
|
29
|
+
return new Promise((resolvePromise, reject) => {
|
|
30
|
+
const buf = Buffer.isBuffer(bytes) ? bytes : Buffer.from(bytes)
|
|
31
|
+
if (buf.length < 2 || buf[0] !== GZIP_MAGIC_0 || buf[1] !== GZIP_MAGIC_1) {
|
|
32
|
+
reject(new ArchiveParseError('the archive is not gzip-compressed'))
|
|
33
|
+
return
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
let rawEntries
|
|
37
|
+
try {
|
|
38
|
+
rawEntries = walkRawEntries(buf).filter((e) => !e.isMeta)
|
|
39
|
+
} catch (err) {
|
|
40
|
+
reject(new ArchiveParseError(err?.message ?? String(err)))
|
|
41
|
+
return
|
|
42
|
+
}
|
|
43
|
+
let rawIndex = 0
|
|
44
|
+
let desynced = false
|
|
45
|
+
const nextRaw = () => (rawIndex < rawEntries.length ? rawEntries[rawIndex++] : undefined)
|
|
46
|
+
|
|
47
|
+
// `zstd: false` (P-11/C-07): `tar`'s own format auto-detection would otherwise accept a zstd
|
|
48
|
+
// body the raw walker cannot decode at all, letting an attacker fully control what typeflag
|
|
49
|
+
// the walker sees at every offset.
|
|
50
|
+
const parser = new Parser({ strict: true, zstd: false })
|
|
51
|
+
const entries = []
|
|
52
|
+
let comment
|
|
53
|
+
|
|
54
|
+
function readComment(entry) {
|
|
55
|
+
if (entry.globalExtended?.comment !== undefined) comment = entry.globalExtended.comment
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function crossCheck(entry) {
|
|
59
|
+
const raw = nextRaw()
|
|
60
|
+
if (raw === undefined || raw.path !== entry.header.path || raw.size !== entry.header.size) {
|
|
61
|
+
desynced = true
|
|
62
|
+
}
|
|
63
|
+
return raw
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
parser.on('entry', (entry) => {
|
|
67
|
+
const raw = crossCheck(entry)
|
|
68
|
+
const chunks = []
|
|
69
|
+
entry.on('data', (chunk) => chunks.push(chunk))
|
|
70
|
+
entry.on('end', () => {
|
|
71
|
+
readComment(entry)
|
|
72
|
+
entries.push({
|
|
73
|
+
path: entry.path,
|
|
74
|
+
headerPath: entry.header.path,
|
|
75
|
+
type: entry.type,
|
|
76
|
+
rawType: raw?.rawTypeflag,
|
|
77
|
+
mode: entry.mode,
|
|
78
|
+
content: Buffer.concat(chunks),
|
|
79
|
+
})
|
|
80
|
+
})
|
|
81
|
+
entry.resume()
|
|
82
|
+
})
|
|
83
|
+
// Finding P-10/C-12: a pax global `comment` must be read whatever kind the entry turns out to
|
|
84
|
+
// be, not only when it happens to be a regular 'entry' -- otherwise an archive whose only
|
|
85
|
+
// entries are of an ignored type reports a missing comment instead of naming the real
|
|
86
|
+
// violation (rule 1).
|
|
87
|
+
parser.on('ignoredEntry', (entry) => {
|
|
88
|
+
const raw = crossCheck(entry)
|
|
89
|
+
readComment(entry)
|
|
90
|
+
entries.push({
|
|
91
|
+
path: entry.path,
|
|
92
|
+
headerPath: entry.header.path,
|
|
93
|
+
type: entry.type,
|
|
94
|
+
rawType: raw?.rawTypeflag,
|
|
95
|
+
mode: entry.mode,
|
|
96
|
+
content: undefined,
|
|
97
|
+
})
|
|
98
|
+
})
|
|
99
|
+
parser.on('error', (err) => reject(new ArchiveParseError(err?.message ?? String(err))))
|
|
100
|
+
parser.on('end', () => {
|
|
101
|
+
if (desynced || rawIndex !== rawEntries.length) {
|
|
102
|
+
reject(new ArchiveParseError('the archive could not be parsed consistently'))
|
|
103
|
+
return
|
|
104
|
+
}
|
|
105
|
+
resolvePromise({ entries, comment })
|
|
106
|
+
})
|
|
107
|
+
parser.end(buf)
|
|
108
|
+
})
|
|
109
|
+
}
|
package/src/argv.mjs
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// Command grammar (traceability.md "Command grammar"): `parseArgvStrict` is the full grammar.
|
|
2
|
+
// The `--no-setup` + setup-flag rule (C03) is checked separately by `run.mjs`.
|
|
3
|
+
|
|
4
|
+
import { VERSION_RE, COMMIT_RE } from './patterns.mjs'
|
|
5
|
+
|
|
6
|
+
export class ArgvError extends Error {
|
|
7
|
+
constructor(token, { usage = false, reason = 'unknown' } = {}) {
|
|
8
|
+
super(`invalid argument: ${token}`)
|
|
9
|
+
this.name = 'ArgvError'
|
|
10
|
+
this.token = token
|
|
11
|
+
this.usage = usage
|
|
12
|
+
this.reason = reason
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const VALUE_FLAGS = new Set(['--author', '--repo', '--host', '--origin', '--site-name', '--lang'])
|
|
17
|
+
export const SETUP_FLAG_NAMES = ['author', 'repo', 'host', 'origin', 'site-name']
|
|
18
|
+
|
|
19
|
+
export function parseArgvStrict(argv) {
|
|
20
|
+
const result = { directory: undefined, noSetup: false, lang: undefined, flags: {}, mode: undefined }
|
|
21
|
+
const seen = new Set()
|
|
22
|
+
let positionalCount = 0
|
|
23
|
+
let endOfFlags = false
|
|
24
|
+
// U02: every create-only flag seen, in argv order, whether before or after `upgrade` -- checked
|
|
25
|
+
// once the loop ends (below), once `mode` is known for certain.
|
|
26
|
+
const createOnlyFlagsSeen = []
|
|
27
|
+
|
|
28
|
+
for (let i = 0; i < argv.length; i++) {
|
|
29
|
+
const arg = argv[i]
|
|
30
|
+
|
|
31
|
+
if (!endOfFlags && arg === '--') {
|
|
32
|
+
endOfFlags = true
|
|
33
|
+
continue
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (!endOfFlags && (arg === '-h' || arg === '--help' || arg === '--version')) {
|
|
37
|
+
throw new ArgvError(arg, { usage: true })
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (!endOfFlags && arg === '-') {
|
|
41
|
+
throw new ArgvError(arg)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (!endOfFlags && arg.startsWith('-')) {
|
|
45
|
+
const eq = arg.indexOf('=')
|
|
46
|
+
const name = eq === -1 ? arg : arg.slice(0, eq)
|
|
47
|
+
const inlineValue = eq === -1 ? undefined : arg.slice(eq + 1)
|
|
48
|
+
|
|
49
|
+
if (name === '--no-setup') {
|
|
50
|
+
if (eq !== -1) throw new ArgvError(arg)
|
|
51
|
+
if (seen.has('--no-setup')) throw new ArgvError(arg, { reason: 'repeated' })
|
|
52
|
+
seen.add('--no-setup')
|
|
53
|
+
createOnlyFlagsSeen.push('--no-setup')
|
|
54
|
+
result.noSetup = true
|
|
55
|
+
continue
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Command grammar: "`--from` after `upgrade` only" (decision (2)) -- recognised only once
|
|
59
|
+
// `upgrade` has already been read as the mode, using the same value-flag shape every other
|
|
60
|
+
// VALUE_FLAGS member uses (repeated/missing-value/inline `=`); its value goes to
|
|
61
|
+
// `result.from`, never `result.flags`, so it can never reach `buildEngineArgv`. Anywhere
|
|
62
|
+
// else (before `upgrade`, or on the create path) it falls through to the unknown-flag throw
|
|
63
|
+
// below, unchanged.
|
|
64
|
+
if (name === '--from' && result.mode === 'upgrade') {
|
|
65
|
+
if (seen.has('--from')) throw new ArgvError('--from', { reason: 'repeated' })
|
|
66
|
+
seen.add('--from')
|
|
67
|
+
|
|
68
|
+
let fromValue
|
|
69
|
+
if (eq !== -1) {
|
|
70
|
+
if (inlineValue === '') throw new ArgvError('--from', { reason: 'missing-value' })
|
|
71
|
+
fromValue = inlineValue
|
|
72
|
+
} else {
|
|
73
|
+
const next = argv[i + 1]
|
|
74
|
+
if (next === undefined || next.startsWith('-')) {
|
|
75
|
+
throw new ArgvError('--from', { reason: 'missing-value' })
|
|
76
|
+
}
|
|
77
|
+
fromValue = next
|
|
78
|
+
i++
|
|
79
|
+
}
|
|
80
|
+
// Command grammar: "`<version>` matches ... `<commit>` matches ...; any other `--from`
|
|
81
|
+
// value ... -> CW-E02" -- named by the value itself, not by "--from" (U02).
|
|
82
|
+
if (!VERSION_RE.test(fromValue) && !COMMIT_RE.test(fromValue)) {
|
|
83
|
+
throw new ArgvError(fromValue)
|
|
84
|
+
}
|
|
85
|
+
result.from = fromValue
|
|
86
|
+
continue
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (!VALUE_FLAGS.has(name)) throw new ArgvError(arg)
|
|
90
|
+
if (seen.has(name)) throw new ArgvError(name, { reason: 'repeated' })
|
|
91
|
+
seen.add(name)
|
|
92
|
+
|
|
93
|
+
let value
|
|
94
|
+
if (eq !== -1) {
|
|
95
|
+
if (inlineValue === '') throw new ArgvError(name, { reason: 'missing-value' })
|
|
96
|
+
value = inlineValue
|
|
97
|
+
} else {
|
|
98
|
+
const next = argv[i + 1]
|
|
99
|
+
// A next argument beginning with `-` (including a lone `-`) is a missing value, not a
|
|
100
|
+
// value: the Command grammar makes no exception for `-` on its own.
|
|
101
|
+
if (next === undefined || next.startsWith('-')) {
|
|
102
|
+
throw new ArgvError(name, { reason: 'missing-value' })
|
|
103
|
+
}
|
|
104
|
+
value = next
|
|
105
|
+
i++
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (name === '--lang') {
|
|
109
|
+
if (value !== 'en' && value !== 'zh-TW') throw new ArgvError(value)
|
|
110
|
+
result.lang = value
|
|
111
|
+
} else {
|
|
112
|
+
result.flags[name.slice(2)] = value
|
|
113
|
+
createOnlyFlagsSeen.push(name)
|
|
114
|
+
}
|
|
115
|
+
continue
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// positional
|
|
119
|
+
if (arg === '') throw new ArgvError(arg)
|
|
120
|
+
// U02: once `upgrade` has been read, any further positional (another directory-shaped
|
|
121
|
+
// argument, or `upgrade` again) is rejected by its own literal token -- the upgrade path never
|
|
122
|
+
// takes a directory argument.
|
|
123
|
+
if (result.mode === 'upgrade') throw new ArgvError(arg)
|
|
124
|
+
// Command grammar: "chosen when the first positional argument is `upgrade`" -- Q2's reading
|
|
125
|
+
// (the first positional's literal covers one after `--` too) makes this check come before the
|
|
126
|
+
// positional count, not folded into it: a later `upgrade` (positionalCount > 0) falls through
|
|
127
|
+
// to the ordinary positional handling below, so `['range', 'upgrade']` still fails on the
|
|
128
|
+
// second-positional check with token "upgrade".
|
|
129
|
+
if (arg === 'upgrade' && positionalCount === 0) {
|
|
130
|
+
result.mode = 'upgrade'
|
|
131
|
+
continue
|
|
132
|
+
}
|
|
133
|
+
positionalCount++
|
|
134
|
+
if (positionalCount > 1) throw new ArgvError(arg)
|
|
135
|
+
result.directory = arg
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Command grammar: "a create-only flag (...) -> CW-E02" -- checked once `mode` is known for
|
|
139
|
+
// certain, so a create-only flag given BEFORE `upgrade` is caught too (U02's 2026-11-07
|
|
140
|
+
// sub-case), named by whichever such flag argv gave first.
|
|
141
|
+
if (result.mode === 'upgrade' && createOnlyFlagsSeen.length > 0) {
|
|
142
|
+
throw new ArgvError(createOnlyFlagsSeen[0])
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return result
|
|
146
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// D2 (10): the oldest base version this tool upgrades (traceability.md "Decisions", "Step order"
|
|
2
|
+
// row 6). `MINIMUM_BASE_VERSION` is the numeric form `upgrade.mjs`'s own comparison uses;
|
|
3
|
+
// `MINIMUM_BASE_VERSION_TEXT` is the one string both message tables print for UP-E10 (S-10: one
|
|
4
|
+
// source with the actual check, not a literal "3.0.0" repeated in both tables) -- mirroring
|
|
5
|
+
// `git-version.mjs`'s `MINIMUM_GIT_VERSION`/`messages.mjs`'s own `MINIMUM_GIT_VERSION_TEXT` pair.
|
|
6
|
+
// Lives in its own module, not in `upgrade.mjs`, so `messages.mjs` can import this constant
|
|
7
|
+
// without a cycle.
|
|
8
|
+
|
|
9
|
+
export const MINIMUM_BASE_VERSION = [3, 0, 0]
|
|
10
|
+
export const MINIMUM_BASE_VERSION_TEXT = MINIMUM_BASE_VERSION.join('.')
|
package/src/fsops.mjs
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { dirname, join } from 'node:path'
|
|
2
|
+
import { constants as fsConstants } from 'node:fs'
|
|
3
|
+
import { tagReportPath } from './text.mjs'
|
|
4
|
+
|
|
5
|
+
/** Step 4's rule, returning `null` when the target is acceptable, or `{ id, detail? }` naming the
|
|
6
|
+
* CW-E04 condition. The parent is checked with `stat` (a symlink parent, e.g. macOS's `/tmp`, is
|
|
7
|
+
* followed); the target itself is always checked with `lstat` (any symlink is rejected without
|
|
8
|
+
* being followed). Only `ENOENT` from the target's `lstat` means "does not exist": any other
|
|
9
|
+
* error is reported with its own message instead of silently treated as absent. */
|
|
10
|
+
export async function checkTarget(deps, target) {
|
|
11
|
+
const parent = dirname(target)
|
|
12
|
+
let parentStat
|
|
13
|
+
try {
|
|
14
|
+
parentStat = await deps.fs.stat(parent)
|
|
15
|
+
} catch {
|
|
16
|
+
return { id: 'parent-missing' }
|
|
17
|
+
}
|
|
18
|
+
if (!parentStat.isDirectory()) return { id: 'parent-not-directory' }
|
|
19
|
+
|
|
20
|
+
try {
|
|
21
|
+
await deps.fs.access(parent, fsConstants.W_OK)
|
|
22
|
+
} catch {
|
|
23
|
+
return { id: 'parent-not-writable' }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
let targetStat
|
|
27
|
+
try {
|
|
28
|
+
targetStat = await deps.fs.lstat(target)
|
|
29
|
+
} catch (err) {
|
|
30
|
+
if (err && err.code === 'ENOENT') return null // does not exist: ok
|
|
31
|
+
return { id: 'lstat-error', detail: err?.message ?? String(err) }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (targetStat.isSymbolicLink()) return { id: 'symlink' }
|
|
35
|
+
if (!targetStat.isDirectory()) return { id: 'file' }
|
|
36
|
+
|
|
37
|
+
const names = await deps.fs.readdir(target)
|
|
38
|
+
if (names.length > 0) return { id: 'non-empty' }
|
|
39
|
+
|
|
40
|
+
const targetDeviceStat = await deps.fs.stat(target)
|
|
41
|
+
if (targetDeviceStat.dev !== parentStat.dev) return { id: 'cross-device' }
|
|
42
|
+
|
|
43
|
+
return null
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Step 8: writes the entries `archive-rules.mjs`'s `checkEntryRules` already validated into
|
|
47
|
+
* `tmpDir`. Writes from the SAME validated data it checked rules 1-5 against -- `entry.rest`
|
|
48
|
+
* (the header path with the first component already stripped off) and `entry.kind`
|
|
49
|
+
* (`'file'`/`'dir'`, from the raw typeflag) -- rather than re-deriving a path or type from `tar`'s
|
|
50
|
+
* own `entry.path`/`entry.type` (review finding C-01: the two used to disagree about which
|
|
51
|
+
* component to drop, and about how a `tar`-reclassified type maps to file-system writes). Files
|
|
52
|
+
* use flag `wx` (fails on collision); directories without `recursive`. Mode is 0755 when the
|
|
53
|
+
* entry has any execute bit, else 0644, each masked by the process umask through the normal
|
|
54
|
+
* `open`/`mkdir` syscalls (no manual umask arithmetic needed). */
|
|
55
|
+
/** Returns `{ stopped: true }` when `shouldStop` ended the loop early (S-19: a typed result the
|
|
56
|
+
* caller cannot accidentally ignore, rather than a silent early `return` it would have to
|
|
57
|
+
* remember to notice through a separate check right after the call), `{ stopped: false }` once
|
|
58
|
+
* every entry is written. */
|
|
59
|
+
export async function extractEntries(deps, entries, tmpDir, { shouldStop } = {}) {
|
|
60
|
+
for (const entry of entries) {
|
|
61
|
+
// traceability.md "Signals": "after a signal no new file-system operation starts" -- checked
|
|
62
|
+
// BEFORE each entry's write (unlike `moveIntoPlace`'s per-rename check, there is no rollback
|
|
63
|
+
// to do mid-step-8, so stopping here just means not starting the next one).
|
|
64
|
+
if (shouldStop && shouldStop()) return { stopped: true }
|
|
65
|
+
if (entry.rest === '') continue // the prefix directory entry itself
|
|
66
|
+
const rel = entry.rest.endsWith('/') ? entry.rest.slice(0, -1) : entry.rest
|
|
67
|
+
const full = join(tmpDir, rel)
|
|
68
|
+
try {
|
|
69
|
+
if (entry.kind === 'dir') {
|
|
70
|
+
await deps.fs.mkdir(full, { mode: 0o755 })
|
|
71
|
+
} else {
|
|
72
|
+
const executable = (entry.mode & 0o111) !== 0
|
|
73
|
+
await deps.fs.writeFile(full, entry.content, { flag: 'wx', mode: executable ? 0o755 : 0o644 })
|
|
74
|
+
}
|
|
75
|
+
} catch (err) {
|
|
76
|
+
// CW-E09 (run.mjs) names the path; a real `fs` error already carries its own `.path`, but
|
|
77
|
+
// tagging it here (rather than relying on that) also covers an injected test error.
|
|
78
|
+
throw tagReportPath(err, full)
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return { stopped: false }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Step 10's own failure (traceability.md "Writes and cleanup"): a rename failed, or a `shouldStop`
|
|
85
|
+
* callback (checked after each successful rename, shared by a real failure and a signal) asked to
|
|
86
|
+
* stop. `stranded`: entries rollback itself could not move back (CW-E11 names them and keeps the
|
|
87
|
+
* temporary directory); `signaled: true` means nothing actually failed -- the caller asked to stop. */
|
|
88
|
+
export class MoveIntoPlaceError extends Error {
|
|
89
|
+
constructor(message, { failedEntry, stranded, tmpDir, signaled = false, cause } = {}) {
|
|
90
|
+
super(message, cause !== undefined ? { cause } : undefined)
|
|
91
|
+
this.name = 'MoveIntoPlaceError'
|
|
92
|
+
this.failedEntry = failedEntry
|
|
93
|
+
this.stranded = stranded ?? []
|
|
94
|
+
this.tmpDir = tmpDir
|
|
95
|
+
this.signaled = signaled
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Step 10: move the temporary directory into place. `shouldStop`, when given, is checked after
|
|
100
|
+
* each successful rename (not before): a signal arriving mid-rename still lets that one rename
|
|
101
|
+
* finish before anything stops (traceability.md "Signals": "the cleanup waits for the operation in
|
|
102
|
+
* flight"). On any failure or stop, every entry already moved into the target is renamed back,
|
|
103
|
+
* trying every one even when an earlier one could not be moved back (Notes: "rollback tries every
|
|
104
|
+
* entry"); only once every entry is back is the temporary directory removed. */
|
|
105
|
+
export async function moveIntoPlace(deps, tmpDir, target, { shouldStop } = {}) {
|
|
106
|
+
let exists = true
|
|
107
|
+
try {
|
|
108
|
+
await deps.fs.lstat(target)
|
|
109
|
+
} catch {
|
|
110
|
+
exists = false
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (!exists) {
|
|
114
|
+
// C-04/P-14: a failing whole-directory rename is CW-E11 (nothing moved, nothing to roll
|
|
115
|
+
// back), not an uncaught "bug" (CW-E14).
|
|
116
|
+
try {
|
|
117
|
+
await deps.fs.rename(tmpDir, target)
|
|
118
|
+
} catch (err) {
|
|
119
|
+
// Nothing moved: the temporary directory is exactly as it was, so it is removed the same
|
|
120
|
+
// way a successful rollback's empty `stranded` always is.
|
|
121
|
+
await deps.fs.rm(tmpDir, { recursive: true, force: true })
|
|
122
|
+
throw new MoveIntoPlaceError(err.message, { stranded: [], tmpDir, cause: err })
|
|
123
|
+
}
|
|
124
|
+
// P-02/C-01: checked after the rename, the same "wait for the operation in flight" rule the
|
|
125
|
+
// per-entry branch below follows. A signal here still gets a clean "target as it was": rename
|
|
126
|
+
// the whole range straight back, matching MoveIntoPlaceError's `signaled` path the per-entry
|
|
127
|
+
// branch already uses (the caller's outer cleanup then removes the temporary directory).
|
|
128
|
+
if (shouldStop && shouldStop()) {
|
|
129
|
+
await deps.fs.rename(target, tmpDir)
|
|
130
|
+
throw new MoveIntoPlaceError('aborted by signal', { stranded: [], tmpDir, signaled: true })
|
|
131
|
+
}
|
|
132
|
+
try {
|
|
133
|
+
// `chmod` does not apply the umask (unlike `open`/`mkdir`), so it is masked by hand here.
|
|
134
|
+
await deps.fs.chmod(target, 0o755 & ~process.umask())
|
|
135
|
+
} catch (err) {
|
|
136
|
+
// C-04/P-14: the range is already physically at `target`; move it back into the temporary
|
|
137
|
+
// directory where possible, so "target as it was" still holds, and report CW-E11.
|
|
138
|
+
let movedBack = true
|
|
139
|
+
try {
|
|
140
|
+
await deps.fs.rename(target, tmpDir)
|
|
141
|
+
} catch {
|
|
142
|
+
movedBack = false
|
|
143
|
+
}
|
|
144
|
+
if (movedBack) await deps.fs.rm(tmpDir, { recursive: true, force: true })
|
|
145
|
+
throw new MoveIntoPlaceError(err.message, { stranded: movedBack ? [] : ['the range'], tmpDir, cause: err })
|
|
146
|
+
}
|
|
147
|
+
return
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const names = (await deps.fs.readdir(tmpDir)).slice().sort()
|
|
151
|
+
const moved = []
|
|
152
|
+
let failedEntry
|
|
153
|
+
let failedError
|
|
154
|
+
let signaled = false
|
|
155
|
+
for (const name of names) {
|
|
156
|
+
try {
|
|
157
|
+
await deps.fs.rename(join(tmpDir, name), join(target, name))
|
|
158
|
+
} catch (err) {
|
|
159
|
+
failedEntry = name
|
|
160
|
+
failedError = err
|
|
161
|
+
break
|
|
162
|
+
}
|
|
163
|
+
moved.push(name)
|
|
164
|
+
if (shouldStop && shouldStop()) {
|
|
165
|
+
signaled = true
|
|
166
|
+
break
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (failedError === undefined && !signaled) {
|
|
171
|
+
try {
|
|
172
|
+
await deps.fs.rmdir(tmpDir)
|
|
173
|
+
} catch (err) {
|
|
174
|
+
// C-04/P-14: every entry already succeeded into `target`; move them all back where
|
|
175
|
+
// possible, so "target as it was" still holds, and report CW-E11 instead of an uncaught bug.
|
|
176
|
+
const strandedByRmdir = []
|
|
177
|
+
for (const name of names) {
|
|
178
|
+
try {
|
|
179
|
+
await deps.fs.rename(join(target, name), join(tmpDir, name))
|
|
180
|
+
} catch {
|
|
181
|
+
strandedByRmdir.push(name)
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
throw new MoveIntoPlaceError(err.message, { stranded: strandedByRmdir, tmpDir, cause: err })
|
|
185
|
+
}
|
|
186
|
+
return
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const stranded = []
|
|
190
|
+
for (const name of moved) {
|
|
191
|
+
try {
|
|
192
|
+
await deps.fs.rename(join(target, name), join(tmpDir, name))
|
|
193
|
+
} catch {
|
|
194
|
+
stranded.push(name)
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
if (stranded.length === 0) {
|
|
198
|
+
await deps.fs.rm(tmpDir, { recursive: true, force: true })
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (signaled) {
|
|
202
|
+
throw new MoveIntoPlaceError('aborted by signal', { stranded, tmpDir, signaled: true })
|
|
203
|
+
}
|
|
204
|
+
throw new MoveIntoPlaceError(failedError.message, { failedEntry, stranded, tmpDir, cause: failedError })
|
|
205
|
+
}
|