@williamthorsen/nmr 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +100 -0
- package/bin/nmr-report-overrides.js +2 -0
- package/bin/nmr-sync-pnpm-version.js +2 -0
- package/bin/nmr.js +2 -0
- package/dist/esm/.cache +1 -0
- package/dist/esm/cli-report-overrides.d.ts +1 -0
- package/dist/esm/cli-report-overrides.js +9 -0
- package/dist/esm/cli-sync-pnpm-version.d.ts +1 -0
- package/dist/esm/cli-sync-pnpm-version.js +9 -0
- package/dist/esm/cli.d.ts +1 -0
- package/dist/esm/cli.js +110 -0
- package/dist/esm/commands/report-overrides.d.ts +1 -0
- package/dist/esm/commands/report-overrides.js +15 -0
- package/dist/esm/commands/sync-pnpm-version.d.ts +3 -0
- package/dist/esm/commands/sync-pnpm-version.js +42 -0
- package/dist/esm/config.d.ts +6 -0
- package/dist/esm/config.js +52 -0
- package/dist/esm/context.d.ts +11 -0
- package/dist/esm/context.js +84 -0
- package/dist/esm/help.d.ts +2 -0
- package/dist/esm/help.js +31 -0
- package/dist/esm/helpers/code-quality-pnpm-action.d.ts +12 -0
- package/dist/esm/helpers/code-quality-pnpm-action.js +17 -0
- package/dist/esm/helpers/package-json.d.ts +11 -0
- package/dist/esm/helpers/package-json.js +45 -0
- package/dist/esm/helpers/type-guards.d.ts +1 -0
- package/dist/esm/helpers/type-guards.js +6 -0
- package/dist/esm/helpers/yaml-utils.d.ts +1 -0
- package/dist/esm/helpers/yaml-utils.js +14 -0
- package/dist/esm/index.d.ts +3 -0
- package/dist/esm/index.js +4 -0
- package/dist/esm/registries.d.ts +5 -0
- package/dist/esm/registries.js +67 -0
- package/dist/esm/resolver.d.ts +11 -0
- package/dist/esm/resolver.js +67 -0
- package/dist/esm/runner.d.ts +4 -0
- package/dist/esm/runner.js +38 -0
- package/dist/esm/tests/consistency.d.ts +1 -0
- package/dist/esm/tests/consistency.js +59 -0
- package/dist/esm/tests/helpers/get-runtime-version-from-asdf.d.ts +1 -0
- package/dist/esm/tests/helpers/get-runtime-version-from-asdf.js +15 -0
- package/dist/esm/tests/helpers/get-string-from-yaml-file.d.ts +1 -0
- package/dist/esm/tests/helpers/get-string-from-yaml-file.js +15 -0
- package/dist/esm/tests/helpers/get-value-at-path.d.ts +1 -0
- package/dist/esm/tests/helpers/get-value-at-path.js +34 -0
- package/package.json +37 -0
package/README.md
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# @williamthorsen/nmr
|
|
2
|
+
|
|
3
|
+
Context-aware script runner for PNPM monorepos. Ships an `nmr` (node-monorepo run) binary that provides centralized, consistent script execution across workspace packages and the monorepo root.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pnpm add -D @williamthorsen/nmr
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## CLI usage
|
|
12
|
+
|
|
13
|
+
```
|
|
14
|
+
nmr <command> # Context-aware: root vs package
|
|
15
|
+
nmr -F, --filter <pattern> <command> # Run in matching packages
|
|
16
|
+
nmr -R, --recursive <command> # Run in all packages
|
|
17
|
+
nmr -w, --workspace-root <command> # Force root script registry
|
|
18
|
+
nmr -?, --help # Show available commands
|
|
19
|
+
nmr --int-test <command> # Use integration test scripts
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
### Examples
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
# From a package directory
|
|
26
|
+
nmr test # Runs workspace test script
|
|
27
|
+
nmr build # Runs compile && generate-typings
|
|
28
|
+
|
|
29
|
+
# From the monorepo root
|
|
30
|
+
nmr test # Runs root test + recursive workspace tests
|
|
31
|
+
nmr ci # Runs check:strict && build
|
|
32
|
+
|
|
33
|
+
# Targeting specific packages
|
|
34
|
+
nmr -F core test # Test only the core package
|
|
35
|
+
nmr -R lint # Lint all workspace packages
|
|
36
|
+
|
|
37
|
+
# Force root context from anywhere
|
|
38
|
+
nmr -w check # Run root check from a package dir
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Configuration
|
|
42
|
+
|
|
43
|
+
Create `.config/nmr.config.ts` in the monorepo root to add or override scripts:
|
|
44
|
+
|
|
45
|
+
```ts
|
|
46
|
+
import { defineConfig } from '@williamthorsen/nmr';
|
|
47
|
+
|
|
48
|
+
export default defineConfig({
|
|
49
|
+
workspaceScripts: {
|
|
50
|
+
'copy-content': 'tsx scripts/copy-content.ts',
|
|
51
|
+
},
|
|
52
|
+
rootScripts: {
|
|
53
|
+
'demo:catwalk': 'pnpx http-server --port=5189 demos/catwalk/',
|
|
54
|
+
},
|
|
55
|
+
});
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Three-tier override system
|
|
59
|
+
|
|
60
|
+
1. **Package defaults** — built-in scripts shipped with this package
|
|
61
|
+
2. **Repo-wide config** — additions/overrides in `.config/nmr.config.ts`
|
|
62
|
+
3. **Per-package overrides** — in a package's `package.json` `scripts` field
|
|
63
|
+
|
|
64
|
+
Per-package overrides take highest precedence. Set a script to `""` in `package.json` to skip it for that package.
|
|
65
|
+
|
|
66
|
+
Script values can be `string` or `string[]`. Arrays expand to chained `nmr` invocations:
|
|
67
|
+
|
|
68
|
+
```ts
|
|
69
|
+
// "build": ["compile", "generate-typings"]
|
|
70
|
+
// expands to: nmr compile && nmr generate-typings
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Consumer migration
|
|
74
|
+
|
|
75
|
+
After installing, a consuming repo's root `package.json` scripts shrink to lifecycle hooks:
|
|
76
|
+
|
|
77
|
+
```json
|
|
78
|
+
{
|
|
79
|
+
"prepare": "lefthook install",
|
|
80
|
+
"postinstall": "nmr report-overrides"
|
|
81
|
+
}
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Per-package `package.json` files no longer need script entries. Run `nmr <command>` directly.
|
|
85
|
+
|
|
86
|
+
## Consistency tests
|
|
87
|
+
|
|
88
|
+
Export structural consistency checks for use in your test suite:
|
|
89
|
+
|
|
90
|
+
```ts
|
|
91
|
+
// __tests__/consistency.test.ts
|
|
92
|
+
import { runConsistencyChecks } from '@williamthorsen/nmr/tests';
|
|
93
|
+
|
|
94
|
+
runConsistencyChecks();
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
This verifies:
|
|
98
|
+
|
|
99
|
+
- pnpm version matches between `package.json` and GitHub workflow
|
|
100
|
+
- Node.js version matches between `.tool-versions` and GitHub workflow
|
package/bin/nmr.js
ADDED
package/dist/esm/.cache
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
231a8d6ece4406ea0590ca7f31cff212ed0b31b0afa24e5b4cc00f048c5ce2d2
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { reportOverrides } from "./commands/report-overrides.js";
|
|
2
|
+
import { findMonorepoRoot } from "./context.js";
|
|
3
|
+
try {
|
|
4
|
+
const monorepoRoot = findMonorepoRoot();
|
|
5
|
+
reportOverrides(monorepoRoot);
|
|
6
|
+
} catch (error) {
|
|
7
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
8
|
+
process.exit(1);
|
|
9
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { syncPnpmVersion } from "./commands/sync-pnpm-version.js";
|
|
2
|
+
import { findMonorepoRoot } from "./context.js";
|
|
3
|
+
try {
|
|
4
|
+
const monorepoRoot = findMonorepoRoot();
|
|
5
|
+
syncPnpmVersion(monorepoRoot);
|
|
6
|
+
} catch (error) {
|
|
7
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
8
|
+
process.exit(1);
|
|
9
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/esm/cli.js
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import process from "node:process";
|
|
2
|
+
import { resolveContext } from "./context.js";
|
|
3
|
+
import { generateHelp } from "./help.js";
|
|
4
|
+
import { buildRootRegistry, buildWorkspaceRegistry, resolveScript } from "./resolver.js";
|
|
5
|
+
import { runCommand } from "./runner.js";
|
|
6
|
+
function shellQuote(arg) {
|
|
7
|
+
return "'" + arg.replace(/'/g, String.raw`'\''`) + "'";
|
|
8
|
+
}
|
|
9
|
+
function parseArgs(argv) {
|
|
10
|
+
const args = argv.slice(2);
|
|
11
|
+
const result = {
|
|
12
|
+
quiet: false,
|
|
13
|
+
recursive: false,
|
|
14
|
+
workspaceRoot: false,
|
|
15
|
+
help: false,
|
|
16
|
+
intTest: false,
|
|
17
|
+
passthrough: []
|
|
18
|
+
};
|
|
19
|
+
let i = 0;
|
|
20
|
+
while (i < args.length) {
|
|
21
|
+
const arg = args[i];
|
|
22
|
+
if (arg === void 0) break;
|
|
23
|
+
if (arg === "-F" || arg === "--filter") {
|
|
24
|
+
i++;
|
|
25
|
+
const filterValue = args[i];
|
|
26
|
+
if (filterValue === void 0) {
|
|
27
|
+
console.error("Error: -F/--filter requires a pattern argument");
|
|
28
|
+
process.exit(1);
|
|
29
|
+
}
|
|
30
|
+
result.filter = filterValue;
|
|
31
|
+
i++;
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
if (arg === "-R" || arg === "--recursive") {
|
|
35
|
+
result.recursive = true;
|
|
36
|
+
i++;
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
if (arg === "-w" || arg === "--workspace-root") {
|
|
40
|
+
result.workspaceRoot = true;
|
|
41
|
+
i++;
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
if (arg === "-?" || arg === "--help") {
|
|
45
|
+
result.help = true;
|
|
46
|
+
i++;
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
if (arg === "-q" || arg === "--quiet") {
|
|
50
|
+
result.quiet = true;
|
|
51
|
+
i++;
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
if (arg === "--int-test") {
|
|
55
|
+
result.intTest = true;
|
|
56
|
+
i++;
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
result.command = arg;
|
|
60
|
+
result.passthrough = args.slice(i + 1);
|
|
61
|
+
break;
|
|
62
|
+
}
|
|
63
|
+
return result;
|
|
64
|
+
}
|
|
65
|
+
async function main() {
|
|
66
|
+
const parsed = parseArgs(process.argv);
|
|
67
|
+
const context = await resolveContext();
|
|
68
|
+
if (parsed.help || !parsed.command) {
|
|
69
|
+
console.info(generateHelp(context.config));
|
|
70
|
+
process.exit(0);
|
|
71
|
+
}
|
|
72
|
+
const { command } = parsed;
|
|
73
|
+
const passthrough = parsed.passthrough.length > 0 ? " " + parsed.passthrough.map(shellQuote).join(" ") : "";
|
|
74
|
+
const runOptions = { quiet: parsed.quiet };
|
|
75
|
+
if (parsed.filter) {
|
|
76
|
+
const delegateCmd = `pnpm --filter ${shellQuote(parsed.filter)} exec nmr ${command}${passthrough}`;
|
|
77
|
+
const code2 = runCommand(delegateCmd, context.monorepoRoot, runOptions);
|
|
78
|
+
process.exit(code2);
|
|
79
|
+
}
|
|
80
|
+
if (parsed.recursive) {
|
|
81
|
+
const delegateCmd = `pnpm --recursive exec nmr ${command}${passthrough}`;
|
|
82
|
+
const code2 = runCommand(delegateCmd, context.monorepoRoot, runOptions);
|
|
83
|
+
process.exit(code2);
|
|
84
|
+
}
|
|
85
|
+
const useRoot = parsed.workspaceRoot || context.isRoot;
|
|
86
|
+
const registry = useRoot ? buildRootRegistry(context.config) : buildWorkspaceRegistry(context.config, parsed.intTest);
|
|
87
|
+
const resolved = resolveScript(command, registry, context.packageDir);
|
|
88
|
+
if (!resolved) {
|
|
89
|
+
console.error(`Unknown command: ${command}`);
|
|
90
|
+
process.exit(1);
|
|
91
|
+
}
|
|
92
|
+
if (resolved.command === "") {
|
|
93
|
+
if (!parsed.quiet) {
|
|
94
|
+
console.info("Override script is defined but empty. Skipping.");
|
|
95
|
+
}
|
|
96
|
+
process.exit(0);
|
|
97
|
+
}
|
|
98
|
+
if (resolved.source === "package" && !parsed.quiet) {
|
|
99
|
+
console.info(`Using override script: ${resolved.command}`);
|
|
100
|
+
}
|
|
101
|
+
const fullCommand = resolved.command + passthrough;
|
|
102
|
+
const code = runCommand(fullCommand, void 0, runOptions);
|
|
103
|
+
process.exit(code);
|
|
104
|
+
}
|
|
105
|
+
try {
|
|
106
|
+
await main();
|
|
107
|
+
} catch (error) {
|
|
108
|
+
console.error(error);
|
|
109
|
+
process.exit(1);
|
|
110
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function reportOverrides(monorepoRoot: string): void;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { getPnpmOverrides, readPackageJson } from "../helpers/package-json.js";
|
|
2
|
+
function reportOverrides(monorepoRoot) {
|
|
3
|
+
const pkg = readPackageJson(monorepoRoot);
|
|
4
|
+
const overrides = getPnpmOverrides(pkg);
|
|
5
|
+
if (!overrides || Object.keys(overrides).length === 0) {
|
|
6
|
+
return;
|
|
7
|
+
}
|
|
8
|
+
console.warn("\u{1F512} WARN: pnpm overrides are active! Check whether these are still needed:");
|
|
9
|
+
for (const [name, version] of Object.entries(overrides)) {
|
|
10
|
+
console.warn(`- ${name} \u2192 ${version}`);
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
export {
|
|
14
|
+
reportOverrides
|
|
15
|
+
};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { CodeQualityPnpmWorkflowSchema, getPnpmVersion } from "../helpers/code-quality-pnpm-action.js";
|
|
4
|
+
import { readPackageJson } from "../helpers/package-json.js";
|
|
5
|
+
import { readYamlFile } from "../helpers/yaml-utils.js";
|
|
6
|
+
const WORKFLOW_RELATIVE_PATH = ".github/workflows/code-quality.yaml";
|
|
7
|
+
function extractPnpmVersion(packageManager) {
|
|
8
|
+
if (!packageManager) {
|
|
9
|
+
return null;
|
|
10
|
+
}
|
|
11
|
+
const match = /^pnpm@(.+)$/.exec(packageManager);
|
|
12
|
+
return match?.[1] ?? null;
|
|
13
|
+
}
|
|
14
|
+
function syncPnpmVersion(monorepoRoot) {
|
|
15
|
+
console.info("Synchronizing pnpm version in GitHub workflow...");
|
|
16
|
+
const pkg = readPackageJson(monorepoRoot);
|
|
17
|
+
const pnpmVersion = extractPnpmVersion(pkg.packageManager);
|
|
18
|
+
if (!pnpmVersion) {
|
|
19
|
+
throw new Error(
|
|
20
|
+
`Could not extract pnpm version from package.json packageManager field
|
|
21
|
+
packageManager field: ${pkg.packageManager ?? "(not set)"}`
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
console.info(`Package.json pnpm version: ${pnpmVersion}`);
|
|
25
|
+
const workflowPath = path.join(monorepoRoot, WORKFLOW_RELATIVE_PATH);
|
|
26
|
+
const workflowData = readYamlFile(workflowPath);
|
|
27
|
+
const workflow = CodeQualityPnpmWorkflowSchema.parse(workflowData);
|
|
28
|
+
const currentWorkflowVersion = getPnpmVersion(workflow);
|
|
29
|
+
console.info(`Current workflow pnpm version: ${currentWorkflowVersion}`);
|
|
30
|
+
if (currentWorkflowVersion === pnpmVersion) {
|
|
31
|
+
console.info("Workflow pnpm version is already up to date");
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
const originalContent = readFileSync(workflowPath, "utf8");
|
|
35
|
+
const updatedContent = originalContent.replace(/(\s+pnpm-version:\s+)(['"]?)[\d.]+\2/, `$1$2${pnpmVersion}$2`);
|
|
36
|
+
writeFileSync(workflowPath, updatedContent, "utf8");
|
|
37
|
+
console.info(`\u2713 Updated workflow pnpm version: ${currentWorkflowVersion} \u2192 ${pnpmVersion}`);
|
|
38
|
+
}
|
|
39
|
+
export {
|
|
40
|
+
extractPnpmVersion,
|
|
41
|
+
syncPnpmVersion
|
|
42
|
+
};
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export interface NmrConfig {
|
|
2
|
+
workspaceScripts?: Record<string, string | string[]>;
|
|
3
|
+
rootScripts?: Record<string, string | string[]>;
|
|
4
|
+
}
|
|
5
|
+
export declare function defineConfig(config: NmrConfig): NmrConfig;
|
|
6
|
+
export declare function loadConfig(monorepoRoot: string): Promise<NmrConfig>;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { createJiti } from "jiti";
|
|
4
|
+
import { isObject } from "./helpers/type-guards.js";
|
|
5
|
+
const CONFIG_FILENAME = "nmr.config.ts";
|
|
6
|
+
const CONFIG_DIR = ".config";
|
|
7
|
+
function defineConfig(config) {
|
|
8
|
+
return config;
|
|
9
|
+
}
|
|
10
|
+
function isScriptRecord(value) {
|
|
11
|
+
if (!isObject(value)) return false;
|
|
12
|
+
for (const v of Object.values(value)) {
|
|
13
|
+
if (typeof v !== "string" && !Array.isArray(v)) return false;
|
|
14
|
+
if (Array.isArray(v) && v.some((item) => typeof item !== "string")) return false;
|
|
15
|
+
}
|
|
16
|
+
return true;
|
|
17
|
+
}
|
|
18
|
+
function validateScriptField(value, fieldName, configPath) {
|
|
19
|
+
if (!(fieldName in value) || value[fieldName] === void 0) {
|
|
20
|
+
return void 0;
|
|
21
|
+
}
|
|
22
|
+
if (!isScriptRecord(value[fieldName])) {
|
|
23
|
+
throw new Error(
|
|
24
|
+
`Invalid nmr config at ${configPath}: \`${fieldName}\` must be a Record<string, string | string[]>`
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
return value[fieldName];
|
|
28
|
+
}
|
|
29
|
+
function validateConfig(value, configPath) {
|
|
30
|
+
if (!isObject(value)) {
|
|
31
|
+
throw new Error(`Invalid nmr config at ${configPath}: expected an object, got ${typeof value}`);
|
|
32
|
+
}
|
|
33
|
+
const config = {};
|
|
34
|
+
const workspaceScripts = validateScriptField(value, "workspaceScripts", configPath);
|
|
35
|
+
if (workspaceScripts) config.workspaceScripts = workspaceScripts;
|
|
36
|
+
const rootScripts = validateScriptField(value, "rootScripts", configPath);
|
|
37
|
+
if (rootScripts) config.rootScripts = rootScripts;
|
|
38
|
+
return config;
|
|
39
|
+
}
|
|
40
|
+
async function loadConfig(monorepoRoot) {
|
|
41
|
+
const configPath = path.join(monorepoRoot, CONFIG_DIR, CONFIG_FILENAME);
|
|
42
|
+
if (!existsSync(configPath)) {
|
|
43
|
+
return {};
|
|
44
|
+
}
|
|
45
|
+
const jiti = createJiti(path.join(monorepoRoot, "package.json"));
|
|
46
|
+
const loaded = await jiti.import(configPath, { default: true });
|
|
47
|
+
return validateConfig(loaded, configPath);
|
|
48
|
+
}
|
|
49
|
+
export {
|
|
50
|
+
defineConfig,
|
|
51
|
+
loadConfig
|
|
52
|
+
};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { NmrConfig } from './config.js';
|
|
2
|
+
export interface ResolvedContext {
|
|
3
|
+
monorepoRoot: string;
|
|
4
|
+
isRoot: boolean;
|
|
5
|
+
packageDir?: string;
|
|
6
|
+
config: NmrConfig;
|
|
7
|
+
}
|
|
8
|
+
export declare function findMonorepoRoot(startDir?: string): string;
|
|
9
|
+
export declare function getWorkspacePackageDirs(monorepoRoot: string): string[];
|
|
10
|
+
export declare function findContainingPackageDir(dir: string, workspacePackageDirs: string[]): string | undefined;
|
|
11
|
+
export declare function resolveContext(cwd?: string): Promise<ResolvedContext>;
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import yaml from "js-yaml";
|
|
4
|
+
import { loadConfig } from "./config.js";
|
|
5
|
+
import { isObject } from "./helpers/type-guards.js";
|
|
6
|
+
function findMonorepoRoot(startDir) {
|
|
7
|
+
let dir = path.resolve(startDir ?? process.cwd());
|
|
8
|
+
for (; ; ) {
|
|
9
|
+
if (existsSync(path.join(dir, "pnpm-workspace.yaml"))) {
|
|
10
|
+
return dir;
|
|
11
|
+
}
|
|
12
|
+
const parent = path.dirname(dir);
|
|
13
|
+
if (parent === dir) {
|
|
14
|
+
throw new Error("Could not find monorepo root: no pnpm-workspace.yaml found in any parent directory");
|
|
15
|
+
}
|
|
16
|
+
dir = parent;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
function getWorkspacePackageDirs(monorepoRoot) {
|
|
20
|
+
const workspaceFile = path.join(monorepoRoot, "pnpm-workspace.yaml");
|
|
21
|
+
const content = readFileSync(workspaceFile, "utf8");
|
|
22
|
+
const parsed = yaml.load(content);
|
|
23
|
+
const packages = getPackagesFromParsedYaml(parsed);
|
|
24
|
+
if (!packages) {
|
|
25
|
+
return [];
|
|
26
|
+
}
|
|
27
|
+
const dirs = [];
|
|
28
|
+
for (const pattern of packages) {
|
|
29
|
+
if (pattern.endsWith("/*")) {
|
|
30
|
+
const prefix = pattern.slice(0, -2);
|
|
31
|
+
const prefixDir = path.resolve(monorepoRoot, prefix);
|
|
32
|
+
if (existsSync(prefixDir)) {
|
|
33
|
+
for (const entry of readdirSync(prefixDir)) {
|
|
34
|
+
const fullPath = path.join(prefixDir, entry);
|
|
35
|
+
if (statSync(fullPath).isDirectory() && existsSync(path.join(fullPath, "package.json"))) {
|
|
36
|
+
dirs.push(fullPath);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
} else if (!pattern.includes("*")) {
|
|
41
|
+
const fullPath = path.resolve(monorepoRoot, pattern);
|
|
42
|
+
if (existsSync(fullPath) && existsSync(path.join(fullPath, "package.json"))) {
|
|
43
|
+
dirs.push(fullPath);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return dirs;
|
|
48
|
+
}
|
|
49
|
+
function findContainingPackageDir(dir, workspacePackageDirs) {
|
|
50
|
+
const resolved = path.resolve(dir);
|
|
51
|
+
for (const pkgDir of workspacePackageDirs) {
|
|
52
|
+
const resolvedPkgDir = path.resolve(pkgDir);
|
|
53
|
+
if (resolved === resolvedPkgDir || resolved.startsWith(resolvedPkgDir + path.sep)) {
|
|
54
|
+
return resolvedPkgDir;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return void 0;
|
|
58
|
+
}
|
|
59
|
+
function getPackagesFromParsedYaml(parsed) {
|
|
60
|
+
if (!isObject(parsed)) return void 0;
|
|
61
|
+
const packages = parsed.packages;
|
|
62
|
+
if (!Array.isArray(packages)) return void 0;
|
|
63
|
+
if (!packages.every((p) => typeof p === "string")) return void 0;
|
|
64
|
+
return packages;
|
|
65
|
+
}
|
|
66
|
+
async function resolveContext(cwd) {
|
|
67
|
+
const resolvedCwd = path.resolve(cwd ?? process.cwd());
|
|
68
|
+
const monorepoRoot = findMonorepoRoot(resolvedCwd);
|
|
69
|
+
const config = await loadConfig(monorepoRoot);
|
|
70
|
+
const workspaceDirs = getWorkspacePackageDirs(monorepoRoot);
|
|
71
|
+
const packageDir = findContainingPackageDir(resolvedCwd, workspaceDirs);
|
|
72
|
+
return {
|
|
73
|
+
monorepoRoot,
|
|
74
|
+
isRoot: packageDir === void 0,
|
|
75
|
+
...packageDir === void 0 ? {} : { packageDir },
|
|
76
|
+
config
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
export {
|
|
80
|
+
findContainingPackageDir,
|
|
81
|
+
findMonorepoRoot,
|
|
82
|
+
getWorkspacePackageDirs,
|
|
83
|
+
resolveContext
|
|
84
|
+
};
|
package/dist/esm/help.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { buildRootRegistry, buildWorkspaceRegistry } from "./resolver.js";
|
|
2
|
+
import { describeScript } from "./resolver.js";
|
|
3
|
+
function generateHelp(config) {
|
|
4
|
+
const lines = [
|
|
5
|
+
"Usage: nmr [flags] <command> [args...]",
|
|
6
|
+
"",
|
|
7
|
+
"Flags:",
|
|
8
|
+
" -F, --filter <pattern> Run command in matching packages",
|
|
9
|
+
" -R, --recursive Run command in all packages",
|
|
10
|
+
" -w, --workspace-root Run root command regardless of cwd",
|
|
11
|
+
" -q, --quiet Suppress output on success; show full output on failure",
|
|
12
|
+
" -?, --help Show this help",
|
|
13
|
+
" --int-test Use integration test scripts",
|
|
14
|
+
"",
|
|
15
|
+
"Workspace commands:"
|
|
16
|
+
];
|
|
17
|
+
formatRegistry(buildWorkspaceRegistry(config, false), lines);
|
|
18
|
+
lines.push("", "Root commands:");
|
|
19
|
+
formatRegistry(buildRootRegistry(config), lines);
|
|
20
|
+
return lines.join("\n");
|
|
21
|
+
}
|
|
22
|
+
function formatRegistry(registry, lines) {
|
|
23
|
+
const maxKeyLen = Math.max(...Object.keys(registry).map((k) => k.length));
|
|
24
|
+
const pad = Math.max(maxKeyLen + 2, 20);
|
|
25
|
+
for (const [key, value] of Object.entries(registry)) {
|
|
26
|
+
lines.push(` ${key.padEnd(pad)} ${describeScript(value)}`);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
export {
|
|
30
|
+
generateHelp
|
|
31
|
+
};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
export declare const CodeQualityPnpmWorkflowSchema: z.ZodObject<{
|
|
3
|
+
jobs: z.ZodObject<{
|
|
4
|
+
'code-quality': z.ZodObject<{
|
|
5
|
+
with: z.ZodObject<{
|
|
6
|
+
'pnpm-version': z.ZodString;
|
|
7
|
+
}, z.core.$loose>;
|
|
8
|
+
}, z.core.$loose>;
|
|
9
|
+
}, z.core.$loose>;
|
|
10
|
+
}, z.core.$loose>;
|
|
11
|
+
export type CodeQualityPnpmWorkflow = z.infer<typeof CodeQualityPnpmWorkflowSchema>;
|
|
12
|
+
export declare function getPnpmVersion(workflow: CodeQualityPnpmWorkflow): string;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
const CodeQualityPnpmWorkflowSchema = z.looseObject({
|
|
3
|
+
jobs: z.looseObject({
|
|
4
|
+
"code-quality": z.looseObject({
|
|
5
|
+
with: z.looseObject({
|
|
6
|
+
"pnpm-version": z.string()
|
|
7
|
+
})
|
|
8
|
+
})
|
|
9
|
+
})
|
|
10
|
+
});
|
|
11
|
+
function getPnpmVersion(workflow) {
|
|
12
|
+
return workflow.jobs["code-quality"].with["pnpm-version"];
|
|
13
|
+
}
|
|
14
|
+
export {
|
|
15
|
+
CodeQualityPnpmWorkflowSchema,
|
|
16
|
+
getPnpmVersion
|
|
17
|
+
};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export interface PackageJson {
|
|
2
|
+
name?: string;
|
|
3
|
+
version?: string;
|
|
4
|
+
packageManager?: string;
|
|
5
|
+
scripts?: Record<string, string>;
|
|
6
|
+
pnpm?: {
|
|
7
|
+
overrides?: Record<string, string>;
|
|
8
|
+
};
|
|
9
|
+
}
|
|
10
|
+
export declare function readPackageJson(dir: string): PackageJson;
|
|
11
|
+
export declare function getPnpmOverrides(pkg: PackageJson): Record<string, string> | undefined;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { isObject } from "./type-guards.js";
|
|
4
|
+
function readPackageJson(dir) {
|
|
5
|
+
const content = readFileSync(path.join(dir, "package.json"), "utf8");
|
|
6
|
+
const parsed = JSON.parse(content);
|
|
7
|
+
if (!isObject(parsed)) {
|
|
8
|
+
throw new TypeError(`Invalid package.json in ${dir}: expected an object`);
|
|
9
|
+
}
|
|
10
|
+
const pkg = {};
|
|
11
|
+
if (typeof parsed.name === "string") pkg.name = parsed.name;
|
|
12
|
+
if (typeof parsed.version === "string") pkg.version = parsed.version;
|
|
13
|
+
if (typeof parsed.packageManager === "string") pkg.packageManager = parsed.packageManager;
|
|
14
|
+
if (isObject(parsed.scripts)) {
|
|
15
|
+
const scripts = {};
|
|
16
|
+
for (const [key, val] of Object.entries(parsed.scripts)) {
|
|
17
|
+
if (typeof val === "string") scripts[key] = val;
|
|
18
|
+
}
|
|
19
|
+
pkg.scripts = scripts;
|
|
20
|
+
}
|
|
21
|
+
if (isObject(parsed.pnpm)) {
|
|
22
|
+
const pnpm = parsed.pnpm;
|
|
23
|
+
if (isObject(pnpm.overrides)) {
|
|
24
|
+
const overrides = {};
|
|
25
|
+
for (const [key, val] of Object.entries(pnpm.overrides)) {
|
|
26
|
+
if (typeof val === "string") overrides[key] = val;
|
|
27
|
+
}
|
|
28
|
+
pkg.pnpm = { overrides };
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return pkg;
|
|
32
|
+
}
|
|
33
|
+
function getPnpmOverrides(pkg) {
|
|
34
|
+
if (!isObject(pkg.pnpm)) return void 0;
|
|
35
|
+
const overrides = pkg.pnpm.overrides;
|
|
36
|
+
if (!isObject(overrides)) return void 0;
|
|
37
|
+
for (const value of Object.values(overrides)) {
|
|
38
|
+
if (typeof value !== "string") return void 0;
|
|
39
|
+
}
|
|
40
|
+
return overrides;
|
|
41
|
+
}
|
|
42
|
+
export {
|
|
43
|
+
getPnpmOverrides,
|
|
44
|
+
readPackageJson
|
|
45
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function isObject(value: unknown): value is Record<string, unknown>;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function readYamlFile(filepath: string): unknown;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import yaml from "js-yaml";
|
|
3
|
+
function readYamlFile(filepath) {
|
|
4
|
+
try {
|
|
5
|
+
const content = readFileSync(filepath, "utf8");
|
|
6
|
+
return yaml.load(content);
|
|
7
|
+
} catch (error) {
|
|
8
|
+
throw new Error(`Failed to read YAML file: ${filepath}
|
|
9
|
+
${error instanceof Error ? error.message : String(error)}`);
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
export {
|
|
13
|
+
readYamlFile
|
|
14
|
+
};
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export type ScriptValue = string | string[];
|
|
2
|
+
export type ScriptRegistry = Record<string, ScriptValue>;
|
|
3
|
+
declare function getDefaultWorkspaceScripts(useIntTests: boolean): ScriptRegistry;
|
|
4
|
+
declare function getDefaultRootScripts(): ScriptRegistry;
|
|
5
|
+
export { getDefaultRootScripts, getDefaultWorkspaceScripts };
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
function getDefaultWorkspaceScripts(useIntTests) {
|
|
2
|
+
const commonScripts = {
|
|
3
|
+
build: ["compile", "generate-typings"],
|
|
4
|
+
check: ["typecheck", "fmt:check", "lint:check", "test"],
|
|
5
|
+
"check:strict": ["typecheck", "fmt:check", "lint:strict", "test:coverage"],
|
|
6
|
+
clean: "pnpm exec rimraf dist/*",
|
|
7
|
+
compile: "tsx ../../config/build.ts",
|
|
8
|
+
fmt: "prettier --list-different --write .",
|
|
9
|
+
"fmt:check": "prettier --check .",
|
|
10
|
+
"generate-typings": "tsc --project tsconfig.generate-typings.json",
|
|
11
|
+
lint: "eslint --fix .",
|
|
12
|
+
"lint:check": "eslint .",
|
|
13
|
+
"lint:strict": "strict-lint",
|
|
14
|
+
typecheck: "tsgo --noEmit",
|
|
15
|
+
"view-coverage": "open coverage/index.html"
|
|
16
|
+
};
|
|
17
|
+
const integrationTestOverrides = {
|
|
18
|
+
test: "pnpm exec vitest --config=vitest.standalone.config.ts",
|
|
19
|
+
"test:coverage": "pnpm exec vitest --config=vitest.standalone.config.ts --coverage",
|
|
20
|
+
"test:integration": "pnpm exec vitest --config=vitest.integration.config.ts",
|
|
21
|
+
"test:watch": "pnpm exec vitest --config=vitest.standalone.config.ts --watch"
|
|
22
|
+
};
|
|
23
|
+
const standardTestOverrides = {
|
|
24
|
+
test: "pnpm exec vitest",
|
|
25
|
+
"test:coverage": "pnpm exec vitest --coverage",
|
|
26
|
+
"test:watch": "pnpm exec vitest --watch"
|
|
27
|
+
};
|
|
28
|
+
return {
|
|
29
|
+
...commonScripts,
|
|
30
|
+
...useIntTests ? integrationTestOverrides : standardTestOverrides
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
function getDefaultRootScripts() {
|
|
34
|
+
return {
|
|
35
|
+
audit: ["audit:prod", "audit:dev"],
|
|
36
|
+
"audit:dev": "pnpm dlx audit-ci@^6 --config .audit-ci/config.dev.json5",
|
|
37
|
+
"audit:prod": "pnpm dlx audit-ci@^6 --config .audit-ci/config.prod.json5",
|
|
38
|
+
build: "pnpm --recursive exec nmr build",
|
|
39
|
+
check: ["typecheck", "fmt:check", "lint:check", "test"],
|
|
40
|
+
"check:strict": ["typecheck", "fmt:check", "lint:strict", "test:coverage", "audit"],
|
|
41
|
+
ci: ["check:strict", "build"],
|
|
42
|
+
fmt: `sh -c 'prettier --list-different --write "\${@:-.}"' --`,
|
|
43
|
+
"fmt:check": `sh -c 'prettier --check "\${@:-.}"' --`,
|
|
44
|
+
lint: "nmr root:lint && pnpm --recursive exec nmr lint",
|
|
45
|
+
"lint:check": "nmr root:lint:check && pnpm --recursive exec nmr lint:check",
|
|
46
|
+
"lint:strict": "nmr root:lint:strict && pnpm --recursive exec nmr lint:strict",
|
|
47
|
+
outdated: "pnpm outdated --compatible --recursive",
|
|
48
|
+
"outdated:latest": "pnpm outdated --recursive",
|
|
49
|
+
"report-overrides": "nmr-report-overrides",
|
|
50
|
+
"root:lint": "eslint --fix --ignore-pattern 'packages/**' .",
|
|
51
|
+
"root:lint:check": "eslint --ignore-pattern 'packages/**' .",
|
|
52
|
+
"root:lint:strict": "strict-lint --ignore-pattern 'packages/**' .",
|
|
53
|
+
"root:test": "vitest --config ./vitest.root.config.ts",
|
|
54
|
+
"root:typecheck": "tsgo --noEmit",
|
|
55
|
+
"sync-pnpm-version": "nmr-sync-pnpm-version",
|
|
56
|
+
test: "nmr root:test && pnpm --recursive exec nmr test",
|
|
57
|
+
"test:coverage": "nmr root:test && pnpm --recursive exec nmr test:coverage",
|
|
58
|
+
"test:watch": "vitest --watch",
|
|
59
|
+
typecheck: "nmr root:typecheck && pnpm --recursive exec nmr typecheck",
|
|
60
|
+
update: "pnpm update --recursive",
|
|
61
|
+
"update:latest": "pnpm update --latest --recursive"
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
export {
|
|
65
|
+
getDefaultRootScripts,
|
|
66
|
+
getDefaultWorkspaceScripts
|
|
67
|
+
};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { NmrConfig } from './config.js';
|
|
2
|
+
import type { ScriptRegistry, ScriptValue } from './registries.js';
|
|
3
|
+
export interface ResolvedScript {
|
|
4
|
+
command: string;
|
|
5
|
+
source: 'default' | 'package';
|
|
6
|
+
}
|
|
7
|
+
export declare function expandScript(script: ScriptValue): string;
|
|
8
|
+
export declare function describeScript(script: ScriptValue): string;
|
|
9
|
+
export declare function buildWorkspaceRegistry(config: NmrConfig, useIntTests: boolean): ScriptRegistry;
|
|
10
|
+
export declare function buildRootRegistry(config: NmrConfig): ScriptRegistry;
|
|
11
|
+
export declare function resolveScript(commandName: string, registry: ScriptRegistry, packageDir?: string): ResolvedScript | undefined;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { isObject } from "./helpers/type-guards.js";
|
|
4
|
+
import { getDefaultRootScripts, getDefaultWorkspaceScripts } from "./registries.js";
|
|
5
|
+
function expandScript(script) {
|
|
6
|
+
if (typeof script === "string") {
|
|
7
|
+
return script;
|
|
8
|
+
}
|
|
9
|
+
return script.map((s) => `nmr ${s}`).join(" && ");
|
|
10
|
+
}
|
|
11
|
+
function describeScript(script) {
|
|
12
|
+
return typeof script === "string" ? script : `[${script.join(", ")}]`;
|
|
13
|
+
}
|
|
14
|
+
function readPackageJsonScripts(packageDir) {
|
|
15
|
+
try {
|
|
16
|
+
const raw = readFileSync(path.join(packageDir, "package.json"), "utf8");
|
|
17
|
+
const parsed = JSON.parse(raw);
|
|
18
|
+
if (!isObject(parsed)) return void 0;
|
|
19
|
+
const scripts = parsed.scripts;
|
|
20
|
+
if (!isObject(scripts)) return void 0;
|
|
21
|
+
const result = {};
|
|
22
|
+
for (const [key, val] of Object.entries(scripts)) {
|
|
23
|
+
if (typeof val === "string") result[key] = val;
|
|
24
|
+
}
|
|
25
|
+
return result;
|
|
26
|
+
} catch (error) {
|
|
27
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT") {
|
|
28
|
+
return void 0;
|
|
29
|
+
}
|
|
30
|
+
throw error;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
function buildWorkspaceRegistry(config, useIntTests) {
|
|
34
|
+
return {
|
|
35
|
+
...getDefaultWorkspaceScripts(useIntTests),
|
|
36
|
+
...config.workspaceScripts
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
function buildRootRegistry(config) {
|
|
40
|
+
return {
|
|
41
|
+
...getDefaultRootScripts(),
|
|
42
|
+
...config.rootScripts
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
function resolveScript(commandName, registry, packageDir) {
|
|
46
|
+
if (packageDir) {
|
|
47
|
+
const pkgScripts = readPackageJsonScripts(packageDir);
|
|
48
|
+
if (pkgScripts && commandName in pkgScripts) {
|
|
49
|
+
const override = pkgScripts[commandName];
|
|
50
|
+
if (override !== void 0) {
|
|
51
|
+
return { command: override, source: "package" };
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
const registryEntry = registry[commandName];
|
|
56
|
+
if (registryEntry === void 0) {
|
|
57
|
+
return void 0;
|
|
58
|
+
}
|
|
59
|
+
return { command: expandScript(registryEntry), source: "default" };
|
|
60
|
+
}
|
|
61
|
+
export {
|
|
62
|
+
buildRootRegistry,
|
|
63
|
+
buildWorkspaceRegistry,
|
|
64
|
+
describeScript,
|
|
65
|
+
expandScript,
|
|
66
|
+
resolveScript
|
|
67
|
+
};
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { execSync } from "node:child_process";
|
|
2
|
+
import process from "node:process";
|
|
3
|
+
function runCommand(command, cwd, options) {
|
|
4
|
+
const quiet = options?.quiet === true;
|
|
5
|
+
const stdio = quiet ? "pipe" : "inherit";
|
|
6
|
+
try {
|
|
7
|
+
execSync(command, { stdio, cwd });
|
|
8
|
+
return 0;
|
|
9
|
+
} catch (error) {
|
|
10
|
+
if (error !== null && typeof error === "object") {
|
|
11
|
+
if (quiet) {
|
|
12
|
+
writeErrorOutput(error);
|
|
13
|
+
}
|
|
14
|
+
if ("status" in error) {
|
|
15
|
+
const { status } = error;
|
|
16
|
+
return typeof status === "number" ? status : 1;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
return 1;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
function writeErrorOutput(error) {
|
|
23
|
+
if ("stdout" in error) {
|
|
24
|
+
const { stdout } = error;
|
|
25
|
+
if (Buffer.isBuffer(stdout) && stdout.length > 0) {
|
|
26
|
+
process.stderr.write(stdout);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
if ("stderr" in error) {
|
|
30
|
+
const { stderr } = error;
|
|
31
|
+
if (Buffer.isBuffer(stderr) && stderr.length > 0) {
|
|
32
|
+
process.stderr.write(stderr);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
export {
|
|
37
|
+
runCommand
|
|
38
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function runConsistencyChecks(): void;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
import { findMonorepoRoot } from "../context.js";
|
|
5
|
+
import { getRuntimeVersionFromAsdf } from "./helpers/get-runtime-version-from-asdf.js";
|
|
6
|
+
import { getStringFromYamlFile } from "./helpers/get-string-from-yaml-file.js";
|
|
7
|
+
import { getValueAtPathOrThrow } from "./helpers/get-value-at-path.js";
|
|
8
|
+
const GITHUB_ACTION_FILE_PATH = ".github/workflows/code-quality.yaml";
|
|
9
|
+
function checkPnpmVersionConsistency(monorepoRoot) {
|
|
10
|
+
describe("pnpm version consistency", () => {
|
|
11
|
+
it("pnpm version is the same in GitHub action and package.json", async () => {
|
|
12
|
+
const actionVersion = await getPnpmVersionFromAction(monorepoRoot);
|
|
13
|
+
const packageJsonVersion = getPnpmVersionFromPackageJson(monorepoRoot);
|
|
14
|
+
expect(actionVersion).toBe(packageJsonVersion);
|
|
15
|
+
});
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
function checkNodeVersionConsistency(monorepoRoot) {
|
|
19
|
+
describe("Node.js version consistency", () => {
|
|
20
|
+
it("version is the same in GitHub action and .tool-versions", async () => {
|
|
21
|
+
const toolVersion = await getRuntimeVersionFromAsdf("nodejs", monorepoRoot);
|
|
22
|
+
const actionVersion = await getNodeVersionFromAction(monorepoRoot);
|
|
23
|
+
expect(toolVersion).toBe(actionVersion);
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
async function getPnpmVersionFromAction(monorepoRoot) {
|
|
28
|
+
const actionPath = path.join(monorepoRoot, GITHUB_ACTION_FILE_PATH);
|
|
29
|
+
return getStringFromYamlFile(actionPath, "jobs.code-quality.with.pnpm-version", "pnpm version");
|
|
30
|
+
}
|
|
31
|
+
function getPnpmVersionFromPackageJson(monorepoRoot) {
|
|
32
|
+
const pkgPath = path.join(monorepoRoot, "package.json");
|
|
33
|
+
const raw = fs.readFileSync(pkgPath, "utf8");
|
|
34
|
+
const pkg = JSON.parse(raw);
|
|
35
|
+
const pm = getValueAtPathOrThrow(pkg, "packageManager");
|
|
36
|
+
if (typeof pm !== "string") {
|
|
37
|
+
throw new TypeError('"packageManager" field missing or not a string in package.json.');
|
|
38
|
+
}
|
|
39
|
+
const [name, version] = pm.split("@");
|
|
40
|
+
if (name !== "pnpm") {
|
|
41
|
+
throw new Error("packageManager is not pnpm.");
|
|
42
|
+
}
|
|
43
|
+
if (!version) {
|
|
44
|
+
throw new Error("pnpm version missing in package.json.");
|
|
45
|
+
}
|
|
46
|
+
return version;
|
|
47
|
+
}
|
|
48
|
+
async function getNodeVersionFromAction(monorepoRoot) {
|
|
49
|
+
const actionPath = path.join(monorepoRoot, GITHUB_ACTION_FILE_PATH);
|
|
50
|
+
return getStringFromYamlFile(actionPath, "jobs.code-quality.with.node-version", "Node.js version");
|
|
51
|
+
}
|
|
52
|
+
function runConsistencyChecks() {
|
|
53
|
+
const monorepoRoot = findMonorepoRoot();
|
|
54
|
+
checkPnpmVersionConsistency(monorepoRoot);
|
|
55
|
+
checkNodeVersionConsistency(monorepoRoot);
|
|
56
|
+
}
|
|
57
|
+
export {
|
|
58
|
+
runConsistencyChecks
|
|
59
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function getRuntimeVersionFromAsdf(runtime: string, monorepoRoot: string): Promise<string>;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import assert from "node:assert";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
async function getRuntimeVersionFromAsdf(runtime, monorepoRoot) {
|
|
5
|
+
const toolVersionsPath = path.join(monorepoRoot, ".tool-versions");
|
|
6
|
+
const toolVersions = await fs.promises.readFile(toolVersionsPath, { encoding: "utf8" });
|
|
7
|
+
const versionLine = toolVersions.split("\n").find((line) => line.trim().startsWith(runtime));
|
|
8
|
+
assert.ok(versionLine, `${runtime} not found in .tool-versions`);
|
|
9
|
+
const [, version] = versionLine.trim().split(/\s+/);
|
|
10
|
+
assert.ok(version, `${runtime} version missing in .tool-versions`);
|
|
11
|
+
return version;
|
|
12
|
+
}
|
|
13
|
+
export {
|
|
14
|
+
getRuntimeVersionFromAsdf
|
|
15
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function getStringFromYamlFile(filePath: string, keyPath: string, label: string): Promise<string>;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import assert from "node:assert";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import yaml from "js-yaml";
|
|
4
|
+
import { getValueAtPathOrThrow } from "./get-value-at-path.js";
|
|
5
|
+
async function getStringFromYamlFile(filePath, keyPath, label) {
|
|
6
|
+
const raw = await fs.promises.readFile(filePath, { encoding: "utf8" });
|
|
7
|
+
const parsed = yaml.load(raw);
|
|
8
|
+
assert.ok(parsed, `YAML content not found in ${filePath}`);
|
|
9
|
+
const value = getValueAtPathOrThrow(parsed, keyPath);
|
|
10
|
+
assert.ok(typeof value === "string" && value.length > 0, `${label} not found at ${keyPath}`);
|
|
11
|
+
return value;
|
|
12
|
+
}
|
|
13
|
+
export {
|
|
14
|
+
getStringFromYamlFile
|
|
15
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function getValueAtPathOrThrow(obj: unknown, objPath: string): unknown;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
function isObject(value) {
|
|
2
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
3
|
+
}
|
|
4
|
+
function getValueAtPathOrThrow(obj, objPath) {
|
|
5
|
+
if (!isObject(obj)) {
|
|
6
|
+
throw new Error("Expected an object as root value.");
|
|
7
|
+
}
|
|
8
|
+
const keys = objPath.split(".");
|
|
9
|
+
let current = obj;
|
|
10
|
+
for (const key of keys) {
|
|
11
|
+
if (Array.isArray(current)) {
|
|
12
|
+
const index = Number(key);
|
|
13
|
+
if (Number.isNaN(index)) {
|
|
14
|
+
throw new TypeError(`Expected array index at segment "${key}" in path "${objPath}"`);
|
|
15
|
+
}
|
|
16
|
+
if (index >= 0 && index < current.length) {
|
|
17
|
+
current = current[index];
|
|
18
|
+
} else {
|
|
19
|
+
throw new Error(`Array index out of bounds: "${key}" in path "${objPath}"`);
|
|
20
|
+
}
|
|
21
|
+
} else if (isObject(current)) {
|
|
22
|
+
if (!(key in current)) {
|
|
23
|
+
throw new Error(`Missing key "${key}" in path "${objPath}"`);
|
|
24
|
+
}
|
|
25
|
+
current = current[key];
|
|
26
|
+
} else {
|
|
27
|
+
throw new Error(`Unexpected non-object/non-array at segment "${key}" in path "${objPath}"`);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return current;
|
|
31
|
+
}
|
|
32
|
+
export {
|
|
33
|
+
getValueAtPathOrThrow
|
|
34
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@williamthorsen/nmr",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"private": false,
|
|
5
|
+
"description": "Context-aware script runner for PNPM monorepos",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"monorepo",
|
|
8
|
+
"pnpm",
|
|
9
|
+
"script-runner",
|
|
10
|
+
"workspace"
|
|
11
|
+
],
|
|
12
|
+
"license": "MIT",
|
|
13
|
+
"author": "William Thorsen <william@thorsen.dev> (https://github.com/williamthorsen)",
|
|
14
|
+
"type": "module",
|
|
15
|
+
"exports": {
|
|
16
|
+
".": "./dist/esm/index.js",
|
|
17
|
+
"./tests": "./dist/esm/tests/consistency.js"
|
|
18
|
+
},
|
|
19
|
+
"bin": {
|
|
20
|
+
"nmr": "bin/nmr.js",
|
|
21
|
+
"nmr-report-overrides": "bin/nmr-report-overrides.js",
|
|
22
|
+
"nmr-sync-pnpm-version": "bin/nmr-sync-pnpm-version.js"
|
|
23
|
+
},
|
|
24
|
+
"files": [
|
|
25
|
+
"bin",
|
|
26
|
+
"dist"
|
|
27
|
+
],
|
|
28
|
+
"dependencies": {
|
|
29
|
+
"jiti": "2.6.1",
|
|
30
|
+
"js-yaml": "4.1.1",
|
|
31
|
+
"zod": "4.3.6"
|
|
32
|
+
},
|
|
33
|
+
"publishConfig": {
|
|
34
|
+
"access": "public"
|
|
35
|
+
},
|
|
36
|
+
"scripts": {}
|
|
37
|
+
}
|