@kitschpatrol/shared-config 8.12.1 → 8.13.1
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/bin/cli.js +1 -1
- package/package.json +15 -14
- package/readme.md +8 -2
- package/skills/ksc-update/SKILL.md +419 -693
- package/skills/ksc-update/references/configuration.md +373 -0
- package/skills/ksc-update/references/verification.md +256 -134
- package/skills/ksc-update/scripts/compare-outputs.mjs +144 -0
- package/skills/ksc-update/scripts/consumer-ranges.mjs +127 -0
- package/skills/ksc-update/scripts/lib.mjs +307 -0
- package/skills/ksc-update/scripts/packed-fixture.mjs +247 -0
- package/skills/ksc-update/scripts/production-engines.mjs +119 -0
- package/skills/ksc-update/scripts/release-age.mjs +174 -0
- package/skills/ksc-update/scripts/run-at-node.mjs +108 -0
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Builds the consumer-range table for step 3: for every runtime dependency of
|
|
3
|
+
* every published workspace package, the declared range, the locked version,
|
|
4
|
+
* and the newest stable version the range allows with its publish time. Rows
|
|
5
|
+
* flag `ahead` when consumers can resolve something newer than the lockfile and
|
|
6
|
+
* `young` when that newest version is under 24 hours old at the run start, so
|
|
7
|
+
* it cannot be installed here and must be reviewed as consumer exposure.
|
|
8
|
+
*
|
|
9
|
+
* Range matching uses `pnpm view <name>@<range>`, so it honors the repository's
|
|
10
|
+
* registry settings. `catalog:` specifiers are reported with
|
|
11
|
+
* `needsManualReview` instead of being resolved.
|
|
12
|
+
*
|
|
13
|
+
* Usage: `node consumer-ranges.mjs [--run-start <iso-time>] >
|
|
14
|
+
* consumer-ranges.json`, run from the workspace root. A human-readable summary
|
|
15
|
+
* goes to stderr.
|
|
16
|
+
*/
|
|
17
|
+
import { join } from 'node:path'
|
|
18
|
+
import process from 'node:process'
|
|
19
|
+
import {
|
|
20
|
+
asString,
|
|
21
|
+
isRecord,
|
|
22
|
+
mapLimit,
|
|
23
|
+
MATURITY_MS,
|
|
24
|
+
pnpmJson,
|
|
25
|
+
pnpmView,
|
|
26
|
+
publishTimes,
|
|
27
|
+
readJson,
|
|
28
|
+
} from './lib.mjs'
|
|
29
|
+
|
|
30
|
+
const runStartIndex = process.argv.indexOf('--run-start')
|
|
31
|
+
const runStartText = runStartIndex === -1 ? undefined : process.argv[runStartIndex + 1]
|
|
32
|
+
const runStart = runStartText === undefined ? Date.now() : Date.parse(runStartText)
|
|
33
|
+
if (Number.isNaN(runStart)) {
|
|
34
|
+
throw new TypeError(`Invalid --run-start value: ${runStartText}`)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* @typedef {object} Task
|
|
39
|
+
* @property {string} package - Published workspace package name.
|
|
40
|
+
* @property {string} name - Dependency name as declared.
|
|
41
|
+
* @property {string} range - Declared specifier.
|
|
42
|
+
* @property {string | undefined} locked - Version in the lockfile.
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
/** @type {Task[]} */
|
|
46
|
+
const tasks = []
|
|
47
|
+
const projects = pnpmJson(['-r', 'ls', '--depth', '0', '--prod', '--json'])
|
|
48
|
+
if (!Array.isArray(projects)) {
|
|
49
|
+
throw new TypeError('Expected a JSON array from pnpm -r ls')
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
for (const project of projects) {
|
|
53
|
+
if (!isRecord(project) || project.private === true) {
|
|
54
|
+
continue
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const projectPath = asString(project.path)
|
|
58
|
+
if (projectPath === undefined) {
|
|
59
|
+
continue
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const manifest = readJson(join(projectPath, 'package.json'))
|
|
63
|
+
if (!isRecord(manifest) || manifest.private === true) {
|
|
64
|
+
continue
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const packageName = asString(manifest.name) ?? projectPath
|
|
68
|
+
const declared = isRecord(manifest.dependencies) ? manifest.dependencies : {}
|
|
69
|
+
const installed = isRecord(project.dependencies) ? project.dependencies : {}
|
|
70
|
+
for (const [name, rangeValue] of Object.entries(declared)) {
|
|
71
|
+
const range = asString(rangeValue)
|
|
72
|
+
if (range === undefined || /^(?:workspace|link|file):/v.test(range)) {
|
|
73
|
+
continue
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const entry = installed[name]
|
|
77
|
+
tasks.push({
|
|
78
|
+
locked: isRecord(entry) ? asString(entry.version) : undefined,
|
|
79
|
+
name,
|
|
80
|
+
package: packageName,
|
|
81
|
+
range,
|
|
82
|
+
})
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const NPM_ALIAS_PATTERN = /^npm:(.+)@([^@]+)$/v
|
|
87
|
+
|
|
88
|
+
const rows = await mapLimit(tasks, 8, async (task) => {
|
|
89
|
+
if (task.range.startsWith('catalog:')) {
|
|
90
|
+
return { ...task, needsManualReview: 'catalog specifier' }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const alias = NPM_ALIAS_PATTERN.exec(task.range)
|
|
94
|
+
const target = alias?.[1] ?? task.name
|
|
95
|
+
const range = alias?.[2] ?? task.range
|
|
96
|
+
const matched = await pnpmView([`${target}@${range}`, 'version'])
|
|
97
|
+
const versions = Array.isArray(matched) ? matched.map(String) : [asString(matched)]
|
|
98
|
+
const stable = versions.filter((version) => version !== undefined && !version.includes('-'))
|
|
99
|
+
const newestAllowed = stable.at(-1)
|
|
100
|
+
if (newestAllowed === undefined) {
|
|
101
|
+
return { ...task, needsManualReview: 'no stable version satisfies the range' }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const times = await publishTimes(target)
|
|
105
|
+
const time = times[newestAllowed]
|
|
106
|
+
const young = time === undefined ? undefined : Date.parse(time) > runStart - MATURITY_MS
|
|
107
|
+
return {
|
|
108
|
+
...task,
|
|
109
|
+
ahead: task.locked !== newestAllowed,
|
|
110
|
+
newestAllowed,
|
|
111
|
+
newestAllowedTime: time,
|
|
112
|
+
target: target === task.name ? undefined : target,
|
|
113
|
+
young,
|
|
114
|
+
}
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
process.stdout.write(`${JSON.stringify(rows, undefined, 2)}\n`)
|
|
118
|
+
|
|
119
|
+
const flagged = rows.filter((row) => 'needsManualReview' in row || row.ahead)
|
|
120
|
+
process.stderr.write(`${rows.length} runtime dependency entries checked\n`)
|
|
121
|
+
for (const row of flagged) {
|
|
122
|
+
const detail =
|
|
123
|
+
'needsManualReview' in row
|
|
124
|
+
? `needs manual review: ${row.needsManualReview}`
|
|
125
|
+
: `locked ${row.locked ?? '?'} < newest allowed ${row.newestAllowed} (${row.newestAllowedTime ?? 'time unknown'}${row.young ? ', YOUNG' : ''})`
|
|
126
|
+
process.stderr.write(` ${row.package} > ${row.name} ${row.range}: ${detail}\n`)
|
|
127
|
+
}
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared helpers for the ksc-update scripts. Everything here uses only Node.js
|
|
3
|
+
* built-ins and the pnpm CLI so the scripts run in any repository without
|
|
4
|
+
* installing anything.
|
|
5
|
+
*/
|
|
6
|
+
import { execFile, execFileSync } from 'node:child_process'
|
|
7
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
8
|
+
import { isAbsolute, join } from 'node:path'
|
|
9
|
+
import process from 'node:process'
|
|
10
|
+
|
|
11
|
+
const NOT_FOUND_PATTERN = /E404|ERR_PNPM_NO_MATCHING_VERSION|not found/iv
|
|
12
|
+
const TOP_LEVEL_LINE_PATTERN = /^\S/v
|
|
13
|
+
const QUOTES_PATTERN = /^['"]|['"]$/gv
|
|
14
|
+
const LOCKFILE_KEY_PATTERN = /^ {2}'?([^\s']+?)'?:$/v
|
|
15
|
+
|
|
16
|
+
/** Milliseconds in the 24-hour maturity window. */
|
|
17
|
+
export const MATURITY_MS = 24 * 60 * 60 * 1000
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Narrows an unknown JSON value to a plain object.
|
|
21
|
+
*
|
|
22
|
+
* @param {unknown} value - Any parsed JSON value.
|
|
23
|
+
*
|
|
24
|
+
* @returns {value is Record<string, unknown>} Whether the value is a non-array
|
|
25
|
+
* object.
|
|
26
|
+
*/
|
|
27
|
+
export function isRecord(value) {
|
|
28
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Returns the value when it is a string.
|
|
33
|
+
*
|
|
34
|
+
* @param {unknown} value - Any parsed JSON value.
|
|
35
|
+
*
|
|
36
|
+
* @returns {string | undefined} The string, or undefined for any other type.
|
|
37
|
+
*/
|
|
38
|
+
export function asString(value) {
|
|
39
|
+
return typeof value === 'string' ? value : undefined
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Resolves the run directory the skill uses for logs and state, creating it.
|
|
44
|
+
*
|
|
45
|
+
* @returns {string} The absolute path in `KSC_UPDATE_RUN_DIR`.
|
|
46
|
+
*/
|
|
47
|
+
export function runDirectory() {
|
|
48
|
+
const directory = process.env.KSC_UPDATE_RUN_DIR
|
|
49
|
+
if (directory === undefined || !isAbsolute(directory)) {
|
|
50
|
+
throw new Error('Set KSC_UPDATE_RUN_DIR to the absolute run directory created during Setup')
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
mkdirSync(directory, { recursive: true })
|
|
54
|
+
return directory
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Reads and parses a JSON file, failing with the path in the message.
|
|
59
|
+
*
|
|
60
|
+
* @param {string} path - File to read.
|
|
61
|
+
*
|
|
62
|
+
* @returns {unknown} The parsed value.
|
|
63
|
+
*/
|
|
64
|
+
export function readJson(path) {
|
|
65
|
+
try {
|
|
66
|
+
return JSON.parse(readFileSync(path, 'utf8'))
|
|
67
|
+
} catch (error) {
|
|
68
|
+
throw new Error(`Could not read JSON from ${path}`, { cause: error })
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Runs pnpm synchronously and parses its JSON stdout, dropping `[WARN]` lines
|
|
74
|
+
* that pnpm prints to stdout.
|
|
75
|
+
*
|
|
76
|
+
* @param {string[]} args - Arguments for pnpm.
|
|
77
|
+
* @param {string} [cwd] - Working directory.
|
|
78
|
+
*
|
|
79
|
+
* @returns {unknown} Parsed JSON output.
|
|
80
|
+
*/
|
|
81
|
+
export function pnpmJson(args, cwd) {
|
|
82
|
+
const stdout = execFileSync('pnpm', args, {
|
|
83
|
+
cwd,
|
|
84
|
+
encoding: 'utf8',
|
|
85
|
+
maxBuffer: 1024 ** 3,
|
|
86
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
87
|
+
})
|
|
88
|
+
return parsePnpmJson(stdout, args)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Parses pnpm stdout as JSON after removing `[WARN]` lines.
|
|
93
|
+
*
|
|
94
|
+
* @param {string} stdout - Raw pnpm output.
|
|
95
|
+
* @param {string[]} args - The pnpm arguments, for error messages.
|
|
96
|
+
*
|
|
97
|
+
* @returns {unknown} Parsed JSON, or undefined for empty output.
|
|
98
|
+
*/
|
|
99
|
+
function parsePnpmJson(stdout, args) {
|
|
100
|
+
const text = stdout
|
|
101
|
+
.split('\n')
|
|
102
|
+
.filter((line) => !line.startsWith('[WARN]'))
|
|
103
|
+
.join('\n')
|
|
104
|
+
.trim()
|
|
105
|
+
try {
|
|
106
|
+
return text === '' ? undefined : JSON.parse(text)
|
|
107
|
+
} catch (error) {
|
|
108
|
+
throw new Error(`pnpm ${args.join(' ')} did not print valid JSON`, { cause: error })
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Runs `pnpm view` asynchronously and parses its JSON output. A missing package
|
|
114
|
+
* or an unsatisfiable range resolves to undefined instead of throwing.
|
|
115
|
+
*
|
|
116
|
+
* @param {string[]} args - Arguments after `pnpm view`.
|
|
117
|
+
*
|
|
118
|
+
* @returns {Promise<unknown>} Parsed JSON, or undefined when nothing matched.
|
|
119
|
+
*/
|
|
120
|
+
export async function pnpmView(args) {
|
|
121
|
+
const fullArgs = ['view', ...args, '--json']
|
|
122
|
+
/** @type {{ error: Error | null; stderr: string; stdout: string }} */
|
|
123
|
+
const { error, stderr, stdout } = await new Promise((resolve) => {
|
|
124
|
+
execFile('pnpm', fullArgs, { maxBuffer: 1024 ** 3 }, (failure, out, errorOutput) => {
|
|
125
|
+
resolve({ error: failure, stderr: errorOutput, stdout: out })
|
|
126
|
+
})
|
|
127
|
+
})
|
|
128
|
+
if (error === null) {
|
|
129
|
+
return parsePnpmJson(stdout, fullArgs)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (NOT_FOUND_PATTERN.test(stderr)) {
|
|
133
|
+
return
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
throw new Error(`pnpm ${fullArgs.join(' ')} failed: ${stderr.trim()}`, { cause: error })
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Returns a package's publish times from the registry, cached as JSON under
|
|
141
|
+
* `<run directory>/meta/` so reviewers and scripts share one fetch.
|
|
142
|
+
*
|
|
143
|
+
* @param {string} name - Package name.
|
|
144
|
+
*
|
|
145
|
+
* @returns {Promise<Record<string, string>>} Version to ISO publish time.
|
|
146
|
+
*/
|
|
147
|
+
export async function publishTimes(name) {
|
|
148
|
+
const directory = join(runDirectory(), 'meta')
|
|
149
|
+
mkdirSync(directory, { recursive: true })
|
|
150
|
+
const file = join(directory, `${name.replaceAll('/', '_')}.time.json`)
|
|
151
|
+
if (existsSync(file)) {
|
|
152
|
+
const cached = readJson(file)
|
|
153
|
+
if (isRecord(cached)) {
|
|
154
|
+
return toStringRecord(cached)
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const times = await pnpmView([name, 'time'])
|
|
159
|
+
const record = isRecord(times) ? toStringRecord(times) : {}
|
|
160
|
+
writeFileSync(file, JSON.stringify(record))
|
|
161
|
+
return record
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Keeps only the string-valued entries of an object.
|
|
166
|
+
*
|
|
167
|
+
* @param {Record<string, unknown>} value - Object to filter.
|
|
168
|
+
*
|
|
169
|
+
* @returns {Record<string, string>} The string entries.
|
|
170
|
+
*/
|
|
171
|
+
function toStringRecord(value) {
|
|
172
|
+
/** @type {Record<string, string>} */
|
|
173
|
+
const result = {}
|
|
174
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
175
|
+
const text = asString(entry)
|
|
176
|
+
if (text !== undefined) {
|
|
177
|
+
result[key] = text
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return result
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Maps items through an async function with bounded concurrency, preserving
|
|
186
|
+
* order.
|
|
187
|
+
*
|
|
188
|
+
* @template T, R
|
|
189
|
+
* @param {T[]} items - Inputs.
|
|
190
|
+
* @param {number} limit - Maximum concurrent calls.
|
|
191
|
+
* @param {(item: T) => Promise<R>} worker - Async mapper.
|
|
192
|
+
*
|
|
193
|
+
* @returns {Promise<R[]>} Results in input order.
|
|
194
|
+
*/
|
|
195
|
+
export async function mapLimit(items, limit, worker) {
|
|
196
|
+
/** @type {R[]} */
|
|
197
|
+
const results = Array.from({ length: items.length })
|
|
198
|
+
let next = 0
|
|
199
|
+
const runners = Array.from({ length: Math.min(limit, items.length) }, async () => {
|
|
200
|
+
while (next < items.length) {
|
|
201
|
+
const index = next++
|
|
202
|
+
|
|
203
|
+
results[index] = await worker(/** @type {T} */ (items[index]))
|
|
204
|
+
}
|
|
205
|
+
})
|
|
206
|
+
await Promise.all(runners)
|
|
207
|
+
return results
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Splits a `name@version` string, keeping the leading `@` of scoped names.
|
|
212
|
+
*
|
|
213
|
+
* @param {string} spec - A `name@version` string.
|
|
214
|
+
*
|
|
215
|
+
* @returns {{ name: string; version: string } | undefined} The parts, or
|
|
216
|
+
* undefined when there is no version.
|
|
217
|
+
*/
|
|
218
|
+
export function splitSpec(spec) {
|
|
219
|
+
const at = spec.lastIndexOf('@')
|
|
220
|
+
return at <= 0 ? undefined : { name: spec.slice(0, at), version: spec.slice(at + 1) }
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Lists the `name@version` keys of every `packages` section in a pnpm lockfile.
|
|
225
|
+
* Recent pnpm lockfiles hold several YAML documents, including one for the
|
|
226
|
+
* package manager's own dependencies, so all sections are read.
|
|
227
|
+
*
|
|
228
|
+
* @param {string} lockfilePath - Path to `pnpm-lock.yaml`.
|
|
229
|
+
*
|
|
230
|
+
* @returns {Set<string>} Package keys without quotes, excluding non-registry
|
|
231
|
+
* entries such as `file:` and `link:` specifiers.
|
|
232
|
+
*/
|
|
233
|
+
export function lockedPackages(lockfilePath) {
|
|
234
|
+
const lines = readFileSync(lockfilePath, 'utf8').split('\n')
|
|
235
|
+
/** @type {Set<string>} */
|
|
236
|
+
const keys = new Set()
|
|
237
|
+
let inPackages = false
|
|
238
|
+
for (const line of lines) {
|
|
239
|
+
if (TOP_LEVEL_LINE_PATTERN.test(line)) {
|
|
240
|
+
inPackages = line === 'packages:'
|
|
241
|
+
continue
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if (!inPackages) {
|
|
245
|
+
continue
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const match = LOCKFILE_KEY_PATTERN.exec(line)
|
|
249
|
+
if (match?.[1] !== undefined && !match[1].includes(':')) {
|
|
250
|
+
keys.add(match[1])
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (keys.size === 0) {
|
|
255
|
+
throw new Error(`No package entries found in ${lockfilePath}`)
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
return keys
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Reads a top-level string list from a YAML file without a YAML parser. Handles
|
|
263
|
+
* the block style pnpm and its fixers write: `key:` followed by ` - item`
|
|
264
|
+
* lines, with optional quotes and comments.
|
|
265
|
+
*
|
|
266
|
+
* @param {string} path - YAML file.
|
|
267
|
+
* @param {string} key - Top-level key.
|
|
268
|
+
*
|
|
269
|
+
* @returns {string[]} The list items, empty when the key is absent.
|
|
270
|
+
*/
|
|
271
|
+
export function readYamlList(path, key) {
|
|
272
|
+
if (!existsSync(path)) {
|
|
273
|
+
return []
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const lines = readFileSync(path, 'utf8').split('\n')
|
|
277
|
+
const start = lines.indexOf(`${key}:`)
|
|
278
|
+
if (start === -1) {
|
|
279
|
+
if (lines.some((line) => line.startsWith(`${key}:`))) {
|
|
280
|
+
throw new Error(`${key} in ${path} is not a block list; read it by hand`)
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
return []
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** @type {string[]} */
|
|
287
|
+
const items = []
|
|
288
|
+
const following = lines.slice(start + 1)
|
|
289
|
+
for (const line of following) {
|
|
290
|
+
if (TOP_LEVEL_LINE_PATTERN.test(line)) {
|
|
291
|
+
break
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const trimmed = line.trim()
|
|
295
|
+
if (!trimmed.startsWith('- ')) {
|
|
296
|
+
continue
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const [value = ''] = trimmed.slice(2).split(' #', 1)
|
|
300
|
+
const item = value.trim().replaceAll(QUOTES_PATTERN, '')
|
|
301
|
+
if (item !== '') {
|
|
302
|
+
items.push(item)
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
return items
|
|
307
|
+
}
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Creates an isolated consumer fixture for the packed-build checks in
|
|
3
|
+
* verification.md and installs it. It only sets up and installs; run the
|
|
4
|
+
* repository's representative CLI and API checks inside the printed fixture
|
|
5
|
+
* directory afterwards.
|
|
6
|
+
*
|
|
7
|
+
* Packed mode (default) runs `pnpm pack` for every published workspace package,
|
|
8
|
+
* depends on the requested packages through `file:` tarballs, and adds an
|
|
9
|
+
* `overrides` entry for every packed package. Packing rewrites `workspace:`
|
|
10
|
+
* ranges to registry versions, so without the overrides sibling packages would
|
|
11
|
+
* silently come from the registry instead of this build.
|
|
12
|
+
*
|
|
13
|
+
* Published mode (`--published`) depends on the given `name@version` specs from
|
|
14
|
+
* the registry instead, for the fresh-install check of the current release.
|
|
15
|
+
*
|
|
16
|
+
* `--before-install <command>` runs a shell command in the fixture before the
|
|
17
|
+
* install, for consumer setup that must exist first, such as an init command
|
|
18
|
+
* that writes hoisting settings. It receives `FIXTURE_TARBALLS`, the path of a
|
|
19
|
+
* JSON file mapping package names to tarball paths. If the command writes
|
|
20
|
+
* `pnpm-workspace.yaml`, the script appends its `overrides` and `allowBuilds`
|
|
21
|
+
* blocks only when those keys are absent, and fails otherwise.
|
|
22
|
+
*
|
|
23
|
+
* Usage, from the workspace root:
|
|
24
|
+
*
|
|
25
|
+
* ```
|
|
26
|
+
* node packed-fixture.mjs <state> [package...] [--before-install <command>]
|
|
27
|
+
* node packed-fixture.mjs <state> --published <name@version>... [--before-install <command>]
|
|
28
|
+
* ```
|
|
29
|
+
*
|
|
30
|
+
* Results go to `<run directory>/<state>/`: `fixture/`, `tarballs/`,
|
|
31
|
+
* `tarballs.json`, `packed-manifests.json`, `install.log`, and `install.exit`.
|
|
32
|
+
* Packages default to every published workspace package.
|
|
33
|
+
*/
|
|
34
|
+
import { execFileSync, spawnSync } from 'node:child_process'
|
|
35
|
+
import {
|
|
36
|
+
appendFileSync,
|
|
37
|
+
existsSync,
|
|
38
|
+
mkdirSync,
|
|
39
|
+
readdirSync,
|
|
40
|
+
readFileSync,
|
|
41
|
+
realpathSync,
|
|
42
|
+
rmSync,
|
|
43
|
+
writeFileSync,
|
|
44
|
+
} from 'node:fs'
|
|
45
|
+
import { join, relative, resolve } from 'node:path'
|
|
46
|
+
import process from 'node:process'
|
|
47
|
+
import { asString, isRecord, pnpmJson, readJson, runDirectory } from './lib.mjs'
|
|
48
|
+
|
|
49
|
+
const argv = process.argv.slice(2)
|
|
50
|
+
const state = argv.shift()
|
|
51
|
+
if (state === undefined || state.startsWith('--') || !/^[\w.\-]+$/v.test(state)) {
|
|
52
|
+
throw new Error(
|
|
53
|
+
'Usage: node packed-fixture.mjs <state> [package...] [--published] [--before-install <command>]',
|
|
54
|
+
)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
let published = false
|
|
58
|
+
/** @type {string | undefined} */
|
|
59
|
+
let beforeInstall
|
|
60
|
+
/** @type {string[]} */
|
|
61
|
+
const requested = []
|
|
62
|
+
for (let index = 0; index < argv.length; index++) {
|
|
63
|
+
const argument = argv[index] ?? ''
|
|
64
|
+
if (argument === '--published') {
|
|
65
|
+
published = true
|
|
66
|
+
} else if (argument === '--before-install') {
|
|
67
|
+
beforeInstall = argv[++index]
|
|
68
|
+
} else {
|
|
69
|
+
requested.push(argument)
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const run = runDirectory()
|
|
74
|
+
const stateDirectory = resolve(run, state)
|
|
75
|
+
if (relative(run, stateDirectory).startsWith('..')) {
|
|
76
|
+
throw new Error(`State directory ${stateDirectory} escapes the run directory ${run}`)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const fixture = join(stateDirectory, 'fixture')
|
|
80
|
+
const tarballDirectory = join(stateDirectory, 'tarballs')
|
|
81
|
+
rmSync(fixture, { force: true, recursive: true })
|
|
82
|
+
rmSync(tarballDirectory, { force: true, recursive: true })
|
|
83
|
+
mkdirSync(fixture, { recursive: true })
|
|
84
|
+
mkdirSync(tarballDirectory, { recursive: true })
|
|
85
|
+
|
|
86
|
+
/** @type {Record<string, string>} */
|
|
87
|
+
const tarballs = {}
|
|
88
|
+
/** @type {Record<string, string>} */
|
|
89
|
+
const dependencies = {}
|
|
90
|
+
/** @type {Record<string, unknown>[]} */
|
|
91
|
+
const manifests = []
|
|
92
|
+
|
|
93
|
+
if (published) {
|
|
94
|
+
if (requested.length === 0) {
|
|
95
|
+
throw new Error('--published needs at least one name@version spec')
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
for (const spec of requested) {
|
|
99
|
+
const at = spec.lastIndexOf('@')
|
|
100
|
+
if (at <= 0) {
|
|
101
|
+
throw new Error(`Expected name@version, got ${spec}`)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
dependencies[spec.slice(0, at)] = spec.slice(at + 1)
|
|
105
|
+
}
|
|
106
|
+
} else {
|
|
107
|
+
const projects = pnpmJson(['-r', 'ls', '--depth', '-1', '--json'])
|
|
108
|
+
if (!Array.isArray(projects)) {
|
|
109
|
+
throw new TypeError('Expected a JSON array from pnpm -r ls')
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
for (const project of projects) {
|
|
113
|
+
const projectPath = isRecord(project) ? asString(project.path) : undefined
|
|
114
|
+
if (projectPath === undefined) {
|
|
115
|
+
continue
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const manifest = readJson(join(projectPath, 'package.json'))
|
|
119
|
+
if (!isRecord(manifest) || manifest.private === true) {
|
|
120
|
+
continue
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const name = asString(manifest.name)
|
|
124
|
+
if (name === undefined) {
|
|
125
|
+
continue
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const before = new Set(readdirSync(tarballDirectory))
|
|
129
|
+
execFileSync('pnpm', ['pack', '--pack-destination', tarballDirectory], {
|
|
130
|
+
cwd: projectPath,
|
|
131
|
+
stdio: ['ignore', 'ignore', 'inherit'],
|
|
132
|
+
})
|
|
133
|
+
const created = readdirSync(tarballDirectory).find((file) => !before.has(file))
|
|
134
|
+
if (created === undefined) {
|
|
135
|
+
throw new Error(`pnpm pack produced no tarball for ${name}`)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
tarballs[name] = join(tarballDirectory, created)
|
|
139
|
+
const packedManifest = execFileSync('tar', ['-xzOf', tarballs[name], 'package/package.json'], {
|
|
140
|
+
encoding: 'utf8',
|
|
141
|
+
})
|
|
142
|
+
const parsed = /** @type {unknown} */ (JSON.parse(packedManifest))
|
|
143
|
+
if (isRecord(parsed)) {
|
|
144
|
+
manifests.push({
|
|
145
|
+
bin: parsed.bin,
|
|
146
|
+
dependencies: parsed.dependencies,
|
|
147
|
+
engines: parsed.engines,
|
|
148
|
+
exports: parsed.exports,
|
|
149
|
+
files: execFileSync('tar', ['-tzf', tarballs[name]], { encoding: 'utf8' })
|
|
150
|
+
.split('\n')
|
|
151
|
+
.filter(Boolean)
|
|
152
|
+
.toSorted(),
|
|
153
|
+
name,
|
|
154
|
+
peerDependencies: parsed.peerDependencies,
|
|
155
|
+
sideEffects: parsed.sideEffects,
|
|
156
|
+
version: parsed.version,
|
|
157
|
+
})
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const targets = requested.length > 0 ? requested : Object.keys(tarballs)
|
|
162
|
+
for (const name of targets) {
|
|
163
|
+
const tarball = tarballs[name]
|
|
164
|
+
if (tarball === undefined) {
|
|
165
|
+
throw new Error(`${name} is not a published workspace package`)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
dependencies[name] = `file:${tarball}`
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const tarballsJson = join(stateDirectory, 'tarballs.json')
|
|
173
|
+
writeFileSync(tarballsJson, `${JSON.stringify(tarballs, undefined, 2)}\n`)
|
|
174
|
+
writeFileSync(
|
|
175
|
+
join(stateDirectory, 'packed-manifests.json'),
|
|
176
|
+
`${JSON.stringify(manifests, undefined, 2)}\n`,
|
|
177
|
+
)
|
|
178
|
+
writeFileSync(
|
|
179
|
+
join(fixture, 'package.json'),
|
|
180
|
+
`${JSON.stringify({ dependencies, name: 'ksc-update-fixture', private: true, type: 'module', version: '0.0.0' }, undefined, 2)}\n`,
|
|
181
|
+
)
|
|
182
|
+
execFileSync('git', ['init', '-q'], { cwd: fixture })
|
|
183
|
+
|
|
184
|
+
if (beforeInstall !== undefined) {
|
|
185
|
+
// eslint-disable-next-line ts/naming-convention -- Environment variable names are uppercase.
|
|
186
|
+
const fixtureEnvironment = { ...process.env, FIXTURE_TARBALLS: tarballsJson }
|
|
187
|
+
const result = spawnSync(beforeInstall, {
|
|
188
|
+
cwd: fixture,
|
|
189
|
+
env: fixtureEnvironment,
|
|
190
|
+
shell: true,
|
|
191
|
+
stdio: 'inherit',
|
|
192
|
+
})
|
|
193
|
+
if (result.status !== 0) {
|
|
194
|
+
throw new Error(`--before-install command exited with ${result.status ?? result.signal}`)
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const workspaceFile = join(fixture, 'pnpm-workspace.yaml')
|
|
199
|
+
const existing = existsSync(workspaceFile) ? readFileSync(workspaceFile, 'utf8') : ''
|
|
200
|
+
/** @type {string[]} */
|
|
201
|
+
const blocks = []
|
|
202
|
+
const overrideLines = Object.entries(tarballs).map(([name, path]) => ` '${name}': file:${path}`)
|
|
203
|
+
if (overrideLines.length > 0) {
|
|
204
|
+
if (/^overrides:/mv.test(existing)) {
|
|
205
|
+
throw new Error(`${workspaceFile} already has overrides; merge the tarball overrides by hand`)
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
blocks.push(['overrides:', ...overrideLines].join('\n'))
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const repositoryWorkspace = existsSync('pnpm-workspace.yaml')
|
|
212
|
+
? readFileSync('pnpm-workspace.yaml', 'utf8')
|
|
213
|
+
: ''
|
|
214
|
+
const allowBuilds = /^allowBuilds:\n((?: {2}.+\n)+)/mv.exec(repositoryWorkspace)?.[1]
|
|
215
|
+
if (allowBuilds !== undefined && !/^allowBuilds:/mv.test(existing)) {
|
|
216
|
+
blocks.push(`allowBuilds:\n${allowBuilds.trimEnd()}`)
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (blocks.length > 0) {
|
|
220
|
+
appendFileSync(
|
|
221
|
+
workspaceFile,
|
|
222
|
+
`${existing === '' || existing.endsWith('\n') ? '' : '\n'}${blocks.join('\n')}\n`,
|
|
223
|
+
)
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const install = spawnSync('pnpm', ['install'], { cwd: fixture, encoding: 'utf8' })
|
|
227
|
+
writeFileSync(join(stateDirectory, 'install.log'), `${install.stdout}${install.stderr}`)
|
|
228
|
+
writeFileSync(join(stateDirectory, 'install.exit'), `${install.status}\n`)
|
|
229
|
+
|
|
230
|
+
/** @type {Record<string, string | undefined>} */
|
|
231
|
+
const resolved = {}
|
|
232
|
+
for (const name of Object.keys(dependencies)) {
|
|
233
|
+
const manifestPath = join(fixture, 'node_modules', name, 'package.json')
|
|
234
|
+
resolved[name] = existsSync(manifestPath)
|
|
235
|
+
? `${asString(/** @type {Record<string, unknown>} */ (readJson(manifestPath)).version)} at ${realpathSync(join(fixture, 'node_modules', name))}`
|
|
236
|
+
: undefined
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
process.stdout.write(
|
|
240
|
+
`${JSON.stringify({ fixture, installExit: install.status, resolved, stateDirectory }, undefined, 2)}\n`,
|
|
241
|
+
)
|
|
242
|
+
if (install.status !== 0) {
|
|
243
|
+
process.stderr.write(
|
|
244
|
+
`pnpm install failed in ${fixture}; see ${join(stateDirectory, 'install.log')}\n`,
|
|
245
|
+
)
|
|
246
|
+
process.exitCode = 1
|
|
247
|
+
}
|