@kitschpatrol/shared-config 8.12.0 → 8.13.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/bin/cli.js +1 -1
- package/package.json +15 -14
- package/readme.md +8 -2
- package/skills/ksc-update/SKILL.md +403 -617
- package/skills/ksc-update/references/configuration.md +373 -0
- package/skills/ksc-update/references/verification.md +255 -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 +305 -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,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Prints one sorted row per package in a `pnpm list --prod --depth Infinity
|
|
3
|
+
* --json` tree: the name, version, and declared `engines.node`, read from each
|
|
4
|
+
* installed `package.json` instead of trusting the list output. A dependency
|
|
5
|
+
* whose installed directory is missing, typically a platform-specific optional
|
|
6
|
+
* dependency not installed on this host, is reported with `installed: false`
|
|
7
|
+
* and the name and version from the list; review its engines from registry
|
|
8
|
+
* metadata.
|
|
9
|
+
*
|
|
10
|
+
* Usage: `node production-engines.mjs production-tree.json`
|
|
11
|
+
*/
|
|
12
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
13
|
+
import { join } from 'node:path'
|
|
14
|
+
import process from 'node:process'
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Narrows an unknown JSON value to a plain object.
|
|
18
|
+
*
|
|
19
|
+
* @param {unknown} value - Any parsed JSON value.
|
|
20
|
+
*
|
|
21
|
+
* @returns {value is Record<string, unknown>} Whether the value is a non-array
|
|
22
|
+
* object.
|
|
23
|
+
*/
|
|
24
|
+
function isRecord(value) {
|
|
25
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Returns the value when it is a string.
|
|
30
|
+
*
|
|
31
|
+
* @param {unknown} value - Any parsed JSON value.
|
|
32
|
+
*
|
|
33
|
+
* @returns {string | undefined} The string, or undefined for any other type.
|
|
34
|
+
*/
|
|
35
|
+
function asString(value) {
|
|
36
|
+
return typeof value === 'string' ? value : undefined
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const inputPath = process.argv[2]
|
|
40
|
+
if (inputPath === undefined) {
|
|
41
|
+
throw new Error('Usage: node production-engines.mjs <pnpm-list-json-file>')
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** @type {unknown} */
|
|
45
|
+
const parsed = JSON.parse(readFileSync(inputPath, 'utf8'))
|
|
46
|
+
if (!Array.isArray(parsed)) {
|
|
47
|
+
throw new TypeError(`Expected a JSON array from pnpm list in ${inputPath}`)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** @type {Set<string>} */
|
|
51
|
+
const visited = new Set()
|
|
52
|
+
/** @type {Set<string>} */
|
|
53
|
+
const rows = new Set()
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Records one tree entry and recurses into its production and optional
|
|
57
|
+
* dependencies, skipping subtrees already walked.
|
|
58
|
+
*
|
|
59
|
+
* @param {Record<string, unknown>} entry - A project or dependency node from
|
|
60
|
+
* the pnpm list output.
|
|
61
|
+
*/
|
|
62
|
+
function visit(entry) {
|
|
63
|
+
const installedPath = asString(entry.path)
|
|
64
|
+
if (installedPath === undefined) {
|
|
65
|
+
const label = asString(entry.name) ?? asString(entry.version) ?? 'unnamed entry'
|
|
66
|
+
throw new Error(`Missing path property for ${label}`)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (visited.has(installedPath)) {
|
|
70
|
+
return
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
visited.add(installedPath)
|
|
74
|
+
|
|
75
|
+
const manifestPath = join(installedPath, 'package.json')
|
|
76
|
+
if (existsSync(manifestPath)) {
|
|
77
|
+
/** @type {unknown} */
|
|
78
|
+
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'))
|
|
79
|
+
if (!isRecord(manifest)) {
|
|
80
|
+
throw new TypeError(`Expected an object in ${manifestPath}`)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const engines = isRecord(manifest.engines) ? manifest.engines : {}
|
|
84
|
+
rows.add(
|
|
85
|
+
JSON.stringify({
|
|
86
|
+
name: asString(manifest.name),
|
|
87
|
+
node: asString(engines.node),
|
|
88
|
+
version: asString(manifest.version),
|
|
89
|
+
}),
|
|
90
|
+
)
|
|
91
|
+
} else {
|
|
92
|
+
rows.add(
|
|
93
|
+
JSON.stringify({
|
|
94
|
+
installed: false,
|
|
95
|
+
name: asString(entry.name) ?? asString(entry.from),
|
|
96
|
+
version: asString(entry.version),
|
|
97
|
+
}),
|
|
98
|
+
)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
for (const group of ['dependencies', 'optionalDependencies']) {
|
|
102
|
+
const children = entry[group]
|
|
103
|
+
if (isRecord(children)) {
|
|
104
|
+
for (const [name, child] of Object.entries(children)) {
|
|
105
|
+
if (isRecord(child)) {
|
|
106
|
+
visit({ name, ...child })
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
for (const root of parsed) {
|
|
114
|
+
if (isRecord(root)) {
|
|
115
|
+
visit(root)
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
process.stdout.write(`${[...rows].toSorted().join('\n')}\n`)
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Release-age helpers for the exemption policy in configuration.md.
|
|
3
|
+
*
|
|
4
|
+
* `inventory` classifies every `minimumReleaseAgeExclude` entry in
|
|
5
|
+
* `pnpm-workspace.yaml` against the lockfile, the registry publish time, and
|
|
6
|
+
* the authored-package list, printing the policy action for each. Run it during
|
|
7
|
+
* Setup before the baseline install and again during step 6 cleanup.
|
|
8
|
+
*
|
|
9
|
+
* `new-versions` lists every package version present in the after-lockfile but
|
|
10
|
+
* not the before-lockfile that is under 24 hours old at the run start, with
|
|
11
|
+
* whether it is authored and when it becomes installable. Run it after each
|
|
12
|
+
* resolution to find authored versions that need an exact exemption and
|
|
13
|
+
* third-party versions that must not get one.
|
|
14
|
+
*
|
|
15
|
+
* Usage, from the workspace root:
|
|
16
|
+
*
|
|
17
|
+
* ```
|
|
18
|
+
* node release-age.mjs inventory [--workspace <file>] [--lockfile <file>] [--run-start <iso>] [--authored <file>]
|
|
19
|
+
* node release-age.mjs new-versions <before-lock> [after-lock] [--run-start <iso>] [--authored <file>]
|
|
20
|
+
* ```
|
|
21
|
+
*
|
|
22
|
+
* `--authored` defaults to `<run directory>/authored-packages.txt`, the
|
|
23
|
+
* comma-separated ownership list from Setup step 5. `--workspace` and
|
|
24
|
+
* `--lockfile` default to the files in the current directory; point them at
|
|
25
|
+
* saved copies to inspect another state without touching the repository. Output
|
|
26
|
+
* is JSON on stdout with a summary on stderr.
|
|
27
|
+
*/
|
|
28
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
29
|
+
import { join } from 'node:path'
|
|
30
|
+
import process from 'node:process'
|
|
31
|
+
import {
|
|
32
|
+
lockedPackages,
|
|
33
|
+
mapLimit,
|
|
34
|
+
MATURITY_MS,
|
|
35
|
+
publishTimes,
|
|
36
|
+
readYamlList,
|
|
37
|
+
runDirectory,
|
|
38
|
+
splitSpec,
|
|
39
|
+
} from './lib.mjs'
|
|
40
|
+
|
|
41
|
+
const [mode, ...rest] = process.argv.slice(2)
|
|
42
|
+
/** @type {string[]} */
|
|
43
|
+
const positional = []
|
|
44
|
+
/** @type {Record<string, string>} */
|
|
45
|
+
const options = {}
|
|
46
|
+
for (let index = 0; index < rest.length; index++) {
|
|
47
|
+
const argument = rest[index] ?? ''
|
|
48
|
+
if (argument.startsWith('--')) {
|
|
49
|
+
options[argument.slice(2)] = rest[++index] ?? ''
|
|
50
|
+
} else {
|
|
51
|
+
positional.push(argument)
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const runStart = options['run-start'] === undefined ? Date.now() : Date.parse(options['run-start'])
|
|
56
|
+
if (Number.isNaN(runStart)) {
|
|
57
|
+
throw new TypeError(`Invalid --run-start value: ${options['run-start']}`)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const authoredPath = options.authored ?? join(runDirectory(), 'authored-packages.txt')
|
|
61
|
+
if (!existsSync(authoredPath)) {
|
|
62
|
+
throw new Error(
|
|
63
|
+
`Authored package list not found at ${authoredPath}. Run Setup step 5 or pass --authored.`,
|
|
64
|
+
)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const LIST_SEPARATOR = /[\s,]+/v
|
|
68
|
+
const RANGE_CHARACTERS = /[\s*<=>\|^~]/v
|
|
69
|
+
const authored = new Set(
|
|
70
|
+
readFileSync(authoredPath, 'utf8')
|
|
71
|
+
.split(LIST_SEPARATOR)
|
|
72
|
+
.filter((name) => name !== ''),
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* @typedef {object} Age
|
|
77
|
+
* @property {boolean} authored - Whether kitschpatrol owns the package.
|
|
78
|
+
* @property {string} [installableAt] - When the version turns 24 hours old.
|
|
79
|
+
* @property {string} [published] - Registry publish time.
|
|
80
|
+
* @property {boolean} [young] - Under 24 hours old at the run start; undefined
|
|
81
|
+
* when the publish time is unknown.
|
|
82
|
+
*/
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Looks up a version's publish time and derived maturity facts.
|
|
86
|
+
*
|
|
87
|
+
* @param {string} name - Package name.
|
|
88
|
+
* @param {string} version - Exact version.
|
|
89
|
+
*
|
|
90
|
+
* @returns {Promise<Age>} The version's age facts.
|
|
91
|
+
*/
|
|
92
|
+
async function ageOf(name, version) {
|
|
93
|
+
const times = await publishTimes(name)
|
|
94
|
+
const published = times[version]
|
|
95
|
+
if (published === undefined) {
|
|
96
|
+
return { authored: authored.has(name) }
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const publishedMs = Date.parse(published)
|
|
100
|
+
return {
|
|
101
|
+
authored: authored.has(name),
|
|
102
|
+
installableAt: new Date(publishedMs + MATURITY_MS).toISOString(),
|
|
103
|
+
published,
|
|
104
|
+
young: publishedMs > runStart - MATURITY_MS,
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (mode === 'inventory') {
|
|
109
|
+
const locked = lockedPackages(options.lockfile ?? 'pnpm-lock.yaml')
|
|
110
|
+
const lockedNames = Array.from(locked, (key) => splitSpec(key))
|
|
111
|
+
const entries = readYamlList(
|
|
112
|
+
options.workspace ?? 'pnpm-workspace.yaml',
|
|
113
|
+
'minimumReleaseAgeExclude',
|
|
114
|
+
)
|
|
115
|
+
const rows = await mapLimit(entries, 8, async (entry) => {
|
|
116
|
+
const spec = splitSpec(entry)
|
|
117
|
+
if (spec === undefined || RANGE_CHARACTERS.test(spec.version) || spec.version === '') {
|
|
118
|
+
const name = spec?.name ?? entry
|
|
119
|
+
return {
|
|
120
|
+
action: 'broad entry: rewrite as exact versions resolved from the lockfile',
|
|
121
|
+
entry,
|
|
122
|
+
lockedVersions: lockedNames
|
|
123
|
+
.filter((part) => part?.name === name)
|
|
124
|
+
.map((part) => part?.version),
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const age = await ageOf(spec.name, spec.version)
|
|
129
|
+
const isLocked = locked.has(entry)
|
|
130
|
+
/** @type {string} */
|
|
131
|
+
let action
|
|
132
|
+
if (age.published === undefined) {
|
|
133
|
+
action = 'unknown version: check by hand'
|
|
134
|
+
} else if (!age.young) {
|
|
135
|
+
action = 'remove: at least 24 hours old'
|
|
136
|
+
} else if (age.authored) {
|
|
137
|
+
action = 'keep if installed or an accepted target'
|
|
138
|
+
} else if (isLocked) {
|
|
139
|
+
action = `keep only to avoid a downgrade; removable after ${age.installableAt}`
|
|
140
|
+
} else {
|
|
141
|
+
action = 'remove: young third-party version that is not locked'
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return { action, entry, locked: isLocked, ...age }
|
|
145
|
+
})
|
|
146
|
+
process.stdout.write(`${JSON.stringify(rows, undefined, 2)}\n`)
|
|
147
|
+
for (const row of rows) {
|
|
148
|
+
process.stderr.write(`${row.entry}: ${row.action}\n`)
|
|
149
|
+
}
|
|
150
|
+
} else if (mode === 'new-versions') {
|
|
151
|
+
const [beforePath, afterPath = 'pnpm-lock.yaml'] = positional
|
|
152
|
+
if (beforePath === undefined) {
|
|
153
|
+
throw new Error('Usage: node release-age.mjs new-versions <before-lock> [after-lock]')
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const before = lockedPackages(beforePath)
|
|
157
|
+
const added = [...lockedPackages(afterPath).difference(before)]
|
|
158
|
+
const rows = await mapLimit(added, 8, async (key) => {
|
|
159
|
+
const spec = splitSpec(key)
|
|
160
|
+
const age = spec === undefined ? { authored: false } : await ageOf(spec.name, spec.version)
|
|
161
|
+
return { key, ...age }
|
|
162
|
+
})
|
|
163
|
+
const flagged = rows.filter((row) => row.young !== false)
|
|
164
|
+
process.stdout.write(`${JSON.stringify(flagged, undefined, 2)}\n`)
|
|
165
|
+
process.stderr.write(`${added.length} new versions, ${flagged.length} young or unknown\n`)
|
|
166
|
+
for (const row of flagged) {
|
|
167
|
+
const kind = row.authored ? 'authored' : 'third-party'
|
|
168
|
+
const when =
|
|
169
|
+
row.installableAt === undefined ? 'publish time unknown' : `installable ${row.installableAt}`
|
|
170
|
+
process.stderr.write(` ${row.key} (${kind}): ${when}\n`)
|
|
171
|
+
}
|
|
172
|
+
} else {
|
|
173
|
+
throw new Error('Usage: node release-age.mjs <inventory|new-versions> [options]')
|
|
174
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runs a command with an exact Node.js release first in PATH, for the floor
|
|
3
|
+
* checks in verification.md, and proves which binary every process used.
|
|
4
|
+
*
|
|
5
|
+
* The release is downloaded once into `<run directory>/node-<version>/` through
|
|
6
|
+
* a `devEngines.runtime` fixture, so pnpm verifies the checksum and the user's
|
|
7
|
+
* default runtime is untouched. A logging `node` shim placed first in PATH
|
|
8
|
+
* records each invocation's version and arguments, so child processes that
|
|
9
|
+
* resolve `node` from PATH, such as package bin shims, are covered too. A child
|
|
10
|
+
* that hard-codes another binary does not appear in the log; treat a
|
|
11
|
+
* suspiciously low invocation count as unverified.
|
|
12
|
+
*
|
|
13
|
+
* Usage:
|
|
14
|
+
*
|
|
15
|
+
* ```
|
|
16
|
+
* node run-at-node.mjs <version> [--cwd <dir>] [--out <dir>] -- <command> [args...]
|
|
17
|
+
* ```
|
|
18
|
+
*
|
|
19
|
+
* Writes `stdout.txt`, `stderr.txt`, `exit-code.txt`, and
|
|
20
|
+
* `node-invocations.log` to `--out` (default `<run
|
|
21
|
+
* directory>/node-<version>/last-run/`), prints a summary, and exits with the
|
|
22
|
+
* command's exit code. The shim needs a POSIX shell, so this does not work on
|
|
23
|
+
* Windows.
|
|
24
|
+
*/
|
|
25
|
+
import { execFileSync, spawnSync } from 'node:child_process'
|
|
26
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
27
|
+
import { delimiter, dirname, join, resolve } from 'node:path'
|
|
28
|
+
import process from 'node:process'
|
|
29
|
+
import { runDirectory } from './lib.mjs'
|
|
30
|
+
|
|
31
|
+
const separator = process.argv.indexOf('--')
|
|
32
|
+
const head = process.argv.slice(2, separator === -1 ? undefined : separator)
|
|
33
|
+
const command = separator === -1 ? [] : process.argv.slice(separator + 1)
|
|
34
|
+
const version = head.shift()
|
|
35
|
+
if (version === undefined || !/^\d+\.\d+\.\d+$/v.test(version) || command.length === 0) {
|
|
36
|
+
throw new Error(
|
|
37
|
+
'Usage: node run-at-node.mjs <x.y.z> [--cwd <dir>] [--out <dir>] -- <command> [args...]',
|
|
38
|
+
)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** @type {Record<string, string>} */
|
|
42
|
+
const options = {}
|
|
43
|
+
for (let index = 0; index < head.length; index += 2) {
|
|
44
|
+
options[(head[index] ?? '').replace(/^--/v, '')] = head[index + 1] ?? ''
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const runtimeDirectory = join(runDirectory(), `node-${version}`)
|
|
48
|
+
mkdirSync(runtimeDirectory, { recursive: true })
|
|
49
|
+
writeFileSync(
|
|
50
|
+
join(runtimeDirectory, 'package.json'),
|
|
51
|
+
`${JSON.stringify({ devEngines: { runtime: { name: 'node', onFail: 'download', version } }, name: `node-runtime-${version}`, private: true }, undefined, 2)}\n`,
|
|
52
|
+
)
|
|
53
|
+
execFileSync('pnpm', ['install'], { cwd: runtimeDirectory, stdio: ['ignore', 'ignore', 'inherit'] })
|
|
54
|
+
const nodeBinary = execFileSync('pnpm', ['exec', 'node', '-p', 'process.execPath'], {
|
|
55
|
+
cwd: runtimeDirectory,
|
|
56
|
+
encoding: 'utf8',
|
|
57
|
+
}).trim()
|
|
58
|
+
const reported = execFileSync(nodeBinary, ['--version'], { encoding: 'utf8' }).trim()
|
|
59
|
+
if (reported !== `v${version}`) {
|
|
60
|
+
throw new Error(`Expected Node.js v${version} at ${nodeBinary}, got ${reported}`)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const out = resolve(options.out ?? join(runtimeDirectory, 'last-run'))
|
|
64
|
+
const shimDirectory = join(out, 'shim')
|
|
65
|
+
const invocations = join(out, 'node-invocations.log')
|
|
66
|
+
mkdirSync(shimDirectory, { recursive: true })
|
|
67
|
+
writeFileSync(invocations, '')
|
|
68
|
+
const shim = join(shimDirectory, 'node')
|
|
69
|
+
writeFileSync(
|
|
70
|
+
shim,
|
|
71
|
+
[
|
|
72
|
+
'#!/bin/sh',
|
|
73
|
+
String.raw`printf '%s %s\n' '${reported}' "$*" | cut -c1-300 >> '${invocations}'`,
|
|
74
|
+
`exec '${nodeBinary}' "$@"`,
|
|
75
|
+
'',
|
|
76
|
+
].join('\n'),
|
|
77
|
+
)
|
|
78
|
+
chmodSync(shim, 0o755)
|
|
79
|
+
|
|
80
|
+
const [executable = '', ...args] = command
|
|
81
|
+
const childEnvironment = {
|
|
82
|
+
...process.env,
|
|
83
|
+
// eslint-disable-next-line ts/naming-convention -- Environment variable names are uppercase.
|
|
84
|
+
PATH: [shimDirectory, dirname(nodeBinary), process.env.PATH ?? ''].join(delimiter),
|
|
85
|
+
}
|
|
86
|
+
const result = spawnSync(executable, args, {
|
|
87
|
+
cwd: options.cwd === undefined ? process.cwd() : resolve(options.cwd),
|
|
88
|
+
encoding: 'utf8',
|
|
89
|
+
env: childEnvironment,
|
|
90
|
+
maxBuffer: 1024 ** 3,
|
|
91
|
+
})
|
|
92
|
+
writeFileSync(join(out, 'stdout.txt'), result.stdout)
|
|
93
|
+
writeFileSync(join(out, 'stderr.txt'), result.stderr)
|
|
94
|
+
const exitCode = result.status ?? 1
|
|
95
|
+
writeFileSync(join(out, 'exit-code.txt'), `${exitCode}\n`)
|
|
96
|
+
|
|
97
|
+
const lines = existsSync(invocations)
|
|
98
|
+
? readFileSync(invocations, 'utf8').split('\n').filter(Boolean)
|
|
99
|
+
: []
|
|
100
|
+
const versions = [...new Set(lines.map((line) => line.split(' ', 1)[0]))]
|
|
101
|
+
process.stdout.write(
|
|
102
|
+
`${JSON.stringify({ exitCode, invocations: lines.length, nodeBinary, out, versions }, undefined, 2)}\n`,
|
|
103
|
+
)
|
|
104
|
+
if (result.error !== undefined) {
|
|
105
|
+
throw result.error
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
process.exitCode = exitCode
|