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/src/run.mjs
ADDED
|
@@ -0,0 +1,437 @@
|
|
|
1
|
+
import { dirname, resolve as resolvePath } from 'node:path'
|
|
2
|
+
import { createHash } from 'node:crypto'
|
|
3
|
+
import { messages, cwE12FinishingCommandsList } from './messages.mjs'
|
|
4
|
+
import { jsonValue } from './text.mjs'
|
|
5
|
+
import { selectLanguage } from './language.mjs'
|
|
6
|
+
import { parseArgvStrict, ArgvError, SETUP_FLAG_NAMES } from './argv.mjs'
|
|
7
|
+
import { checkTarget, moveIntoPlace, MoveIntoPlaceError } from './fsops.mjs'
|
|
8
|
+
import { isNodeVersionOk, MINIMUM_NODE_VERSION } from './node-version.mjs'
|
|
9
|
+
import { makeQuestioner, QuestionEofError, buildEngineArgv, missingRequiredFlags, collectInteractiveAnswers } from './questions.mjs'
|
|
10
|
+
import { runGitSteps, orderedPrintedForms } from './git-steps.mjs'
|
|
11
|
+
import { buildNextSteps } from './next-steps.mjs'
|
|
12
|
+
import { createSignalGuard, SignalAbort } from './signals.mjs'
|
|
13
|
+
import { VERSION_RE, COMMIT_RE } from './patterns.mjs'
|
|
14
|
+
import { removeTmpDir, downloadArchive, readArchiveEntries, makeTmpDir, extractInto, applyPristine } from './run-shared.mjs'
|
|
15
|
+
import { runUpgrade } from './upgrade.mjs'
|
|
16
|
+
|
|
17
|
+
/** Step 2's package source record check (D2): `deps.engineFile` is read through `deps.fs` and its
|
|
18
|
+
* SHA-256 compared with `source.engineSha256`. */
|
|
19
|
+
async function validateSourceRecord(deps) {
|
|
20
|
+
const source = deps.source
|
|
21
|
+
if (!source || typeof source !== 'object') return false
|
|
22
|
+
if (source.repository !== deps.engine.UPSTREAM_SLUG) return false
|
|
23
|
+
if (typeof source.version !== 'string' || source.version !== deps.packageVersion) return false
|
|
24
|
+
if (!VERSION_RE.test(source.version)) return false
|
|
25
|
+
if (typeof source.commit !== 'string' || !COMMIT_RE.test(source.commit)) return false
|
|
26
|
+
|
|
27
|
+
let engineBytes
|
|
28
|
+
try {
|
|
29
|
+
engineBytes = await deps.fs.readFile(deps.engineFile)
|
|
30
|
+
} catch {
|
|
31
|
+
return false
|
|
32
|
+
}
|
|
33
|
+
const actualSha256 = createHash('sha256').update(engineBytes).digest('hex')
|
|
34
|
+
return actualSha256 === source.engineSha256
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* create-wxl's one exported seam (0005-S1): the Step order table's steps 1-13, every signal
|
|
39
|
+
* window (traceability.md "Signals"), and the full set of failure-path messages (CW-E09,
|
|
40
|
+
* CW-E11, CW-E14, CW-W05). An argv whose first positional is `upgrade` is dispatched to
|
|
41
|
+
* 0006's upgrade path instead (0006-S1, `runUpgrade` in upgrade.mjs).
|
|
42
|
+
*/
|
|
43
|
+
export async function run(argv, io, deps) {
|
|
44
|
+
// traceability.md "Signals": "errors writing the messages are ignored". Without a listener, a
|
|
45
|
+
// `Writable`'s own `write` error is an unhandled `'error'` event (an uncaught exception), not a
|
|
46
|
+
// rejection any `await` here could catch; a no-op listener is the only way to ignore it.
|
|
47
|
+
// S-30: removed in `finally` below, so a run never leaves a listener behind on `io`'s streams.
|
|
48
|
+
const ignoreWriteError = () => {}
|
|
49
|
+
io.stdout.on('error', ignoreWriteError)
|
|
50
|
+
io.stderr.on('error', ignoreWriteError)
|
|
51
|
+
try {
|
|
52
|
+
return await runBody(argv, io, deps)
|
|
53
|
+
} finally {
|
|
54
|
+
// A `Writable`'s own `write` error is emitted on a later tick (`process.nextTick`), not
|
|
55
|
+
// synchronously from `write()` -- removing the listener right away could drop it before it
|
|
56
|
+
// fires, turning the very error this listener exists to ignore back into an uncaught
|
|
57
|
+
// exception. One `setImmediate` tick runs after any already-scheduled `nextTick` callback.
|
|
58
|
+
await new Promise((resolve) => setImmediate(resolve))
|
|
59
|
+
io.stdout.removeListener('error', ignoreWriteError)
|
|
60
|
+
io.stderr.removeListener('error', ignoreWriteError)
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function runBody(argv, io, deps) {
|
|
65
|
+
// CW-E01 and CW-E02 use the environment language rule only (traceability.md "Language"): they
|
|
66
|
+
// happen before or during parsing, so a `--lang` flag cannot select their table yet.
|
|
67
|
+
const envLang = selectLanguage(deps)
|
|
68
|
+
const t = messages[envLang]
|
|
69
|
+
|
|
70
|
+
// Step 1: Node version, before anything else (including argv parsing).
|
|
71
|
+
if (!isNodeVersionOk(deps.nodeVersion)) {
|
|
72
|
+
io.stderr.write(t['CW-E01']({ running: deps.nodeVersion, minimum: MINIMUM_NODE_VERSION }) + '\n')
|
|
73
|
+
return 1
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// traceability.md "Signals": "an error that is none of the CW codes (a bug) ... is printed as
|
|
77
|
+
// CW-E14 ... and exits 1" -- steps 1-2 (before `runSteps`, which has its own such catch) need
|
|
78
|
+
// the same fallback, in the environment language (no `--lang` is parsed yet).
|
|
79
|
+
let parsed
|
|
80
|
+
try {
|
|
81
|
+
parsed = parseArgvStrict(argv)
|
|
82
|
+
} catch (err) {
|
|
83
|
+
if (err instanceof ArgvError) {
|
|
84
|
+
io.stderr.write(t['CW-E02']({ value: err.token, usage: err.usage, reason: err.reason }) + '\n')
|
|
85
|
+
return 1
|
|
86
|
+
}
|
|
87
|
+
io.stderr.write(t['CW-E14']({ message: err?.message ?? String(err) }) + '\n')
|
|
88
|
+
return 1
|
|
89
|
+
}
|
|
90
|
+
if (parsed.noSetup) {
|
|
91
|
+
const presentSetupFlag = SETUP_FLAG_NAMES.find((name) => name in parsed.flags)
|
|
92
|
+
if (presentSetupFlag) {
|
|
93
|
+
io.stderr.write(
|
|
94
|
+
t['CW-E02']({ value: `--${presentSetupFlag}`, usage: false, reason: 'conflicts-with-no-setup' }) + '\n',
|
|
95
|
+
)
|
|
96
|
+
return 1
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const overrideRaw = deps.env.CREATE_WXL_COMMIT
|
|
101
|
+
const hasOverride = typeof overrideRaw === 'string' && overrideRaw !== ''
|
|
102
|
+
if (hasOverride && !COMMIT_RE.test(overrideRaw)) {
|
|
103
|
+
io.stderr.write(t['CW-E02']({ value: 'CREATE_WXL_COMMIT', usage: false, reason: 'bad-env', badValue: overrideRaw }) + '\n')
|
|
104
|
+
return 1
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (!(await validateSourceRecord(deps))) {
|
|
108
|
+
io.stderr.write(t['CW-E13']() + '\n')
|
|
109
|
+
return 1
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// From here on, a valid `--lang` (now that argv has parsed) selects the table.
|
|
113
|
+
const lang2 = selectLanguage(deps, parsed.lang)
|
|
114
|
+
const t2 = messages[lang2]
|
|
115
|
+
|
|
116
|
+
// The guard is created here and passed down (T-0024 ticket Comments, "increment plan
|
|
117
|
+
// confirmed"), so every window below throwing/catching `SignalAbort` shares the same one, and
|
|
118
|
+
// its listeners come off `deps.signals` exactly once, however `runSteps` returns.
|
|
119
|
+
const guard = createSignalGuard(deps.signals, ['SIGINT', 'SIGTERM', 'SIGHUP'])
|
|
120
|
+
const questioner = makeQuestioner(io, deps, guard)
|
|
121
|
+
try {
|
|
122
|
+
if (parsed.mode === 'upgrade') {
|
|
123
|
+
return await runUpgrade(parsed, io, deps, t2, lang2, guard, { hasOverride, overrideRaw })
|
|
124
|
+
}
|
|
125
|
+
return await runSteps(parsed, io, deps, t2, lang2, questioner, guard, { hasOverride, overrideRaw })
|
|
126
|
+
} catch (err) {
|
|
127
|
+
if (err instanceof QuestionEofError) {
|
|
128
|
+
io.stderr.write(t2['CW-E15']() + '\n')
|
|
129
|
+
return 1
|
|
130
|
+
}
|
|
131
|
+
throw err
|
|
132
|
+
} finally {
|
|
133
|
+
questioner.close()
|
|
134
|
+
guard.off()
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Step 3/4's directory question (S-12): asks once, re-asking on its own (printing the `~` reason,
|
|
139
|
+
* traceability.md "Step order" row 3) until it has an answer that does not begin with `~`. Never
|
|
140
|
+
* touches `checkTarget`, so the step-4 re-ask loop that calls this only prints CW-E04 for a
|
|
141
|
+
* rejection `checkTarget` itself returned, never for a stale one left over from a `~` answer
|
|
142
|
+
* (S-13/P-17/C-06). */
|
|
143
|
+
async function askDirectory(io, t2, questioner) {
|
|
144
|
+
while (true) {
|
|
145
|
+
io.stdout.write(t2['Q-DIRECTORY']() + '\n')
|
|
146
|
+
const raw = await questioner.ask()
|
|
147
|
+
const answer = raw.trim() === '' ? 'wxl-range' : raw.trim()
|
|
148
|
+
if (answer.startsWith('~')) {
|
|
149
|
+
io.stdout.write(t2['Q-DIRECTORY-TILDE']() + '\n')
|
|
150
|
+
continue
|
|
151
|
+
}
|
|
152
|
+
return answer
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async function runSteps(parsed, io, deps, t2, lang2, questioner, guard, { hasOverride, overrideRaw }) {
|
|
157
|
+
// `tmpDir`, once step 8 creates it, is this function's one piece of cleanup state: a signal or a
|
|
158
|
+
// "bug" (an error that is none of the expected CW conditions) caught below removes it, both
|
|
159
|
+
// handled alike (traceability.md "Signals": "an error that is none of the CW codes ... is
|
|
160
|
+
// handled like a signal in its window"). `remainingCommandsFn`, once step 11 starts, tells the
|
|
161
|
+
// catch below which commands a bug in steps 11-13 still has to list (C-07/P-04/S-04: the same
|
|
162
|
+
// decision (3) CW-W05 already follows for a signal in that window).
|
|
163
|
+
let tmpDir = null
|
|
164
|
+
let remainingCommandsFn = null
|
|
165
|
+
try {
|
|
166
|
+
return await runStepsBody(
|
|
167
|
+
parsed,
|
|
168
|
+
io,
|
|
169
|
+
deps,
|
|
170
|
+
t2,
|
|
171
|
+
lang2,
|
|
172
|
+
questioner,
|
|
173
|
+
guard,
|
|
174
|
+
{ hasOverride, overrideRaw },
|
|
175
|
+
(dir) => {
|
|
176
|
+
tmpDir = dir
|
|
177
|
+
},
|
|
178
|
+
(fn) => {
|
|
179
|
+
remainingCommandsFn = fn
|
|
180
|
+
},
|
|
181
|
+
)
|
|
182
|
+
} catch (err) {
|
|
183
|
+
if (err instanceof QuestionEofError) throw err
|
|
184
|
+
if (tmpDir) await removeTmpDir(deps, tmpDir)
|
|
185
|
+
if (err instanceof SignalAbort) return 130
|
|
186
|
+
const remainingCommands = remainingCommandsFn ? remainingCommandsFn() : undefined
|
|
187
|
+
io.stderr.write(t2['CW-E14']({ message: err?.message ?? String(err), remainingCommands }) + '\n')
|
|
188
|
+
return 1
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async function runStepsBody(
|
|
193
|
+
parsed,
|
|
194
|
+
io,
|
|
195
|
+
deps,
|
|
196
|
+
t2,
|
|
197
|
+
lang2,
|
|
198
|
+
questioner,
|
|
199
|
+
guard,
|
|
200
|
+
{ hasOverride, overrideRaw },
|
|
201
|
+
setTmpDir,
|
|
202
|
+
setRemainingCommandsFn,
|
|
203
|
+
) {
|
|
204
|
+
deps.checkpoint('step-3')
|
|
205
|
+
let directory = parsed.directory
|
|
206
|
+
let askedDirectory = false
|
|
207
|
+
if (directory === undefined) {
|
|
208
|
+
if (io.interactive) {
|
|
209
|
+
askedDirectory = true
|
|
210
|
+
directory = await askDirectory(io, t2, questioner)
|
|
211
|
+
} else {
|
|
212
|
+
io.stderr.write(t2['CW-E03']() + '\n')
|
|
213
|
+
return 1
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
let target = resolvePath(deps.cwd, directory)
|
|
217
|
+
|
|
218
|
+
deps.checkpoint('step-4')
|
|
219
|
+
guard.throwIfRaised()
|
|
220
|
+
|
|
221
|
+
const printCwE04 = (rejectionToPrint) =>
|
|
222
|
+
io.stderr.write(t2['CW-E04']({ path: target, conditionId: rejectionToPrint.id, detail: rejectionToPrint.detail }) + '\n')
|
|
223
|
+
|
|
224
|
+
let rejection = await checkTarget(deps, target)
|
|
225
|
+
guard.throwIfRaised() // P-17/C-14: a signal already received wins over a rejection found after
|
|
226
|
+
while (rejection && askedDirectory) {
|
|
227
|
+
printCwE04(rejection)
|
|
228
|
+
directory = await askDirectory(io, t2, questioner)
|
|
229
|
+
target = resolvePath(deps.cwd, directory)
|
|
230
|
+
rejection = await checkTarget(deps, target)
|
|
231
|
+
guard.throwIfRaised()
|
|
232
|
+
}
|
|
233
|
+
if (rejection) {
|
|
234
|
+
printCwE04(rejection)
|
|
235
|
+
return 1
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
let setupArgs = null
|
|
239
|
+
let setupArgv = null
|
|
240
|
+
if (!parsed.noSetup) {
|
|
241
|
+
deps.checkpoint('step-5')
|
|
242
|
+
guard.throwIfRaised()
|
|
243
|
+
if (!io.interactive) {
|
|
244
|
+
const missing = missingRequiredFlags(parsed.flags)
|
|
245
|
+
if (missing.length > 0) {
|
|
246
|
+
io.stderr.write(t2['CW-E05']({ missing }) + '\n')
|
|
247
|
+
return 1
|
|
248
|
+
}
|
|
249
|
+
setupArgv = buildEngineArgv(parsed.flags)
|
|
250
|
+
try {
|
|
251
|
+
setupArgs = deps.engine.parseForkInitArgs(setupArgv)
|
|
252
|
+
} catch (err) {
|
|
253
|
+
if (err instanceof deps.engine.ForkInitArgError) {
|
|
254
|
+
io.stderr.write(t2['CW-E06']({ message: err.message }) + '\n')
|
|
255
|
+
return 1
|
|
256
|
+
}
|
|
257
|
+
throw err
|
|
258
|
+
}
|
|
259
|
+
} else {
|
|
260
|
+
;({ parsedArgs: setupArgs, argv: setupArgv } = await collectInteractiveAnswers(io, deps, t2, parsed.flags, questioner))
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const commit = hasOverride ? overrideRaw : deps.source.commit
|
|
265
|
+
|
|
266
|
+
deps.checkpoint('step-6')
|
|
267
|
+
guard.throwIfRaised()
|
|
268
|
+
if (hasOverride) {
|
|
269
|
+
io.stderr.write(t2['CW-W04']({ commit }) + '\n')
|
|
270
|
+
}
|
|
271
|
+
// The URL/AbortController/timeout/race/CW-E07 window lives in `run-shared.mjs`'s
|
|
272
|
+
// `downloadArchive`, shared with the upgrade path (traceability.md "Step order" row 7); this
|
|
273
|
+
// call site's own checkpoints and CW-W04 are unchanged.
|
|
274
|
+
const downloaded = await downloadArchive({ io, deps, t2, guard, repository: deps.source.repository, ref: commit })
|
|
275
|
+
if (!downloaded) return 1
|
|
276
|
+
const { body } = downloaded
|
|
277
|
+
|
|
278
|
+
deps.checkpoint('step-7')
|
|
279
|
+
guard.throwIfRaised()
|
|
280
|
+
// Parsing, entry-rule validation, mkdtemp, extraction and P1-P4 live in `run-shared.mjs`,
|
|
281
|
+
// shared with the upgrade path; this call site's own checkpoints, `setTmpDir` and argument
|
|
282
|
+
// shape are unchanged.
|
|
283
|
+
const validatedEntries = await readArchiveEntries({ io, t2, body, commit })
|
|
284
|
+
if (!validatedEntries) return 1
|
|
285
|
+
|
|
286
|
+
deps.checkpoint('step-8')
|
|
287
|
+
guard.throwIfRaised()
|
|
288
|
+
const parentDir = dirname(target)
|
|
289
|
+
const tmpDir = await makeTmpDir({ io, deps, t2, parent: parentDir, prefix: '.create-wxl-' })
|
|
290
|
+
if (!tmpDir) return 1 // mkdtemp itself failed: nothing was created, nothing to clean up
|
|
291
|
+
setTmpDir(tmpDir)
|
|
292
|
+
if (!(await extractInto({ io, deps, t2, guard, entries: validatedEntries, tmpDir }))) return 1
|
|
293
|
+
|
|
294
|
+
deps.checkpoint('step-9')
|
|
295
|
+
guard.throwIfRaised()
|
|
296
|
+
if (
|
|
297
|
+
!(await applyPristine({
|
|
298
|
+
io,
|
|
299
|
+
deps,
|
|
300
|
+
t2,
|
|
301
|
+
guard,
|
|
302
|
+
tmpDir,
|
|
303
|
+
record: { repository: deps.source.repository, version: deps.source.version, commit },
|
|
304
|
+
}))
|
|
305
|
+
) {
|
|
306
|
+
return 1
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
deps.checkpoint('step-10')
|
|
310
|
+
guard.throwIfRaised() // P-02/C-01
|
|
311
|
+
// Step 10's own re-check (traceability.md "Step order" row 10): another process may have created
|
|
312
|
+
// something at the target since step 4. An empty directory is still accepted (moveIntoPlace's
|
|
313
|
+
// existing-target branch handles it); anything else cleans up the temporary directory and reports
|
|
314
|
+
// CW-E04 as step 4 would, instead of letting `rename` itself fail with an opaque ENOTDIR/ENOTEMPTY.
|
|
315
|
+
const recheck = await checkTarget(deps, target)
|
|
316
|
+
guard.throwIfRaised()
|
|
317
|
+
if (recheck) {
|
|
318
|
+
await removeTmpDir(deps, tmpDir)
|
|
319
|
+
printCwE04(recheck)
|
|
320
|
+
return 1
|
|
321
|
+
}
|
|
322
|
+
try {
|
|
323
|
+
await moveIntoPlace(deps, tmpDir, target, { shouldStop: () => guard.isRaised() })
|
|
324
|
+
} catch (err) {
|
|
325
|
+
if (err instanceof MoveIntoPlaceError) {
|
|
326
|
+
if (err.signaled) {
|
|
327
|
+
// C-02/P-03/S-23: a stranded entry still keeps and names the temporary directory (the
|
|
328
|
+
// same "kept and named" guarantee a non-signaled CW-E11 has); the signal still decides
|
|
329
|
+
// the exit code (130), and `setTmpDir(null)` here stops the outer catch from deleting a
|
|
330
|
+
// temporary directory `moveIntoPlace` deliberately kept.
|
|
331
|
+
if (err.stranded.length > 0) {
|
|
332
|
+
io.stderr.write(
|
|
333
|
+
t2['CW-E11']({ target, detail: err.message, stranded: err.stranded, tmpDir: err.tmpDir }) + '\n',
|
|
334
|
+
)
|
|
335
|
+
setTmpDir(null)
|
|
336
|
+
return 130
|
|
337
|
+
}
|
|
338
|
+
throw new SignalAbort(guard.raised())
|
|
339
|
+
}
|
|
340
|
+
io.stderr.write(
|
|
341
|
+
t2['CW-E11']({ target, detail: err.message, stranded: err.stranded, tmpDir: err.tmpDir }) + '\n',
|
|
342
|
+
)
|
|
343
|
+
return 1
|
|
344
|
+
}
|
|
345
|
+
throw err
|
|
346
|
+
}
|
|
347
|
+
setTmpDir(null) // step 10 succeeded: the range stays on disk whatever happens from here on
|
|
348
|
+
|
|
349
|
+
// Steps 11-13 (traceability.md "Signals"): a signal here is printed directly, inline (CW-W05),
|
|
350
|
+
// instead of being thrown and routed through the generic `SignalAbort` catch above -- there is
|
|
351
|
+
// no tmpDir cleanup left to do, and each of the three steps' CW-W05 names a different set of
|
|
352
|
+
// remaining commands.
|
|
353
|
+
// S-21: `lang2` is the language `selectLanguage` already picked in `run()`; reusing it here
|
|
354
|
+
// avoids re-deriving the same answer from `t2`'s own object identity.
|
|
355
|
+
const lang = lang2
|
|
356
|
+
|
|
357
|
+
if (!parsed.noSetup) {
|
|
358
|
+
deps.checkpoint('step-11')
|
|
359
|
+
setRemainingCommandsFn(() =>
|
|
360
|
+
cwE12FinishingCommandsList({ directory, argv: setupArgv, version: deps.source.version, lang }),
|
|
361
|
+
)
|
|
362
|
+
if (guard.raised()) {
|
|
363
|
+
io.stderr.write(
|
|
364
|
+
t2['CW-W05']({
|
|
365
|
+
remainingCommands: cwE12FinishingCommandsList({
|
|
366
|
+
directory,
|
|
367
|
+
argv: setupArgv,
|
|
368
|
+
version: deps.source.version,
|
|
369
|
+
lang,
|
|
370
|
+
}),
|
|
371
|
+
}) +
|
|
372
|
+
'\n',
|
|
373
|
+
)
|
|
374
|
+
return 130
|
|
375
|
+
}
|
|
376
|
+
// S-12: both CW-E12 call sites (the thrown-error and the non-zero-exitCode path) otherwise
|
|
377
|
+
// repeat the same `{ directory, argv: setupArgv, version: deps.source.version }`.
|
|
378
|
+
const printCwE12 = (message) =>
|
|
379
|
+
io.stderr.write(t2['CW-E12']({ message, directory, argv: setupArgv, version: deps.source.version }) + '\n')
|
|
380
|
+
let result
|
|
381
|
+
try {
|
|
382
|
+
result = deps.engine.runForkInit(setupArgs, { projectRoot: target })
|
|
383
|
+
} catch (err) {
|
|
384
|
+
printCwE12(err?.message ?? String(err))
|
|
385
|
+
return 1
|
|
386
|
+
}
|
|
387
|
+
if (result.exitCode !== 0) {
|
|
388
|
+
printCwE12(result.message)
|
|
389
|
+
return 1
|
|
390
|
+
}
|
|
391
|
+
for (const warning of result.warnings) {
|
|
392
|
+
io.stdout.write(jsonValue(warning) + '\n')
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
let gitResult = { warningId: null }
|
|
397
|
+
if (!parsed.noSetup) {
|
|
398
|
+
deps.checkpoint('step-12')
|
|
399
|
+
setRemainingCommandsFn(() => orderedPrintedForms(deps.source.version))
|
|
400
|
+
if (guard.raised()) {
|
|
401
|
+
io.stderr.write(t2['CW-W05']({ remainingCommands: orderedPrintedForms(deps.source.version) }) + '\n')
|
|
402
|
+
return 130
|
|
403
|
+
}
|
|
404
|
+
gitResult = await runGitSteps({
|
|
405
|
+
runGit: deps.runGit,
|
|
406
|
+
cwd: target,
|
|
407
|
+
env: deps.env,
|
|
408
|
+
version: deps.source.version,
|
|
409
|
+
shouldStop: () => guard.isRaised(),
|
|
410
|
+
})
|
|
411
|
+
if (gitResult.signal) {
|
|
412
|
+
io.stderr.write(t2['CW-W05']({ remainingCommands: gitResult.remainingCommands }) + '\n')
|
|
413
|
+
return 130
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
if (gitResult.warningId) {
|
|
417
|
+
io.stderr.write(t2[gitResult.warningId](gitResult) + '\n')
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
deps.checkpoint('step-13')
|
|
421
|
+
setRemainingCommandsFn(() => [])
|
|
422
|
+
if (guard.raised()) {
|
|
423
|
+
io.stderr.write(t2['CW-W05']({ remainingCommands: [] }) + '\n')
|
|
424
|
+
return 130
|
|
425
|
+
}
|
|
426
|
+
const nextSteps = buildNextSteps({
|
|
427
|
+
t: t2,
|
|
428
|
+
directory,
|
|
429
|
+
noSetup: parsed.noSetup,
|
|
430
|
+
parsedArgs: setupArgs,
|
|
431
|
+
gitResult,
|
|
432
|
+
})
|
|
433
|
+
for (const line of nextSteps) {
|
|
434
|
+
io.stdout.write(line + '\n')
|
|
435
|
+
}
|
|
436
|
+
return 0
|
|
437
|
+
}
|
package/src/signals.mjs
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// Signal handling (traceability.md "Signals"): `SIGINT`, `SIGTERM` and `SIGHUP` are handled
|
|
2
|
+
// alike, from step 3 on. `createSignalGuard` listens on `deps.signals` and remembers only the
|
|
3
|
+
// FIRST signal received (further signals during cleanup are ignored, traceability.md "Signals").
|
|
4
|
+
// A pending question's `ask()` can register itself to be rejected the moment a signal arrives
|
|
5
|
+
// (questions.mjs), instead of only being checked at the next `checkpoint`.
|
|
6
|
+
|
|
7
|
+
export class SignalAbort extends Error {
|
|
8
|
+
constructor(signal) {
|
|
9
|
+
super(`aborted by ${signal}`)
|
|
10
|
+
this.name = 'SignalAbort'
|
|
11
|
+
this.signal = signal
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** `signals`: the `EventEmitter` this run's `deps.signals` is. `names`: which signal names to
|
|
16
|
+
* listen for; defaults to all three traceability.md "Signals" handles alike. */
|
|
17
|
+
export function createSignalGuard(signals, names = ['SIGINT', 'SIGTERM', 'SIGHUP']) {
|
|
18
|
+
let raised = null
|
|
19
|
+
let pendingReject = null
|
|
20
|
+
const onRaiseCallbacks = []
|
|
21
|
+
const handlers = new Map()
|
|
22
|
+
|
|
23
|
+
function onSignal(name) {
|
|
24
|
+
if (raised) return // one-time: a second signal during cleanup is ignored
|
|
25
|
+
raised = name
|
|
26
|
+
for (const cb of onRaiseCallbacks) cb(name)
|
|
27
|
+
if (pendingReject) {
|
|
28
|
+
const reject = pendingReject
|
|
29
|
+
pendingReject = null
|
|
30
|
+
reject(new SignalAbort(name))
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
for (const name of names) {
|
|
35
|
+
const handler = () => onSignal(name)
|
|
36
|
+
handlers.set(name, handler)
|
|
37
|
+
signals.on(name, handler)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
raised() {
|
|
42
|
+
return raised
|
|
43
|
+
},
|
|
44
|
+
// S-18: one name for "has a signal arrived" wherever a caller needs a plain boolean (the
|
|
45
|
+
// `shouldStop` callback `fsops.mjs`/`git-steps.mjs` are given), instead of each call site
|
|
46
|
+
// re-writing `guard.raised() !== null` for itself.
|
|
47
|
+
isRaised() {
|
|
48
|
+
return raised !== null
|
|
49
|
+
},
|
|
50
|
+
throwIfRaised() {
|
|
51
|
+
if (raised) throw new SignalAbort(raised)
|
|
52
|
+
},
|
|
53
|
+
/** Registers `reject` to be called the moment a signal arrives while something else is
|
|
54
|
+
* waiting (a pending question's `ask()`). The caller clears this itself (`clearWait()`) once
|
|
55
|
+
* settled any other way (a line arrived, EOF) -- `waitForSignal` only knows about the signal
|
|
56
|
+
* side of that race, not the other one. */
|
|
57
|
+
waitForSignal(reject) {
|
|
58
|
+
if (raised) {
|
|
59
|
+
reject(new SignalAbort(raised))
|
|
60
|
+
return
|
|
61
|
+
}
|
|
62
|
+
pendingReject = reject
|
|
63
|
+
},
|
|
64
|
+
clearWait() {
|
|
65
|
+
pendingReject = null
|
|
66
|
+
},
|
|
67
|
+
/** Registers `cb` to run once, synchronously, the moment a signal is first raised --
|
|
68
|
+
* immediately when one already has been. Independent of `waitForSignal`'s single pending
|
|
69
|
+
* slot (decision 6: step 6's download aborts its own `AbortController` this way). Returns an
|
|
70
|
+
* unsubscribe function, so a caller whose own window has already closed (step 6 finishing)
|
|
71
|
+
* does not leave a stale callback registered for the rest of the run. */
|
|
72
|
+
onRaise(cb) {
|
|
73
|
+
if (raised) {
|
|
74
|
+
cb(raised)
|
|
75
|
+
return () => {}
|
|
76
|
+
}
|
|
77
|
+
onRaiseCallbacks.push(cb)
|
|
78
|
+
return () => {
|
|
79
|
+
const i = onRaiseCallbacks.indexOf(cb)
|
|
80
|
+
if (i !== -1) onRaiseCallbacks.splice(i, 1)
|
|
81
|
+
}
|
|
82
|
+
},
|
|
83
|
+
off() {
|
|
84
|
+
for (const [name, handler] of handlers) signals.removeListener(name, handler)
|
|
85
|
+
handlers.clear()
|
|
86
|
+
},
|
|
87
|
+
}
|
|
88
|
+
}
|
package/src/text.mjs
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// Shared text helpers: JSON string literals for named values (every C0, DEL and C1 character
|
|
2
|
+
// escaped per traceability.md "Error and warning codes", D10), and POSIX quoting for commands
|
|
3
|
+
// printed for copying (N1, N4, N5, N7, N8, CW-E12, CW-W05).
|
|
4
|
+
|
|
5
|
+
/** S-20: the one definition of "C0, DEL or C1" (traceability.md "Error and warning codes" and
|
|
6
|
+
* AC-24's `hasControlChar`), shared with `escapeChar`'s own, wider "needs `\uXXXX`" rule below
|
|
7
|
+
* (which also covers lone surrogates, a separate reason to escape, review finding C-09 -- NOT a
|
|
8
|
+
* reason to withhold a copyable command, so `hasControlChar` does not call this with that range
|
|
9
|
+
* folded in). */
|
|
10
|
+
function isControlCodePoint(code) {
|
|
11
|
+
return code < 0x20 || (code >= 0x7f && code <= 0x9f)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function isLoneSurrogate(code) {
|
|
15
|
+
return code >= 0xd800 && code <= 0xdfff
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** D10 (the user-approved reading; stage 6/7 correction P-01/S-02/C-11): a line feed inside a
|
|
19
|
+
* value is the JSON short escape `\n` (the matrix's own value, traceability.md C60); every other
|
|
20
|
+
* C0, DEL, C1 character and lone surrogate (review finding C-09: `JSON.stringify` has written
|
|
21
|
+
* U+D800-U+DFFF as `\uXXXX` since ES2019's well-formed `JSON.stringify`) is written `\uXXXX` --
|
|
22
|
+
* `JSON.stringify`'s own short escapes for tab/CR/backspace/form-feed are NOT used for those,
|
|
23
|
+
* and DEL/C1 (review finding C-05) would otherwise pass through `JSON.stringify` unescaped at
|
|
24
|
+
* all. */
|
|
25
|
+
function escapeChar(ch) {
|
|
26
|
+
if (ch === '"') return '\\"'
|
|
27
|
+
if (ch === '\\') return '\\\\'
|
|
28
|
+
if (ch === '\n') return '\\n'
|
|
29
|
+
const code = ch.codePointAt(0)
|
|
30
|
+
if (isControlCodePoint(code) || isLoneSurrogate(code)) {
|
|
31
|
+
return `\\u${code.toString(16).padStart(4, '0')}`
|
|
32
|
+
}
|
|
33
|
+
return ch
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Prints `value` as a JSON string literal, one code point at a time through `escapeChar` (full
|
|
37
|
+
* control over which escapes it uses, D10). S-35: a non-string value is converted with
|
|
38
|
+
* `String()` first, so one escaping rule applies to every call site -- a number, boolean, etc.
|
|
39
|
+
* never carries a control character in this codebase's actual call sites, but an array or object
|
|
40
|
+
* holding one (e.g. a warning list entry) now escapes the same way a plain string does. */
|
|
41
|
+
export function jsonValue(value) {
|
|
42
|
+
const str = typeof value === 'string' ? value : String(value)
|
|
43
|
+
let out = '"'
|
|
44
|
+
for (const ch of str) out += escapeChar(ch)
|
|
45
|
+
return out + '"'
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Candidate 22 (stage 7, security-T-0026): prints `value` through `JSON.stringify` -- unlike
|
|
49
|
+
* `jsonValue`, without wrapping the result in its own extra quotes, so an array or object prints
|
|
50
|
+
* as itself (`["3.1.0"]`, `{"toString":1}`), not through `String(value)` (which calls `toString`,
|
|
51
|
+
* throwing for a value whose own `toString` property is not callable, and collapsing an array
|
|
52
|
+
* like `["3.1.0"]` into the indistinguishable-from-a-real-version `"3.1.0"`). Any DEL/C1
|
|
53
|
+
* character or lone surrogate `JSON.stringify`'s own algorithm leaves unescaped (it already
|
|
54
|
+
* escapes every C0 character and lone surrogate itself, ES2019) is escaped the same way
|
|
55
|
+
* `escapeChar` would (D10). Returns `undefined` when `JSON.stringify` throws (a `BigInt`, a
|
|
56
|
+
* circular structure) or itself returns `undefined` (a bare `function` or `undefined` value) --
|
|
57
|
+
* the caller falls back to a type name in that case. */
|
|
58
|
+
const SHORT_ESCAPE_CODES = { b: '0008', f: '000c', r: '000d', t: '0009' }
|
|
59
|
+
|
|
60
|
+
export function jsonStringifyEscaped(value) {
|
|
61
|
+
let str
|
|
62
|
+
try {
|
|
63
|
+
str = JSON.stringify(value)
|
|
64
|
+
} catch {
|
|
65
|
+
return undefined
|
|
66
|
+
}
|
|
67
|
+
if (typeof str !== 'string') return undefined
|
|
68
|
+
// D10: JSON.stringify's own short escapes for backspace, form feed, CR and tab become `\uXXXX`
|
|
69
|
+
// (a line feed keeps `\n`); matching escape pairs left to right leaves `\\` followed by a
|
|
70
|
+
// letter untouched.
|
|
71
|
+
str = str.replace(/\\(.)/g, (pair, c) => (c in SHORT_ESCAPE_CODES ? `\\u${SHORT_ESCAPE_CODES[c]}` : pair))
|
|
72
|
+
let out = ''
|
|
73
|
+
for (const ch of str) {
|
|
74
|
+
const code = ch.codePointAt(0)
|
|
75
|
+
out += isControlCodePoint(code) || isLoneSurrogate(code) ? `\\u${code.toString(16).padStart(4, '0')}` : ch
|
|
76
|
+
}
|
|
77
|
+
return out
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const BARE_RE = /^[A-Za-z0-9._\-/@%+=:,]+$/
|
|
81
|
+
|
|
82
|
+
export function posixQuoteWord(word) {
|
|
83
|
+
if (BARE_RE.test(word)) return word
|
|
84
|
+
return `'${word.replace(/'/g, "'\\''")}'`
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function posixCommand(words) {
|
|
88
|
+
return words.map(posixQuoteWord).join(' ')
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** A directory beginning with `-` gets the `./` prefix before it is quoted. */
|
|
92
|
+
export function formatDirectoryArg(directory) {
|
|
93
|
+
return directory.startsWith('-') ? `./${directory}` : directory
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** S-22: the one place every CW-E09 call site (fsops.mjs, pristine.mjs) tags a caught file-system
|
|
97
|
+
* error with the path `run.mjs` should report, without overwriting one an earlier catch already
|
|
98
|
+
* set (a write's own catch, re-thrown through another wrapper). */
|
|
99
|
+
export function tagReportPath(err, path) {
|
|
100
|
+
if (err && typeof err.reportPath !== 'string') err.reportPath = path
|
|
101
|
+
return err
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** traceability.md "Error and warning codes": "When a value to be placed in such a command
|
|
105
|
+
* contains a C0, DEL or C1 character, the command is not printed". True when any code point in
|
|
106
|
+
* `str` is C0 (U+0000-U+001F), DEL (U+007F) or C1 (U+0080-U+009F). */
|
|
107
|
+
export function hasControlChar(str) {
|
|
108
|
+
for (const ch of str) {
|
|
109
|
+
if (isControlCodePoint(ch.codePointAt(0))) return true
|
|
110
|
+
}
|
|
111
|
+
return false
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** `\` is Windows' own path separator; comparing after turning `\` into `/` (traceability.md
|
|
115
|
+
* "Step order" row 3, Vocabulary "name on disk") makes a path comparison platform-neutral. Shared
|
|
116
|
+
* by step 3 and P-9 since T-0062. */
|
|
117
|
+
export function toSlash(value) {
|
|
118
|
+
return String(value).replace(/\\/g, '/')
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** ASCII-only case folding (`[A-Z]` to lowercase), the way git's own `tolower` and J5's repository
|
|
122
|
+
* comparison fold -- never JavaScript's Unicode-aware lowercasing. Shared by J5 and entry rule 3
|
|
123
|
+
* since T-0062. */
|
|
124
|
+
export function foldAsciiCase(value) {
|
|
125
|
+
return value.replace(/[A-Z]/g, (c) => c.toLowerCase())
|
|
126
|
+
}
|
|
127
|
+
|