@williamthorsen/nmr 0.17.0 → 0.17.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 +25 -6
- package/bin/nmr-attw.js +11 -0
- package/dist/esm/cli-attw.d.ts +1 -0
- package/dist/esm/cli-attw.js +15 -0
- package/dist/esm/commands/attw.d.ts +34 -0
- package/dist/esm/commands/attw.js +87 -0
- package/dist/esm/commands/build.d.ts +1 -1
- package/dist/esm/commands/build.js +24 -8
- package/dist/esm/default-scripts.js +1 -1
- package/dist/esm/helpers/package-json.d.ts +3 -0
- package/dist/esm/helpers/package-json.js +7 -0
- package/package.json +2 -1
- package/dist/esm/.cache +0 -1
package/README.md
CHANGED
|
@@ -3,13 +3,18 @@
|
|
|
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.17.
|
|
6
|
+
## Release notes — v0.17.2 (2026-07-06)
|
|
7
7
|
|
|
8
|
-
###
|
|
8
|
+
### 🐛 Bug fixes
|
|
9
9
|
|
|
10
|
-
-
|
|
10
|
+
- Replace the raw attw script with an nmr-attw wrapper (#461)
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
Fixes the issues that made the default `nmr attw` check fail across monorepos:
|
|
13
|
+
|
|
14
|
+
- packages with no published API failed with a false `NoResolution` error;
|
|
15
|
+
- the check left stray `.tgz` files in the working tree ;
|
|
16
|
+
- output was a verbose per-package table; now output is terse on success, full diagnostics only on failure (`--verbose` to force full output);
|
|
17
|
+
- a missing `@arethetypeswrong/cli` gave a raw command-not-found; now a hint is given.
|
|
13
18
|
<!-- /section:release-notes -->
|
|
14
19
|
|
|
15
20
|
## Installation
|
|
@@ -141,7 +146,7 @@ These scripts are available out of the box. Repo-wide config (tier 2) and per-pa
|
|
|
141
146
|
|
|
142
147
|
| Command | Runs |
|
|
143
148
|
| --------------- | ---------------------------------------------------------------- |
|
|
144
|
-
| `attw` | `attw
|
|
149
|
+
| `attw` | `nmr-attw` |
|
|
145
150
|
| `build` | `compile` |
|
|
146
151
|
| `check` | `typecheck`, `fmt:check`, `lint:check`, `test` |
|
|
147
152
|
| `check:strict` | `typecheck`, `fmt:check`, `lint:strict`, `test:coverage`, `attw` |
|
|
@@ -345,7 +350,7 @@ nmr sync-pnpm-version
|
|
|
345
350
|
|
|
346
351
|
### `nmr-compile`
|
|
347
352
|
|
|
348
|
-
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
|
|
353
|
+
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.
|
|
349
354
|
|
|
350
355
|
`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.
|
|
351
356
|
|
|
@@ -353,6 +358,20 @@ Compile a single package's `src` tree to `dist/esm` with the TypeScript compiler
|
|
|
353
358
|
nmr-compile
|
|
354
359
|
```
|
|
355
360
|
|
|
361
|
+
### `nmr-attw`
|
|
362
|
+
|
|
363
|
+
Validate a package's published type-resolution surface with [`@arethetypeswrong/cli`](https://github.com/arethetypeswrong/arethetypeswrong.github.io). This is the default `attw` script — run it from a package directory. It wraps attw so that it behaves well across a monorepo:
|
|
364
|
+
|
|
365
|
+
- **Skips packages with no publishable entry point** (neither `main` nor `exports`) — a message and exit 0, before attw runs. A package with no importable surface has nothing for attw to resolve, so running it would false-positive with `NoResolution`. A private package that _does_ declare `exports` is still checked.
|
|
366
|
+
- **Leaves no `.tgz` in the working tree.** attw analyzes a packed tarball; nmr-attw packs into a temp directory instead of the package directory, so nothing litters the tree.
|
|
367
|
+
- **Condenses output** to a terse per-package result on success and full attw diagnostics only on failure. Pass `--verbose` to print the full attw output on success too.
|
|
368
|
+
|
|
369
|
+
`@arethetypeswrong/cli` is not bundled — a package that declares an entry point must have it installed; nmr-attw reports an actionable message if it is missing. Any other flags are forwarded to attw (the profile defaults to `esm-only`).
|
|
370
|
+
|
|
371
|
+
```bash
|
|
372
|
+
nmr-attw
|
|
373
|
+
```
|
|
374
|
+
|
|
356
375
|
### `ensure-prepublish-hooks`
|
|
357
376
|
|
|
358
377
|
Verify that all publishable workspace packages have a `prepublishOnly` script. Exits non-zero if any are missing.
|
package/bin/nmr-attw.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
try {
|
|
3
|
+
await import('../dist/esm/cli-attw.js');
|
|
4
|
+
} catch (error) {
|
|
5
|
+
if (error.code === 'ERR_MODULE_NOT_FOUND') {
|
|
6
|
+
process.stderr.write('nmr-attw: build output not found — run `pnpm run build` first\n');
|
|
7
|
+
} else {
|
|
8
|
+
process.stderr.write(`nmr-attw: failed to load: ${error.message}\n`);
|
|
9
|
+
}
|
|
10
|
+
process.exit(1);
|
|
11
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { reportError } from '@williamthorsen/nmr-core';
|
|
2
|
+
import { runAttw } from "./commands/attw.js";
|
|
3
|
+
try {
|
|
4
|
+
process.exitCode = runAttw({
|
|
5
|
+
packageDir: process.cwd(),
|
|
6
|
+
argv: process.argv.slice(2),
|
|
7
|
+
stdout: process.stdout,
|
|
8
|
+
stderr: process.stderr,
|
|
9
|
+
env: process.env,
|
|
10
|
+
});
|
|
11
|
+
}
|
|
12
|
+
catch (error) {
|
|
13
|
+
reportError(error instanceof Error ? error.message : String(error));
|
|
14
|
+
process.exitCode = 1;
|
|
15
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { type SpawnSyncReturns } from 'node:child_process';
|
|
2
|
+
import type { Writable } from 'node:stream';
|
|
3
|
+
export type SpawnSyncFn = (command: string, args: string[], options: {
|
|
4
|
+
cwd: string;
|
|
5
|
+
encoding: 'utf8';
|
|
6
|
+
env: NodeJS.ProcessEnv;
|
|
7
|
+
}) => Pick<SpawnSyncReturns<string>, 'error' | 'status' | 'stdout' | 'stderr'>;
|
|
8
|
+
export interface RunAttwOptions {
|
|
9
|
+
packageDir: string;
|
|
10
|
+
argv: string[];
|
|
11
|
+
stdout: Writable;
|
|
12
|
+
stderr: Writable;
|
|
13
|
+
env: NodeJS.ProcessEnv;
|
|
14
|
+
spawn?: SpawnSyncFn;
|
|
15
|
+
}
|
|
16
|
+
export declare function runAttw(options: RunAttwOptions): number;
|
|
17
|
+
export declare function buildAttwArgs(argv: string[]): {
|
|
18
|
+
verbose: boolean;
|
|
19
|
+
attwArgs: string[];
|
|
20
|
+
};
|
|
21
|
+
interface AttwOutcome {
|
|
22
|
+
status: number;
|
|
23
|
+
stdout: string;
|
|
24
|
+
stderr: string;
|
|
25
|
+
}
|
|
26
|
+
export declare function formatAttwResult(params: {
|
|
27
|
+
label: string;
|
|
28
|
+
verbose: boolean;
|
|
29
|
+
attwStatus: number | null;
|
|
30
|
+
attwStdout: string;
|
|
31
|
+
attwStderr: string;
|
|
32
|
+
}): AttwOutcome;
|
|
33
|
+
export declare function attwSpawnErrorMessage(label: string, error: Error): string;
|
|
34
|
+
export {};
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { mkdtempSync, readdirSync, rmSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { hasPublishableEntryPoint, readPackageJson } from "../helpers/package-json.js";
|
|
6
|
+
const DEFAULT_PROFILE = 'esm-only';
|
|
7
|
+
export function runAttw(options) {
|
|
8
|
+
const { packageDir, argv, stdout, stderr, env } = options;
|
|
9
|
+
const spawn = options.spawn ?? ((command, args, spawnOptions) => spawnSync(command, args, spawnOptions));
|
|
10
|
+
const pkg = readPackageJson(packageDir);
|
|
11
|
+
const label = pkg.name ?? path.basename(packageDir);
|
|
12
|
+
if (!hasPublishableEntryPoint(pkg)) {
|
|
13
|
+
stdout.write(`⛔ ${label}: No publishable entry point (no "main"/"exports"). Skipping attw.\n`);
|
|
14
|
+
return 0;
|
|
15
|
+
}
|
|
16
|
+
const { verbose, attwArgs } = buildAttwArgs(argv);
|
|
17
|
+
const tempDir = mkdtempSync(path.join(tmpdir(), 'nmr-attw-'));
|
|
18
|
+
try {
|
|
19
|
+
const pack = spawn('npm', ['pack', '--pack-destination', tempDir], { cwd: packageDir, encoding: 'utf8', env });
|
|
20
|
+
if (pack.error !== undefined) {
|
|
21
|
+
stderr.write(`nmr-attw: npm pack failed for ${label}: ${pack.error.message}\n`);
|
|
22
|
+
return 1;
|
|
23
|
+
}
|
|
24
|
+
if (pack.status !== 0) {
|
|
25
|
+
stderr.write(pack.stderr || `nmr-attw: npm pack failed for ${label}\n`);
|
|
26
|
+
return pack.status ?? 1;
|
|
27
|
+
}
|
|
28
|
+
const tarball = readdirSync(tempDir).find((file) => file.endsWith('.tgz'));
|
|
29
|
+
if (tarball === undefined) {
|
|
30
|
+
stderr.write(`nmr-attw: npm pack produced no tarball for ${label}\n`);
|
|
31
|
+
return 1;
|
|
32
|
+
}
|
|
33
|
+
const attw = spawn('attw', [path.join(tempDir, tarball), ...attwArgs], {
|
|
34
|
+
cwd: packageDir,
|
|
35
|
+
encoding: 'utf8',
|
|
36
|
+
env,
|
|
37
|
+
});
|
|
38
|
+
if (attw.error !== undefined) {
|
|
39
|
+
stderr.write(attwSpawnErrorMessage(label, attw.error));
|
|
40
|
+
return 1;
|
|
41
|
+
}
|
|
42
|
+
const outcome = formatAttwResult({
|
|
43
|
+
label,
|
|
44
|
+
verbose,
|
|
45
|
+
attwStatus: attw.status,
|
|
46
|
+
attwStdout: attw.stdout,
|
|
47
|
+
attwStderr: attw.stderr,
|
|
48
|
+
});
|
|
49
|
+
if (outcome.stdout)
|
|
50
|
+
stdout.write(outcome.stdout);
|
|
51
|
+
if (outcome.stderr)
|
|
52
|
+
stderr.write(outcome.stderr);
|
|
53
|
+
return outcome.status;
|
|
54
|
+
}
|
|
55
|
+
finally {
|
|
56
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
export function buildAttwArgs(argv) {
|
|
60
|
+
let verbose = false;
|
|
61
|
+
const attwArgs = [];
|
|
62
|
+
for (const arg of argv) {
|
|
63
|
+
if (arg === '--verbose' || arg === '-v') {
|
|
64
|
+
verbose = true;
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
attwArgs.push(arg);
|
|
68
|
+
}
|
|
69
|
+
if (!attwArgs.some((arg) => arg === '--profile' || arg.startsWith('--profile='))) {
|
|
70
|
+
attwArgs.push('--profile', DEFAULT_PROFILE);
|
|
71
|
+
}
|
|
72
|
+
return { verbose, attwArgs };
|
|
73
|
+
}
|
|
74
|
+
export function formatAttwResult(params) {
|
|
75
|
+
const { label, verbose, attwStatus, attwStdout, attwStderr } = params;
|
|
76
|
+
const status = attwStatus ?? 1;
|
|
77
|
+
if (status === 0 && !verbose) {
|
|
78
|
+
return { status: 0, stdout: `✓ ${label}: types OK\n`, stderr: '' };
|
|
79
|
+
}
|
|
80
|
+
return { status, stdout: attwStdout, stderr: attwStderr };
|
|
81
|
+
}
|
|
82
|
+
export function attwSpawnErrorMessage(label, error) {
|
|
83
|
+
if ('code' in error && error.code === 'ENOENT') {
|
|
84
|
+
return `⚠ ${label}: attw not found — install @arethetypeswrong/cli to validate published types.\n`;
|
|
85
|
+
}
|
|
86
|
+
return `nmr-attw: failed to run attw for ${label}: ${error.message}\n`;
|
|
87
|
+
}
|
|
@@ -2,8 +2,8 @@ export interface BuildOptions {
|
|
|
2
2
|
entryGlobs?: string[];
|
|
3
3
|
ignore?: string[];
|
|
4
4
|
outdir?: string;
|
|
5
|
-
cacheFile?: string;
|
|
6
5
|
}
|
|
7
6
|
export declare function buildPackage(packageDir: string, options?: BuildOptions): Promise<void>;
|
|
8
7
|
export declare function computeBuildHash(packageDir: string, files: string[], emitConfig: object): Promise<string>;
|
|
9
8
|
export declare function resolveTsconfigChain(packageDir: string, configFileName?: string): string[];
|
|
9
|
+
export declare function resolveBuildCachePath(packageDir: string): string;
|
|
@@ -9,7 +9,6 @@ const SKIPPED_ICON = '⏭️';
|
|
|
9
9
|
const DEFAULT_ENTRY_GLOBS = ['src/**/*.ts'];
|
|
10
10
|
const DEFAULT_IGNORE = ['**/__tests__/**'];
|
|
11
11
|
const DEFAULT_OUTDIR = 'dist/esm/';
|
|
12
|
-
const DEFAULT_CACHE_FILE = 'dist/esm/.cache';
|
|
13
12
|
const SOURCE_ROOT = 'src';
|
|
14
13
|
const MINIMUM_TYPESCRIPT_MAJOR = 5;
|
|
15
14
|
const MINIMUM_TYPESCRIPT_MINOR = 7;
|
|
@@ -17,7 +16,7 @@ const TS_EXTENSION = '.ts';
|
|
|
17
16
|
const JS_EXTENSION = '.js';
|
|
18
17
|
export async function buildPackage(packageDir, options = {}) {
|
|
19
18
|
assertSupportedTypeScript();
|
|
20
|
-
const
|
|
19
|
+
const cachePath = resolveBuildCachePath(packageDir);
|
|
21
20
|
const outdir = options.outdir ?? DEFAULT_OUTDIR;
|
|
22
21
|
const emitConfig = { outdir, declaration: true, rewriteRelativeImportExtensions: true };
|
|
23
22
|
const entryPoints = await glob(options.entryGlobs ?? DEFAULT_ENTRY_GLOBS, {
|
|
@@ -25,12 +24,12 @@ export async function buildPackage(packageDir, options = {}) {
|
|
|
25
24
|
ignore: options.ignore ?? DEFAULT_IGNORE,
|
|
26
25
|
});
|
|
27
26
|
const dependencies = ['package.json', ...resolveTsconfigChain(packageDir)];
|
|
28
|
-
const { changed, currentHash } = await detectBuildChanges(packageDir, [...entryPoints, ...dependencies], emitConfig,
|
|
27
|
+
const { changed, currentHash } = await detectBuildChanges(packageDir, [...entryPoints, ...dependencies], emitConfig, cachePath);
|
|
29
28
|
if (!changed) {
|
|
30
29
|
return;
|
|
31
30
|
}
|
|
32
31
|
emitPackage(packageDir, entryPoints, outdir);
|
|
33
|
-
await writeBuildCache(
|
|
32
|
+
await writeBuildCache(cachePath, currentHash);
|
|
34
33
|
}
|
|
35
34
|
export async function computeBuildHash(packageDir, files, emitConfig) {
|
|
36
35
|
const hash = createHash('sha256');
|
|
@@ -181,9 +180,27 @@ function getModuleSpecifier(node) {
|
|
|
181
180
|
}
|
|
182
181
|
return undefined;
|
|
183
182
|
}
|
|
184
|
-
|
|
183
|
+
export function resolveBuildCachePath(packageDir) {
|
|
184
|
+
const absolutePackageDir = path.resolve(packageDir);
|
|
185
|
+
const home = findNearestNodeModulesHost(absolutePackageDir) ?? absolutePackageDir;
|
|
186
|
+
const digest = createHash('sha256').update(absolutePackageDir).digest('hex').slice(0, 8);
|
|
187
|
+
const key = `${path.basename(absolutePackageDir)}-${digest}.hash`;
|
|
188
|
+
return path.join(home, 'node_modules', '.cache', 'nmr-compile', key);
|
|
189
|
+
}
|
|
190
|
+
function findNearestNodeModulesHost(startDir) {
|
|
191
|
+
let current = startDir;
|
|
192
|
+
let parent = path.dirname(current);
|
|
193
|
+
while (!existsSync(path.join(current, 'node_modules'))) {
|
|
194
|
+
if (parent === current) {
|
|
195
|
+
return undefined;
|
|
196
|
+
}
|
|
197
|
+
current = parent;
|
|
198
|
+
parent = path.dirname(current);
|
|
199
|
+
}
|
|
200
|
+
return current;
|
|
201
|
+
}
|
|
202
|
+
async function detectBuildChanges(packageDir, files, emitConfig, cachePath) {
|
|
185
203
|
const packageName = path.basename(packageDir);
|
|
186
|
-
const cachePath = path.join(packageDir, cacheFile);
|
|
187
204
|
const previousHash = existsSync(cachePath) ? readFileSync(cachePath, 'utf8') : undefined;
|
|
188
205
|
const currentHash = await computeBuildHash(packageDir, files, emitConfig);
|
|
189
206
|
if (previousHash === currentHash) {
|
|
@@ -193,8 +210,7 @@ async function detectBuildChanges(packageDir, files, emitConfig, cacheFile) {
|
|
|
193
210
|
console.info(`${PACKAGE_ICON} ${packageName}: Changes detected.`);
|
|
194
211
|
return { changed: true, currentHash };
|
|
195
212
|
}
|
|
196
|
-
async function writeBuildCache(
|
|
197
|
-
const cachePath = path.join(packageDir, cacheFile);
|
|
213
|
+
async function writeBuildCache(cachePath, hash) {
|
|
198
214
|
await mkdir(path.dirname(cachePath), { recursive: true });
|
|
199
215
|
await writeFile(cachePath, hash);
|
|
200
216
|
}
|
|
@@ -3,10 +3,13 @@ export interface PackageJson {
|
|
|
3
3
|
private?: boolean;
|
|
4
4
|
version?: string;
|
|
5
5
|
packageManager?: string;
|
|
6
|
+
main?: string;
|
|
7
|
+
exports?: unknown;
|
|
6
8
|
scripts?: Record<string, string>;
|
|
7
9
|
pnpm?: {
|
|
8
10
|
overrides?: Record<string, string>;
|
|
9
11
|
};
|
|
10
12
|
}
|
|
11
13
|
export declare function readPackageJson(dir: string): PackageJson;
|
|
14
|
+
export declare function hasPublishableEntryPoint(pkg: PackageJson): boolean;
|
|
12
15
|
export declare function getPnpmOverrides(pkg: PackageJson): Record<string, string> | undefined;
|
|
@@ -16,6 +16,10 @@ export function readPackageJson(dir) {
|
|
|
16
16
|
pkg.version = parsed.version;
|
|
17
17
|
if (typeof parsed.packageManager === 'string')
|
|
18
18
|
pkg.packageManager = parsed.packageManager;
|
|
19
|
+
if (typeof parsed.main === 'string')
|
|
20
|
+
pkg.main = parsed.main;
|
|
21
|
+
if (parsed.exports !== undefined)
|
|
22
|
+
pkg.exports = parsed.exports;
|
|
19
23
|
if (isObject(parsed.scripts)) {
|
|
20
24
|
const scripts = {};
|
|
21
25
|
for (const [key, val] of Object.entries(parsed.scripts)) {
|
|
@@ -37,6 +41,9 @@ export function readPackageJson(dir) {
|
|
|
37
41
|
}
|
|
38
42
|
return pkg;
|
|
39
43
|
}
|
|
44
|
+
export function hasPublishableEntryPoint(pkg) {
|
|
45
|
+
return pkg.main !== undefined || pkg.exports !== undefined;
|
|
46
|
+
}
|
|
40
47
|
export function getPnpmOverrides(pkg) {
|
|
41
48
|
if (!isObject(pkg.pnpm))
|
|
42
49
|
return undefined;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@williamthorsen/nmr",
|
|
3
|
-
"version": "0.17.
|
|
3
|
+
"version": "0.17.2",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Context-aware script runner for PNPM monorepos",
|
|
6
6
|
"keywords": [
|
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
"bin": {
|
|
30
30
|
"ensure-prepublish-hooks": "bin/ensure-prepublish-hooks.js",
|
|
31
31
|
"nmr": "bin/nmr.js",
|
|
32
|
+
"nmr-attw": "bin/nmr-attw.js",
|
|
32
33
|
"nmr-compile": "bin/nmr-compile.js",
|
|
33
34
|
"nmr-report-overrides": "bin/nmr-report-overrides.js",
|
|
34
35
|
"nmr-sync-agent-files": "bin/nmr-sync-agent-files.js",
|
package/dist/esm/.cache
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
49f79592247d14646b1445473aa445a8bcbeafb478c6dc9949eb31cee8e2a627
|