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
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { isAtLeast } from './numeric-version.mjs'
|
|
2
|
+
|
|
3
|
+
/** The one place the minimum supported Node.js version is written down; `run.mjs`'s CW-E01
|
|
4
|
+
* message and `isNodeVersionOk`'s default both derive from it (S-21: no duplicated literal). */
|
|
5
|
+
export const MINIMUM_NODE_VERSION = '22.6.0'
|
|
6
|
+
|
|
7
|
+
const NUMERIC_VERSION_RE = /^\d+(\.\d+)*$/
|
|
8
|
+
|
|
9
|
+
/** Numeric (not string) comparison, so `22.10.0` is not mistaken for less than `22.6.0`. A
|
|
10
|
+
* version that is not purely `\d+(\.\d+)*` (e.g. a stray `v` prefix, or empty) is rejected as too
|
|
11
|
+
* old rather than silently accepted (C-10 / P-17 / S-21: no fail-open on an unparsable string). */
|
|
12
|
+
export function isNodeVersionOk(version, minimum = MINIMUM_NODE_VERSION.split('.').map(Number)) {
|
|
13
|
+
const str = String(version)
|
|
14
|
+
if (!NUMERIC_VERSION_RE.test(str)) return false
|
|
15
|
+
const parts = str.split('.').map(Number)
|
|
16
|
+
return isAtLeast(parts, minimum)
|
|
17
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// A single numeric-parts comparison (S-11), shared by `node-version.mjs` and `git-version.mjs`:
|
|
2
|
+
// both read a dotted version string into an array of numbers and need the same "is this at least
|
|
3
|
+
// the minimum" rule, so there is one place to fix if it is ever wrong.
|
|
4
|
+
|
|
5
|
+
/** `parts` and `minimum`: arrays of non-negative integers (major, minor, patch, ...), most
|
|
6
|
+
* significant first. A part missing past the end of either array counts as `0`. Returns `true`
|
|
7
|
+
* when `parts` is `minimum` or newer. */
|
|
8
|
+
export function isAtLeast(parts, minimum) {
|
|
9
|
+
for (let i = 0; i < minimum.length; i++) {
|
|
10
|
+
const a = parts[i] ?? 0
|
|
11
|
+
const b = minimum[i]
|
|
12
|
+
if (a > b) return true
|
|
13
|
+
if (a < b) return false
|
|
14
|
+
}
|
|
15
|
+
return true
|
|
16
|
+
}
|
package/src/pristine.mjs
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { join } from 'node:path'
|
|
2
|
+
import { tagReportPath } from './text.mjs'
|
|
3
|
+
|
|
4
|
+
export class PristineError extends Error {
|
|
5
|
+
constructor(file, reason, rule) {
|
|
6
|
+
super(`pristine rule failed: ${file} (${reason})`)
|
|
7
|
+
this.name = 'PristineError'
|
|
8
|
+
this.file = file
|
|
9
|
+
this.reason = reason
|
|
10
|
+
this.rule = rule
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function stripCR(line) {
|
|
15
|
+
return line.endsWith('\r') ? line.slice(0, -1) : line
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Only `ENOENT` means "the file is missing" (P-09/C-10): any other read error (`EACCES`,
|
|
19
|
+
* `EISDIR`, ...) is not a pristine-rule failure, so it is rethrown for CW-E09 to report
|
|
20
|
+
* (T-0024) instead of being misreported here as CW-E10 "missing". */
|
|
21
|
+
async function readPristineFile(fs, path, file, rule) {
|
|
22
|
+
try {
|
|
23
|
+
return await fs.readFile(path, 'utf8')
|
|
24
|
+
} catch (err) {
|
|
25
|
+
if (err && err.code === 'ENOENT') throw new PristineError(file, 'missing', rule)
|
|
26
|
+
throw tagReportPath(err, path)
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Writes through `fs.writeFile`, tagging a non-ENOENT failure with the path CW-E09 (run.mjs)
|
|
31
|
+
* reports: the write calls below have no `readPristineFile`-style catch of their own. */
|
|
32
|
+
async function writePristineFile(fs, path, content) {
|
|
33
|
+
try {
|
|
34
|
+
await fs.writeFile(path, content)
|
|
35
|
+
} catch (err) {
|
|
36
|
+
throw tagReportPath(err, path)
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** T4/T11 (scripts/fork-init.ts): a manifest's line ending, decided by its first line break only.
|
|
41
|
+
* Copied rather than imported: `fork-init.ts` does not export it (traceability.md only adds
|
|
42
|
+
* `deriveGithubPagesOrigin` to the engine's exports). */
|
|
43
|
+
function firstLineEnding(raw) {
|
|
44
|
+
const nl = raw.indexOf('\n')
|
|
45
|
+
return nl > 0 && raw[nl - 1] === '\r' ? '\r\n' : '\n'
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** P1: keep every line up to and including `## [Unreleased]`; LF endings. */
|
|
49
|
+
export async function applyChangelog(fs, dir) {
|
|
50
|
+
const path = join(dir, 'CHANGELOG.md')
|
|
51
|
+
const raw = await readPristineFile(fs, path, 'CHANGELOG.md', 'P1')
|
|
52
|
+
const lines = raw.split('\n')
|
|
53
|
+
const idx = lines.findIndex((line) => stripCR(line) === '## [Unreleased]')
|
|
54
|
+
if (idx === -1) throw new PristineError('CHANGELOG.md', 'marker-absent', 'P1')
|
|
55
|
+
const kept = lines.slice(0, idx + 1).map(stripCR)
|
|
56
|
+
await writePristineFile(fs, path, kept.join('\n') + '\n')
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** P2: keep every line before the first `^## OQ-\d{3} ` line; drop trailing blank/`---` lines. */
|
|
60
|
+
export async function applyOpenQuestions(fs, dir) {
|
|
61
|
+
const path = join(dir, '.proj.specs/OPEN-QUESTIONS.md')
|
|
62
|
+
const raw = await readPristineFile(fs, path, '.proj.specs/OPEN-QUESTIONS.md', 'P2')
|
|
63
|
+
const lines = raw.split('\n')
|
|
64
|
+
const markerRe = /^## OQ-\d{3} /
|
|
65
|
+
const idx = lines.findIndex((line) => markerRe.test(stripCR(line)))
|
|
66
|
+
if (idx === -1) throw new PristineError('.proj.specs/OPEN-QUESTIONS.md', 'marker-absent', 'P2')
|
|
67
|
+
const kept = lines.slice(0, idx).map(stripCR)
|
|
68
|
+
while (kept.length > 0 && (kept[kept.length - 1] === '' || kept[kept.length - 1] === '---')) {
|
|
69
|
+
kept.pop()
|
|
70
|
+
}
|
|
71
|
+
if (kept.length === 0) throw new PristineError('.proj.specs/OPEN-QUESTIONS.md', 'empty', 'P2')
|
|
72
|
+
await writePristineFile(fs, path, kept.join('\n') + '\n')
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** P3: parse, set `version` to `0.1.0` (added last when absent), serialise with fork-init's
|
|
76
|
+
* manifest serialisation (`JSON.stringify(obj, null, 2) + '\n'`, 0004 T4), reapplying CRLF when
|
|
77
|
+
* the input's first line break was CRLF (T4 also preserves CRLF; T-0021's C26). A leading BOM is
|
|
78
|
+
* its own failure reason (distinct from "not JSON": `JSON.parse` rejects it too, but spec lists
|
|
79
|
+
* it as its own condition), checked before parsing. */
|
|
80
|
+
export async function applyPackageJson(fs, dir) {
|
|
81
|
+
const path = join(dir, 'package.json')
|
|
82
|
+
const raw = await readPristineFile(fs, path, 'package.json', 'P3')
|
|
83
|
+
if (raw.charCodeAt(0) === 0xfeff) throw new PristineError('package.json', 'bom', 'P3')
|
|
84
|
+
let obj
|
|
85
|
+
try {
|
|
86
|
+
obj = JSON.parse(raw)
|
|
87
|
+
} catch {
|
|
88
|
+
throw new PristineError('package.json', 'not-json', 'P3')
|
|
89
|
+
}
|
|
90
|
+
if (obj === null || typeof obj !== 'object' || Array.isArray(obj)) {
|
|
91
|
+
throw new PristineError('package.json', 'not-object', 'P3')
|
|
92
|
+
}
|
|
93
|
+
obj.version = '0.1.0'
|
|
94
|
+
const serialized = JSON.stringify(obj, null, 2) + '\n'
|
|
95
|
+
const eol = firstLineEnding(raw)
|
|
96
|
+
await writePristineFile(fs, path, eol === '\r\n' ? serialized.replace(/\n/g, '\r\n') : serialized)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** P4: write `range.json`, replacing any copy the archive carried. */
|
|
100
|
+
export async function writeRangeJson(fs, dir, { repository, version, commit }) {
|
|
101
|
+
const path = join(dir, 'range.json')
|
|
102
|
+
const body = { upstream: { repository, version, commit } }
|
|
103
|
+
await writePristineFile(fs, path, JSON.stringify(body, null, 2) + '\n')
|
|
104
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// Real IO `scripts/check-publish.mjs` and `scripts/prepare-dist.mjs` wire in (0005-S2's `npmView`,
|
|
2
|
+
// `npmWhoami` and `archive`, plus `prepareDist`'s own `git rev-parse HEAD`): kept here, in `src/`,
|
|
3
|
+
// so this logic is testable like the rest of the seam -- the `scripts/*.mjs` files stay thin
|
|
4
|
+
// wiring layers with no logic of their own (stage 6/7 fix assignment section A, S-05/P-08/C-02,
|
|
5
|
+
// P-23, S-08/S-09).
|
|
6
|
+
|
|
7
|
+
import { spawn } from 'node:child_process'
|
|
8
|
+
import { buildGitEnv } from './git-env.mjs'
|
|
9
|
+
|
|
10
|
+
/** Captures stdout/stderr as UTF-8 text. Each stream gets its own `setEncoding('utf8')`
|
|
11
|
+
* (`git-runner.mjs`'s own convention): decoding a chunk on its own as it arrives would mangle a
|
|
12
|
+
* multibyte character split across two `data` events (an `npm whoami`/`npm view` maintainer name
|
|
13
|
+
* with non-ASCII characters, for instance). */
|
|
14
|
+
export function spawnCapture(command, args, options = {}) {
|
|
15
|
+
return new Promise((resolvePromise, reject) => {
|
|
16
|
+
const child = spawn(command, args, { ...options, shell: false })
|
|
17
|
+
child.stdout.setEncoding('utf8')
|
|
18
|
+
child.stderr.setEncoding('utf8')
|
|
19
|
+
let stdout = ''
|
|
20
|
+
let stderr = ''
|
|
21
|
+
child.stdout.on('data', (chunk) => {
|
|
22
|
+
stdout += chunk
|
|
23
|
+
})
|
|
24
|
+
child.stderr.on('data', (chunk) => {
|
|
25
|
+
stderr += chunk
|
|
26
|
+
})
|
|
27
|
+
child.on('error', reject)
|
|
28
|
+
child.on('close', (code) => resolvePromise({ code, stdout, stderr }))
|
|
29
|
+
})
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Captures stdout as bytes (`git archive`'s binary tar output); stderr is still decoded as text
|
|
33
|
+
* for the rejection message. Rejects on a non-zero exit (the one call site, `createArchive`,
|
|
34
|
+
* never needs to inspect a failed archive's own exit code separately). */
|
|
35
|
+
export function spawnCaptureBytes(command, args, options = {}) {
|
|
36
|
+
return new Promise((resolvePromise, reject) => {
|
|
37
|
+
const child = spawn(command, args, { ...options, shell: false })
|
|
38
|
+
const chunks = []
|
|
39
|
+
let stderr = ''
|
|
40
|
+
child.stderr.setEncoding('utf8')
|
|
41
|
+
child.stdout.on('data', (chunk) => chunks.push(chunk))
|
|
42
|
+
child.stderr.on('data', (chunk) => {
|
|
43
|
+
stderr += chunk
|
|
44
|
+
})
|
|
45
|
+
child.on('error', reject)
|
|
46
|
+
child.on('close', (code) => {
|
|
47
|
+
if (code !== 0) reject(new Error(`${command} ${args.join(' ')} failed: ${stderr}`))
|
|
48
|
+
else resolvePromise(Buffer.concat(chunks))
|
|
49
|
+
})
|
|
50
|
+
})
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** npm's binary name per platform (P-23): on win32, npm installs as `npm.cmd` (a shim); spawning
|
|
54
|
+
* the bare name `npm` without a shell fails there (`ENOENT`, or `EINVAL` since Node's
|
|
55
|
+
* CVE-2024-27980 fix). Elsewhere the bare name resolves through `PATH` normally. */
|
|
56
|
+
export function npmCommand(platform = process.platform) {
|
|
57
|
+
return platform === 'win32' ? 'npm.cmd' : 'npm'
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Parses `npm view <name> maintainers --json`'s result into a maintainer list (S-05/P-08/C-02):
|
|
62
|
+
* an E404 (the name is unpublished) becomes `[]`; a single maintainer is npm's own output quirk
|
|
63
|
+
* (a bare string, not a one-item array) and is normalised to one; any OTHER non-zero exit --
|
|
64
|
+
* a network error, a registry 5xx, an auth failure -- throws instead of silently becoming `[]`.
|
|
65
|
+
* The previous version mapped every non-zero exit to "unpublished", which let a real failure make
|
|
66
|
+
* X9 fail open (reported by stage 6/7 review as S-05/P-08/C-02).
|
|
67
|
+
*/
|
|
68
|
+
export function parseNpmViewMaintainers(result) {
|
|
69
|
+
if (result.code !== 0) {
|
|
70
|
+
const text = `${result.stdout}\n${result.stderr}`
|
|
71
|
+
if (/\bE404\b/.test(text)) return []
|
|
72
|
+
throw new Error(`npm view failed (not E404): ${result.stderr || result.stdout}`)
|
|
73
|
+
}
|
|
74
|
+
const trimmed = result.stdout.trim()
|
|
75
|
+
if (trimmed === '') return []
|
|
76
|
+
const parsed = JSON.parse(trimmed)
|
|
77
|
+
return Array.isArray(parsed) ? parsed : [parsed]
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function createNpmView({ root, platform = process.platform } = {}) {
|
|
81
|
+
return async function npmView() {
|
|
82
|
+
const result = await spawnCapture(npmCommand(platform), ['view', 'create-wxl', 'maintainers', '--json'], {
|
|
83
|
+
cwd: root,
|
|
84
|
+
env: process.env,
|
|
85
|
+
})
|
|
86
|
+
return parseNpmViewMaintainers(result)
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function createNpmWhoami({ root, platform = process.platform } = {}) {
|
|
91
|
+
return async function npmWhoami() {
|
|
92
|
+
const result = await spawnCapture(npmCommand(platform), ['whoami'], { cwd: root, env: process.env })
|
|
93
|
+
if (result.code !== 0) throw new Error(`npm whoami failed: ${result.stderr || result.stdout}`)
|
|
94
|
+
return result.stdout.trim()
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** S-24: `archive({ commit, name })` takes the values `checkPublish` already resolved, instead of
|
|
99
|
+
* re-deriving them with a second `git rev-parse HEAD` and a second engine import.
|
|
100
|
+
*
|
|
101
|
+
* `git archive` run with `cwd: root` set to the package subdirectory archives only that
|
|
102
|
+
* subdirectory's own subtree (git's documented behaviour) -- the X7 defect this fixes: when the
|
|
103
|
+
* package folder is itself `export-ignore`d, the archive comes back empty. `git archive` always
|
|
104
|
+
* archives relative to the repository's top level regardless of `cwd`, so resolving it first
|
|
105
|
+
* (through `buildGitEnv`, the same isolation `gitRevParseHead` already requires) and running the
|
|
106
|
+
* archive there gets the whole tree every time, independent of which subdirectory `root` names. */
|
|
107
|
+
export function createArchive({ root } = {}) {
|
|
108
|
+
return async function archive({ commit, name }) {
|
|
109
|
+
const topLevel = await spawnCapture('git', ['rev-parse', '--show-toplevel'], { cwd: root, env: buildGitEnv(process.env) })
|
|
110
|
+
if (topLevel.code !== 0) throw new Error(`git rev-parse --show-toplevel failed: ${topLevel.stderr}`)
|
|
111
|
+
const cwd = topLevel.stdout.trim()
|
|
112
|
+
return spawnCaptureBytes('git', ['archive', `--prefix=${name}-${commit}/`, commit], { cwd, env: process.env })
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** `prepareDist`'s own `git rev-parse HEAD` (S-10/P-24): through `buildGitEnv` so an outer
|
|
117
|
+
* `GIT_DIR`/`GIT_INDEX_FILE` (a git hook running this process, `git rebase --exec`, ...) cannot
|
|
118
|
+
* leak into it, the same isolation "Git steps" already requires for the range's own git calls. */
|
|
119
|
+
export async function gitRevParseHead(cwd, env = process.env) {
|
|
120
|
+
const result = await spawnCapture('git', ['rev-parse', 'HEAD'], { cwd, env: buildGitEnv(env) })
|
|
121
|
+
if (result.code !== 0) throw new Error(`git rev-parse HEAD failed: ${result.stderr}`)
|
|
122
|
+
return result.stdout.trim()
|
|
123
|
+
}
|
package/src/publish.mjs
ADDED
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
// "Package and publishing" (traceability.md 0005-S2): `prepareDist` carries the type-stripped
|
|
2
|
+
// engine, the package source record and `LICENSE` into `dist/`; `checkPublish` (X1-X9) refuses a
|
|
3
|
+
// release that does not match upstream's pushed tag.
|
|
4
|
+
|
|
5
|
+
import { rm, mkdir, readFile, writeFile, cp, mkdtemp } from 'node:fs/promises'
|
|
6
|
+
import * as fsp from 'node:fs/promises'
|
|
7
|
+
import { join, resolve } from 'node:path'
|
|
8
|
+
import { tmpdir } from 'node:os'
|
|
9
|
+
import { pathToFileURL } from 'node:url'
|
|
10
|
+
import { createHash } from 'node:crypto'
|
|
11
|
+
import { gzipSync } from 'node:zlib'
|
|
12
|
+
import { parse as parseYaml } from 'yaml'
|
|
13
|
+
import { parseArchive, ArchiveParseError } from './archive.mjs'
|
|
14
|
+
import { checkEntryRules, ArchiveRuleError } from './archive-rules.mjs'
|
|
15
|
+
import { extractEntries } from './fsops.mjs'
|
|
16
|
+
import { applyChangelog, applyOpenQuestions, applyPackageJson, writeRangeJson, PristineError } from './pristine.mjs'
|
|
17
|
+
import { gitRevParseHead } from './publish-io.mjs'
|
|
18
|
+
|
|
19
|
+
const GZIP_MAGIC_0 = 0x1f
|
|
20
|
+
const GZIP_MAGIC_1 = 0x8b
|
|
21
|
+
|
|
22
|
+
function looksGzip(buf) {
|
|
23
|
+
return buf.length >= 2 && buf[0] === GZIP_MAGIC_0 && buf[1] === GZIP_MAGIC_1
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** A cache-busting dynamic import: the same `path` can be (re)written with different content
|
|
27
|
+
* across a test file's several scenarios (`buildRelease`-style fixtures), and Node's ESM import
|
|
28
|
+
* cache is keyed on the specifier string, not the file's mtime -- without a unique query string
|
|
29
|
+
* each re-import after a rewrite would silently return the stale module. */
|
|
30
|
+
function importFresh(path) {
|
|
31
|
+
const href = pathToFileURL(path).href
|
|
32
|
+
const specifier = `${href}?t=${Date.now()}-${Math.random().toString(36).slice(2)}`
|
|
33
|
+
return import(/* @vite-ignore */ specifier)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** One short, human-readable line per check id (traceability.md "Package and publishing"'s own
|
|
37
|
+
* table), so a maintainer reading `prepare-dist.mjs`'s or `check-publish.mjs`'s failure output
|
|
38
|
+
* does not have to go look the id up before knowing what failed. */
|
|
39
|
+
export const CHECK_EXPLANATIONS = {
|
|
40
|
+
D1: 'module.stripTypeScriptTypes is missing',
|
|
41
|
+
X1: 'module.stripTypeScriptTypes is missing',
|
|
42
|
+
X2: "create-wxl's version does not equal the root manifest's version, or is not exact",
|
|
43
|
+
X3: 'no local tag v<version> exists at HEAD',
|
|
44
|
+
X4: 'the working tree is not clean',
|
|
45
|
+
X5: "create-wxl's yaml dependency is not an exact version equal to the lockfile's",
|
|
46
|
+
X6: "the pushed tag on GitHub does not resolve to HEAD",
|
|
47
|
+
X7: "the release's git archive fails an entry rule or a pristine (P1-P4) rule",
|
|
48
|
+
X8: 'dist/ or LICENSE does not match HEAD',
|
|
49
|
+
X9: 'the npm name is already maintained by someone else',
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** `checks` is always an array (even with one element, D1): every caller asserts
|
|
53
|
+
* `expect(err.checks).toEqual([...])`. */
|
|
54
|
+
export class PublishError extends Error {
|
|
55
|
+
constructor(checks, message) {
|
|
56
|
+
super(message ?? checks.map((id) => `${id} (${CHECK_EXPLANATIONS[id] ?? 'see traceability.md'})`).join(', '))
|
|
57
|
+
this.name = 'PublishError'
|
|
58
|
+
this.checks = checks
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** `root` is the create-wxl package directory; the repository root is always its parent (the
|
|
63
|
+
* seam's own contract, traceability.md 0005-S2). */
|
|
64
|
+
function repoRootOf(root) {
|
|
65
|
+
return resolve(root, '..')
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* "Package and publishing" steps 1-5: remove `dist/` and the package's own `LICENSE` copy first
|
|
70
|
+
* (so a later step never leaves a stale one behind on failure); fail (D1) unless
|
|
71
|
+
* `module.stripTypeScriptTypes` exists; write the type-stripped engine, the package source
|
|
72
|
+
* record (`dist/source.json`) and the `LICENSE` copy.
|
|
73
|
+
*/
|
|
74
|
+
export async function prepareDist({ root, module }) {
|
|
75
|
+
await rm(join(root, 'dist'), { recursive: true, force: true })
|
|
76
|
+
await rm(join(root, 'LICENSE'), { force: true })
|
|
77
|
+
|
|
78
|
+
if (typeof module?.stripTypeScriptTypes !== 'function') {
|
|
79
|
+
throw new PublishError(['D1'])
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const repoRoot = repoRootOf(root)
|
|
83
|
+
const enginePath = join(repoRoot, 'scripts', 'fork-init.ts')
|
|
84
|
+
const engineSource = await readFile(enginePath, 'utf8')
|
|
85
|
+
const strippedText = module.stripTypeScriptTypes(engineSource, { mode: 'strip' })
|
|
86
|
+
const engineBytes = Buffer.from(strippedText.toString(), 'utf8')
|
|
87
|
+
|
|
88
|
+
await mkdir(join(root, 'dist'), { recursive: true })
|
|
89
|
+
await writeFile(join(root, 'dist', 'fork-init.mjs'), engineBytes)
|
|
90
|
+
|
|
91
|
+
const engineSha256 = createHash('sha256').update(engineBytes).digest('hex')
|
|
92
|
+
const rootPkg = JSON.parse(await readFile(join(repoRoot, 'package.json'), 'utf8'))
|
|
93
|
+
const commit = await gitRevParseHead(root)
|
|
94
|
+
|
|
95
|
+
const engineModule = await importFresh(join(root, 'dist', 'fork-init.mjs'))
|
|
96
|
+
|
|
97
|
+
const source = {
|
|
98
|
+
repository: engineModule.UPSTREAM_SLUG,
|
|
99
|
+
version: rootPkg.version,
|
|
100
|
+
commit,
|
|
101
|
+
engineSha256,
|
|
102
|
+
}
|
|
103
|
+
await writeFile(join(root, 'dist', 'source.json'), JSON.stringify(source, null, 2) + '\n')
|
|
104
|
+
|
|
105
|
+
await cp(join(repoRoot, 'LICENSE'), join(root, 'LICENSE'))
|
|
106
|
+
|
|
107
|
+
// S-23: callers can read this off the return value instead of importing the engine a second
|
|
108
|
+
// time themselves (`buildRelease`'s own test helper does; `prepare-dist.mjs`/`check-publish.mjs`
|
|
109
|
+
// run as separate processes and still need their own import).
|
|
110
|
+
return { upstreamSlug: engineModule.UPSTREAM_SLUG }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const VERSION_RE = /^\d+\.\d+\.\d+$/
|
|
114
|
+
|
|
115
|
+
/** The root importer's (`.`) resolved version for `depName`, across all three dependency
|
|
116
|
+
* sections (traceability.md X5: "the version the root importer resolves for `yaml`"), with
|
|
117
|
+
* pnpm's own trailing `(peer@version...)` suffix stripped. */
|
|
118
|
+
function resolvedLockfileVersion(lockfileDoc, depName) {
|
|
119
|
+
const importer = lockfileDoc?.importers?.['.']
|
|
120
|
+
for (const section of ['dependencies', 'devDependencies', 'optionalDependencies']) {
|
|
121
|
+
const entry = importer?.[section]?.[depName]
|
|
122
|
+
if (entry?.version) return entry.version.replace(/\(.*\)$/, '')
|
|
123
|
+
}
|
|
124
|
+
return undefined
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* `checkPublish` (run by `prepublishOnly`, right after its own `prepareDist`): X1-X9 in order
|
|
129
|
+
* (traceability.md "Package and publishing"), each check throwing `PublishError` naming only its
|
|
130
|
+
* own id on failure.
|
|
131
|
+
*/
|
|
132
|
+
export async function checkPublish({ root, module, runGit, archive, npmView, npmWhoami }) {
|
|
133
|
+
if (typeof module?.stripTypeScriptTypes !== 'function') {
|
|
134
|
+
throw new PublishError(['X1'])
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const repoRoot = repoRootOf(root)
|
|
138
|
+
const v = JSON.parse(await readFile(join(repoRoot, 'package.json'), 'utf8')).version
|
|
139
|
+
const createWxlPkg = JSON.parse(await readFile(join(root, 'package.json'), 'utf8'))
|
|
140
|
+
|
|
141
|
+
if (!VERSION_RE.test(createWxlPkg.version) || createWxlPkg.version !== v) {
|
|
142
|
+
throw new PublishError(['X2'])
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// The engine import (needed below for `UPSTREAM_SLUG`, X6's URL and X7's archive prefix) is
|
|
146
|
+
// itself an X8-shaped problem when it fails: a `dist/` that cannot even be imported does not
|
|
147
|
+
// match `HEAD` (T-0025 stage 6/7 fix assignment section A, S-06/P-06).
|
|
148
|
+
let engineModule
|
|
149
|
+
try {
|
|
150
|
+
engineModule = await importFresh(join(root, 'dist', 'fork-init.mjs'))
|
|
151
|
+
} catch {
|
|
152
|
+
throw new PublishError(['X8'])
|
|
153
|
+
}
|
|
154
|
+
const name = engineModule.UPSTREAM_SLUG.split('/')[1]
|
|
155
|
+
|
|
156
|
+
// X3: a local tag `v<V>` exists and resolves (through `^{commit}`, so an annotated tag's own
|
|
157
|
+
// object sha is dereferenced) to `HEAD`. `--verify --quiet`: without it, a missing ref makes
|
|
158
|
+
// `git rev-parse` print the argument back on stdout with exit 0 instead of failing, and a
|
|
159
|
+
// same-named branch could be matched instead of the tag. A `runGit` rejection, or `HEAD` itself
|
|
160
|
+
// not resolving, is also X3: there is no commit to compare the tag against.
|
|
161
|
+
let head
|
|
162
|
+
try {
|
|
163
|
+
const headResult = await runGit(['rev-parse', 'HEAD'], { cwd: root, env: process.env })
|
|
164
|
+
if (headResult.code !== 0) throw new Error('git rev-parse HEAD failed')
|
|
165
|
+
head = headResult.stdout.trim()
|
|
166
|
+
|
|
167
|
+
const tagRevParse = await runGit(['rev-parse', '--verify', '--quiet', `refs/tags/v${v}^{commit}`], {
|
|
168
|
+
cwd: root,
|
|
169
|
+
env: process.env,
|
|
170
|
+
})
|
|
171
|
+
if (tagRevParse.code !== 0 || tagRevParse.stdout.trim() !== head) {
|
|
172
|
+
throw new PublishError(['X3'])
|
|
173
|
+
}
|
|
174
|
+
} catch (err) {
|
|
175
|
+
if (err instanceof PublishError) throw err
|
|
176
|
+
throw new PublishError(['X3'])
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// X4: the working tree is clean. `--untracked-files=all`: without it,
|
|
180
|
+
// `status.showUntrackedFiles=no` would hide an untracked file from this check. `dist/` and
|
|
181
|
+
// `LICENSE` are already excluded by `create-wxl/.gitignore`, so `prepareDist`'s own output
|
|
182
|
+
// never shows up here. A rejection or a non-zero exit (e.g. a corrupt index) is itself X4: an
|
|
183
|
+
// unreadable working tree is not a clean one.
|
|
184
|
+
let status
|
|
185
|
+
try {
|
|
186
|
+
status = await runGit(['status', '--porcelain', '--untracked-files=all'], { cwd: root, env: process.env })
|
|
187
|
+
} catch {
|
|
188
|
+
throw new PublishError(['X4'])
|
|
189
|
+
}
|
|
190
|
+
if (status.code !== 0 || status.stdout !== '') {
|
|
191
|
+
throw new PublishError(['X4'])
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// X5: create-wxl's own `yaml` dependency is an exact version (no range operator), equal to the
|
|
195
|
+
// version the root importer resolves for `yaml` in the root `pnpm-lock.yaml`.
|
|
196
|
+
let yamlVersion, resolvedYamlVersion
|
|
197
|
+
try {
|
|
198
|
+
yamlVersion = createWxlPkg.dependencies?.yaml
|
|
199
|
+
const lockfileDoc = parseYaml(await readFile(join(repoRoot, 'pnpm-lock.yaml'), 'utf8'))
|
|
200
|
+
resolvedYamlVersion = resolvedLockfileVersion(lockfileDoc, 'yaml')
|
|
201
|
+
} catch {
|
|
202
|
+
throw new PublishError(['X5'])
|
|
203
|
+
}
|
|
204
|
+
if (typeof yamlVersion !== 'string' || !VERSION_RE.test(yamlVersion) || yamlVersion !== resolvedYamlVersion) {
|
|
205
|
+
throw new PublishError(['X5'])
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// X6: the pushed tag `v<V>` (lightweight or annotated) resolves, on GitHub itself, to `HEAD`.
|
|
209
|
+
// `ls-remote` prints one line per matched ref; an annotated tag's own `^{}` (dereferenced) line
|
|
210
|
+
// is authoritative when present: the plain line can equal `HEAD` by coincidence and must not be
|
|
211
|
+
// trusted on its own. A rejection or non-zero exit is also X6: the upstream state is unknown.
|
|
212
|
+
let reportedSha
|
|
213
|
+
try {
|
|
214
|
+
const lsRemote = await runGit(
|
|
215
|
+
['ls-remote', `https://github.com/${engineModule.UPSTREAM_SLUG}.git`, `refs/tags/v${v}`, `refs/tags/v${v}^{}`],
|
|
216
|
+
{ cwd: root, env: process.env },
|
|
217
|
+
)
|
|
218
|
+
if (lsRemote.code !== 0) throw new Error('ls-remote failed')
|
|
219
|
+
const lines = lsRemote.stdout
|
|
220
|
+
.split('\n')
|
|
221
|
+
.map((line) => line.trim())
|
|
222
|
+
.filter((line) => line !== '')
|
|
223
|
+
const dereferenced = lines.find((line) => line.endsWith('^{}'))
|
|
224
|
+
const plain = lines.find((line) => !line.endsWith('^{}'))
|
|
225
|
+
reportedSha = (dereferenced ?? plain)?.split('\t')[0]
|
|
226
|
+
} catch {
|
|
227
|
+
throw new PublishError(['X6'])
|
|
228
|
+
}
|
|
229
|
+
if (reportedSha !== head) {
|
|
230
|
+
throw new PublishError(['X6'])
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// X7: the release's own `git archive` passes entry rules 1-5 (rule 2's comment equal to
|
|
234
|
+
// `HEAD`) and the pristine rules P1-P4. `archive({ commit, name })` takes the commit and name
|
|
235
|
+
// `checkPublish` already resolved, rather than re-deriving them itself. It returns the same
|
|
236
|
+
// (uncompressed) bytes a real `git archive` without `--format=tar.gz` does; `parseArchive`
|
|
237
|
+
// requires gzip, so this gzips first when the bytes are not already gzip-compressed. Any
|
|
238
|
+
// failure in this block -- a parse error, a rule violation, a pristine-rule failure, or a git
|
|
239
|
+
// failure inside `archive()` itself -- is X7: it is all part of validating the same archive.
|
|
240
|
+
let archiveTmpDir
|
|
241
|
+
try {
|
|
242
|
+
const rawOrGzipped = await archive({ commit: head, name })
|
|
243
|
+
const archiveBytes = Buffer.isBuffer(rawOrGzipped) ? rawOrGzipped : Buffer.from(rawOrGzipped)
|
|
244
|
+
const gzipped = looksGzip(archiveBytes) ? archiveBytes : gzipSync(archiveBytes)
|
|
245
|
+
const parsed = await parseArchive(gzipped)
|
|
246
|
+
const validatedEntries = checkEntryRules(parsed, { commit: head })
|
|
247
|
+
|
|
248
|
+
archiveTmpDir = await mkdtemp(join(tmpdir(), 'create-wxl-check-publish-'))
|
|
249
|
+
await extractEntries({ fs: fsp }, validatedEntries, archiveTmpDir)
|
|
250
|
+
await applyChangelog(fsp, archiveTmpDir)
|
|
251
|
+
await applyOpenQuestions(fsp, archiveTmpDir)
|
|
252
|
+
await applyPackageJson(fsp, archiveTmpDir)
|
|
253
|
+
await writeRangeJson(fsp, archiveTmpDir, { repository: engineModule.UPSTREAM_SLUG, version: v, commit: head })
|
|
254
|
+
} catch {
|
|
255
|
+
throw new PublishError(['X7'])
|
|
256
|
+
} finally {
|
|
257
|
+
if (archiveTmpDir) await rm(archiveTmpDir, { recursive: true, force: true })
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// X8: `dist/` and `LICENSE` are the ones matching `HEAD` -- `checkPublish` never rebuilds them
|
|
261
|
+
// itself (that is `prepublishOnly`'s own `prepare-dist.mjs` step, run right before this). Any
|
|
262
|
+
// failure reading or parsing these files, or `source.json` not being a plain object, is X8.
|
|
263
|
+
try {
|
|
264
|
+
const source = JSON.parse(await readFile(join(root, 'dist', 'source.json'), 'utf8'))
|
|
265
|
+
if (source === null || typeof source !== 'object' || Array.isArray(source)) {
|
|
266
|
+
throw new Error('source.json is not an object')
|
|
267
|
+
}
|
|
268
|
+
if (source.commit !== head) {
|
|
269
|
+
throw new Error('commit mismatch')
|
|
270
|
+
}
|
|
271
|
+
const engineBytes = await readFile(join(root, 'dist', 'fork-init.mjs'))
|
|
272
|
+
const actualEngineSha256 = createHash('sha256').update(engineBytes).digest('hex')
|
|
273
|
+
if (actualEngineSha256 !== source.engineSha256) {
|
|
274
|
+
throw new Error('engine hash mismatch')
|
|
275
|
+
}
|
|
276
|
+
const packageLicense = await readFile(join(root, 'LICENSE'))
|
|
277
|
+
const rootLicense = await readFile(join(repoRoot, 'LICENSE'))
|
|
278
|
+
if (!packageLicense.equals(rootLicense)) {
|
|
279
|
+
throw new Error('LICENSE mismatch')
|
|
280
|
+
}
|
|
281
|
+
} catch {
|
|
282
|
+
throw new PublishError(['X8'])
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// X9: the npm name is either unpublished (empty maintainer list) or already maintained by the
|
|
286
|
+
// user about to publish (D12: compared by the part before ` <`, trimmed). `npmView`/`npmWhoami`
|
|
287
|
+
// failing, or a maintainer entry that is not a string, is also X9.
|
|
288
|
+
try {
|
|
289
|
+
const maintainers = await npmView()
|
|
290
|
+
if (maintainers.length > 0) {
|
|
291
|
+
const who = (await npmWhoami()).trim()
|
|
292
|
+
const names = maintainers.map((entry) => {
|
|
293
|
+
if (typeof entry !== 'string') throw new Error('maintainer entry is not a string')
|
|
294
|
+
return entry.split('<')[0].trim()
|
|
295
|
+
})
|
|
296
|
+
if (!names.includes(who)) {
|
|
297
|
+
throw new PublishError(['X9'])
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
} catch (err) {
|
|
301
|
+
if (err instanceof PublishError) throw err
|
|
302
|
+
throw new PublishError(['X9'])
|
|
303
|
+
}
|
|
304
|
+
}
|