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.
package/src/run.mjs ADDED
@@ -0,0 +1,537 @@
1
+ import { dirname, join, 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 { parseArchive, ArchiveParseError } from './archive.mjs'
7
+ import { checkEntryRules, ArchiveRuleError } from './archive-rules.mjs'
8
+ import { parseArgvStrict, ArgvError, SETUP_FLAG_NAMES } from './argv.mjs'
9
+ import { checkTarget, extractEntries, moveIntoPlace, MoveIntoPlaceError } from './fsops.mjs'
10
+ import { applyChangelog, applyOpenQuestions, applyPackageJson, writeRangeJson, PristineError } from './pristine.mjs'
11
+ import { isNodeVersionOk, MINIMUM_NODE_VERSION } from './node-version.mjs'
12
+ import { makeQuestioner, QuestionEofError, buildEngineArgv, missingRequiredFlags, collectInteractiveAnswers } from './questions.mjs'
13
+ import { runGitSteps, orderedPrintedForms } from './git-steps.mjs'
14
+ import { buildNextSteps } from './next-steps.mjs'
15
+ import { createSignalGuard, SignalAbort } from './signals.mjs'
16
+
17
+ const VERSION_RE = /^\d+\.\d+\.\d+$/
18
+ const COMMIT_RE = /^[0-9a-f]{40}$/
19
+
20
+ /** S-20: the one place every "print a failure line, then remove the temporary directory this run
21
+ * created, then fail" sequence lives (steps 8-10's CW-E09/CW-E10/CW-E04 branches, and the
22
+ * generic bug/signal catch, each used to repeat this pair of calls for itself). */
23
+ async function removeTmpDir(deps, tmpDir) {
24
+ await deps.fs.rm(tmpDir, { recursive: true, force: true })
25
+ }
26
+
27
+ async function failWithCleanup(io, deps, tmpDir, line) {
28
+ io.stderr.write(line + '\n')
29
+ await removeTmpDir(deps, tmpDir)
30
+ return 1
31
+ }
32
+
33
+ /** CW-E09 (steps 8-9) vs. CW-E14 (a bug): a real file-system error carries a string `code` that
34
+ * does NOT start with `ERR_` (review finding C-12) -- Node's own argument-validation errors
35
+ * (`ERR_INVALID_ARG_TYPE`, `ERR_INVALID_ARG_VALUE`, `ERR_OUT_OF_RANGE`, ...) also carry a string
36
+ * `code`, but they are a programming bug in this codebase, not an environment problem to report
37
+ * as CW-E09. */
38
+ function isFileSystemError(err) {
39
+ return typeof err?.code === 'string' && !err.code.startsWith('ERR_')
40
+ }
41
+
42
+ /** Step 2's package source record check (D2): `deps.engineFile` is read through `deps.fs` and its
43
+ * SHA-256 compared with `source.engineSha256`. */
44
+ async function validateSourceRecord(deps) {
45
+ const source = deps.source
46
+ if (!source || typeof source !== 'object') return false
47
+ if (source.repository !== deps.engine.UPSTREAM_SLUG) return false
48
+ if (typeof source.version !== 'string' || source.version !== deps.packageVersion) return false
49
+ if (!VERSION_RE.test(source.version)) return false
50
+ if (typeof source.commit !== 'string' || !COMMIT_RE.test(source.commit)) return false
51
+
52
+ let engineBytes
53
+ try {
54
+ engineBytes = await deps.fs.readFile(deps.engineFile)
55
+ } catch {
56
+ return false
57
+ }
58
+ const actualSha256 = createHash('sha256').update(engineBytes).digest('hex')
59
+ return actualSha256 === source.engineSha256
60
+ }
61
+
62
+ /**
63
+ * create-wxl's one exported seam (0005-S1): the Step order table's steps 1-13, every signal
64
+ * window (traceability.md "Signals"), and the full set of failure-path messages (CW-E09,
65
+ * CW-E11, CW-E14, CW-W05).
66
+ */
67
+ export async function run(argv, io, deps) {
68
+ // traceability.md "Signals": "errors writing the messages are ignored". Without a listener, a
69
+ // `Writable`'s own `write` error is an unhandled `'error'` event (an uncaught exception), not a
70
+ // rejection any `await` here could catch; a no-op listener is the only way to ignore it.
71
+ // S-30: removed in `finally` below, so a run never leaves a listener behind on `io`'s streams.
72
+ const ignoreWriteError = () => {}
73
+ io.stdout.on('error', ignoreWriteError)
74
+ io.stderr.on('error', ignoreWriteError)
75
+ try {
76
+ return await runBody(argv, io, deps)
77
+ } finally {
78
+ // A `Writable`'s own `write` error is emitted on a later tick (`process.nextTick`), not
79
+ // synchronously from `write()` -- removing the listener right away could drop it before it
80
+ // fires, turning the very error this listener exists to ignore back into an uncaught
81
+ // exception. One `setImmediate` tick runs after any already-scheduled `nextTick` callback.
82
+ await new Promise((resolve) => setImmediate(resolve))
83
+ io.stdout.removeListener('error', ignoreWriteError)
84
+ io.stderr.removeListener('error', ignoreWriteError)
85
+ }
86
+ }
87
+
88
+ async function runBody(argv, io, deps) {
89
+ // CW-E01 and CW-E02 use the environment language rule only (traceability.md "Language"): they
90
+ // happen before or during parsing, so a `--lang` flag cannot select their table yet.
91
+ const envLang = selectLanguage(deps)
92
+ const t = messages[envLang]
93
+
94
+ // Step 1: Node version, before anything else (including argv parsing).
95
+ if (!isNodeVersionOk(deps.nodeVersion)) {
96
+ io.stderr.write(t['CW-E01']({ running: deps.nodeVersion, minimum: MINIMUM_NODE_VERSION }) + '\n')
97
+ return 1
98
+ }
99
+
100
+ // traceability.md "Signals": "an error that is none of the CW codes (a bug) ... is printed as
101
+ // CW-E14 ... and exits 1" -- steps 1-2 (before `runSteps`, which has its own such catch) need
102
+ // the same fallback, in the environment language (no `--lang` is parsed yet).
103
+ let parsed
104
+ try {
105
+ parsed = parseArgvStrict(argv)
106
+ } catch (err) {
107
+ if (err instanceof ArgvError) {
108
+ io.stderr.write(t['CW-E02']({ value: err.token, usage: err.usage, reason: err.reason }) + '\n')
109
+ return 1
110
+ }
111
+ io.stderr.write(t['CW-E14']({ message: err?.message ?? String(err) }) + '\n')
112
+ return 1
113
+ }
114
+ if (parsed.noSetup) {
115
+ const presentSetupFlag = SETUP_FLAG_NAMES.find((name) => name in parsed.flags)
116
+ if (presentSetupFlag) {
117
+ io.stderr.write(
118
+ t['CW-E02']({ value: `--${presentSetupFlag}`, usage: false, reason: 'conflicts-with-no-setup' }) + '\n',
119
+ )
120
+ return 1
121
+ }
122
+ }
123
+
124
+ const overrideRaw = deps.env.CREATE_WXL_COMMIT
125
+ const hasOverride = typeof overrideRaw === 'string' && overrideRaw !== ''
126
+ if (hasOverride && !COMMIT_RE.test(overrideRaw)) {
127
+ io.stderr.write(t['CW-E02']({ value: 'CREATE_WXL_COMMIT', usage: false, reason: 'bad-env', badValue: overrideRaw }) + '\n')
128
+ return 1
129
+ }
130
+
131
+ if (!(await validateSourceRecord(deps))) {
132
+ io.stderr.write(t['CW-E13']() + '\n')
133
+ return 1
134
+ }
135
+
136
+ // From here on, a valid `--lang` (now that argv has parsed) selects the table.
137
+ const lang2 = selectLanguage(deps, parsed.lang)
138
+ const t2 = messages[lang2]
139
+
140
+ // The guard is created here and passed down (T-0024 ticket Comments, "increment plan
141
+ // confirmed"), so every window below throwing/catching `SignalAbort` shares the same one, and
142
+ // its listeners come off `deps.signals` exactly once, however `runSteps` returns.
143
+ const guard = createSignalGuard(deps.signals, ['SIGINT', 'SIGTERM', 'SIGHUP'])
144
+ const questioner = makeQuestioner(io, deps, guard)
145
+ try {
146
+ return await runSteps(parsed, io, deps, t2, lang2, questioner, guard, { hasOverride, overrideRaw })
147
+ } catch (err) {
148
+ if (err instanceof QuestionEofError) {
149
+ io.stderr.write(t2['CW-E15']() + '\n')
150
+ return 1
151
+ }
152
+ throw err
153
+ } finally {
154
+ questioner.close()
155
+ guard.off()
156
+ }
157
+ }
158
+
159
+ /** Step 3/4's directory question (S-12): asks once, re-asking on its own (printing the `~` reason,
160
+ * traceability.md "Step order" row 3) until it has an answer that does not begin with `~`. Never
161
+ * touches `checkTarget`, so the step-4 re-ask loop that calls this only prints CW-E04 for a
162
+ * rejection `checkTarget` itself returned, never for a stale one left over from a `~` answer
163
+ * (S-13/P-17/C-06). */
164
+ async function askDirectory(io, t2, questioner) {
165
+ while (true) {
166
+ io.stdout.write(t2['Q-DIRECTORY']() + '\n')
167
+ const raw = await questioner.ask()
168
+ const answer = raw.trim() === '' ? 'wxl-range' : raw.trim()
169
+ if (answer.startsWith('~')) {
170
+ io.stdout.write(t2['Q-DIRECTORY-TILDE']() + '\n')
171
+ continue
172
+ }
173
+ return answer
174
+ }
175
+ }
176
+
177
+ async function runSteps(parsed, io, deps, t2, lang2, questioner, guard, { hasOverride, overrideRaw }) {
178
+ // `tmpDir`, once step 8 creates it, is this function's one piece of cleanup state: a signal or a
179
+ // "bug" (an error that is none of the expected CW conditions) caught below removes it, both
180
+ // handled alike (traceability.md "Signals": "an error that is none of the CW codes ... is
181
+ // handled like a signal in its window"). `remainingCommandsFn`, once step 11 starts, tells the
182
+ // catch below which commands a bug in steps 11-13 still has to list (C-07/P-04/S-04: the same
183
+ // decision (3) CW-W05 already follows for a signal in that window).
184
+ let tmpDir = null
185
+ let remainingCommandsFn = null
186
+ try {
187
+ return await runStepsBody(
188
+ parsed,
189
+ io,
190
+ deps,
191
+ t2,
192
+ lang2,
193
+ questioner,
194
+ guard,
195
+ { hasOverride, overrideRaw },
196
+ (dir) => {
197
+ tmpDir = dir
198
+ },
199
+ (fn) => {
200
+ remainingCommandsFn = fn
201
+ },
202
+ )
203
+ } catch (err) {
204
+ if (err instanceof QuestionEofError) throw err
205
+ if (tmpDir) await removeTmpDir(deps, tmpDir)
206
+ if (err instanceof SignalAbort) return 130
207
+ const remainingCommands = remainingCommandsFn ? remainingCommandsFn() : undefined
208
+ io.stderr.write(t2['CW-E14']({ message: err?.message ?? String(err), remainingCommands }) + '\n')
209
+ return 1
210
+ }
211
+ }
212
+
213
+ async function runStepsBody(
214
+ parsed,
215
+ io,
216
+ deps,
217
+ t2,
218
+ lang2,
219
+ questioner,
220
+ guard,
221
+ { hasOverride, overrideRaw },
222
+ setTmpDir,
223
+ setRemainingCommandsFn,
224
+ ) {
225
+ deps.checkpoint('step-3')
226
+ let directory = parsed.directory
227
+ let askedDirectory = false
228
+ if (directory === undefined) {
229
+ if (io.interactive) {
230
+ askedDirectory = true
231
+ directory = await askDirectory(io, t2, questioner)
232
+ } else {
233
+ io.stderr.write(t2['CW-E03']() + '\n')
234
+ return 1
235
+ }
236
+ }
237
+ let target = resolvePath(deps.cwd, directory)
238
+
239
+ deps.checkpoint('step-4')
240
+ guard.throwIfRaised()
241
+
242
+ const printCwE04 = (rejectionToPrint) =>
243
+ io.stderr.write(t2['CW-E04']({ path: target, conditionId: rejectionToPrint.id, detail: rejectionToPrint.detail }) + '\n')
244
+
245
+ let rejection = await checkTarget(deps, target)
246
+ guard.throwIfRaised() // P-17/C-14: a signal already received wins over a rejection found after
247
+ while (rejection && askedDirectory) {
248
+ printCwE04(rejection)
249
+ directory = await askDirectory(io, t2, questioner)
250
+ target = resolvePath(deps.cwd, directory)
251
+ rejection = await checkTarget(deps, target)
252
+ guard.throwIfRaised()
253
+ }
254
+ if (rejection) {
255
+ printCwE04(rejection)
256
+ return 1
257
+ }
258
+
259
+ let setupArgs = null
260
+ let setupArgv = null
261
+ if (!parsed.noSetup) {
262
+ deps.checkpoint('step-5')
263
+ guard.throwIfRaised()
264
+ if (!io.interactive) {
265
+ const missing = missingRequiredFlags(parsed.flags)
266
+ if (missing.length > 0) {
267
+ io.stderr.write(t2['CW-E05']({ missing }) + '\n')
268
+ return 1
269
+ }
270
+ setupArgv = buildEngineArgv(parsed.flags)
271
+ try {
272
+ setupArgs = deps.engine.parseForkInitArgs(setupArgv)
273
+ } catch (err) {
274
+ if (err instanceof deps.engine.ForkInitArgError) {
275
+ io.stderr.write(t2['CW-E06']({ message: err.message }) + '\n')
276
+ return 1
277
+ }
278
+ throw err
279
+ }
280
+ } else {
281
+ ;({ parsedArgs: setupArgs, argv: setupArgv } = await collectInteractiveAnswers(io, deps, t2, parsed.flags, questioner))
282
+ }
283
+ }
284
+
285
+ const commit = hasOverride ? overrideRaw : deps.source.commit
286
+
287
+ deps.checkpoint('step-6')
288
+ guard.throwIfRaised()
289
+ if (hasOverride) {
290
+ io.stderr.write(t2['CW-W04']({ commit }) + '\n')
291
+ }
292
+ const url = `https://codeload.github.com/${deps.source.repository}/tar.gz/${commit}`
293
+ const controller = new AbortController()
294
+ // decision 6: a signal during the download aborts it through this same controller, instead of
295
+ // waiting out the full `downloadTimeoutMs` -- traceability.md "Signals": "after a signal no new
296
+ // file-system operation starts" (the download itself is not a file-system operation, but the
297
+ // rule not to keep running applies the same way).
298
+ const unsubscribeAbort = guard.onRaise(() => controller.abort())
299
+ let timedOut = false
300
+ let timer
301
+ const timeoutPromise = new Promise((_resolveTimeout, rejectTimeout) => {
302
+ timer = setTimeout(() => {
303
+ timedOut = true
304
+ controller.abort()
305
+ rejectTimeout(new Error())
306
+ }, deps.downloadTimeoutMs)
307
+ })
308
+
309
+ let status, body
310
+ try {
311
+ ;({ status, body } = await Promise.race([
312
+ deps.fetchArchive(deps.source.repository, commit, { signal: controller.signal }),
313
+ timeoutPromise,
314
+ ]))
315
+ } catch (err) {
316
+ if (guard.raised()) throw new SignalAbort(guard.raised())
317
+ const detail = timedOut ? { kind: 'timeout' } : { kind: 'error', message: err?.message ?? String(err) }
318
+ io.stderr.write(t2['CW-E07']({ url, detail }) + '\n')
319
+ return 1
320
+ } finally {
321
+ clearTimeout(timer)
322
+ // S-17: step 6's own window has closed either way; this `AbortController` is no longer
323
+ // useful to abort, so the callback is removed instead of lingering for the rest of the run.
324
+ unsubscribeAbort()
325
+ }
326
+
327
+ guard.throwIfRaised() // P-17/C-14: a signal already received wins over a non-200 status
328
+ if (status !== 200) {
329
+ io.stderr.write(t2['CW-E07']({ url, detail: { kind: 'status', status } }) + '\n')
330
+ return 1
331
+ }
332
+
333
+ deps.checkpoint('step-7')
334
+ guard.throwIfRaised()
335
+ let parsedArchive
336
+ try {
337
+ parsedArchive = await parseArchive(body)
338
+ } catch (err) {
339
+ if (err instanceof ArchiveParseError) {
340
+ io.stderr.write(t2['CW-E08']({ parseError: err.message }) + '\n')
341
+ return 1
342
+ }
343
+ throw err
344
+ }
345
+ let validatedEntries
346
+ try {
347
+ validatedEntries = checkEntryRules(parsedArchive, { commit })
348
+ } catch (err) {
349
+ if (err instanceof ArchiveRuleError) {
350
+ io.stderr.write(t2['CW-E08']({ rule: err.rule, path: err.path, isCommentMarker: err.isCommentMarker }) + '\n')
351
+ return 1
352
+ }
353
+ throw err
354
+ }
355
+
356
+ deps.checkpoint('step-8')
357
+ guard.throwIfRaised()
358
+ const parentDir = dirname(target)
359
+ let tmpDir
360
+ try {
361
+ tmpDir = await deps.fs.mkdtemp(join(parentDir, '.create-wxl-'))
362
+ setTmpDir(tmpDir)
363
+ } catch (err) {
364
+ if (isFileSystemError(err)) {
365
+ io.stderr.write(t2['CW-E09']({ path: parentDir, detail: err.message }) + '\n')
366
+ return 1 // mkdtemp itself failed: nothing was created, nothing to clean up
367
+ }
368
+ throw err
369
+ }
370
+ try {
371
+ // S-19: `{ stopped: true }` (never a silent early return) is the one signal `extractEntries`
372
+ // gives for "a signal arrived mid-extraction"; turned into the same `SignalAbort` every other
373
+ // window throws, right here, instead of a separate check the caller could forget.
374
+ const { stopped } = await extractEntries(deps, validatedEntries, tmpDir, { shouldStop: () => guard.isRaised() })
375
+ if (stopped) throw new SignalAbort(guard.raised())
376
+ } catch (err) {
377
+ if (err instanceof SignalAbort) throw err
378
+ if (isFileSystemError(err)) {
379
+ return failWithCleanup(io, deps, tmpDir, t2['CW-E09']({ path: err.reportPath ?? tmpDir, detail: err.message }))
380
+ }
381
+ throw err
382
+ }
383
+
384
+ deps.checkpoint('step-9')
385
+ guard.throwIfRaised()
386
+ try {
387
+ await applyChangelog(deps.fs, tmpDir)
388
+ guard.throwIfRaised() // decision 6: a signal mid-step-9 stops before the next P-rule's write
389
+ await applyOpenQuestions(deps.fs, tmpDir)
390
+ guard.throwIfRaised()
391
+ await applyPackageJson(deps.fs, tmpDir)
392
+ guard.throwIfRaised()
393
+ await writeRangeJson(deps.fs, tmpDir, {
394
+ repository: deps.source.repository,
395
+ version: deps.source.version,
396
+ commit,
397
+ })
398
+ guard.throwIfRaised() // P-02/C-01: a signal during step 9's last write starts no step-10 work
399
+ } catch (err) {
400
+ if (err instanceof PristineError) {
401
+ return failWithCleanup(io, deps, tmpDir, t2['CW-E10']({ file: err.file, rule: err.rule }))
402
+ }
403
+ if (isFileSystemError(err)) {
404
+ return failWithCleanup(io, deps, tmpDir, t2['CW-E09']({ path: err.reportPath ?? tmpDir, detail: err.message }))
405
+ }
406
+ throw err
407
+ }
408
+
409
+ deps.checkpoint('step-10')
410
+ guard.throwIfRaised() // P-02/C-01
411
+ // Step 10's own re-check (traceability.md "Step order" row 10): another process may have created
412
+ // something at the target since step 4. An empty directory is still accepted (moveIntoPlace's
413
+ // existing-target branch handles it); anything else cleans up the temporary directory and reports
414
+ // CW-E04 as step 4 would, instead of letting `rename` itself fail with an opaque ENOTDIR/ENOTEMPTY.
415
+ const recheck = await checkTarget(deps, target)
416
+ guard.throwIfRaised()
417
+ if (recheck) {
418
+ await removeTmpDir(deps, tmpDir)
419
+ printCwE04(recheck)
420
+ return 1
421
+ }
422
+ try {
423
+ await moveIntoPlace(deps, tmpDir, target, { shouldStop: () => guard.isRaised() })
424
+ } catch (err) {
425
+ if (err instanceof MoveIntoPlaceError) {
426
+ if (err.signaled) {
427
+ // C-02/P-03/S-23: a stranded entry still keeps and names the temporary directory (the
428
+ // same "kept and named" guarantee a non-signaled CW-E11 has); the signal still decides
429
+ // the exit code (130), and `setTmpDir(null)` here stops the outer catch from deleting a
430
+ // temporary directory `moveIntoPlace` deliberately kept.
431
+ if (err.stranded.length > 0) {
432
+ io.stderr.write(
433
+ t2['CW-E11']({ target, detail: err.message, stranded: err.stranded, tmpDir: err.tmpDir }) + '\n',
434
+ )
435
+ setTmpDir(null)
436
+ return 130
437
+ }
438
+ throw new SignalAbort(guard.raised())
439
+ }
440
+ io.stderr.write(
441
+ t2['CW-E11']({ target, detail: err.message, stranded: err.stranded, tmpDir: err.tmpDir }) + '\n',
442
+ )
443
+ return 1
444
+ }
445
+ throw err
446
+ }
447
+ setTmpDir(null) // step 10 succeeded: the range stays on disk whatever happens from here on
448
+
449
+ // Steps 11-13 (traceability.md "Signals"): a signal here is printed directly, inline (CW-W05),
450
+ // instead of being thrown and routed through the generic `SignalAbort` catch above -- there is
451
+ // no tmpDir cleanup left to do, and each of the three steps' CW-W05 names a different set of
452
+ // remaining commands.
453
+ // S-21: `lang2` is the language `selectLanguage` already picked in `run()`; reusing it here
454
+ // avoids re-deriving the same answer from `t2`'s own object identity.
455
+ const lang = lang2
456
+
457
+ if (!parsed.noSetup) {
458
+ deps.checkpoint('step-11')
459
+ setRemainingCommandsFn(() =>
460
+ cwE12FinishingCommandsList({ directory, argv: setupArgv, version: deps.source.version, lang }),
461
+ )
462
+ if (guard.raised()) {
463
+ io.stderr.write(
464
+ t2['CW-W05']({
465
+ remainingCommands: cwE12FinishingCommandsList({
466
+ directory,
467
+ argv: setupArgv,
468
+ version: deps.source.version,
469
+ lang,
470
+ }),
471
+ }) +
472
+ '\n',
473
+ )
474
+ return 130
475
+ }
476
+ // S-12: both CW-E12 call sites (the thrown-error and the non-zero-exitCode path) otherwise
477
+ // repeat the same `{ directory, argv: setupArgv, version: deps.source.version }`.
478
+ const printCwE12 = (message) =>
479
+ io.stderr.write(t2['CW-E12']({ message, directory, argv: setupArgv, version: deps.source.version }) + '\n')
480
+ let result
481
+ try {
482
+ result = deps.engine.runForkInit(setupArgs, { projectRoot: target })
483
+ } catch (err) {
484
+ printCwE12(err?.message ?? String(err))
485
+ return 1
486
+ }
487
+ if (result.exitCode !== 0) {
488
+ printCwE12(result.message)
489
+ return 1
490
+ }
491
+ for (const warning of result.warnings) {
492
+ io.stdout.write(jsonValue(warning) + '\n')
493
+ }
494
+ }
495
+
496
+ let gitResult = { warningId: null }
497
+ if (!parsed.noSetup) {
498
+ deps.checkpoint('step-12')
499
+ setRemainingCommandsFn(() => orderedPrintedForms(deps.source.version))
500
+ if (guard.raised()) {
501
+ io.stderr.write(t2['CW-W05']({ remainingCommands: orderedPrintedForms(deps.source.version) }) + '\n')
502
+ return 130
503
+ }
504
+ gitResult = await runGitSteps({
505
+ runGit: deps.runGit,
506
+ cwd: target,
507
+ env: deps.env,
508
+ version: deps.source.version,
509
+ shouldStop: () => guard.isRaised(),
510
+ })
511
+ if (gitResult.signal) {
512
+ io.stderr.write(t2['CW-W05']({ remainingCommands: gitResult.remainingCommands }) + '\n')
513
+ return 130
514
+ }
515
+ }
516
+ if (gitResult.warningId) {
517
+ io.stderr.write(t2[gitResult.warningId](gitResult) + '\n')
518
+ }
519
+
520
+ deps.checkpoint('step-13')
521
+ setRemainingCommandsFn(() => [])
522
+ if (guard.raised()) {
523
+ io.stderr.write(t2['CW-W05']({ remainingCommands: [] }) + '\n')
524
+ return 130
525
+ }
526
+ const nextSteps = buildNextSteps({
527
+ t: t2,
528
+ directory,
529
+ noSetup: parsed.noSetup,
530
+ parsedArgs: setupArgs,
531
+ gitResult,
532
+ })
533
+ for (const line of nextSteps) {
534
+ io.stdout.write(line + '\n')
535
+ }
536
+ return 0
537
+ }
@@ -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
+ }