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/LICENSE +127 -0
- package/bin/create-wxl.mjs +153 -0
- package/dist/fork-init.mjs +1782 -0
- package/dist/source.json +6 -0
- package/package.json +20 -4
- package/src/archive-rules.mjs +148 -0
- package/src/archive.mjs +109 -0
- package/src/argv.mjs +89 -0
- package/src/fsops.mjs +205 -0
- package/src/git-env.mjs +13 -0
- package/src/git-runner.mjs +107 -0
- package/src/git-steps.mjs +148 -0
- package/src/git-version.mjs +25 -0
- package/src/language.mjs +14 -0
- package/src/messages.mjs +323 -0
- package/src/next-steps.mjs +29 -0
- package/src/node-version.mjs +17 -0
- package/src/numeric-version.mjs +16 -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.mjs +537 -0
- package/src/signals.mjs +88 -0
- package/src/text.mjs +80 -0
- package/README.md +0 -3
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
|
+
}
|
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
|
+
}
|