create-wxl 0.0.0-stage → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,274 @@
1
+ // The interactive question machinery (traceability.md "Setup questions", step 3 and step 5):
2
+ // one `readline` interface per run, created on first use and closed before `run` returns (so a
3
+ // `--no-setup` run, or one with every flag given, never touches `io.stdin` at all); the EOF
4
+ // sentinel `run.mjs` turns into CW-E15; and the summary's yes/no/second-pass classification (S6).
5
+
6
+ import { createInterface } from 'node:readline'
7
+ import { SignalAbort } from './signals.mjs'
8
+
9
+ export class QuestionEofError extends Error {
10
+ constructor() {
11
+ super('input ended while a question was open')
12
+ this.name = 'QuestionEofError'
13
+ }
14
+ }
15
+
16
+ /**
17
+ * `ask()` resolves to the next line `io.stdin` writes, after calling `deps.checkpoint('question')`
18
+ * (the test driver uses that call to schedule the next scripted line); it rejects with
19
+ * `QuestionEofError` when `io.stdin` ends (EOF) before a line arrives, including when it had
20
+ * already ended before this `ask()` call. `close()` is idempotent and distinguishes itself
21
+ * closing the `readline` interface from the stream's own EOF, so a run that never asks a second
22
+ * question after EOF does not also reject that EOF on the interface's own `close` event.
23
+ *
24
+ * The `readline` interface's own `line` listener is resident (attached once, for the life of the
25
+ * interface), not attached only while a question is open: `readline`, in non-terminal mode, emits
26
+ * every complete line already sitting in one input chunk synchronously, one after another, so a
27
+ * line arriving before the NEXT `ask()` call had the chance to attach its own listener would
28
+ * otherwise be silently dropped (P-18/C-05) -- type-ahead or a multi-line paste on the real
29
+ * terminal, or, in a test, several scripted answers written in one chunk. A line that arrives with
30
+ * no question open is queued; `ask()` takes the queue's head first, only waiting when the queue is
31
+ * empty.
32
+ */
33
+ export function makeQuestioner(io, deps, guard) {
34
+ let rl = null
35
+ let closedByUs = false
36
+ let ended = false
37
+ const queue = []
38
+ let pending = null
39
+
40
+ function ensureRl() {
41
+ if (rl) return
42
+ rl = createInterface({ input: io.stdin })
43
+ // traceability.md "Signals": "the bin ... forwards a readline interface's own SIGINT event to
44
+ // the same handler, so Ctrl-C at a question ends the run instead of pausing it". `readline`
45
+ // itself only emits this on a real TTY; `deps.signals.emit('SIGINT')` (the bin, or a test) is
46
+ // the one path this module relies on, but forwarding `rl`'s own event keeps that promise
47
+ // explicit here, not just in the bin.
48
+ rl.on('SIGINT', () => deps.signals.emit('SIGINT'))
49
+ rl.on('line', (line) => {
50
+ if (pending) {
51
+ const { resolve } = pending
52
+ pending = null
53
+ guard?.clearWait() // S-17: settled by a line, not a signal -- the pending slot is done
54
+ resolve(line)
55
+ } else {
56
+ queue.push(line)
57
+ }
58
+ })
59
+ rl.on('close', () => {
60
+ ended = true
61
+ if (pending) {
62
+ const { reject } = pending
63
+ pending = null
64
+ guard?.clearWait() // S-17: settled by EOF, not a signal
65
+ if (!closedByUs) reject(new QuestionEofError())
66
+ }
67
+ })
68
+ }
69
+
70
+ function ask() {
71
+ deps.checkpoint('question')
72
+ // A signal already raised by the time this question starts (T-0024's checkpoint-driven
73
+ // tests): reject immediately, never waiting on `io.stdin` at all.
74
+ if (guard?.raised()) return Promise.reject(new SignalAbort(guard.raised()))
75
+ ensureRl()
76
+ if (queue.length > 0) return Promise.resolve(queue.shift())
77
+ if (ended || io.stdin.readableEnded) return Promise.reject(new QuestionEofError())
78
+ return new Promise((resolve, reject) => {
79
+ pending = { resolve, reject }
80
+ // A signal arriving while this question is already pending (decision 4): the guard calls
81
+ // this the moment it is raised, rejecting the same way EOF does above.
82
+ guard?.waitForSignal((err) => {
83
+ pending = null
84
+ reject(err)
85
+ })
86
+ })
87
+ }
88
+
89
+ function close() {
90
+ closedByUs = true
91
+ guard?.clearWait()
92
+ if (rl) rl.close()
93
+ }
94
+
95
+ return { ask, close }
96
+ }
97
+
98
+ /** S6 (traceability.md): trimmed, compared case-insensitively. Empty or `y`/`yes` -> proceed;
99
+ * `n`/`no` -> second pass; anything else -> invalid (asked again). */
100
+ export function classifySummaryAnswer(raw) {
101
+ const trimmed = raw.trim().toLowerCase()
102
+ if (trimmed === '' || trimmed === 'y' || trimmed === 'yes') return 'proceed'
103
+ if (trimmed === 'n' || trimmed === 'no') return 'second-pass'
104
+ return 'invalid'
105
+ }
106
+
107
+ // Step 5's engine argv order (traceability.md "Step order", row 5): author, repo, host, origin,
108
+ // site-name. `site-name`'s flag/answer key uses the hyphenated form the Command grammar and the
109
+ // engine's own argv both use.
110
+ const ENGINE_ARGV_ORDER = ['author', 'repo', 'host', 'origin', 'site-name']
111
+
112
+ /** Builds `--flag=value` items in the fixed order, verbatim, omitting any flag not present. */
113
+ export function buildEngineArgv(flags) {
114
+ return ENGINE_ARGV_ORDER.filter((name) => name in flags).map((name) => `--${name}=${flags[name]}`)
115
+ }
116
+
117
+ /** CW-E05 (traceability.md "Error and warning codes"): `--author`, `--repo`, `--host` always
118
+ * required; `--origin` only when `--host` is EXACTLY `cloudflare-pages` (Command grammar: flag
119
+ * values are verbatim, so this is a plain string compare, never case-insensitive). */
120
+ export function missingRequiredFlags(flags) {
121
+ const missing = []
122
+ if (!('author' in flags)) missing.push('author')
123
+ if (!('repo' in flags)) missing.push('repo')
124
+ if (!('host' in flags)) missing.push('host')
125
+ if (flags.host === 'cloudflare-pages' && !('origin' in flags)) missing.push('origin')
126
+ return missing
127
+ }
128
+
129
+ // ─── interactive setup questions (S1-S6) ──────────────────────────────────────────────────────
130
+
131
+ const FIELD_FLAG_NAME = { author: 'author', repo: 'repo', host: 'host', origin: 'origin', siteName: 'site-name' }
132
+ const FIELD_ORDER = ['author', 'repo', 'host', 'origin', 'siteName']
133
+
134
+ function currentToFlags(current) {
135
+ const flags = {}
136
+ for (const field of FIELD_ORDER) {
137
+ if (current[field] !== undefined) flags[FIELD_FLAG_NAME[field]] = current[field]
138
+ }
139
+ return flags
140
+ }
141
+
142
+ /** S3's mapping (traceability.md "Setup questions"): `1`, `2`, `github-pages`, `cloudflare-pages`
143
+ * (case-insensitive, already trimmed by the caller) map to the canonical host value; anything
144
+ * else is not a valid answer. Only applied to a TYPED answer -- Enter reuses `current.host`
145
+ * verbatim, whatever form it is already in (a flag's raw value, or an earlier mapped answer). */
146
+ function mapHostAnswer(trimmed) {
147
+ const lower = trimmed.toLowerCase()
148
+ if (trimmed === '1' || lower === 'github-pages') return 'github-pages'
149
+ if (trimmed === '2' || lower === 'cloudflare-pages') return 'cloudflare-pages'
150
+ return null
151
+ }
152
+
153
+ /** Asks one field's question, re-asking on an invalid answer (printing why first), and returns
154
+ * the accepted value. Enter keeps `current[field]` verbatim (decision C: a default that came
155
+ * from a flag is never re-trimmed or re-mapped); a non-empty answer is trimmed, then validated
156
+ * (and, for `host`, mapped). `origin`/`siteName` accept a lone `-` to clear the current answer
157
+ * (traceability.md "Setup questions": "A single `-` answered to S4 or S5 clears the current
158
+ * answer"). */
159
+ async function askField(io, t, questioner, field, current) {
160
+ const promptId = { author: 'S1', repo: 'S2', host: 'S3', origin: 'S4', siteName: 'S5' }[field]
161
+ const allowClear = field === 'origin' || field === 'siteName'
162
+
163
+ while (true) {
164
+ io.stdout.write(t[promptId]() + '\n')
165
+ const raw = await questioner.ask()
166
+ const trimmed = raw.trim()
167
+
168
+ let candidate
169
+ if (trimmed === '') {
170
+ candidate = current[field]
171
+ } else if (allowClear && trimmed === '-') {
172
+ candidate = undefined
173
+ } else {
174
+ candidate = trimmed
175
+ }
176
+
177
+ if (field === 'host' && trimmed !== '') {
178
+ const mapped = mapHostAnswer(trimmed)
179
+ if (mapped === null) {
180
+ io.stdout.write(t['S3-INVALID']({ value: trimmed }) + '\n')
181
+ continue
182
+ }
183
+ candidate = mapped
184
+ }
185
+
186
+ if ((field === 'author' || field === 'repo') && (candidate === undefined || candidate === '')) {
187
+ io.stdout.write(t[field === 'author' ? 'S1-EMPTY' : 'S2-EMPTY']() + '\n')
188
+ continue
189
+ }
190
+
191
+ if (field === 'origin' && current.host === 'cloudflare-pages' && (candidate === undefined || candidate === '')) {
192
+ io.stdout.write(t['S4-REQUIRED']() + '\n')
193
+ continue
194
+ }
195
+
196
+ return candidate
197
+ }
198
+ }
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. */
203
+ function printSummary(io, t, deps, current, parsedArgs) {
204
+ io.stdout.write(t['S6-AUTHOR']({ value: current.author }) + '\n')
205
+ io.stdout.write(t['S6-REPO']({ value: current.repo }) + '\n')
206
+ io.stdout.write(t['S6-HOST']({ value: current.host }) + '\n')
207
+ if (current.origin !== undefined) {
208
+ io.stdout.write(t['S6-ORIGIN-ANSWER']({ value: current.origin }) + '\n')
209
+ }
210
+ const origin =
211
+ parsedArgs.origin !== undefined
212
+ ? parsedArgs.origin
213
+ : parsedArgs.host === 'github-pages'
214
+ ? deps.engine.deriveGithubPagesOrigin(parsedArgs.repo)
215
+ : undefined
216
+ if (origin !== undefined) {
217
+ io.stdout.write(t['S6-ORIGIN']({ origin }) + '\n')
218
+ }
219
+ if (current.siteName !== undefined) {
220
+ io.stdout.write(t['S6-SITE-NAME']({ value: current.siteName }) + '\n')
221
+ }
222
+ io.stdout.write(t['S6']() + '\n')
223
+ }
224
+
225
+ /**
226
+ * Steps 3-5's setup-questions orchestration, interactive branch (traceability.md "Setup
227
+ * questions"). First pass asks only the questions whose flag was not given; an engine rejection
228
+ * or a summary `n` starts a second pass that asks every question S1-S5, with the current answers
229
+ * as defaults regardless of flags. Returns the last `parseForkInitArgs` result once the summary
230
+ * is accepted (empty, `y` or `yes`); throws `QuestionEofError` on EOF, or rethrows anything that
231
+ * is not `deps.engine.ForkInitArgError`.
232
+ */
233
+ export async function collectInteractiveAnswers(io, deps, t, flags, questioner) {
234
+ const current = {
235
+ author: flags.author,
236
+ repo: flags.repo,
237
+ host: flags.host !== undefined ? flags.host : 'github-pages',
238
+ origin: flags.origin,
239
+ siteName: flags['site-name'],
240
+ }
241
+ let pass = 1
242
+
243
+ outer: for (;;) {
244
+ const fields = pass === 1 ? FIELD_ORDER.filter((field) => !(FIELD_FLAG_NAME[field] in flags)) : FIELD_ORDER
245
+ for (const field of fields) {
246
+ current[field] = await askField(io, t, questioner, field, current)
247
+ }
248
+
249
+ const argv = buildEngineArgv(currentToFlags(current))
250
+ let parsedArgs
251
+ try {
252
+ parsedArgs = deps.engine.parseForkInitArgs(argv)
253
+ } catch (err) {
254
+ if (err instanceof deps.engine.ForkInitArgError) {
255
+ io.stdout.write(t['ENGINE-REJECTED']({ message: err.message }) + '\n')
256
+ pass = 2
257
+ continue outer
258
+ }
259
+ throw err
260
+ }
261
+
262
+ for (;;) {
263
+ printSummary(io, t, deps, current, parsedArgs)
264
+ const raw = await questioner.ask()
265
+ const classification = classifySummaryAnswer(raw)
266
+ if (classification === 'proceed') return { parsedArgs, argv }
267
+ if (classification === 'second-pass') {
268
+ pass = 2
269
+ continue outer
270
+ }
271
+ io.stdout.write(t['S6-INVALID']() + '\n')
272
+ }
273
+ }
274
+ }
@@ -0,0 +1,188 @@
1
+ // A minimal, read-only raw tar header walker, used by `archive.mjs` to recover the ONE byte
2
+ // `tar`'s own `Header.decode` does not expose faithfully: the typeflag at offset 156.
3
+ // `Header.decode` reclassifies a type `0` entry whose path ends in `/` as type `5` (Directory)
4
+ // (node-tar header.js, "old tar versions marked dirs as a file with a trailing /"), and forces a
5
+ // directory's decoded size to 0 regardless of what is encoded, which makes such an entry
6
+ // indistinguishable from a real directory through node-tar's own `entry.type`/`entry.header.size`
7
+ // alone. T-0021's archive entry rules 1 and 3 need the untouched byte to tell the two apart.
8
+ //
9
+ // Review finding P-01/C-02: two independent parsers walking the SAME bytes must apply the SAME
10
+ // size rules, or they desync (an attacker-chosen typeflag can be paired with the wrong entry,
11
+ // hiding e.g. a symlink from rule 1). This walker therefore replicates `tar`'s own size rules --
12
+ // a pax extended header's `size`/`path` override the next entry's, and a directory's effective
13
+ // size is forced to 0 -- so it advances through the SAME bytes `tar` does. `archive.mjs` still
14
+ // cross-checks each pairing's path and size against `tar`'s own decoded header as a second line
15
+ // of defence: any divergence this walker's replica missed is caught there, not silently accepted.
16
+ //
17
+ // This walker does not decode uid/gid/mtime or GNU long-LINKPATH bodies: only the fields the
18
+ // cross-check in archive.mjs needs (typeflag, path, size), in archive order. It supports both a
19
+ // gzip-compressed body (auto-detected by magic bytes) and a plain, already-decompressed tar
20
+ // (T-0025's X7 reuses it against `git archive`'s plain tar output, through this export only --
21
+ // the `--no-setup` run path itself requires gzip, see archive.mjs).
22
+
23
+ import { gunzipSync } from 'node:zlib'
24
+
25
+ const GZIP_MAGIC_0 = 0x1f
26
+ const GZIP_MAGIC_1 = 0x8b
27
+
28
+ // Matches node-tar's own `maxDecompressionRatio` default (parse.js): bounds the decompressed size
29
+ // so a crafted small gzip body cannot force an unbounded allocation before any rule runs (P-12/C-06).
30
+ const DECOMPRESSION_RATIO_LIMIT = 1000
31
+
32
+ // Typeflags `tar`'s `ReadEntry` treats as metadata (pax extended/global, GNU long-name/long-link,
33
+ // and GNU's older long-path code): these never reach an 'entry' or 'ignoredEntry' event.
34
+ export const META_TYPEFLAGS = new Set(['K', 'L', 'N', 'g', 'x', 'X'])
35
+
36
+ function toBuffer(bytes) {
37
+ return Buffer.isBuffer(bytes) ? bytes : Buffer.from(bytes)
38
+ }
39
+
40
+ function maybeDecompress(bytes) {
41
+ const buf = toBuffer(bytes)
42
+ if (buf.length >= 2 && buf[0] === GZIP_MAGIC_0 && buf[1] === GZIP_MAGIC_1) {
43
+ try {
44
+ return gunzipSync(buf, { maxOutputLength: Math.max(buf.length * DECOMPRESSION_RATIO_LIMIT, 1 << 20) })
45
+ } catch (err) {
46
+ throw new Error(`gzip decompression failed or exceeded the size limit: ${err?.message ?? String(err)}`)
47
+ }
48
+ }
49
+ return buf
50
+ }
51
+
52
+ function isNullBlock(buf, offset) {
53
+ for (let i = offset; i < offset + 512; i++) {
54
+ if (buf[i] !== 0) return false
55
+ }
56
+ return true
57
+ }
58
+
59
+ function decString(buf, off, len) {
60
+ return buf.subarray(off, off + len).toString('utf8').replace(/\0.*/, '')
61
+ }
62
+
63
+ /** Decodes a tar numeric field: the common space/NUL-terminated octal text, or (for a field whose
64
+ * first byte has the high bit set) the GNU base-256 binary extension used for values too large
65
+ * for octal text. */
66
+ function decodeNumericField(buf, offset, length) {
67
+ if ((buf[offset] & 0x80) !== 0) {
68
+ let value = BigInt(buf[offset] & 0x7f)
69
+ for (let i = 1; i < length; i++) value = (value << 8n) | BigInt(buf[offset + i])
70
+ return Number(value)
71
+ }
72
+ let str = ''
73
+ for (let i = 0; i < length; i++) {
74
+ const c = buf[offset + i]
75
+ if (c === 0 || c === 0x20) break
76
+ str += String.fromCharCode(c)
77
+ }
78
+ str = str.trim()
79
+ return str === '' ? 0 : parseInt(str, 8)
80
+ }
81
+
82
+ /** The ustar name, joined with its `prefix` field when the ustar magic is present (header.js's
83
+ * own decode: a byte at offset 475 selects a 155- or 130-byte prefix field). Does not apply any
84
+ * pax/GNU override -- callers do that with the returned name as the fallback. */
85
+ function decodeUstarPath(buf, off) {
86
+ const name = decString(buf, off, 100)
87
+ const isUstar = buf.subarray(off + 257, off + 265).toString('latin1') === 'ustar\u000000'
88
+ if (!isUstar) return name
89
+ const longPrefix = buf[off + 475] !== 0
90
+ const prefix = decString(buf, off + 345, longPrefix ? 155 : 130)
91
+ return prefix ? `${prefix}/${name}` : name
92
+ }
93
+
94
+ /** Parses a pax extended header's body (the length-prefixed `"%d %s=%s\n"` records), returning
95
+ * only the two fields the cross-check needs. */
96
+ function parsePaxOverrides(bodyBuf) {
97
+ const body = bodyBuf.toString('utf8')
98
+ const overrides = {}
99
+ let rest = body
100
+ while (rest.length > 0) {
101
+ const m = /^(\d+) /.exec(rest)
102
+ if (!m) break
103
+ const recLen = parseInt(m[1], 10)
104
+ if (!(recLen > 0) || recLen > rest.length) break
105
+ const record = rest.slice(0, recLen)
106
+ rest = rest.slice(recLen)
107
+ const inner = record.slice(m[1].length + 1)
108
+ const eq = inner.indexOf('=')
109
+ if (eq === -1) continue
110
+ const key = inner.slice(0, eq)
111
+ let value = inner.slice(eq + 1)
112
+ if (value.endsWith('\n')) value = value.slice(0, -1)
113
+ if (key === 'path') overrides.path = value
114
+ if (key === 'size') {
115
+ const n = Number(value)
116
+ // node-tar's own `#slurp` drops a negative `size` override (header.js); mirrored here so a
117
+ // negative pax size cannot make the two walkers disagree on how much to skip.
118
+ if (Number.isFinite(n) && n >= 0) overrides.size = n
119
+ }
120
+ }
121
+ return overrides
122
+ }
123
+
124
+ /**
125
+ * Walks `bytes` (gzip or plain tar) header block by header block, returning one record per block
126
+ * in archive order: `{ rawTypeflag, isMeta }` for a metadata header, or
127
+ * `{ rawTypeflag, isMeta: false, path, size }` for a real one, where `path`/`size` already have a
128
+ * preceding pax extended header's or GNU long-name's override applied (reset after each real
129
+ * entry, exactly as `tar`'s own `EX` is), and `size` is forced to 0 when the (overridden) path
130
+ * ends in `/` or the (NUL-normalised) typeflag is `5` -- matching `Header.decode`'s own forcing
131
+ * rule, so a lying `size` next to a directory typeflag cannot hide another header in "its" data.
132
+ */
133
+ export function walkRawEntries(bytes) {
134
+ const buf = maybeDecompress(bytes)
135
+ const result = []
136
+ let offset = 0
137
+ let consecutiveNulls = 0
138
+ let pendingPath
139
+ let pendingSize
140
+
141
+ while (offset + 512 <= buf.length) {
142
+ if (isNullBlock(buf, offset)) {
143
+ consecutiveNulls += 1
144
+ offset += 512
145
+ if (consecutiveNulls >= 2) break
146
+ continue
147
+ }
148
+ consecutiveNulls = 0
149
+ const rawTypeflag = String.fromCharCode(buf[offset + 156])
150
+ const ustarSize = decodeNumericField(buf, offset + 124, 12)
151
+ const bodyStart = offset + 512
152
+
153
+ if (META_TYPEFLAGS.has(rawTypeflag)) {
154
+ const dataBlocks = Math.ceil(ustarSize / 512)
155
+ if (rawTypeflag === 'L') {
156
+ const bodyBuf = buf.subarray(bodyStart, Math.min(bodyStart + ustarSize, buf.length))
157
+ pendingPath = bodyBuf.toString('utf8').replace(/\0.*/, '')
158
+ } else if (rawTypeflag === 'x') {
159
+ const bodyBuf = buf.subarray(bodyStart, Math.min(bodyStart + ustarSize, buf.length))
160
+ const overrides = parsePaxOverrides(bodyBuf)
161
+ if (overrides.path !== undefined) pendingPath = overrides.path
162
+ if (overrides.size !== undefined) pendingSize = overrides.size
163
+ }
164
+ // 'K' (GNU long linkpath), 'N' (old GNU long path) and 'g'/'X' (pax/old-pax global) are
165
+ // consumed for offset purposes only: linkpath and global headers are never applied to the
166
+ // next entry's path/size by `tar` itself (header.js's `#slurp` excludes `path`/`linkpath`
167
+ // from a global header; `K`/comment handling is unrelated to this cross-check).
168
+ result.push({ rawTypeflag, isMeta: true })
169
+ offset = bodyStart + dataBlocks * 512
170
+ continue
171
+ }
172
+
173
+ const name = decodeUstarPath(buf, offset)
174
+ const path = pendingPath !== undefined ? pendingPath : name
175
+ const sizeBeforeDirRule = pendingSize !== undefined ? pendingSize : ustarSize
176
+ const normalizedType = rawTypeflag === '\u0000' ? '0' : rawTypeflag
177
+ const finalType = normalizedType === '0' && path.endsWith('/') ? '5' : normalizedType
178
+ const size = finalType === '5' ? 0 : sizeBeforeDirRule
179
+
180
+ result.push({ rawTypeflag, isMeta: false, path, size })
181
+ pendingPath = undefined
182
+ pendingSize = undefined
183
+
184
+ const dataBlocks = Math.ceil(size / 512)
185
+ offset = bodyStart + dataBlocks * 512
186
+ }
187
+ return result
188
+ }