@williamthorsen/nmr 0.55.0 → 1.0.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.
Files changed (108) hide show
  1. package/.meta/changelog.json +124 -0
  2. package/.readyup/kits/default.js +667 -167
  3. package/.readyup/manifest.json +77 -46
  4. package/CHANGELOG.md +40 -0
  5. package/README.md +1 -14
  6. package/agents/guidance/rulebooks/nmr.md +1 -1
  7. package/bin/ensure-prepublish-hooks.js +1 -1
  8. package/bin/nmr-actionlint.js +1 -1
  9. package/bin/nmr-clean.js +1 -1
  10. package/bin/nmr-compile.js +1 -1
  11. package/bin/nmr-fmt.js +1 -1
  12. package/bin/nmr-report-catalog.js +1 -1
  13. package/bin/nmr-report-overrides.js +1 -1
  14. package/bin/nmr-shellcheck.js +1 -1
  15. package/bin/nmr-taze.js +1 -1
  16. package/bin/nmr-vitest.js +1 -1
  17. package/bin/nmr.js +1 -1
  18. package/dist/esm/bins.d.ts +11 -0
  19. package/dist/esm/bins.js +11 -0
  20. package/dist/esm/check-cache-entries.js +5 -5
  21. package/dist/esm/check-cache-gate.js +2 -1
  22. package/dist/esm/child-env.js +2 -1
  23. package/dist/esm/child-process.d.ts +2 -0
  24. package/dist/esm/child-process.js +3 -3
  25. package/dist/esm/cli-actionlint.js +2 -1
  26. package/dist/esm/cli-build.js +2 -1
  27. package/dist/esm/cli-clean.js +2 -1
  28. package/dist/esm/cli-ensure-prepublish-hooks.js +29 -16
  29. package/dist/esm/cli-fmt.js +2 -1
  30. package/dist/esm/cli-report-catalog.js +2 -1
  31. package/dist/esm/cli-report-overrides.js +2 -1
  32. package/dist/esm/cli-shellcheck.js +2 -1
  33. package/dist/esm/cli-taze.js +2 -1
  34. package/dist/esm/cli-vitest.js +2 -1
  35. package/dist/esm/cli.js +2 -1
  36. package/dist/esm/commands/actionlint.d.ts +3 -3
  37. package/dist/esm/commands/actionlint.js +5 -5
  38. package/dist/esm/commands/build.js +6 -6
  39. package/dist/esm/commands/clean.js +3 -1
  40. package/dist/esm/commands/compiler.js +3 -3
  41. package/dist/esm/commands/ensure-prepublish-hooks.d.ts +1 -1
  42. package/dist/esm/commands/ensure-prepublish-hooks.js +4 -5
  43. package/dist/esm/commands/fmt.d.ts +3 -3
  44. package/dist/esm/commands/fmt.js +33 -52
  45. package/dist/esm/commands/format-targets.js +1 -13
  46. package/dist/esm/commands/node-pin.js +3 -4
  47. package/dist/esm/commands/rewrite-specifiers.js +2 -2
  48. package/dist/esm/commands/run-vitest.js +5 -5
  49. package/dist/esm/commands/shellcheck.d.ts +3 -3
  50. package/dist/esm/commands/shellcheck.js +8 -11
  51. package/dist/esm/commands/taze.js +5 -4
  52. package/dist/esm/commands/tsconfig-chain.js +3 -3
  53. package/dist/esm/commands/workflow-tools.d.ts +12 -3
  54. package/dist/esm/commands/workflow-tools.js +29 -14
  55. package/dist/esm/config-validation.js +9 -9
  56. package/dist/esm/config.js +2 -3
  57. package/dist/esm/delegate-host.d.ts +1 -1
  58. package/dist/esm/delegate-host.js +5 -3
  59. package/dist/esm/entry-chain.js +2 -3
  60. package/dist/esm/fan-out.d.ts +3 -3
  61. package/dist/esm/fan-out.js +14 -6
  62. package/dist/esm/git-ignored-paths.js +3 -3
  63. package/dist/esm/help.d.ts +14 -15
  64. package/dist/esm/help.js +29 -54
  65. package/dist/esm/helpers/filter-selection.js +2 -2
  66. package/dist/esm/helpers/package-json.js +8 -9
  67. package/dist/esm/helpers/resolveXdgBaseDir.d.ts +5 -0
  68. package/dist/esm/helpers/resolveXdgBaseDir.js +12 -0
  69. package/dist/esm/helpers/type-guards.d.ts +0 -2
  70. package/dist/esm/helpers/type-guards.js +2 -4
  71. package/dist/esm/invocation-args.d.ts +0 -32
  72. package/dist/esm/invocation-args.js +23 -121
  73. package/dist/esm/invocation.d.ts +3 -1
  74. package/dist/esm/invocation.js +1 -0
  75. package/dist/esm/machine-lock.js +3 -4
  76. package/dist/esm/nmr-flags.d.ts +109 -0
  77. package/dist/esm/nmr-flags.js +120 -0
  78. package/dist/esm/output-style.d.ts +1 -1
  79. package/dist/esm/output-style.js +2 -1
  80. package/dist/esm/pass-manifest.js +2 -2
  81. package/dist/esm/presentation.d.ts +1 -1
  82. package/dist/esm/remote-host-session.d.ts +2 -0
  83. package/dist/esm/remote-host-session.js +1 -0
  84. package/dist/esm/remote-probe.js +2 -2
  85. package/dist/esm/remote-run.d.ts +3 -3
  86. package/dist/esm/remote-run.js +6 -4
  87. package/dist/esm/remote-session.js +4 -4
  88. package/dist/esm/remote-snapshot.js +8 -22
  89. package/dist/esm/resolver.js +3 -4
  90. package/dist/esm/route.d.ts +29 -0
  91. package/dist/esm/route.js +94 -0
  92. package/dist/esm/run-chain.d.ts +1 -1
  93. package/dist/esm/run-chain.js +4 -2
  94. package/dist/esm/runCli.d.ts +2 -0
  95. package/dist/esm/runCli.js +36 -78
  96. package/dist/esm/stated-outcome.js +3 -3
  97. package/dist/esm/steps.js +6 -30
  98. package/dist/esm/user-config.js +7 -14
  99. package/dist/esm/vitest-source-resolution.js +5 -6
  100. package/dist/esm/vitest-temp-isolation.d.ts +2 -0
  101. package/dist/esm/vitest-temp-isolation.js +44 -0
  102. package/dist/esm/vitest.d.ts +9 -0
  103. package/dist/esm/vitest.js +8 -5
  104. package/docs/reporting.md +3 -3
  105. package/docs/scripts.md +1 -1
  106. package/docs/utilities.md +5 -5
  107. package/docs/vitest.md +11 -6
  108. package/package.json +4 -4
