@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.
@@ -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
+ }