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