@@ -1,18 +1,18 @@
1
1
  import { spawnSync } from 'node:child_process';
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
- import { UserError } from '@williamthorsen/nmr-core';
4
+ import { runGit, UserError } from '@williamthorsen/nmr-core';
5
5
  import { describeSignalExitCode } from "../child-process.js";
6
- import { ensureTool } from "./workflow-tools.js";
6
+ import { ensureTool, resolveToolCacheRoots } from "./workflow-tools.js";
7
7
  export async function runShellcheck(monorepoRoot, args, options = {}) {
8
8
  const shellcheckArgs = args.length > 0 ? [...args] : listPresentScripts(monorepoRoot, options.listFiles);
9
9
  if (shellcheckArgs.length === 0) {
10
10
  console.info('No shell scripts to lint: the working tree does not contain any tracked *.sh file.');
11
11
  return 0;
12
12
  }
13
- const cacheRoot = options.cacheRoot ?? path.join(monorepoRoot, 'node_modules', '.cache', 'nmr-tools');
14
- const ensure = options.ensure ?? ((tool, root) => ensureTool(tool, { cacheRoot: root }));
15
- const shellcheckPath = await ensure('shellcheck', cacheRoot);
13
+ const cacheRoots = options.cacheRoots ?? resolveToolCacheRoots(monorepoRoot);
14
+ const ensure = options.ensure ?? ((tool, roots) => ensureTool(tool, { cacheRoots: roots }));
15
+ const shellcheckPath = await ensure('shellcheck', cacheRoots);
16
16
  const outcome = (options.spawn ?? spawnInherited)(shellcheckPath, shellcheckArgs, monorepoRoot);
17
17
  if (outcome.error !== undefined) {
18
18
  throw new UserError(`Cannot run shellcheck: ${outcome.error.message}`, { cause: outcome.error });
@@ -23,12 +23,9 @@ export async function runShellcheck(monorepoRoot, args, options = {}) {
23
23
  return outcome.status ?? 1;
24
24
  }
25
25
  export function listTrackedScripts(monorepoRoot) {
26
- const result = spawnSync('git', ['ls-files', '-z', '--', '*.sh'], { cwd: monorepoRoot, encoding: 'utf8' });
27
- if (result.error !== undefined) {
28
- throw new UserError(`Cannot list tracked shell scripts: ${result.error.message}`, { cause: result.error });
29
- }
30
- if (result.status !== 0) {
31
- throw new UserError(`Cannot list tracked shell scripts: ${result.stderr.trim()}`);
26
+ const result = runGit(['ls-files', '-z', '--', '*.sh'], monorepoRoot);
27
+ if (!result.ok) {
28
+ throw new UserError(`Cannot list tracked shell scripts: ${result.error}`);
32
29
  }
33
30
  return result.stdout.split('\0').filter((file) => file !== '');
34
31
  }
@@ -4,6 +4,7 @@ import process from 'node:process';
4
4
  import { fileURLToPath } from 'node:url';
5
5
  import { reportError } from '@williamthorsen/nmr-core';
6
6
  import { describeError } from '@williamthorsen/toolbelt.errors';
7
+ import { NMR_TAZE_BIN } from "../bins.js";
7
8
  import { loadPackageMaturityPeriod } from "./load-package-maturity-period.js";
8
9
  import { checkNodePin as checkNodePinDefault } from "./node-pin.js";
9
10
  import { isSilent, readFlag } from "./taze-args.js";
@@ -24,7 +25,7 @@ export async function runTaze(args, options = {}) {
24
25
  }
25
26
  catch (error) {
26
27
  const detail = describeError(error);
27
- reportError(`Could not resolve '${TAZE_CLI_SPECIFIER}' from nmr's dependencies. Reinstall the workspace to restore it. (${detail})`, stderr);
28
+ reportError(NMR_TAZE_BIN, `Could not resolve '${TAZE_CLI_SPECIFIER}' from nmr's dependencies. Reinstall the workspace to restore it. (${detail})`, stderr);
28
29
  return 1;
29
30
  }
30
31
  let passes;
@@ -32,7 +33,7 @@ export async function runTaze(args, options = {}) {
32
33
  passes = planTazePasses(args, await loadConfig(resolveConfigDir(args)));
33
34
  }
34
35
  catch (error) {
35
- reportError(`Could not read the taze config: ${describeError(error)}`, stderr);
36
+ reportError(NMR_TAZE_BIN, `Could not read the taze config: ${describeError(error)}`, stderr);
36
37
  return 1;
37
38
  }
38
39
  if (readFlag(args, NODE_PIN_SKIP_FLAGS) === undefined) {
@@ -45,7 +46,7 @@ export async function runTaze(args, options = {}) {
45
46
  });
46
47
  }
47
48
  catch (error) {
48
- reportError(`Could not check the nodejs pin: ${describeError(error)}`, stderr);
49
+ reportError(NMR_TAZE_BIN, `Could not check the nodejs pin: ${describeError(error)}`, stderr);
49
50
  }
50
51
  }
51
52
  const showHeadings = !isSilent(args);
@@ -55,7 +56,7 @@ export async function runTaze(args, options = {}) {
55
56
  stderr.write(`${pass.heading}\n`);
56
57
  const outcome = spawn(process.execPath, [cliPath, ...composeRequestTimeoutArgs(pass.args), ...pass.args]);
57
58
  if (outcome.error) {
58
- reportError(`Failed to run taze: ${outcome.error.message}`, stderr);
59
+ reportError(NMR_TAZE_BIN, `Failed to run taze: ${outcome.error.message}`, stderr);
59
60
  return 1;
60
61
  }
61
62
  const status = outcome.status ?? 1;
@@ -15,7 +15,7 @@ export function resolveTsconfigChain(packageDir, configFileName = 'tsconfig.json
15
15
  resolvedChain.push(normalizedPath);
16
16
  const configFile = ts.readConfigFile(normalizedPath, (fileName) => ts.sys.readFile(fileName));
17
17
  if (configFile.error) {
18
- throw new UserError(`nmr-compile: failed to read ${normalizedPath}.\n${formatDiagnostics([configFile.error])}`);
18
+ throw new UserError(`failed to read ${normalizedPath}.\n${formatDiagnostics([configFile.error])}`);
19
19
  }
20
20
  for (const entry of normalizeExtendsField(configFile.config)) {
21
21
  walk(resolveExtendsTarget(entry, normalizedPath));
@@ -48,12 +48,12 @@ function resolveExtendsTarget(extendsEntry, fromConfigPath) {
48
48
  if (ts.sys.fileExists(withJsonExtension)) {
49
49
  return withJsonExtension;
50
50
  }
51
- throw new UserError(`nmr-compile: ${fromConfigPath} extends '${extendsEntry}', which does not exist.`);
51
+ throw new UserError(`${fromConfigPath} extends '${extendsEntry}', which does not exist.`);
52
52
  }
53
53
  const resolvedPath = resolvePackageSpecifier(extendsEntry, fromConfigPath) ??
54
54
  resolvePackageSpecifier(`${extendsEntry}/tsconfig.json`, fromConfigPath);
55
55
  if (resolvedPath === undefined) {
56
- throw new UserError(`nmr-compile: ${fromConfigPath} extends '${extendsEntry}', which does not resolve to a file.`);
56
+ throw new UserError(`${fromConfigPath} extends '${extendsEntry}', which does not resolve to a file.`);
57
57
  }
58
58
  return resolvedPath;
59
59
  }
@@ -15,8 +15,11 @@ export type ToolPins = Record<WorkflowTool, ToolPin>;
15
15
  /** The subset of `fetch` that the download uses. */
16
16
  export type FetchFunction = (url: string, init?: RequestInit) => Promise<Response>;
17
17
  export interface EnsureToolOptions {
18
- /** Directory under which each tool version is cached in a directory of its own. */
19
- cacheRoot: string;
18
+ /**
19
+ * Directories under which each tool version is cached in a directory of its own, in lookup order. A miss installs
20
+ * into the first one that can be written.
21
+ */
22
+ cacheRoots: readonly [string, ...string[]];
20
23
  arch?: string;
21
24
  fetch?: FetchFunction;
22
25
  pins?: ToolPins;
@@ -29,11 +32,17 @@ export interface EnsureToolOptions {
29
32
  */
30
33
  export declare const TOOL_PINS: ToolPins;
31
34
  /**
32
- * Returns the path of the tool's pinned binary, downloading, verifying, and caching it first on a cache miss.
35
+ * Returns the path of the tool's pinned binary, taking the first one cached under any of the roots, and otherwise
36
+ * downloading, verifying, and caching it under the first root that can be written.
33
37
  *
34
38
  * A failure at any step leaves the cache without the tool, and a concurrent run never sees a partial binary: The
35
39
  * binary is assembled in a scratch directory beside its target and renamed into place only once verified.
36
40
  */
37
41
  export declare function ensureTool(tool: WorkflowTool, options: EnsureToolOptions): Promise<string>;
42
+ /**
43
+ * Returns the roots under which the pinned tools are cached, in lookup order: the machine cache under the XDG cache
44
+ * home, when `env` resolves one, then `node_modules/.cache/nmr-tools` at the monorepo root.
45
+ */
46
+ export declare function resolveToolCacheRoots(monorepoRoot: string, env?: NodeJS.ProcessEnv): [string, ...string[]];
38
47
  /** Maps Node's platform and architecture onto a supported pair, rejecting every other pair. */
39
48
  export declare function resolveToolPlatform(platform: NodeJS.Platform, arch: string): ToolPlatform;
@@ -5,6 +5,7 @@ import path from 'node:path';
5
5
  import process from 'node:process';
6
6
  import { UserError } from '@williamthorsen/nmr-core';
7
7
  import { describeError } from '@williamthorsen/toolbelt.errors';
8
+ import { resolveXdgBaseDir } from "../helpers/resolveXdgBaseDir.js";
8
9
  const SUPPORTED_PLATFORMS = ['darwin-arm64', 'darwin-x64', 'linux-arm64', 'linux-x64'];
9
10
  const DOWNLOAD_TIMEOUT_MS = 60_000;
10
11
  const ACTIONLINT_RELEASE = 'https://github.com/rhysd/actionlint/releases/download/v1.7.12';
@@ -58,15 +59,15 @@ export const TOOL_PINS = {
58
59
  export async function ensureTool(tool, options) {
59
60
  const pin = (options.pins ?? TOOL_PINS)[tool];
60
61
  const asset = pin.assets[resolveToolPlatform(options.platform ?? process.platform, options.arch ?? process.arch)];
61
- const targetDir = path.join(options.cacheRoot, `${tool}-${pin.version}`);
62
- const binaryPath = path.join(targetDir, tool);
63
- if (fs.existsSync(binaryPath)) {
64
- return binaryPath;
62
+ const versionDir = `${tool}-${pin.version}`;
63
+ for (const cacheRoot of options.cacheRoots) {
64
+ const cachedPath = path.join(cacheRoot, versionDir, tool);
65
+ if (fs.existsSync(cachedPath)) {
66
+ return cachedPath;
67
+ }
65
68
  }
66
- createCacheRoot(options.cacheRoot);
67
- const scratchDir = `${targetDir}.tmp-${process.pid}`;
68
- fs.rmSync(scratchDir, { force: true, recursive: true });
69
- fs.mkdirSync(scratchDir);
69
+ const { scratchDir, targetDir } = createScratchDir(options.cacheRoots, versionDir);
70
+ const binaryPath = path.join(targetDir, tool);
70
71
  try {
71
72
  const archive = await download(asset.url, options.fetch ?? globalThis.fetch);
72
73
  const actualSha256 = createHash('sha256').update(archive).digest('hex');
@@ -90,6 +91,11 @@ export async function ensureTool(tool, options) {
90
91
  }
91
92
  return binaryPath;
92
93
  }
94
+ export function resolveToolCacheRoots(monorepoRoot, env = process.env) {
95
+ const worktreeRoot = path.join(monorepoRoot, 'node_modules', '.cache', 'nmr-tools');
96
+ const cacheHome = resolveXdgBaseDir(env, 'XDG_CACHE_HOME', '.cache');
97
+ return cacheHome === undefined ? [worktreeRoot] : [path.join(cacheHome, 'nmr', 'tools'), worktreeRoot];
98
+ }
93
99
  export function resolveToolPlatform(platform, arch) {
94
100
  const key = `${platform}-${arch}`;
95
101
  const supported = SUPPORTED_PLATFORMS.find((candidate) => candidate === key);
@@ -98,13 +104,22 @@ export function resolveToolPlatform(platform, arch) {
98
104
  }
99
105
  return supported;
100
106
  }
101
- function createCacheRoot(cacheRoot) {
102
- try {
103
- fs.mkdirSync(cacheRoot, { recursive: true });
104
- }
105
- catch (error) {
106
- throw new UserError(`Cannot create the tool cache at ${cacheRoot}: ${describeError(error)}`, { cause: error });
107
+ function createScratchDir(cacheRoots, versionDir) {
108
+ let lastError;
109
+ for (const cacheRoot of cacheRoots) {
110
+ const targetDir = path.join(cacheRoot, versionDir);
111
+ try {
112
+ fs.mkdirSync(cacheRoot, { recursive: true });
113
+ return { scratchDir: fs.mkdtempSync(`${targetDir}.tmp-`), targetDir };
114
+ }
115
+ catch (error) {
116
+ lastError = error;
117
+ }
107
118
  }
119
+ const lastRoot = cacheRoots.at(-1) ?? cacheRoots[0];
120
+ throw new UserError(`Cannot create the tool cache at ${lastRoot}: ${describeError(lastError)}`, {
121
+ cause: lastError,
122
+ });
108
123
  }
109
124
  async function download(url, fetchImpl) {
110
125
  let response;
@@ -1,6 +1,6 @@
1
- import { UserError } from '@williamthorsen/nmr-core';
1
+ import { isRecord, UserError } from '@williamthorsen/nmr-core';
2
2
  import { assertResolvableCheckCacheCommands } from "./config-check-cache.js";
3
- import { isObject, isStringRecord } from "./helpers/type-guards.js";
3
+ import { isStringRecord } from "./helpers/type-guards.js";
4
4
  import { findUnexpressibleToken } from "./steps.js";
5
5
  import { formatVerbosityRejection, isCommandVerbosity } from "./verbosity.js";
6
6
  export const CONFIG_TIERS = {
@@ -31,7 +31,7 @@ export function assertRecognizedKeys(value, recognizedKeys, configPath, prefix =
31
31
  `${formatKeyList(unrecognizedKeys, prefix)}. Recognized: ${formatKeyList(recognizedKeys, prefix)}.`);
32
32
  }
33
33
  export function validateConfig(value, configPath, baseDir) {
34
- if (!isObject(value)) {
34
+ if (!isRecord(value)) {
35
35
  throw new UserError(`Invalid nmr config at ${configPath}: expected an object, got ${typeof value}`);
36
36
  }
37
37
  assertRecognizedKeys(value, RECOGNIZED_KEYS, configPath);
@@ -99,7 +99,7 @@ function isOptionalBoolean(value) {
99
99
  return value === undefined || typeof value === 'boolean';
100
100
  }
101
101
  function isScriptRecord(value) {
102
- if (!isObject(value))
102
+ if (!isRecord(value))
103
103
  return false;
104
104
  for (const v of Object.values(value)) {
105
105
  if (typeof v === 'string')
@@ -112,7 +112,7 @@ function isScriptRecord(value) {
112
112
  function isScriptElement(value) {
113
113
  if (typeof value === 'string')
114
114
  return true;
115
- if (!isObject(value) || typeof value['run'] !== 'string')
115
+ if (!isRecord(value) || typeof value['run'] !== 'string')
116
116
  return false;
117
117
  return isOptionalBoolean(value['shouldDeclineArguments']) && isOptionalBoolean(value['shouldRunInBackground']);
118
118
  }
@@ -124,7 +124,7 @@ function validateBuildField(value, configPath) {
124
124
  if (build === undefined) {
125
125
  return undefined;
126
126
  }
127
- if (!isObject(build)) {
127
+ if (!isRecord(build)) {
128
128
  throw new UserError(`Invalid nmr config at ${configPath}: \`build\` must be an object`);
129
129
  }
130
130
  assertRecognizedKeys(build, RECOGNIZED_BUILD_KEYS, configPath, 'build.');
@@ -139,7 +139,7 @@ function validateCheckCacheField(value, configPath) {
139
139
  if (checkCache === undefined) {
140
140
  return undefined;
141
141
  }
142
- if (!isObject(checkCache)) {
142
+ if (!isRecord(checkCache)) {
143
143
  throw new UserError(`Invalid nmr config at ${configPath}: \`checkCache\` must be an object`);
144
144
  }
145
145
  assertRecognizedKeys(checkCache, RECOGNIZED_CHECK_CACHE_KEYS, configPath, 'checkCache.');
@@ -175,7 +175,7 @@ function validateOutputField(value, configPath) {
175
175
  if (output === undefined) {
176
176
  return undefined;
177
177
  }
178
- if (!isObject(output)) {
178
+ if (!isRecord(output)) {
179
179
  throw new UserError(`Invalid nmr config at ${configPath}: \`output\` must be an object`);
180
180
  }
181
181
  assertRecognizedKeys(output, RECOGNIZED_OUTPUT_KEYS, configPath, 'output.');
@@ -202,7 +202,7 @@ function validateSelfHostedRunnerField(value, configPath) {
202
202
  if (selfHostedRunner === undefined) {
203
203
  return undefined;
204
204
  }
205
- if (!isObject(selfHostedRunner)) {
205
+ if (!isRecord(selfHostedRunner)) {
206
206
  throw new UserError(`Invalid nmr config at ${configPath}: \`selfHostedRunner\` must be an object`);
207
207
  }
208
208
  assertRecognizedKeys(selfHostedRunner, RECOGNIZED_SELF_HOSTED_RUNNER_KEYS, configPath, 'selfHostedRunner.');
@@ -8,12 +8,11 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
8
8
  };
9
9
  import { existsSync } from 'node:fs';
10
10
  import { pathToFileURL } from 'node:url';
11
- import { UserError } from '@williamthorsen/nmr-core';
11
+ import { isRecord, UserError } from '@williamthorsen/nmr-core';
12
12
  import { CONFIG_TIERS, validateConfig } from "./config-validation.js";
13
13
  import { resolveConfigPath } from "./helpers/config-path.js";
14
14
  import { escapeControlCharacters } from "./helpers/escapeControlCharacters.js";
15
15
  import { isHookName } from "./helpers/hook-name.js";
16
- import { isObject } from "./helpers/type-guards.js";
17
16
  import { getDefaultRootScripts, getDefaultWorkspaceScripts } from "./resolve-scripts.js";
18
17
  import { describeScript } from "./resolver.js";
19
18
  import { readSelfReference, readStepListSelfReference } from "./steps.js";
@@ -27,7 +26,7 @@ export async function loadConfig(baseDir) {
27
26
  return {};
28
27
  }
29
28
  const importedModule = await import(__rewriteRelativeImportExtension(pathToFileURL(configPath).href));
30
- const loadedConfig = isObject(importedModule) ? importedModule['default'] : undefined;
29
+ const loadedConfig = isRecord(importedModule) ? importedModule['default'] : undefined;
31
30
  return validateConfig(loadedConfig, configPath, baseDir);
32
31
  }
33
32
  export async function loadRootConfig(monorepoRoot) {
@@ -1,4 +1,4 @@
1
- import type { ExecutionTarget, Invocation, PhaseResult, RunCliResult, RunPlan } from './invocation.js';
1
+ import { type ExecutionTarget, type Invocation, type PhaseResult, type RunCliResult, type RunPlan } from './invocation.js';
2
2
  import type { RetainedOutput } from './runner.js';
3
3
  import type { VerdictOutcome } from './verdict.js';
4
4
  /** How a run on the host ended, or that the invocation runs here after all. */
@@ -1,10 +1,12 @@
1
1
  import os from 'node:os';
2
2
  import { Writable } from 'node:stream';
3
3
  import { reportError, startTimer } from '@williamthorsen/nmr-core';
4
+ import { NMR_BIN } from "./bins.js";
4
5
  import { NO_CACHE_ENV_VAR, resolveRunId, writeDebugNote } from "./check-cache.js";
5
6
  import { buildChildEnv, buildRemoteEnv } from "./child-env.js";
6
7
  import { resolveDelegationTarget } from "./delegation-target.js";
7
8
  import { createBoundedBuffer } from "./helpers/createBoundedBuffer.js";
9
+ import { USAGE_EXIT_CODE, } from "./invocation.js";
8
10
  import { isDelegationFlag } from "./invocation-args.js";
9
11
  import { reportVerdict } from "./presentation.js";
10
12
  import { chooseHost, probeHost } from "./remote-probe.js";
@@ -29,8 +31,8 @@ export async function resolveExecutionTarget(invocation) {
29
31
  shouldDelegate: parsedArgs.shouldDelegate,
30
32
  });
31
33
  if (target.kind === 'refused') {
32
- reportError(target.error, stderr);
33
- return { settled: true, result: { exitCode: 1 } };
34
+ reportError(NMR_BIN, target.error, stderr);
35
+ return { settled: true, result: { exitCode: USAGE_EXIT_CODE } };
34
36
  }
35
37
  if (target.kind === 'remote') {
36
38
  writeDebugNote(`delegating ${command} per ${describeDelegationSource(target, parsedArgs, env)}`, env, stderr);
@@ -115,7 +117,7 @@ async function delegate(options) {
115
117
  const snapshot = createSnapshot(cwd, env);
116
118
  if (!snapshot.ok && snapshot.isUnsupported) {
117
119
  if (target.source === 'flag') {
118
- reportError(`Cannot delegate to ${listedHosts}: ${snapshot.reason}`, stderr);
120
+ reportError(NMR_BIN, `Cannot delegate to ${listedHosts}: ${snapshot.reason}`, stderr);
119
121
  return { kind: 'refused', exitCode: 1 };
120
122
  }
121
123
  stderr.write(formatRunHereLine(command, listedHosts, snapshot.reason));
@@ -1,7 +1,6 @@
1
1
  import { realpathSync } from 'node:fs';
2
2
  import path from 'node:path';
3
- import { UserError } from '@williamthorsen/nmr-core';
4
- import { isObject } from "./helpers/type-guards.js";
3
+ import { isRecord, UserError } from '@williamthorsen/nmr-core';
5
4
  export const ENTRY_CHAIN_ENV_VAR = 'NMR_ENTRY_CHAIN';
6
5
  export function enterChain(options) {
7
6
  const { anchorDir, command, env, monorepoRoot } = options;
@@ -40,7 +39,7 @@ function formatReentryError(chain, monorepoRoot) {
40
39
  ].join('\n');
41
40
  }
42
41
  function isChainEntry(value) {
43
- return isObject(value) && typeof value['command'] === 'string' && typeof value['dir'] === 'string';
42
+ return isRecord(value) && typeof value['command'] === 'string' && typeof value['dir'] === 'string';
44
43
  }
45
44
  function readEntryChain(value) {
46
45
  if (value === undefined || value === '') {
@@ -1,7 +1,7 @@
1
1
  import type { Writable } from 'node:stream';
2
2
  import type { ResolvedContext } from './context.js';
3
3
  import { type RunCliResult } from './invocation.js';
4
- import type { ParsedArgs } from './invocation-args.js';
4
+ import type { ParsedArgs } from './nmr-flags.js';
5
5
  import { type RunStepsOptions } from './runner.js';
6
6
  import type { Step } from './steps.js';
7
7
  /**
@@ -26,10 +26,10 @@ export declare function composeDelegation(options: {
26
26
  }>;
27
27
  } | undefined;
28
28
  /**
29
- * Reports whether any scope that a delegation can select defines the command. A package config does not define
29
+ * Lists every command that a scope which a delegation can select defines. A package config does not define
30
30
  * scripts, so the root config's registries and each `package.json` cover every scope.
31
31
  */
32
- export declare function isDefinedInAnyScope(command: string, context: ResolvedContext): boolean;
32
+ export declare function listDefinedCommands(context: ResolvedContext): Set<string>;
33
33
  /**
34
34
  * Runs a `-F` or `-R` delegation, or refuses it when it would not select any scope.
35
35
  *
@@ -1,4 +1,5 @@
1
1
  import { reportError } from '@williamthorsen/nmr-core';
2
+ import { NMR_BIN } from "./bins.js";
2
3
  import { readFilterSelection } from "./helpers/filter-selection.js";
3
4
  import { findClosestName } from "./helpers/findClosestName.js";
4
5
  import { renderQuotedList } from "./helpers/renderQuotedList.js";
@@ -30,19 +31,26 @@ export function composeDelegation(options) {
30
31
  },
31
32
  };
32
33
  }
33
- export function isDefinedInAnyScope(command, context) {
34
- const registries = [buildRootRegistry(context.config, context.monorepoRoot), buildWorkspaceRegistry(context.config)];
35
- if (registries.some((registry) => Object.hasOwn(registry, command))) {
36
- return true;
34
+ export function listDefinedCommands(context) {
35
+ const names = new Set([
36
+ ...Object.keys(buildRootRegistry(context.config, context.monorepoRoot)),
37
+ ...Object.keys(buildWorkspaceRegistry(context.config)),
38
+ ]);
39
+ const scopeDirs = [context.monorepoRoot, ...context.workspacePackageDirs];
40
+ for (const dir of scopeDirs) {
41
+ const scripts = readPackageJsonScripts(dir) ?? {};
42
+ for (const name of Object.keys(scripts)) {
43
+ names.add(name);
44
+ }
37
45
  }
38
- return [context.monorepoRoot, ...context.workspacePackageDirs].some((dir) => Object.hasOwn(readPackageJsonScripts(dir) ?? {}, command));
46
+ return names;
39
47
  }
40
48
  export async function runDelegation(options) {
41
49
  const { context, delegation, parsedArgs, runOptions, stderr } = options;
42
50
  const selection = parsedArgs.filter === undefined ? undefined : readFilterSelection(parsedArgs.filter, context.monorepoRoot);
43
51
  const refusal = findEmptySelectionRefusal({ context, parsedArgs, selection });
44
52
  if (refusal !== undefined) {
45
- reportError(refusal, stderr);
53
+ reportError(NMR_BIN, refusal, stderr);
46
54
  return { exitCode: 1 };
47
55
  }
48
56
  const shouldWithholdInput = parsedArgs.filter === undefined || selection === 'multiple';
@@ -1,7 +1,7 @@
1
- import { spawnSync } from 'node:child_process';
1
+ import { runGit } from '@williamthorsen/nmr-core';
2
2
  export function listGitIgnoredPaths(dir) {
3
- const result = spawnSync('git', ['ls-files', '-z', '--others', '--ignored', '--exclude-standard', '--directory', '--no-empty-directory'], { cwd: dir, encoding: 'utf8' });
4
- if (result.error !== undefined || result.status !== 0)
3
+ const result = runGit(['ls-files', '-z', '--others', '--ignored', '--exclude-standard', '--directory', '--no-empty-directory'], dir);
4
+ if (!result.ok)
5
5
  return [];
6
6
  return result.stdout
7
7
  .split('\0')
@@ -1,19 +1,18 @@
1
1
  import type { NmrConfig } from './types.js';
2
+ /** What nmr's help lists for one scope: each command that it runs, and the text that follows the options. */
3
+ export interface HelpListing {
4
+ /** Each command's name, paired with the summary that the `Commands:` block shows for it. */
5
+ commands: Array<[name: string, summary: string]>;
6
+ epilog: string;
7
+ }
2
8
  /**
3
- * Generates the help text for the `nmr` CLI. Renders the names from the
4
- * workspace and root registries, excluding hooks (`*:pre`, `*:post`), and
5
- * the scripts that the package at `packageDir` adds to them.
9
+ * Describes nmr's help for the scope anchored at `packageDir`: the active registry's commands, less hooks
10
+ * (`*:pre`, `*:post`), then the scripts that the package's `package.json` adds to them, each summarized by the
11
+ * shell command to which it resolves.
6
12
  *
7
- * When `packageDir` is provided, tier-3 entries from that package's
8
- * `package.json:scripts` that match a registry name in the active section
9
- * are inlined as overrides: The registry value is replaced with the
10
- * override value and the row's command name is suffixed with `*`. The
11
- * active section is the root section when `shouldUseRoot` is true (root cwd
12
- * or `-w`), otherwise the workspace section. A footnote is appended once
13
- * if any override marker was rendered.
14
- *
15
- * Every other entry, a hook included, is listed under `Package scripts:`
16
- * unless it is an npm or pnpm lifecycle script or the active registry
17
- * contains its name.
13
+ * The active registry is the root's when `shouldUseRoot` is true (root cwd or `-w`), and the workspace's otherwise.
14
+ * A `package.json` entry that matches an active registry name replaces that row's value and marks it with `*`, which
15
+ * a footnote explains. Every other entry, a hook included, is listed unless it is an npm or pnpm lifecycle script.
16
+ * The epilog lists the inactive registry, the environment variables, and the files that nmr reads.
18
17
  */
19
- export declare function generateHelp(config: NmrConfig, monorepoRoot: string, packageDir: string | undefined, shouldUseRoot: boolean): string;
18
+ export declare function describeHelp(config: NmrConfig, monorepoRoot: string, packageDir: string | undefined, shouldUseRoot: boolean): HelpListing;
package/dist/esm/help.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { isHookName } from "./helpers/hook-name.js";
2
- import { OUTPUT_STYLE_ENV_VAR, OUTPUT_STYLE_FLAG } from "./output-style.js";
2
+ import { OUTPUT_STYLE_ENV_VAR } from "./output-style.js";
3
3
  import { DELEGATE_ENV_VAR } from "./remote-run.js";
4
4
  import { buildRootRegistry, buildWorkspaceRegistry, describeScript, isSelfReferential, readPackageJsonScripts, } from "./resolver.js";
5
5
  const LIFECYCLE_SCRIPT_NAMES = new Set([
@@ -24,64 +24,39 @@ const LIFECYCLE_SCRIPT_NAMES = new Set([
24
24
  'uninstall',
25
25
  'version',
26
26
  ]);
27
- export function generateHelp(config, monorepoRoot, packageDir, shouldUseRoot) {
28
- const lines = [
29
- 'Usage: nmr [flags] <command> [args...]',
30
- '',
31
- 'Flags:',
32
- ' -F, --filter <pattern> Run command in packages whose manifest name matches',
33
- ' -R, --recursive Run command in all packages',
34
- ' -w, --workspace-root Use root scripts, running at the monorepo root',
35
- " -q, --quiet Suppress command output, keeping nmr's verdicts",
36
- ' --delegate Run over SSH on the first free host that NMR_DELEGATE or the user config names',
37
- ' --delegate-to <hosts> Run over SSH on the first free of <hosts>, comma-separated',
38
- ' --json Report one JSON object per command, withholding command output',
39
- ' --log Print the recorded run instead of running (up to 256 KiB)',
40
- ' --no-cache Run even if this tree already passed; record the result',
41
- ' --no-delegate Run here, whatever NMR_DELEGATE or the user config says',
42
- ' --no-lock Run without waiting for another heavy command on this machine',
43
- ` ${OUTPUT_STYLE_FLAG} <style> Output style: auto (default), plain, or rich`,
44
- ' -?, --help Show this help',
45
- ' -V, --version Show version number',
46
- '',
47
- 'Environment:',
48
- ` ${DELEGATE_ENV_VAR} Hosts on which to run each command that can be delegated,`,
49
- ' comma-separated; nmr runs on the first free one',
50
- ` ${OUTPUT_STYLE_ENV_VAR} Output style: auto (default), plain, or rich. auto prints`,
51
- ' plain, without emoji, when CI is set or the stream is not a terminal.',
52
- '',
53
- 'Files:',
54
- " ~/.config/nmr/config.ts User config: this machine's delegation hosts, and when to use them",
55
- ' ($XDG_CONFIG_HOME/nmr/config.ts when that is set)',
56
- ' A command that it sends runs here when no host answers',
57
- '',
58
- 'Workspace commands:',
59
- ];
27
+ export function describeHelp(config, monorepoRoot, packageDir, shouldUseRoot) {
60
28
  const packageScripts = packageDir === undefined ? {} : readRunnableScripts(packageDir);
61
- const overrides = collectOverrides(packageScripts);
62
- let hadOverride = false;
63
- const workspaceRegistry = filterHooks(buildWorkspaceRegistry(config));
64
- const workspaceMarkedNames = !shouldUseRoot ? applyOverrides(workspaceRegistry, overrides) : new Set();
65
- if (workspaceMarkedNames.size > 0)
66
- hadOverride = true;
67
- formatRegistry(workspaceRegistry, workspaceMarkedNames, lines);
68
- lines.push('', 'Root commands:');
69
- const rootRegistry = filterHooks(buildRootRegistry(config, monorepoRoot));
70
- const rootMarkedNames = shouldUseRoot ? applyOverrides(rootRegistry, overrides) : new Set();
71
- if (rootMarkedNames.size > 0)
72
- hadOverride = true;
73
- formatRegistry(rootRegistry, rootMarkedNames, lines);
74
- const activeRegistry = shouldUseRoot ? buildRootRegistry(config, monorepoRoot) : buildWorkspaceRegistry(config);
75
- const unregisteredScripts = collectUnregisteredScripts(packageScripts, activeRegistry);
76
- if (Object.keys(unregisteredScripts).length > 0) {
77
- lines.push('', 'Package scripts:');
78
- formatRegistry(unregisteredScripts, new Set(), lines);
29
+ const fullActiveRegistry = shouldUseRoot ? buildRootRegistry(config, monorepoRoot) : buildWorkspaceRegistry(config);
30
+ const activeRegistry = filterHooks(fullActiveRegistry);
31
+ const markedNames = applyOverrides(activeRegistry, collectOverrides(packageScripts));
32
+ const commands = Object.entries(activeRegistry).map(([name, value]) => [
33
+ name,
34
+ `${describeScript(value)}${markedNames.has(name) ? ' *' : ''}`,
35
+ ]);
36
+ const unregisteredScripts = collectUnregisteredScripts(packageScripts, fullActiveRegistry);
37
+ for (const [name, value] of Object.entries(unregisteredScripts)) {
38
+ commands.push([name, describeScript(value)]);
79
39
  }
80
- if (hadOverride) {
40
+ const inactiveRegistry = filterHooks(shouldUseRoot ? buildWorkspaceRegistry(config) : buildRootRegistry(config, monorepoRoot));
41
+ const lines = [...ENVIRONMENT_AND_FILES_LINES, '', shouldUseRoot ? 'Workspace commands:' : 'Root commands:'];
42
+ formatRegistry(inactiveRegistry, new Set(), lines);
43
+ if (markedNames.size > 0) {
81
44
  lines.push('', '* Overridden by package.json');
82
45
  }
83
- return lines.join('\n');
46
+ return { commands, epilog: lines.join('\n') };
84
47
  }
48
+ const ENVIRONMENT_AND_FILES_LINES = [
49
+ 'Environment:',
50
+ ` ${DELEGATE_ENV_VAR} Hosts on which to run each command that can be delegated,`,
51
+ ' comma-separated; nmr runs on the first free one',
52
+ ` ${OUTPUT_STYLE_ENV_VAR} Output style: auto (default), plain, or rich. auto prints`,
53
+ ' plain, without emoji, when CI is set or the stream is not a terminal.',
54
+ '',
55
+ 'Files:',
56
+ " ~/.config/nmr/config.ts User config: this machine's delegation hosts, and when to use them",
57
+ ' ($XDG_CONFIG_HOME/nmr/config.ts when that is set)',
58
+ ' A command that it sends runs here when no host answers',
59
+ ];
85
60
  function collectOverrides(packageScripts) {
86
61
  const candidateOverrides = {};
87
62
  for (const [name, value] of Object.entries(packageScripts)) {
@@ -2,7 +2,7 @@ import { spawnSync } from 'node:child_process';
2
2
  import { realpathSync } from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import process from 'node:process';
5
- import { isObject } from "./type-guards.js";
5
+ import { isRecord } from '@williamthorsen/nmr-core';
6
6
  const SELECTION_MARKER = 'nmr-selected-scope';
7
7
  export function readFilterSelection(pattern, monorepoRoot) {
8
8
  const listingRun = spawnSync('pnpm', ['ls', '--filter', pattern, '--depth', '-1', '--json'], {
@@ -42,7 +42,7 @@ export function interpretDelegateProbe(probe) {
42
42
  return probe.stdout.includes(SELECTION_MARKER) ? 'single' : 'empty';
43
43
  }
44
44
  function isRootEntry(entry, monorepoRoot) {
45
- if (!isObject(entry) || typeof entry['path'] !== 'string') {
45
+ if (!isRecord(entry) || typeof entry['path'] !== 'string') {
46
46
  return false;
47
47
  }
48
48
  return resolveRealPath(entry['path']) === resolveRealPath(monorepoRoot);