create-wxl 0.0.0-stage → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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
+ }
@@ -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
+ }
@@ -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
+ }