template-git-repo 0.1.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/README.md +91 -0
- package/bin/template-git-repo.js +257 -0
- package/docs/ACTIONS.md +195 -0
- package/docs/BADGES.md +221 -0
- package/package.json +56 -0
- package/src/apply.js +164 -0
- package/src/badges.js +353 -0
- package/src/context.js +228 -0
- package/src/index.js +29 -0
- package/src/readme.js +60 -0
- package/template/.github/workflows/auto-merge-and-create-prs.yml +102 -0
- package/template/.github/workflows/auto-merge-claude.yml +54 -0
- package/template/.github/workflows/deploy-test-reports.yml +98 -0
- package/template/.github/workflows/npm-publish.yml +320 -0
- package/template/.github/workflows/tests.yml +100 -0
- package/template/codecov.yml +46 -0
- package/template/scripts/list-test-packages.mjs +137 -0
- package/template/scripts/next-free-version.mjs +169 -0
- package/template/scripts/pin-workspace-deps.mjs +108 -0
- package/template/scripts/restore-pinned-deps.mjs +101 -0
- package/template/scripts/workspace-build-order.mjs +129 -0
- package/template/turbo.json +36 -0
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# Codecov configuration.
|
|
2
|
+
#
|
|
3
|
+
# Every workspace package uploads its own lcov report under its own flag (see
|
|
4
|
+
# .github/workflows/tests.yml), so the dashboard shows a per-package percentage
|
|
5
|
+
# alongside the project total.
|
|
6
|
+
|
|
7
|
+
codecov:
|
|
8
|
+
require_ci_to_pass: false
|
|
9
|
+
|
|
10
|
+
coverage:
|
|
11
|
+
precision: 2
|
|
12
|
+
round: down
|
|
13
|
+
range: "50...90"
|
|
14
|
+
|
|
15
|
+
status:
|
|
16
|
+
project:
|
|
17
|
+
default:
|
|
18
|
+
# Report coverage without failing the build while the suites are still
|
|
19
|
+
# being filled in. Drop `informational` once the numbers settle and you
|
|
20
|
+
# want a coverage drop to actually block a merge.
|
|
21
|
+
target: auto
|
|
22
|
+
threshold: 2%
|
|
23
|
+
informational: true
|
|
24
|
+
patch:
|
|
25
|
+
default:
|
|
26
|
+
target: 70%
|
|
27
|
+
threshold: 5%
|
|
28
|
+
informational: true
|
|
29
|
+
|
|
30
|
+
# `carryforward` keeps a package's last known coverage when a PR does not touch
|
|
31
|
+
# it. Without it, every package not covered by the current upload reads as 0%
|
|
32
|
+
# and the project total collapses on every partial run.
|
|
33
|
+
flag_management:
|
|
34
|
+
default_rules:
|
|
35
|
+
carryforward: true
|
|
36
|
+
statuses:
|
|
37
|
+
- type: project
|
|
38
|
+
target: auto
|
|
39
|
+
threshold: 2%
|
|
40
|
+
informational: true
|
|
41
|
+
|
|
42
|
+
comment:
|
|
43
|
+
layout: "condensed_header, diff, flags, components, condensed_files"
|
|
44
|
+
behavior: default
|
|
45
|
+
require_changes: true
|
|
46
|
+
hide_project_coverage: false
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Emit the GitHub Actions matrix for `.github/workflows/tests.yml`.
|
|
4
|
+
*
|
|
5
|
+
* A hand-written matrix fails silently: a package added to the repo but not to
|
|
6
|
+
* the matrix is simply never tested, and nothing goes red to say so. This reads
|
|
7
|
+
* the workspace globs out of the root package.json instead, so the matrix is
|
|
8
|
+
* whatever the repo actually contains.
|
|
9
|
+
*
|
|
10
|
+
* Each entry gets:
|
|
11
|
+
* name the package name, for `turbo --filter`
|
|
12
|
+
* dir its directory, for `working-directory`
|
|
13
|
+
* flag the Codecov flag — the directory basename, which is what a
|
|
14
|
+
* human reading the Codecov dashboard expects to see
|
|
15
|
+
* script `test:coverage` if it exists, else `test`
|
|
16
|
+
* allowFailure from `"ci": { "allowFailure": true }` in its package.json
|
|
17
|
+
*
|
|
18
|
+
* Output is GitHub Actions `$GITHUB_OUTPUT` key=value lines:
|
|
19
|
+
* matrix={"include":[...]}
|
|
20
|
+
* empty=true|false
|
|
21
|
+
*
|
|
22
|
+
* `empty` exists because GitHub fails a job whose matrix has no entries, which
|
|
23
|
+
* would make a repo with no test suites look broken rather than untested.
|
|
24
|
+
*
|
|
25
|
+
* Usage: node scripts/list-test-packages.mjs [rootDir]
|
|
26
|
+
*/
|
|
27
|
+
import fs from 'node:fs';
|
|
28
|
+
import path from 'node:path';
|
|
29
|
+
import { pathToFileURL } from 'node:url';
|
|
30
|
+
|
|
31
|
+
/** Scripts that count as "this package has a suite", best first. */
|
|
32
|
+
const TEST_SCRIPTS = ['test:coverage', 'test:ci', 'test'];
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Expand the `workspaces` field into the directories it names.
|
|
36
|
+
*
|
|
37
|
+
* Only the one glob shape workspaces actually use in practice is supported —
|
|
38
|
+
* a trailing `/*` — because anything cleverer would need a glob dependency in
|
|
39
|
+
* a script that must run before `install`.
|
|
40
|
+
*
|
|
41
|
+
* @param {string} root repo root
|
|
42
|
+
* @returns {string[]} directories relative to the repo root
|
|
43
|
+
*/
|
|
44
|
+
export function workspaceDirectories(root = '.') {
|
|
45
|
+
const manifestPath = path.join(root, 'package.json');
|
|
46
|
+
if (!fs.existsSync(manifestPath)) return [];
|
|
47
|
+
|
|
48
|
+
let manifest;
|
|
49
|
+
try {
|
|
50
|
+
manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
|
51
|
+
} catch {
|
|
52
|
+
return [];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// npm/bun accept both `workspaces: []` and `workspaces: { packages: [] }`.
|
|
56
|
+
const globs = Array.isArray(manifest.workspaces)
|
|
57
|
+
? manifest.workspaces
|
|
58
|
+
: (manifest.workspaces?.packages ?? []);
|
|
59
|
+
|
|
60
|
+
const directories = [];
|
|
61
|
+
|
|
62
|
+
for (const glob of globs) {
|
|
63
|
+
if (!glob.endsWith('/*')) {
|
|
64
|
+
if (fs.existsSync(path.join(root, glob))) directories.push(glob);
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const parent = glob.slice(0, -2);
|
|
69
|
+
const parentPath = path.join(root, parent);
|
|
70
|
+
if (!fs.existsSync(parentPath)) continue;
|
|
71
|
+
|
|
72
|
+
for (const entry of fs.readdirSync(parentPath, { withFileTypes: true })) {
|
|
73
|
+
if (!entry.isDirectory()) continue;
|
|
74
|
+
directories.push(`${parent}/${entry.name}`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return directories.sort();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* @param {string} [root]
|
|
83
|
+
* @returns {{ name: string, dir: string, flag: string, script: string, allowFailure: boolean }[]}
|
|
84
|
+
*/
|
|
85
|
+
export function testablePackages(root = '.') {
|
|
86
|
+
const entries = [];
|
|
87
|
+
|
|
88
|
+
for (const dir of workspaceDirectories(root)) {
|
|
89
|
+
const manifestPath = path.join(root, dir, 'package.json');
|
|
90
|
+
if (!fs.existsSync(manifestPath)) continue;
|
|
91
|
+
|
|
92
|
+
let pkg;
|
|
93
|
+
try {
|
|
94
|
+
pkg = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
|
95
|
+
} catch {
|
|
96
|
+
console.error(`Skipping ${manifestPath}: not valid JSON`);
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
if (!pkg.name) continue;
|
|
100
|
+
|
|
101
|
+
const script = TEST_SCRIPTS.find((candidate) => pkg.scripts?.[candidate]);
|
|
102
|
+
if (!script) continue;
|
|
103
|
+
|
|
104
|
+
entries.push({
|
|
105
|
+
name: pkg.name,
|
|
106
|
+
dir,
|
|
107
|
+
flag: path.basename(dir),
|
|
108
|
+
script,
|
|
109
|
+
allowFailure: Boolean(pkg.ci?.allowFailure),
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return entries;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* @param {{ name: string }[]} packages
|
|
118
|
+
* @returns {string} the `$GITHUB_OUTPUT` lines, newline-terminated
|
|
119
|
+
*/
|
|
120
|
+
export function formatOutput(packages) {
|
|
121
|
+
return [
|
|
122
|
+
`matrix=${JSON.stringify({ include: packages })}`,
|
|
123
|
+
`empty=${packages.length === 0}`,
|
|
124
|
+
'',
|
|
125
|
+
].join('\n');
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) {
|
|
129
|
+
const packages = testablePackages(process.argv[2]);
|
|
130
|
+
for (const entry of packages) {
|
|
131
|
+
console.error(`✓ ${entry.name} (${entry.dir}) via ${entry.script}`);
|
|
132
|
+
}
|
|
133
|
+
if (packages.length === 0) {
|
|
134
|
+
console.error('No workspace package declares a test script — the test matrix is empty.');
|
|
135
|
+
}
|
|
136
|
+
process.stdout.write(formatOutput(packages));
|
|
137
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Pick the next version npm will actually accept for a package.
|
|
4
|
+
*
|
|
5
|
+
* The publish workflow used to bump one patch above whichever was higher, the
|
|
6
|
+
* local version or the registry's `latest` dist-tag, and assume the result was
|
|
7
|
+
* free. It is not: the registry refuses a PUT for any version it has *ever*
|
|
8
|
+
* seen, and plenty of those are invisible to `latest`.
|
|
9
|
+
*
|
|
10
|
+
* npm error code E409
|
|
11
|
+
* npm error 409 Conflict - PUT https://registry.npmjs.org/<package>
|
|
12
|
+
* - Cannot publish over previously staged version "0.1.275".
|
|
13
|
+
*
|
|
14
|
+
* A version reaches that state when a publish is interrupted part-way, when it
|
|
15
|
+
* was published and later unpublished, or when it only ever carried a dist-tag
|
|
16
|
+
* other than `latest`. Six packages hit this in a single run of the workflow
|
|
17
|
+
* this script was written for, each having "bumped" straight onto a number the
|
|
18
|
+
* registry had already reserved.
|
|
19
|
+
*
|
|
20
|
+
* So treat the registry as the source of truth for which numbers are spent.
|
|
21
|
+
* `versions` lists what is currently published; `time` additionally keeps an
|
|
22
|
+
* entry for every version that ever existed, including staged and unpublished
|
|
23
|
+
* ones — the exact numbers `latest` cannot see. The union of the two is the
|
|
24
|
+
* taken set.
|
|
25
|
+
*
|
|
26
|
+
* Usage: node scripts/next-free-version.mjs <package-name> <local-version>
|
|
27
|
+
* Prints the next free version, always strictly above <local-version>. If
|
|
28
|
+
* the registry says nothing about the package (a first publish, or an
|
|
29
|
+
* unreachable registry) that is just <local-version> plus a patch.
|
|
30
|
+
*/
|
|
31
|
+
import { execFileSync } from 'node:child_process';
|
|
32
|
+
import { pathToFileURL } from 'node:url';
|
|
33
|
+
|
|
34
|
+
/** A plain `major.minor.patch` release — what every package here publishes. */
|
|
35
|
+
const RELEASE = /^(\d+)\.(\d+)\.(\d+)$/;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Compare two `major.minor.patch` versions numerically.
|
|
39
|
+
*
|
|
40
|
+
* @param {string} a
|
|
41
|
+
* @param {string} b
|
|
42
|
+
* @returns {number} negative if a < b, 0 if equal, positive if a > b
|
|
43
|
+
*/
|
|
44
|
+
export function compareVersions(a, b) {
|
|
45
|
+
const left = a.split('.').map(Number);
|
|
46
|
+
const right = b.split('.').map(Number);
|
|
47
|
+
|
|
48
|
+
for (let i = 0; i < 3; i++) {
|
|
49
|
+
const diff = (left[i] || 0) - (right[i] || 0);
|
|
50
|
+
if (diff !== 0) return diff;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return 0;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* @param {string} version
|
|
58
|
+
* @returns {string} the same version with its patch incremented
|
|
59
|
+
*/
|
|
60
|
+
export function bumpPatch(version) {
|
|
61
|
+
const parts = version.split('.').map(Number);
|
|
62
|
+
parts[2] = (parts[2] || 0) + 1;
|
|
63
|
+
return parts.slice(0, 3).join('.');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Every version number the registry has ever handed out for a package.
|
|
68
|
+
*
|
|
69
|
+
* `time` is the important half: npm keeps a timestamp for versions that are no
|
|
70
|
+
* longer listed in `versions` (unpublished, or staged by a publish that never
|
|
71
|
+
* finished), and those are exactly the ones that answer a fresh PUT with E409.
|
|
72
|
+
*
|
|
73
|
+
* @param {{ versions?: string[] | string, time?: Record<string, string> }} packument
|
|
74
|
+
* @returns {Set<string>}
|
|
75
|
+
*/
|
|
76
|
+
export function takenVersions(packument) {
|
|
77
|
+
const taken = new Set();
|
|
78
|
+
if (!packument) return taken;
|
|
79
|
+
|
|
80
|
+
// `npm view <name> versions --json` collapses a single-version package to a
|
|
81
|
+
// bare string rather than a one-element array.
|
|
82
|
+
const versions = packument.versions;
|
|
83
|
+
for (const version of Array.isArray(versions)
|
|
84
|
+
? versions
|
|
85
|
+
: versions
|
|
86
|
+
? [versions]
|
|
87
|
+
: []) {
|
|
88
|
+
if (RELEASE.test(version)) taken.add(version);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// `time` also carries `created`, `modified` and (for a wholly unpublished
|
|
92
|
+
// package) `unpublished`; only the version-shaped keys matter here.
|
|
93
|
+
for (const key of Object.keys(packument.time || {})) {
|
|
94
|
+
if (RELEASE.test(key)) taken.add(key);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return taken;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The lowest free version that is strictly above both `local` and everything
|
|
102
|
+
* the registry has ever spent.
|
|
103
|
+
*
|
|
104
|
+
* Always a bump, never `local` itself: both callers already know `local`
|
|
105
|
+
* cannot be published — the workflow reaches here either because that version
|
|
106
|
+
* is on npm with different content, or because a publish attempt just came
|
|
107
|
+
* back E409 for a version the registry will not admit to knowing.
|
|
108
|
+
*
|
|
109
|
+
* @param {string} local version in the package's own package.json
|
|
110
|
+
* @param {Set<string>} taken every version the registry has ever seen
|
|
111
|
+
* @returns {string}
|
|
112
|
+
*/
|
|
113
|
+
export function nextFreeVersion(local, taken) {
|
|
114
|
+
let highest = RELEASE.test(local) ? local : '0.0.0';
|
|
115
|
+
for (const version of taken) {
|
|
116
|
+
if (compareVersions(version, highest) > 0) highest = version;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
let next = bumpPatch(highest);
|
|
120
|
+
// A gap in the taken set can only be below `highest`, so this loop normally
|
|
121
|
+
// runs zero times; it is here so a registry that reports versions out of
|
|
122
|
+
// order still cannot produce a number that is already spent.
|
|
123
|
+
while (taken.has(next)) next = bumpPatch(next);
|
|
124
|
+
|
|
125
|
+
return next;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Ask npm for a package's published versions and its full release timeline.
|
|
130
|
+
*
|
|
131
|
+
* A package that has never been published (E404) and an unreachable registry
|
|
132
|
+
* look the same from here: both yield an empty packument, so the answer comes
|
|
133
|
+
* from the local version alone.
|
|
134
|
+
*
|
|
135
|
+
* @param {string} name
|
|
136
|
+
* @param {(cmd: string, args: string[]) => string} [run] injection point for tests
|
|
137
|
+
* @returns {{ versions?: string[], time?: Record<string, string> }}
|
|
138
|
+
*/
|
|
139
|
+
export function fetchPackument(name, run = defaultRun) {
|
|
140
|
+
let raw;
|
|
141
|
+
try {
|
|
142
|
+
raw = run('npm', ['view', name, 'versions', 'time', '--json']);
|
|
143
|
+
} catch {
|
|
144
|
+
return {};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (!raw || !raw.trim()) return {};
|
|
148
|
+
|
|
149
|
+
try {
|
|
150
|
+
return JSON.parse(raw) || {};
|
|
151
|
+
} catch {
|
|
152
|
+
return {};
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function defaultRun(cmd, args) {
|
|
157
|
+
return execFileSync(cmd, args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (import.meta.url === pathToFileURL(process.argv[1] || '').href) {
|
|
161
|
+
const [name, local] = process.argv.slice(2);
|
|
162
|
+
|
|
163
|
+
if (!name || !local) {
|
|
164
|
+
console.error('Usage: node scripts/next-free-version.mjs <package-name> <local-version>');
|
|
165
|
+
process.exit(2);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
console.log(nextFreeVersion(local, takenVersions(fetchPackument(name))));
|
|
169
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Rewrite `workspace:*` dependency ranges in the *current directory's*
|
|
4
|
+
* package.json to real semver ranges, immediately before `npm publish` packs it.
|
|
5
|
+
*
|
|
6
|
+
* npm keeps the literal `workspace:*` protocol in the published tarball, and no
|
|
7
|
+
* consumer outside this monorepo can resolve it — `npm install` of such a
|
|
8
|
+
* package dies with EUNSUPPORTEDPROTOCOL. Every workspace has to substitute
|
|
9
|
+
* real ranges at pack time; this is that substitution.
|
|
10
|
+
*
|
|
11
|
+
* A `workspace:*` dependency on a local package that is private (and so never
|
|
12
|
+
* published) is dropped rather than pinned: pinning it would produce a tarball
|
|
13
|
+
* that cannot install at all.
|
|
14
|
+
*
|
|
15
|
+
* The edit is deliberately left in the working tree — `restore-pinned-deps.mjs`
|
|
16
|
+
* takes it back out after the publish, keeping only any version bump.
|
|
17
|
+
*
|
|
18
|
+
* Usage: node scripts/pin-workspace-deps.mjs [packageDir] [workspaceRoot]
|
|
19
|
+
* Both default to what the publish loop needs: the current directory, and its
|
|
20
|
+
* parent as the directory holding sibling packages.
|
|
21
|
+
*/
|
|
22
|
+
import fs from 'node:fs';
|
|
23
|
+
import path from 'node:path';
|
|
24
|
+
import { pathToFileURL } from 'node:url';
|
|
25
|
+
|
|
26
|
+
const DEPENDENCY_FIELDS = ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies'];
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Map local package name -> { version, private } for every sibling.
|
|
30
|
+
*
|
|
31
|
+
* @param {string} siblingsRoot directory containing the package directories
|
|
32
|
+
* @returns {Map<string, { version: string, private: boolean }>}
|
|
33
|
+
*/
|
|
34
|
+
export function readSiblings(siblingsRoot) {
|
|
35
|
+
const siblings = new Map();
|
|
36
|
+
if (!fs.existsSync(siblingsRoot)) return siblings;
|
|
37
|
+
|
|
38
|
+
for (const entry of fs.readdirSync(siblingsRoot, { withFileTypes: true })) {
|
|
39
|
+
if (!entry.isDirectory()) continue;
|
|
40
|
+
|
|
41
|
+
const manifestPath = path.join(siblingsRoot, entry.name, 'package.json');
|
|
42
|
+
if (!fs.existsSync(manifestPath)) continue;
|
|
43
|
+
|
|
44
|
+
try {
|
|
45
|
+
const pkg = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
|
46
|
+
if (pkg.name && pkg.version) {
|
|
47
|
+
siblings.set(pkg.name, { version: pkg.version, private: Boolean(pkg.private) });
|
|
48
|
+
}
|
|
49
|
+
} catch {
|
|
50
|
+
// A sibling with an unreadable manifest simply cannot be pinned against.
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return siblings;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Substitute every `workspace:` range in `pkg`, in place.
|
|
59
|
+
*
|
|
60
|
+
* @param {Record<string, any>} pkg parsed package.json, mutated
|
|
61
|
+
* @param {Map<string, { version: string, private: boolean }>} siblings
|
|
62
|
+
* @returns {string[]} human-readable log lines describing what changed
|
|
63
|
+
*/
|
|
64
|
+
export function pinWorkspaceDeps(pkg, siblings) {
|
|
65
|
+
const changes = [];
|
|
66
|
+
|
|
67
|
+
for (const field of DEPENDENCY_FIELDS) {
|
|
68
|
+
const deps = pkg[field];
|
|
69
|
+
if (!deps) continue;
|
|
70
|
+
|
|
71
|
+
for (const [name, range] of Object.entries(deps)) {
|
|
72
|
+
if (typeof range !== 'string' || !range.startsWith('workspace:')) continue;
|
|
73
|
+
|
|
74
|
+
const sibling = siblings.get(name);
|
|
75
|
+
|
|
76
|
+
if (!sibling || sibling.private) {
|
|
77
|
+
delete deps[name];
|
|
78
|
+
changes.push(`Removed unpublishable workspace dependency: ${name}`);
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// `workspace:1.2.3` and `workspace:^1.2.3` already carry the range the
|
|
83
|
+
// author wants; only the `*`/`~`/`^` shorthands need the local version.
|
|
84
|
+
const suffix = range.slice('workspace:'.length);
|
|
85
|
+
deps[name] = /^[\d]/.test(suffix) || /^[~^][\d]/.test(suffix)
|
|
86
|
+
? suffix
|
|
87
|
+
: `^${sibling.version}`;
|
|
88
|
+
changes.push(`Pinned workspace dependency ${name} -> ${deps[name]}`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return changes;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) {
|
|
96
|
+
const packageDir = process.argv[2] ?? '.';
|
|
97
|
+
const siblingsRoot = process.argv[3] ?? path.join(packageDir, '..');
|
|
98
|
+
|
|
99
|
+
const manifestPath = path.join(packageDir, 'package.json');
|
|
100
|
+
const pkg = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
|
101
|
+
|
|
102
|
+
const changes = pinWorkspaceDeps(pkg, readSiblings(siblingsRoot));
|
|
103
|
+
for (const change of changes) console.log(change);
|
|
104
|
+
|
|
105
|
+
if (changes.length > 0) {
|
|
106
|
+
fs.writeFileSync(manifestPath, `${JSON.stringify(pkg, null, 2)}\n`);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Undo `pin-workspace-deps.mjs` across the workspace, keeping only the version
|
|
4
|
+
* bumps that the publish step made.
|
|
5
|
+
*
|
|
6
|
+
* After a publish run, each published package's package.json holds two kinds of
|
|
7
|
+
* edit: a `version` bump, which must be committed so the next run does not
|
|
8
|
+
* re-derive it, and the `workspace:*` -> `^x.y.z` pinning, which must not —
|
|
9
|
+
* committing that would freeze siblings at whatever version they happened to
|
|
10
|
+
* have and break local development.
|
|
11
|
+
*
|
|
12
|
+
* Restoring each file from `git show HEAD:` and patching only the version
|
|
13
|
+
* string back in keeps the committed diff to one line per bumped package, and
|
|
14
|
+
* keeps each file's original formatting.
|
|
15
|
+
*
|
|
16
|
+
* Usage: node scripts/restore-pinned-deps.mjs [packagesRoot...]
|
|
17
|
+
* Defaults to `packages`.
|
|
18
|
+
*/
|
|
19
|
+
import fs from 'node:fs';
|
|
20
|
+
import path from 'node:path';
|
|
21
|
+
import { execFileSync } from 'node:child_process';
|
|
22
|
+
import { pathToFileURL } from 'node:url';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Put `version` back into the *original* file text, leaving everything else as
|
|
26
|
+
* it was committed.
|
|
27
|
+
*
|
|
28
|
+
* A string replace rather than parse/serialize: reserializing would reformat
|
|
29
|
+
* files that use different indentation or key order, turning a one-line version
|
|
30
|
+
* bump into a whole-file diff.
|
|
31
|
+
*
|
|
32
|
+
* @param {string} original the committed file text
|
|
33
|
+
* @param {string} fromVersion version in the committed text
|
|
34
|
+
* @param {string} toVersion version to restore
|
|
35
|
+
* @returns {string}
|
|
36
|
+
*/
|
|
37
|
+
export function patchVersion(original, fromVersion, toVersion) {
|
|
38
|
+
if (fromVersion === toVersion) return original;
|
|
39
|
+
|
|
40
|
+
const needle = `"version": "${fromVersion}"`;
|
|
41
|
+
if (original.includes(needle)) {
|
|
42
|
+
return original.replace(needle, `"version": "${toVersion}"`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Different spacing around the colon (e.g. 4-space or minified manifests).
|
|
46
|
+
return original.replace(
|
|
47
|
+
new RegExp(`("version"\\s*:\\s*)"${fromVersion.replace(/\./g, '\\.')}"`),
|
|
48
|
+
`$1"${toVersion}"`,
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* @param {string[]} roots directories containing package directories
|
|
54
|
+
* @param {(file: string) => string} [showHead] injection point for tests
|
|
55
|
+
* @returns {string[]} log lines
|
|
56
|
+
*/
|
|
57
|
+
export function restore(roots, showHead = defaultShowHead) {
|
|
58
|
+
const messages = [];
|
|
59
|
+
|
|
60
|
+
for (const root of roots) {
|
|
61
|
+
if (!fs.existsSync(root)) continue;
|
|
62
|
+
|
|
63
|
+
for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
|
|
64
|
+
if (!entry.isDirectory()) continue;
|
|
65
|
+
|
|
66
|
+
const file = path.join(root, entry.name, 'package.json');
|
|
67
|
+
if (!fs.existsSync(file)) continue;
|
|
68
|
+
|
|
69
|
+
let committed;
|
|
70
|
+
try {
|
|
71
|
+
committed = showHead(file);
|
|
72
|
+
} catch {
|
|
73
|
+
// Untracked package: there is no committed text to restore it to, and
|
|
74
|
+
// its whole manifest is new, so leave it exactly as it is.
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const current = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
79
|
+
const original = JSON.parse(committed);
|
|
80
|
+
|
|
81
|
+
if (original.version !== current.version) {
|
|
82
|
+
messages.push(`Keeping version bump for ${current.name} -> ${current.version}`);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
fs.writeFileSync(file, patchVersion(committed, original.version, current.version));
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return messages;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function defaultShowHead(file) {
|
|
93
|
+
return execFileSync('git', ['show', `HEAD:${file}`], { encoding: 'utf8' });
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) {
|
|
97
|
+
const roots = process.argv.slice(2);
|
|
98
|
+
for (const message of restore(roots.length > 0 ? roots : ['packages'])) {
|
|
99
|
+
console.log(message);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Order the workspace's package directories so that every package comes after
|
|
4
|
+
* each sibling it depends on.
|
|
5
|
+
*
|
|
6
|
+
* The npm publish workflow used to walk `packages/*/`, i.e. shell-glob
|
|
7
|
+
* (alphabetical) order, which has nothing to do with the dependency graph.
|
|
8
|
+
* `bun install` links workspace packages into `node_modules` as symlinks, so a
|
|
9
|
+
* sibling that has not been built yet has no `dist/` and the `exports` ->
|
|
10
|
+
* `types` entries in its package.json point at files that do not exist. That
|
|
11
|
+
* makes any package fail its declaration build on every sibling it depends on
|
|
12
|
+
* that happens to sort after it:
|
|
13
|
+
*
|
|
14
|
+
* Cannot find module '<sibling>' or its corresponding type declarations.
|
|
15
|
+
*
|
|
16
|
+
* Usage: node scripts/workspace-build-order.mjs [rootDir]
|
|
17
|
+
* rootDir defaults to `packages`. Prints one directory per line, with a
|
|
18
|
+
* trailing slash, e.g. `packages/my-lib/`.
|
|
19
|
+
*/
|
|
20
|
+
import fs from 'node:fs';
|
|
21
|
+
import path from 'node:path';
|
|
22
|
+
import { pathToFileURL } from 'node:url';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Read every `<root>/<dir>/package.json` that exists.
|
|
26
|
+
*
|
|
27
|
+
* @param {string} root
|
|
28
|
+
* @returns {{ dir: string, pkg: Record<string, any> }[]}
|
|
29
|
+
*/
|
|
30
|
+
export function readWorkspacePackages(root) {
|
|
31
|
+
const packages = [];
|
|
32
|
+
|
|
33
|
+
for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
|
|
34
|
+
if (!entry.isDirectory()) continue;
|
|
35
|
+
|
|
36
|
+
const manifest = path.join(root, entry.name, 'package.json');
|
|
37
|
+
if (!fs.existsSync(manifest)) continue;
|
|
38
|
+
|
|
39
|
+
let pkg;
|
|
40
|
+
try {
|
|
41
|
+
pkg = JSON.parse(fs.readFileSync(manifest, 'utf8'));
|
|
42
|
+
} catch (error) {
|
|
43
|
+
console.error(`Skipping ${manifest}: ${error.message}`);
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
if (!pkg.name) continue;
|
|
47
|
+
|
|
48
|
+
packages.push({ dir: entry.name, pkg });
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return packages;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Map each package directory to the sibling directories it depends on.
|
|
56
|
+
*
|
|
57
|
+
* Local edges are keyed by package *name*, not by the version range: bun links
|
|
58
|
+
* a sibling into node_modules whenever the name matches a workspace package, so
|
|
59
|
+
* `"<sibling>": "^0.1.37"` resolves to the local copy exactly like
|
|
60
|
+
* `"workspace:*"` does, and needs building just the same.
|
|
61
|
+
*
|
|
62
|
+
* @param {{ dir: string, pkg: Record<string, any> }[]} packages
|
|
63
|
+
* @returns {Map<string, Set<string>>}
|
|
64
|
+
*/
|
|
65
|
+
export function localDependencyGraph(packages) {
|
|
66
|
+
const dirByName = new Map(packages.map(({ dir, pkg }) => [pkg.name, dir]));
|
|
67
|
+
const graph = new Map();
|
|
68
|
+
|
|
69
|
+
for (const { dir, pkg } of packages) {
|
|
70
|
+
const local = new Set();
|
|
71
|
+
for (const field of [
|
|
72
|
+
'dependencies',
|
|
73
|
+
'devDependencies',
|
|
74
|
+
'peerDependencies',
|
|
75
|
+
'optionalDependencies',
|
|
76
|
+
]) {
|
|
77
|
+
for (const name of Object.keys(pkg[field] ?? {})) {
|
|
78
|
+
const depDir = dirByName.get(name);
|
|
79
|
+
if (depDir && depDir !== dir) local.add(depDir);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
graph.set(dir, local);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return graph;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Topologically sort the workspace, alphabetically within each ready set so the
|
|
90
|
+
* order is stable across runs and stays close to the old one where the graph
|
|
91
|
+
* allows it.
|
|
92
|
+
*
|
|
93
|
+
* @param {string} [root]
|
|
94
|
+
* @returns {string[]} directories, e.g. `['packages/my-lib/', ...]`
|
|
95
|
+
*/
|
|
96
|
+
export function workspaceBuildOrder(root = 'packages') {
|
|
97
|
+
const packages = readWorkspacePackages(root);
|
|
98
|
+
const dependencies = localDependencyGraph(packages);
|
|
99
|
+
|
|
100
|
+
const pending = packages.map(({ dir }) => dir).sort();
|
|
101
|
+
const built = new Set();
|
|
102
|
+
const order = [];
|
|
103
|
+
|
|
104
|
+
while (pending.length > 0) {
|
|
105
|
+
const ready = pending.findIndex((dir) =>
|
|
106
|
+
[...dependencies.get(dir)].every((dep) => built.has(dep)),
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
// A dependency cycle leaves nothing ready. No order can be right, so take
|
|
110
|
+
// the alphabetically first entry and keep going: everything still gets
|
|
111
|
+
// built and published, and the cycle is reported on stderr where it shows
|
|
112
|
+
// up in the job log.
|
|
113
|
+
if (ready === -1) {
|
|
114
|
+
console.error(
|
|
115
|
+
`Dependency cycle involving ${pending[0]} — falling back to alphabetical order for it`,
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const [dir] = pending.splice(ready === -1 ? 0 : ready, 1);
|
|
120
|
+
built.add(dir);
|
|
121
|
+
order.push(`${root}/${dir}/`);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return order;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) {
|
|
128
|
+
console.log(workspaceBuildOrder(process.argv[2]).join('\n'));
|
|
129
|
+
}
|