@williamthorsen/nmr 0.18.0 → 0.18.2
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 +14 -6
- package/bin/nmr-clean.js +11 -0
- package/dist/esm/cli-clean.d.ts +1 -0
- package/dist/esm/cli-clean.js +9 -0
- package/dist/esm/commands/build.js +26 -6
- package/dist/esm/commands/clean.d.ts +2 -0
- package/dist/esm/commands/clean.js +52 -0
- package/dist/esm/default-scripts.js +2 -2
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -3,13 +3,13 @@
|
|
|
3
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
4
|
|
|
5
5
|
<!-- section:release-notes -->
|
|
6
|
-
## Release notes — v0.18.
|
|
6
|
+
## Release notes — v0.18.2 (2026-07-18)
|
|
7
7
|
|
|
8
8
|
### 🐛 Bug fixes
|
|
9
9
|
|
|
10
|
-
-
|
|
10
|
+
- Fail the build on aliased imports that escape the package source (#475)
|
|
11
11
|
|
|
12
|
-
Fixes an issue where a
|
|
12
|
+
Fixes an issue where an aliased import that resolved to a target outside the package's own source (one Node could not reach at runtime) passed the build but produced published output that crashed with a module-not-found error. `nmr-compile` now fails the build and reports the unresolvable import and where it pointed, so the problem surfaces before publish instead of in production. Aliased imports that resolve outside the source but remain genuinely external and runnable continue to build and ship unchanged.
|
|
13
13
|
<!-- /section:release-notes -->
|
|
14
14
|
|
|
15
15
|
## Installation
|
|
@@ -144,7 +144,7 @@ These scripts are available out of the box. Repo-wide config (tier 2) and per-pa
|
|
|
144
144
|
| `build` | `compile` |
|
|
145
145
|
| `check` | `typecheck`, `fmt:check`, `lint:check`, `test` |
|
|
146
146
|
| `check:strict` | `typecheck`, `fmt:check`, `lint:strict`, `test:coverage` |
|
|
147
|
-
| `clean` | `
|
|
147
|
+
| `clean` | `nmr-clean` |
|
|
148
148
|
| `compile` | `nmr-compile` |
|
|
149
149
|
| `fix` | `lint`, `fmt` |
|
|
150
150
|
| `fix:check` | `fmt:check`, `lint:check` |
|
|
@@ -179,7 +179,7 @@ A package gets this variant automatically when it contains a `vitest.integration
|
|
|
179
179
|
| ------- | --------------------------------- |
|
|
180
180
|
| `build` | `pnpm --recursive exec nmr build` |
|
|
181
181
|
| `ci` | `build`, `check:strict` |
|
|
182
|
-
| `clean` | `
|
|
182
|
+
| `clean` | `nmr-clean` |
|
|
183
183
|
|
|
184
184
|
#### Check and quality
|
|
185
185
|
|
|
@@ -341,9 +341,17 @@ nmr sync-pnpm-version
|
|
|
341
341
|
|
|
342
342
|
## Standalone utilities
|
|
343
343
|
|
|
344
|
+
### `nmr-clean`
|
|
345
|
+
|
|
346
|
+
Remove a package's build output (`dist`) and its `nmr-compile` cache entry, leaving no state behind for the next build to skip on. Run from a package directory it cleans that package; run from the monorepo root it sweeps every workspace package in a single pass, running each package's resolved `clean` — so a package that overrides `clean`, in `.config/nmr.config.ts` or in its own `package.json`, gets its own command rather than the sweep. Removal is idempotent — cleaning an unbuilt package is a silent no-op. This is the default `clean` script at both levels.
|
|
347
|
+
|
|
348
|
+
```bash
|
|
349
|
+
nmr-clean
|
|
350
|
+
```
|
|
351
|
+
|
|
344
352
|
### `nmr-compile`
|
|
345
353
|
|
|
346
|
-
Compile a single package's `src` tree to `dist/esm` with the TypeScript compiler API, emitting `.js` and `.d.ts` in one pass. Because the compiler parses each source file, every relative import form — static, re-export, dynamic `import()`, and bare side-effect — is rewritten from `.ts` to `.js` in both outputs, and `.ts` occurrences inside strings and comments are left intact. tsconfig `paths` aliases are resolved to runnable relative `.js` specifiers in both outputs, sourced from the package's tsconfig. The build is skipped when no input has changed (a content-and-path hash is cached under `node_modules/.cache/nmr-compile/`, outside the published output). This is the default `compile` script — run it from a package directory.
|
|
354
|
+
Compile a single package's `src` tree to `dist/esm` with the TypeScript compiler API, emitting `.js` and `.d.ts` in one pass. Because the compiler parses each source file, every relative import form — static, re-export, dynamic `import()`, and bare side-effect — is rewritten from `.ts` to `.js` in both outputs, and `.ts` occurrences inside strings and comments are left intact. tsconfig `paths` aliases are resolved to runnable relative `.js` specifiers in both outputs, sourced from the package's tsconfig. An aliased import whose target resolves outside the package's `src/` and is not resolvable without the alias mapping fails the build with a diagnostic, rather than being emitted verbatim to produce output that fails at runtime. The build is skipped when no input has changed and the previous output is still on disk (a content-and-path hash is cached under `node_modules/.cache/nmr-compile/`, outside the published output). Deleting the output by any means — `nmr clean`, `rm -rf dist`, `git clean` — therefore forces a rebuild rather than a skip. This is the default `compile` script — run it from a package directory.
|
|
347
355
|
|
|
348
356
|
`typescript` is a peer dependency (`>=5.7.0`, required for `rewriteRelativeImportExtensions`); the consuming repo provides it. Relative imports in source must carry explicit `.ts` extensions for them to be rewritten.
|
|
349
357
|
|
package/bin/nmr-clean.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
try {
|
|
3
|
+
await import('../dist/esm/cli-clean.js');
|
|
4
|
+
} catch (error) {
|
|
5
|
+
if (error.code === 'ERR_MODULE_NOT_FOUND') {
|
|
6
|
+
process.stderr.write('nmr-clean: build output not found — run `pnpm run build` first\n');
|
|
7
|
+
} else {
|
|
8
|
+
process.stderr.write(`nmr-clean: failed to load: ${error.message}\n`);
|
|
9
|
+
}
|
|
10
|
+
process.exit(1);
|
|
11
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { reportError } from '@williamthorsen/nmr-core';
|
|
2
|
+
import { runClean } from "./commands/clean.js";
|
|
3
|
+
try {
|
|
4
|
+
await runClean(process.cwd());
|
|
5
|
+
}
|
|
6
|
+
catch (error) {
|
|
7
|
+
reportError(error instanceof Error ? error.message : String(error));
|
|
8
|
+
process.exitCode = 1;
|
|
9
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
|
-
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
3
3
|
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import { glob } from 'glob';
|
|
@@ -24,7 +24,7 @@ export async function buildPackage(packageDir, options = {}) {
|
|
|
24
24
|
ignore: options.ignore ?? DEFAULT_IGNORE,
|
|
25
25
|
});
|
|
26
26
|
const dependencies = ['package.json', ...resolveTsconfigChain(packageDir)];
|
|
27
|
-
const { changed, currentHash } = await detectBuildChanges(packageDir, [...entryPoints, ...dependencies], emitConfig, cachePath);
|
|
27
|
+
const { changed, currentHash } = await detectBuildChanges(packageDir, [...entryPoints, ...dependencies], emitConfig, cachePath, hasExpectedBuildOutput(packageDir, outdir, entryPoints));
|
|
28
28
|
if (!changed) {
|
|
29
29
|
return;
|
|
30
30
|
}
|
|
@@ -149,7 +149,15 @@ function resolveSpecifierReplacement(specifier, sourceContainingFile, compilerOp
|
|
|
149
149
|
`Verify the tsconfig 'paths' mapping and that the target file exists.`);
|
|
150
150
|
}
|
|
151
151
|
if (!isWithin(sourceRoot, resolved.resolvedFileName)) {
|
|
152
|
-
|
|
152
|
+
const { paths: _paths, baseUrl: _baseUrl, rootDirs: _rootDirs, ...nodeResolutionOptions } = compilerOptions;
|
|
153
|
+
const bareResolved = ts.resolveModuleName(specifier, sourceContainingFile, nodeResolutionOptions, ts.sys).resolvedModule;
|
|
154
|
+
if (bareResolved) {
|
|
155
|
+
return undefined;
|
|
156
|
+
}
|
|
157
|
+
throw new Error(`nmr-compile: aliased import '${specifier}' from ${sourceContainingFile} resolves to ` +
|
|
158
|
+
`${resolved.resolvedFileName}, outside the package source root ${sourceRoot}, and does not resolve ` +
|
|
159
|
+
`the way Node will at runtime, which ignores tsconfig 'paths', 'baseUrl', and 'rootDirs'. The ` +
|
|
160
|
+
`emitted specifier would fail at runtime; re-anchor the alias inside the package.`);
|
|
153
161
|
}
|
|
154
162
|
const relative = toRelativeSpecifier(path.dirname(sourceContainingFile), resolved.resolvedFileName);
|
|
155
163
|
return swapTypeScriptExtension(relative);
|
|
@@ -199,17 +207,29 @@ function findNearestNodeModulesHost(startDir) {
|
|
|
199
207
|
}
|
|
200
208
|
return current;
|
|
201
209
|
}
|
|
202
|
-
async function detectBuildChanges(packageDir, files, emitConfig, cachePath) {
|
|
210
|
+
async function detectBuildChanges(packageDir, files, emitConfig, cachePath, outputPresent) {
|
|
203
211
|
const packageName = path.basename(packageDir);
|
|
204
212
|
const previousHash = existsSync(cachePath) ? readFileSync(cachePath, 'utf8') : undefined;
|
|
205
213
|
const currentHash = await computeBuildHash(packageDir, files, emitConfig);
|
|
206
214
|
if (previousHash === currentHash) {
|
|
207
|
-
|
|
208
|
-
|
|
215
|
+
if (outputPresent) {
|
|
216
|
+
console.info(`${SKIPPED_ICON} ${packageName}: No changes detected. Skipping build.`);
|
|
217
|
+
return { changed: false, currentHash };
|
|
218
|
+
}
|
|
219
|
+
console.info(`${PACKAGE_ICON} ${packageName}: Build output is missing. Rebuilding.`);
|
|
220
|
+
return { changed: true, currentHash };
|
|
209
221
|
}
|
|
210
222
|
console.info(`${PACKAGE_ICON} ${packageName}: Changes detected.`);
|
|
211
223
|
return { changed: true, currentHash };
|
|
212
224
|
}
|
|
225
|
+
function hasExpectedBuildOutput(packageDir, outdir, entryPoints) {
|
|
226
|
+
const emitsOutput = entryPoints.some((entry) => !entry.endsWith('.d.ts'));
|
|
227
|
+
if (!emitsOutput) {
|
|
228
|
+
return true;
|
|
229
|
+
}
|
|
230
|
+
const outputDir = path.resolve(packageDir, outdir);
|
|
231
|
+
return existsSync(outputDir) && readdirSync(outputDir).length > 0;
|
|
232
|
+
}
|
|
213
233
|
async function writeBuildCache(cachePath, hash) {
|
|
214
234
|
await mkdir(path.dirname(cachePath), { recursive: true });
|
|
215
235
|
await writeFile(cachePath, hash);
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { rm } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { loadConfig } from "../config.js";
|
|
4
|
+
import { findContainingPackageDir, findMonorepoRoot, getWorkspacePackageDirs } from "../context.js";
|
|
5
|
+
import { applyDevBin, buildWorkspaceRegistry, hasIntegrationTestConfig, resolveScript } from "../resolver.js";
|
|
6
|
+
import { runCommand } from "../runner.js";
|
|
7
|
+
import { resolveBuildCachePath } from "./build.js";
|
|
8
|
+
const CLEAN_ICON = '🧹';
|
|
9
|
+
const CLEAN_COMMAND = 'clean';
|
|
10
|
+
const BUILT_IN_CLEAN = 'nmr-clean';
|
|
11
|
+
const OUTPUT_ROOT = 'dist';
|
|
12
|
+
export async function cleanPackage(packageDir) {
|
|
13
|
+
await rm(path.resolve(packageDir, OUTPUT_ROOT), { recursive: true, force: true });
|
|
14
|
+
await rm(resolveBuildCachePath(packageDir), { force: true });
|
|
15
|
+
console.info(`${CLEAN_ICON} ${path.basename(packageDir)}: Removed build output and cache.`);
|
|
16
|
+
}
|
|
17
|
+
export async function runClean(cwd = process.cwd()) {
|
|
18
|
+
let monorepoRoot;
|
|
19
|
+
try {
|
|
20
|
+
monorepoRoot = findMonorepoRoot(cwd);
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
await cleanPackage(cwd);
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
const workspacePackageDirs = getWorkspacePackageDirs(monorepoRoot);
|
|
27
|
+
const packageDir = findContainingPackageDir(cwd, workspacePackageDirs);
|
|
28
|
+
if (packageDir !== undefined) {
|
|
29
|
+
await cleanPackage(packageDir);
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
await sweepWorkspace(monorepoRoot, workspacePackageDirs);
|
|
33
|
+
}
|
|
34
|
+
async function sweepWorkspace(monorepoRoot, workspacePackageDirs) {
|
|
35
|
+
const config = await loadConfig(monorepoRoot);
|
|
36
|
+
for (const packageDir of workspacePackageDirs) {
|
|
37
|
+
const registry = buildWorkspaceRegistry(config, hasIntegrationTestConfig(packageDir));
|
|
38
|
+
const resolved = resolveScript(CLEAN_COMMAND, registry, packageDir, false);
|
|
39
|
+
if (resolved === undefined || resolved.command === '') {
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
if (resolved.command === BUILT_IN_CLEAN) {
|
|
43
|
+
await cleanPackage(packageDir);
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
const command = applyDevBin(resolved.command, config.devBin, monorepoRoot);
|
|
47
|
+
const exitCode = runCommand(command, packageDir);
|
|
48
|
+
if (exitCode !== 0) {
|
|
49
|
+
throw new Error(`nmr-clean: \`${command}\` failed in ${path.basename(packageDir)} with exit code ${exitCode}.`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -2,7 +2,7 @@ export const commonWorkspaceScripts = {
|
|
|
2
2
|
build: ['compile'],
|
|
3
3
|
check: ['typecheck', 'fmt:check', 'lint:check', 'test'],
|
|
4
4
|
'check:strict': ['typecheck', 'fmt:check', 'lint:strict', 'test:coverage'],
|
|
5
|
-
clean: '
|
|
5
|
+
clean: 'nmr-clean',
|
|
6
6
|
compile: 'nmr-compile',
|
|
7
7
|
fix: ['lint', 'fmt'],
|
|
8
8
|
'fix:check': ['fmt:check', 'lint:check'],
|
|
@@ -35,7 +35,7 @@ export const rootScripts = {
|
|
|
35
35
|
'check:agent-files': 'nmr-sync-agent-files --check',
|
|
36
36
|
'check:strict': ['typecheck', 'fmt:check', 'lint:strict', 'test:coverage', 'check:agent-files'],
|
|
37
37
|
ci: ['build', 'check:strict', 'audit'],
|
|
38
|
-
clean: '
|
|
38
|
+
clean: 'nmr-clean',
|
|
39
39
|
fix: ['lint', 'fmt'],
|
|
40
40
|
'fix:check': ['fmt:check', 'lint:check'],
|
|
41
41
|
fmt: 'sh -c \'prettier --list-different --write "${@:-.}"\' --',
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@williamthorsen/nmr",
|
|
3
|
-
"version": "0.18.
|
|
3
|
+
"version": "0.18.2",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Context-aware script runner for PNPM monorepos",
|
|
6
6
|
"keywords": [
|
|
@@ -38,6 +38,7 @@
|
|
|
38
38
|
"bin": {
|
|
39
39
|
"ensure-prepublish-hooks": "bin/ensure-prepublish-hooks.js",
|
|
40
40
|
"nmr": "bin/nmr.js",
|
|
41
|
+
"nmr-clean": "bin/nmr-clean.js",
|
|
41
42
|
"nmr-compile": "bin/nmr-compile.js",
|
|
42
43
|
"nmr-report-overrides": "bin/nmr-report-overrides.js",
|
|
43
44
|
"nmr-sync-agent-files": "bin/nmr-sync-agent-files.js",
|