@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,6 +1,6 @@
1
1
  import path from 'node:path';
2
+ import { isRecord } from '@williamthorsen/nmr-core';
2
3
  import { quoteForShell } from "./helpers/quoteForShell.js";
3
- import { isObject } from "./helpers/type-guards.js";
4
4
  import { LOCK_DIR_ENV_VAR, LOCK_FILE, STALE_AFTER_MS } from "./machine-lock.js";
5
5
  import { CHECKOUT_LOCK_PATH } from "./remote-host-session.js";
6
6
  import { probeHostLocks } from "./remote-probe-host.js";
@@ -80,7 +80,7 @@ export function selectHost(hosts, results) {
80
80
  function readBusyField(line) {
81
81
  try {
82
82
  const value = JSON.parse(line);
83
- return isObject(value) && typeof value['busy'] === 'boolean' ? value['busy'] : undefined;
83
+ return isRecord(value) && typeof value['busy'] === 'boolean' ? value['busy'] : undefined;
84
84
  }
85
85
  catch {
86
86
  return undefined;
@@ -3,13 +3,13 @@ import { type OutputStyle } from '@williamthorsen/nmr-core';
3
3
  /** Names the hosts to which nmr delegates a command when the invocation does not say otherwise. */
4
4
  export declare const DELEGATE_ENV_VAR = "NMR_DELEGATE";
5
5
  /** The flag that runs the command on a host that `NMR_DELEGATE` or the user config names. */
6
- export declare const DELEGATE_FLAG = "--delegate";
6
+ export declare const DELEGATE_FLAG: string;
7
7
  /** The flag that names the hosts on which to run the command. */
8
- export declare const DELEGATE_TO_FLAG = "--delegate-to";
8
+ export declare const DELEGATE_TO_FLAG: string;
9
9
  /** The exit status of a delegation that failed outside its command, which matches `ssh`'s own. */
10
10
  export declare const DELEGATION_FAILURE_EXIT_CODE = 255;
11
11
  /** The flag that runs the command here, whatever `NMR_DELEGATE` or the user config says. */
12
- export declare const NO_DELEGATE_FLAG = "--no-delegate";
12
+ export declare const NO_DELEGATE_FLAG: string;
13
13
  /** Names the ssh command that the environment supplies, which git and nmr run in place of plain `ssh`. */
14
14
  export declare const SSH_COMMAND_ENV_VAR = "GIT_SSH_COMMAND";
15
15
  /** Keeps `ssh` from prompting or waiting on a terminal, and bounds the wait for a host that does not answer. */
@@ -3,18 +3,19 @@ import { createHash, randomUUID } from 'node:crypto';
3
3
  import os from 'node:os';
4
4
  import path from 'node:path';
5
5
  import process from 'node:process';
6
- import { startTimer, STATUS_GLYPHS } from '@williamthorsen/nmr-core';
6
+ import { GIT_OUTPUT_LIMIT_BYTES, startTimer, STATUS_GLYPHS } from '@williamthorsen/nmr-core';
7
7
  import { PASS_MANIFEST_DIR_ENV_VAR, writeDebugNote } from "./check-cache.js";
8
8
  import { createBoundedBuffer } from "./helpers/createBoundedBuffer.js";
9
9
  import { quoteForShell } from "./helpers/quoteForShell.js";
10
10
  import { STALE_AFTER_MS } from "./machine-lock.js";
11
+ import { formatFlagSpelling } from "./nmr-flags.js";
11
12
  import { CHECKOUT_LOCK_PATH } from "./remote-host-session.js";
12
13
  import { composeSessionScript, createSessionDemultiplexer, formatDelegationWaitingLine } from "./remote-session.js";
13
14
  export const DELEGATE_ENV_VAR = 'NMR_DELEGATE';
14
- export const DELEGATE_FLAG = '--delegate';
15
- export const DELEGATE_TO_FLAG = '--delegate-to';
15
+ export const DELEGATE_FLAG = formatFlagSpelling('delegate');
16
+ export const DELEGATE_TO_FLAG = formatFlagSpelling('delegateTo');
16
17
  export const DELEGATION_FAILURE_EXIT_CODE = 255;
17
- export const NO_DELEGATE_FLAG = '--no-delegate';
18
+ export const NO_DELEGATE_FLAG = formatFlagSpelling('noDelegate');
18
19
  export const SSH_COMMAND_ENV_VAR = 'GIT_SSH_COMMAND';
19
20
  export const SSH_OPTIONS = ['-T', '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=10'];
20
21
  export function composeGitSshCommand(env) {
@@ -102,6 +103,7 @@ export async function runRemotely(options) {
102
103
  const script = composeSessionScript({
103
104
  checkoutDir,
104
105
  params: {
106
+ gitOutputLimitBytes: GIT_OUTPUT_LIMIT_BYTES,
105
107
  holder: { command, dir: cwd, pid: process.pid },
106
108
  lockPath: CHECKOUT_LOCK_PATH,
107
109
  marker,
@@ -1,6 +1,6 @@
1
1
  import { Writable } from 'node:stream';
2
+ import { isRecord } from '@williamthorsen/nmr-core';
2
3
  import { quoteForShell } from "./helpers/quoteForShell.js";
3
- import { isObject } from "./helpers/type-guards.js";
4
4
  import { runHostSession, } from "./remote-host-session.js";
5
5
  export function composeSessionScript(options) {
6
6
  const { checkoutDir, params } = options;
@@ -91,7 +91,7 @@ export function createSessionDemultiplexer(options) {
91
91
  catch {
92
92
  return 'other';
93
93
  }
94
- if (!isObject(value) || value['kind'] !== 'passes') {
94
+ if (!isRecord(value) || value['kind'] !== 'passes') {
95
95
  return 'other';
96
96
  }
97
97
  const entries = value['entries'];
@@ -116,13 +116,13 @@ export function formatDelegationWaitingLine(holder, host) {
116
116
  }
117
117
  const NEWLINE = 0x0a;
118
118
  function isDelegationHolder(value) {
119
- return (isObject(value) &&
119
+ return (isRecord(value) &&
120
120
  typeof value['command'] === 'string' &&
121
121
  typeof value['dir'] === 'string' &&
122
122
  typeof value['pid'] === 'number');
123
123
  }
124
124
  function isSessionRecord(value) {
125
- if (!isObject(value))
125
+ if (!isRecord(value))
126
126
  return false;
127
127
  switch (value['kind']) {
128
128
  case 'failed':
@@ -1,10 +1,9 @@
1
- import { spawnSync } from 'node:child_process';
2
1
  import fs from 'node:fs';
3
2
  import os from 'node:os';
4
3
  import path from 'node:path';
5
- import { GIT_OUTPUT_LIMIT_BYTES } from '@williamthorsen/nmr-core';
4
+ import { runGit } from '@williamthorsen/nmr-core';
6
5
  export function createSnapshot(cwd, env) {
7
- const toplevelResult = runGit(['rev-parse', '--show-toplevel'], cwd, env);
6
+ const toplevelResult = runGit(['rev-parse', '--show-toplevel'], cwd, undefined, env);
8
7
  if (!toplevelResult.ok) {
9
8
  return { ok: false, isUnsupported: true, reason: `not a git repository: ${toplevelResult.error}` };
10
9
  }
@@ -12,7 +11,7 @@ export function createSnapshot(cwd, env) {
12
11
  if (fs.existsSync(path.join(toplevel, '.gitmodules'))) {
13
12
  return { ok: false, isUnsupported: true, reason: 'the repository declares submodules' };
14
13
  }
15
- const indexPathResult = runGit(['rev-parse', '--git-path', 'index'], toplevel, env);
14
+ const indexPathResult = runGit(['rev-parse', '--git-path', 'index'], toplevel, undefined, env);
16
15
  if (!indexPathResult.ok) {
17
16
  return { ok: false, isUnsupported: false, reason: indexPathResult.error };
18
17
  }
@@ -24,28 +23,25 @@ export function createSnapshot(cwd, env) {
24
23
  copyIndex(indexPath, tempIndex);
25
24
  }
26
25
  const indexEnv = { ...env, GIT_INDEX_FILE: tempIndex };
27
- const addResult = runGit(['add', '--all'], toplevel, indexEnv);
26
+ const addResult = runGit(['add', '--all'], toplevel, undefined, indexEnv);
28
27
  if (!addResult.ok) {
29
28
  return { ok: false, isUnsupported: false, reason: addResult.error };
30
29
  }
31
- const stageResult = runGit(['ls-files', '--stage', '-z'], toplevel, indexEnv);
30
+ const stageResult = runGit(['ls-files', '--stage', '-z'], toplevel, undefined, indexEnv);
32
31
  if (!stageResult.ok) {
33
32
  return { ok: false, isUnsupported: false, reason: stageResult.error };
34
33
  }
35
34
  if (stageResult.stdout.split('\0').some((entry) => entry.startsWith('160000 '))) {
36
35
  return { ok: false, isUnsupported: true, reason: 'the working tree contains a submodule or a nested repository' };
37
36
  }
38
- const treeResult = runGit(['write-tree'], toplevel, indexEnv);
37
+ const treeResult = runGit(['write-tree'], toplevel, undefined, indexEnv);
39
38
  if (!treeResult.ok) {
40
39
  return { ok: false, isUnsupported: false, reason: treeResult.error };
41
40
  }
42
- const headResult = runGit(['rev-parse', '--verify', '--quiet', 'HEAD^{commit}'], toplevel, env);
41
+ const headResult = runGit(['rev-parse', '--verify', '--quiet', 'HEAD^{commit}'], toplevel, undefined, env);
43
42
  const parentArgs = headResult.ok ? ['-p', headResult.stdout.trim()] : [];
44
43
  const treeId = treeResult.stdout.trim();
45
- const commitResult = runGit(['commit-tree', treeId, ...parentArgs, '-m', 'nmr delegate snapshot'], toplevel, {
46
- ...env,
47
- ...SNAPSHOT_IDENTITY_ENV,
48
- });
44
+ const commitResult = runGit(['commit-tree', treeId, ...parentArgs, '-m', 'nmr delegate snapshot'], toplevel, undefined, { ...env, ...SNAPSHOT_IDENTITY_ENV });
49
45
  if (!commitResult.ok) {
50
46
  return { ok: false, isUnsupported: false, reason: commitResult.error };
51
47
  }
@@ -68,13 +64,3 @@ function copyIndex(source, destination) {
68
64
  const { atime, mtime } = fs.statSync(source);
69
65
  fs.utimesSync(destination, atime, mtime);
70
66
  }
71
- function runGit(args, cwd, env) {
72
- const result = spawnSync('git', args, { cwd, encoding: 'utf8', env, maxBuffer: GIT_OUTPUT_LIMIT_BYTES });
73
- if (result.error !== undefined) {
74
- return { ok: false, error: result.error.message };
75
- }
76
- if (result.status !== 0) {
77
- return { ok: false, error: result.stderr.trim() || `git ${args[0] ?? ''} exited ${result.status ?? 'abnormally'}` };
78
- }
79
- return { ok: true, stdout: result.stdout };
80
- }
@@ -1,8 +1,7 @@
1
1
  import { readFileSync } from 'node:fs';
2
2
  import path from 'node:path';
3
- import { hasErrnoCode } from '@williamthorsen/nmr-core';
3
+ import { hasErrnoCode, isRecord } from '@williamthorsen/nmr-core';
4
4
  import { parsePackageJson, readScriptRecord, resolvePackageJsonPath } from "./helpers/package-json.js";
5
- import { isObject } from "./helpers/type-guards.js";
6
5
  import { getDefaultRootScripts, getDefaultWorkspaceScripts, } from "./resolve-scripts.js";
7
6
  import { composeNmrStep, readSelfReference } from "./steps.js";
8
7
  import { isMonorepoRoot } from "./workspace.js";
@@ -77,10 +76,10 @@ export function readPackageJsonScripts(packageDir) {
77
76
  throw error;
78
77
  }
79
78
  const parsedManifest = parsePackageJson(rawText, file);
80
- if (!isObject(parsedManifest))
79
+ if (!isRecord(parsedManifest))
81
80
  return undefined;
82
81
  const scripts = parsedManifest['scripts'];
83
- if (!isObject(scripts))
82
+ if (!isRecord(scripts))
84
83
  return undefined;
85
84
  return readScriptRecord(packageDir, scripts);
86
85
  }
@@ -0,0 +1,29 @@
1
+ import type { Writable } from 'node:stream';
2
+ import { type CommandNode } from '@williamthorsen/nmr-core/cli';
3
+ import type { ResolvedContext } from './context.js';
4
+ import { type Invocation } from './invocation.js';
5
+ import { type ParsedArgs } from './nmr-flags.js';
6
+ /** What the command tree of one invocation is built from. */
7
+ export interface RouteInput {
8
+ context: ResolvedContext;
9
+ cwd: string;
10
+ env: NodeJS.ProcessEnv;
11
+ /** The invocation's flags as the first pass read them, or `undefined` when they do not parse. */
12
+ firstPass: ParsedArgs | undefined;
13
+ /** Runs an invocation that reached a command and resolves to its exit code. */
14
+ runInvocation: (invocation: Invocation) => Promise<number>;
15
+ stderr: Writable;
16
+ stdout: Writable;
17
+ }
18
+ /**
19
+ * Builds the command tree of one invocation: nmr's flags, and a passthrough command for each script that help lists
20
+ * for the scope.
21
+ *
22
+ * The command that the invocation names is added when any scope defines it, or when nmr answers for it on its own
23
+ * (a run on a delegating caller's behalf, or a host name after a bare `--delegate`), so that nmr rather than the
24
+ * router decides what an invocation that a scope cannot run reports. A request for help adds nothing, so that help
25
+ * lists the scope alone.
26
+ */
27
+ export declare function buildRoot(input: RouteInput): CommandNode<undefined>;
28
+ /** Builds a root that does not have any commands, which answers `--version` without loading the config. */
29
+ export declare function buildVersionRoot(): CommandNode<undefined>;
@@ -0,0 +1,94 @@
1
+ import path from 'node:path';
2
+ import { createCli, UsageError } from '@williamthorsen/nmr-core/cli';
3
+ import { listDefinedCommands } from "./fan-out.js";
4
+ import { describeHelp } from "./help.js";
5
+ import { RUN_IF_PRESENT_ENV_VAR } from "./invocation.js";
6
+ import { NMR_FLAGS, toParsedArgs } from "./nmr-flags.js";
7
+ import { isTerminalStream } from "./output-style.js";
8
+ import { readPresentation, resolveReportingVerbosity } from "./presentation.js";
9
+ import { resolveReportFormat } from "./report-format.js";
10
+ const { defineGroup } = createCli();
11
+ const { defineCommand } = createCli();
12
+ export function buildRoot(input) {
13
+ const { context, env, firstPass } = input;
14
+ const shouldUseRoot = (firstPass?.isWorkspaceRoot ?? false) || context.isRoot;
15
+ const anchorDir = resolveAnchorDir(context, shouldUseRoot);
16
+ const listing = describeHelp(context.config, context.monorepoRoot, anchorDir, shouldUseRoot);
17
+ const commands = {};
18
+ for (const [name, summary] of listing.commands) {
19
+ commands[name] = defineScriptCommand(name, summary, input);
20
+ }
21
+ const command = firstPass?.command;
22
+ if (command !== undefined && !firstPass?.shouldShowHelp && !Object.hasOwn(commands, command)) {
23
+ const isAnsweredByNmr = env[RUN_IF_PRESENT_ENV_VAR] === '1' ||
24
+ (firstPass?.shouldDelegate === true && firstPass.delegateHosts === undefined) ||
25
+ listDefinedCommands(context).has(command);
26
+ if (isAnsweredByNmr) {
27
+ commands[command] = defineScriptCommand(command, 'A script that another scope defines', input);
28
+ }
29
+ }
30
+ return defineGroup({
31
+ summary: 'Runs the scripts of a pnpm monorepo in the scope of the current directory',
32
+ flags: NMR_FLAGS,
33
+ deriveContext: (flags) => deriveInvocation(flags, input),
34
+ commands,
35
+ epilog: listing.epilog,
36
+ });
37
+ }
38
+ export function buildVersionRoot() {
39
+ return defineGroup({ summary: 'nmr', flags: NMR_FLAGS, commands: {} });
40
+ }
41
+ function defineScriptCommand(name, summary, input) {
42
+ return defineCommand({
43
+ summary,
44
+ passthrough: true,
45
+ run: ({ args, context }) => input.runInvocation({
46
+ ...context,
47
+ command: name,
48
+ parsedArgs: { ...context.parsedArgs, command: name, passthrough: args },
49
+ }),
50
+ });
51
+ }
52
+ function deriveInvocation(flags, input) {
53
+ const { context, cwd, env, firstPass, stderr, stdout } = input;
54
+ const parsedArgs = toParsedArgs(flags, []);
55
+ const presentationRead = readPresentation({
56
+ env,
57
+ flagValue: parsedArgs.outputStyle,
58
+ stderrIsTty: isTerminalStream(stderr),
59
+ stdoutIsTty: isTerminalStream(stdout),
60
+ });
61
+ if (!presentationRead.ok) {
62
+ throw new UsageError(presentationRead.error);
63
+ }
64
+ if (parsedArgs.shouldPrintLog && firstPass?.command === undefined) {
65
+ throw new UsageError('--log requires a command name: `nmr --log <command>`');
66
+ }
67
+ const format = resolveReportFormat({ envFormat: presentationRead.format, hasJsonFlag: parsedArgs.shouldEmitJson });
68
+ const verbosity = resolveReportingVerbosity({
69
+ env,
70
+ envVerbosity: presentationRead.verbosity,
71
+ format,
72
+ output: context.config.output,
73
+ hasQuietFlag: parsedArgs.quiet,
74
+ });
75
+ const shouldUseRoot = parsedArgs.isWorkspaceRoot || context.isRoot;
76
+ const anchorDir = resolveAnchorDir(context, shouldUseRoot);
77
+ return {
78
+ anchorDir,
79
+ context,
80
+ cwd,
81
+ env,
82
+ format,
83
+ parsedArgs,
84
+ scope: path.basename(anchorDir),
85
+ shouldUseRoot,
86
+ stderr,
87
+ stdout,
88
+ styles: presentationRead.styles,
89
+ verbosity,
90
+ };
91
+ }
92
+ function resolveAnchorDir(context, shouldUseRoot) {
93
+ return shouldUseRoot ? context.monorepoRoot : (context.packageDir ?? context.monorepoRoot);
94
+ }
@@ -1,6 +1,6 @@
1
1
  import { type OutputStyle } from '@williamthorsen/nmr-core';
2
2
  import type { TreeSnapshot } from './check-cache.js';
3
- import type { Invocation, PhaseResult, RunPlan } from './invocation.js';
3
+ import { type Invocation, type PhaseResult, type RunPlan } from './invocation.js';
4
4
  import type { ScriptRegistry } from './resolve-scripts.js';
5
5
  import { type ResolvedScript } from './resolver.js';
6
6
  /**
@@ -1,11 +1,13 @@
1
1
  import path from 'node:path';
2
2
  import { formatGlyphLine, reportError } from '@williamthorsen/nmr-core';
3
+ import { NMR_BIN } from "./bins.js";
3
4
  import { resolveCacheKey } from "./check-cache-gate.js";
4
5
  import { reportNmrCrossing } from "./crossing-diagnostics.js";
5
6
  import { NMR_GLYPHS } from "./glyphs.js";
6
7
  import { isHookName } from "./helpers/hook-name.js";
7
8
  import { quoteForShell } from "./helpers/quoteForShell.js";
8
9
  import { wrapWithHooks } from "./hooks.js";
10
+ import { USAGE_EXIT_CODE } from "./invocation.js";
9
11
  import { reportVerdict } from "./presentation.js";
10
12
  import { applyDevBinToSteps } from "./resolver.js";
11
13
  import { dropRecursiveSteps, renderChain } from "./steps.js";
@@ -23,8 +25,8 @@ export function assembleChain(options) {
23
25
  const substitutedCommand = renderChain(substitutedSteps);
24
26
  const bindResult = bindPassthrough(substitutedSteps, parsedArgs.passthrough, command);
25
27
  if (!bindResult.ok) {
26
- reportError(bindResult.error, stderr);
27
- return { settled: true, result: { exitCode: 1 } };
28
+ reportError(NMR_BIN, bindResult.error, stderr);
29
+ return { settled: true, result: { exitCode: USAGE_EXIT_CODE } };
28
30
  }
29
31
  const mainSteps = bindResult.steps;
30
32
  const isHookInvocation = isHookName(command);
@@ -17,6 +17,8 @@ export interface RunCliOptions {
17
17
  * Executes the nmr CLI flow in-process and returns the resulting exit code.
18
18
  * Does not hold global state, does not read `process.*` globals, and never calls `process.exit`.
19
19
  *
20
+ * nmr's flags decide which commands the tree holds, so a first pass reads them before the router parses them again.
21
+ *
20
22
  * @internal
21
23
  */
22
24
  export declare function runCli(options: RunCliOptions): Promise<RunCliResult>;
@@ -1,6 +1,7 @@
1
- import path from 'node:path';
2
1
  import { reportError } from '@williamthorsen/nmr-core';
2
+ import { runCli as runRouter } from '@williamthorsen/nmr-core/cli';
3
3
  import { prependBinDirs } from "./bin-path.js";
4
+ import { NMR_BIN } from "./bins.js";
4
5
  import { NO_CACHE_ENV_VAR, resolveRunId, RUN_ID_ENV_VAR } from "./check-cache.js";
5
6
  import { openGate, runGated } from "./check-cache-gate.js";
6
7
  import { reportRecording } from "./check-cache-record.js";
@@ -9,32 +10,48 @@ import { resolveContext } from "./context.js";
9
10
  import { assertNoSelfReference } from "./crossing-diagnostics.js";
10
11
  import { resolveExecutionTarget, runInvocationOnHost } from "./delegate-host.js";
11
12
  import { enterChain } from "./entry-chain.js";
12
- import { composeDelegation, isDefinedInAnyScope, runDelegation } from "./fan-out.js";
13
- import { generateHelp } from "./help.js";
14
- import { RUN_IF_PRESENT_ENV_VAR, VERSION, } from "./invocation.js";
15
- import { formatMisplacedFlagHint, parseArgs } from "./invocation-args.js";
16
- import { isTerminalStream } from "./output-style.js";
17
- import { readPresentation, reportVerdict, resolveReportingVerbosity } from "./presentation.js";
13
+ import { composeDelegation, runDelegation } from "./fan-out.js";
14
+ import { RUN_IF_PRESENT_ENV_VAR, USAGE_EXIT_CODE, VERSION, } from "./invocation.js";
15
+ import { formatMisplacedFlagHint } from "./invocation-args.js";
16
+ import { readInvocationFlags } from "./nmr-flags.js";
17
+ import { reportVerdict } from "./presentation.js";
18
18
  import { DELEGATE_TO_FLAG } from "./remote-run.js";
19
- import { resolveReportFormat } from "./report-format.js";
20
19
  import { buildRootRegistry, buildWorkspaceRegistry, resolveScript } from "./resolver.js";
20
+ import { buildRoot, buildVersionRoot } from "./route.js";
21
21
  import { assembleChain, formatOverrideNotice } from "./run-chain.js";
22
22
  export async function runCli(options) {
23
- const established = await establishInvocation(options);
24
- if (established.settled) {
25
- return { exitCode: established.result.exitCode };
23
+ const { args, cwd, env, stdout, stderr } = options;
24
+ const reading = readInvocationFlags(args);
25
+ const firstPass = reading.ok ? reading.parsedArgs : undefined;
26
+ const routerOptions = { name: NMR_BIN, context: undefined, stdout, stderr, baseDir: cwd, version: () => VERSION };
27
+ if (firstPass?.shouldShowVersion === true && !firstPass.shouldShowHelp) {
28
+ return { exitCode: await runRouter(args, buildVersionRoot(), routerOptions) };
26
29
  }
27
- const invocation = established.value;
30
+ const context = await resolveContext(cwd);
31
+ const root = buildRoot({
32
+ context,
33
+ cwd,
34
+ env,
35
+ firstPass,
36
+ runInvocation: (invocation) => runInvocation(invocation, options),
37
+ stderr,
38
+ stdout,
39
+ });
40
+ const isBare = firstPass !== undefined && firstPass.command === undefined && !firstPass.shouldPrintLog && !args.includes('--');
41
+ const routedArgs = isBare ? [...args, '--help'] : args;
42
+ return { exitCode: await runRouter(routedArgs, root, routerOptions) };
43
+ }
44
+ async function runInvocation(invocation, options) {
28
45
  const resolvedTarget = await resolveExecutionTarget(invocation);
29
46
  if (resolvedTarget.settled) {
30
- return { exitCode: resolvedTarget.result.exitCode };
47
+ return resolvedTarget.result.exitCode;
31
48
  }
32
49
  let target = resolvedTarget.value;
33
50
  const { filter, recursive } = invocation.parsedArgs;
34
51
  if (target.kind === 'remote' && (filter !== undefined || recursive)) {
35
52
  const delegated = await runInvocationOnHost(invocation, target);
36
53
  if (delegated !== undefined) {
37
- return delegated;
54
+ return delegated.exitCode;
38
55
  }
39
56
  target = { kind: 'local' };
40
57
  }
@@ -46,64 +63,7 @@ export async function runCli(options) {
46
63
  options.stderr.write(`${hint}\n`);
47
64
  }
48
65
  }
49
- return { exitCode };
50
- }
51
- async function establishInvocation(options) {
52
- const { args, cwd, env, stdout, stderr } = options;
53
- const parseResult = parseArgs(args);
54
- if (!parseResult.ok) {
55
- reportError(parseResult.error, stderr);
56
- return { settled: true, result: { exitCode: 1 } };
57
- }
58
- const { parsedArgs } = parseResult;
59
- const presentationRead = readPresentation({
60
- env,
61
- flagValue: parsedArgs.outputStyle,
62
- stderrIsTty: isTerminalStream(stderr),
63
- stdoutIsTty: isTerminalStream(stdout),
64
- });
65
- if (!presentationRead.ok) {
66
- reportError(presentationRead.error, stderr);
67
- return { settled: true, result: { exitCode: 1 } };
68
- }
69
- const { styles } = presentationRead;
70
- if (parsedArgs.shouldShowVersion) {
71
- stdout.write(`${VERSION}\n`);
72
- return { settled: true, result: { exitCode: 0 } };
73
- }
74
- const context = await resolveContext(cwd);
75
- const format = resolveReportFormat({ envFormat: presentationRead.format, hasJsonFlag: parsedArgs.shouldEmitJson });
76
- const verbosity = resolveReportingVerbosity({
77
- env,
78
- envVerbosity: presentationRead.verbosity,
79
- format,
80
- output: context.config.output,
81
- hasQuietFlag: parsedArgs.quiet,
82
- });
83
- const shouldUseRoot = parsedArgs.isWorkspaceRoot || context.isRoot;
84
- const anchorDir = shouldUseRoot ? context.monorepoRoot : (context.packageDir ?? context.monorepoRoot);
85
- if (parsedArgs.shouldShowHelp || !parsedArgs.command) {
86
- stdout.write(`${generateHelp(context.config, context.monorepoRoot, anchorDir, shouldUseRoot)}\n`);
87
- return { settled: true, result: { exitCode: 0 } };
88
- }
89
- return {
90
- settled: false,
91
- value: {
92
- anchorDir,
93
- command: parsedArgs.command,
94
- context,
95
- cwd,
96
- env,
97
- format,
98
- parsedArgs,
99
- scope: path.basename(anchorDir),
100
- shouldUseRoot,
101
- stderr,
102
- stdout,
103
- styles,
104
- verbosity,
105
- },
106
- };
66
+ return exitCode;
107
67
  }
108
68
  async function planRun(invocation, target) {
109
69
  const { anchorDir, command, context, env, parsedArgs, stderr, stdout, styles } = invocation;
@@ -131,6 +91,7 @@ async function planRun(invocation, target) {
131
91
  verbosity: invocation.verbosity,
132
92
  });
133
93
  const runOptions = {
94
+ bin: NMR_BIN,
134
95
  quiet,
135
96
  stdout,
136
97
  stderr,
@@ -147,10 +108,7 @@ async function planRun(invocation, target) {
147
108
  finally {
148
109
  await lock?.release();
149
110
  }
150
- return {
151
- settled: true,
152
- result: { ...result, isUnknownCommand: result.exitCode !== 0 && !isDefinedInAnyScope(command, context) },
153
- };
111
+ return { settled: true, result };
154
112
  }
155
113
  const registry = invocation.shouldUseRoot
156
114
  ? buildRootRegistry(context.config, context.monorepoRoot)
@@ -171,8 +129,8 @@ async function planRun(invocation, target) {
171
129
  const delegateToHint = parsedArgs.shouldDelegate && parsedArgs.delegateHosts === undefined
172
130
  ? `. \`${command}\` is not a command; name a host with ${DELEGATE_TO_FLAG} ${command}`
173
131
  : '';
174
- reportError(`Unknown command: ${command}${delegateToHint}`, stderr);
175
- return { settled: true, result: { exitCode: 1, isUnknownCommand: true } };
132
+ reportError(NMR_BIN, `Unknown command: ${command}${delegateToHint}`, stderr);
133
+ return { settled: true, result: { exitCode: USAGE_EXIT_CODE, isUnknownCommand: true } };
176
134
  }
177
135
  const entryRunOptions = parsedArgs.shouldPrintLog
178
136
  ? runOptions
@@ -1,7 +1,7 @@
1
1
  import { readFile, writeFile } from 'node:fs/promises';
2
2
  import process from 'node:process';
3
+ import { isRecord } from '@williamthorsen/nmr-core';
3
4
  import { describeError } from '@williamthorsen/toolbelt.errors';
4
- import { isObject } from "./helpers/type-guards.js";
5
5
  export const OUTCOME_FILE_ENV_VAR = 'NMR_OUTCOME_FILE';
6
6
  export function omitOutcomeFile(env) {
7
7
  return Object.fromEntries(Object.entries(env).filter(([name]) => name !== OUTCOME_FILE_ENV_VAR));
@@ -49,10 +49,10 @@ export async function writeStatedOutcome(env, outcome) {
49
49
  }
50
50
  }
51
51
  function isMissingFileError(error) {
52
- return isObject(error) && error['code'] === 'ENOENT';
52
+ return isRecord(error) && error['code'] === 'ENOENT';
53
53
  }
54
54
  function isStatedOutcome(value) {
55
- if (!isObject(value) || value['kind'] !== 'build-unchanged' || !Number.isSafeInteger(value['parentPid'])) {
55
+ if (!isRecord(value) || value['kind'] !== 'build-unchanged' || !Number.isSafeInteger(value['parentPid'])) {
56
56
  return false;
57
57
  }
58
58
  const savedMs = value['savedMs'];
package/dist/esm/steps.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { readInvocationFlags } from "./nmr-flags.js";
1
2
  import { dropLeadingAssignments, isShellSafeToken, quoteToken, splitSegments, tokenize, tokenizeSegment, } from "./shell-syntax.js";
2
3
  const LAUNCHERS = new Map([
3
4
  ['bun', new Set(['x'])],
@@ -141,37 +142,12 @@ function readNmrTail(segment) {
141
142
  : undefined;
142
143
  }
143
144
  function readNmrTarget(tokens) {
144
- let isDelegate = false;
145
- let isRecursive = false;
146
- let isWorkspaceRoot = false;
147
- let index = 0;
148
- while (index < tokens.length) {
149
- const token = tokens[index] ?? '';
150
- switch (token) {
151
- case '-F':
152
- case '--filter':
153
- isDelegate = true;
154
- index += 2;
155
- break;
156
- case '-R':
157
- case '--recursive':
158
- isDelegate = true;
159
- isRecursive = true;
160
- index += 1;
161
- break;
162
- case '-w':
163
- case '--workspace-root':
164
- isWorkspaceRoot = true;
165
- index += 1;
166
- break;
167
- default:
168
- if (!token.startsWith('-')) {
169
- return { command: token, isDelegate, isRecursive, isWorkspaceRoot };
170
- }
171
- index += 1;
172
- }
145
+ const reading = readInvocationFlags(tokens);
146
+ if (!reading.ok || reading.parsedArgs.command === undefined) {
147
+ return undefined;
173
148
  }
174
- return undefined;
149
+ const { command, filter, isWorkspaceRoot, recursive } = reading.parsedArgs;
150
+ return { command, isDelegate: filter !== undefined || recursive, isRecursive: recursive, isWorkspaceRoot };
175
151
  }
176
152
  function renderStep(step) {
177
153
  return step.kind === 'opaque' ? step.command : step.argv.map(quoteToken).join(' ');
@@ -9,9 +9,9 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
9
9
  import { existsSync } from 'node:fs';
10
10
  import path from 'node:path';
11
11
  import { pathToFileURL } from 'node:url';
12
- import { UserError } from '@williamthorsen/nmr-core';
12
+ import { isRecord, UserError } from '@williamthorsen/nmr-core';
13
13
  import { assertRecognizedKeys } from "./config-validation.js";
14
- import { isObject } from "./helpers/type-guards.js";
14
+ import { resolveXdgBaseDir } from "./helpers/resolveXdgBaseDir.js";
15
15
  export const DELEGATE_WHEN_VALUES = ['agent', 'always'];
16
16
  export function formatDisplayPath(filePath, env) {
17
17
  const home = env['HOME'];
@@ -30,19 +30,12 @@ export async function loadUserConfig(env) {
30
30
  return undefined;
31
31
  }
32
32
  const importedModule = await import(__rewriteRelativeImportExtension(pathToFileURL(configPath).href));
33
- const loadedConfig = isObject(importedModule) ? importedModule['default'] : undefined;
33
+ const loadedConfig = isRecord(importedModule) ? importedModule['default'] : undefined;
34
34
  return { config: validateUserConfig(loadedConfig, configPath), path: configPath };
35
35
  }
36
36
  export function resolveUserConfigPath(env) {
37
- const configHome = env['XDG_CONFIG_HOME'];
38
- if (configHome !== undefined && path.isAbsolute(configHome)) {
39
- return path.join(configHome, USER_CONFIG_RELATIVE_PATH);
40
- }
41
- const home = env['HOME'];
42
- if (home !== undefined && path.isAbsolute(home)) {
43
- return path.join(home, '.config', USER_CONFIG_RELATIVE_PATH);
44
- }
45
- return undefined;
37
+ const configHome = resolveXdgBaseDir(env, 'XDG_CONFIG_HOME', '.config');
38
+ return configHome === undefined ? undefined : path.join(configHome, USER_CONFIG_RELATIVE_PATH);
46
39
  }
47
40
  const USER_CONFIG_RELATIVE_PATH = path.join('nmr', 'config.ts');
48
41
  const RECOGNIZED_KEYS = ['delegate'];
@@ -56,7 +49,7 @@ function validateDelegateField(value, configPath) {
56
49
  if (delegate === undefined) {
57
50
  return undefined;
58
51
  }
59
- if (!isObject(delegate)) {
52
+ if (!isRecord(delegate)) {
60
53
  throw new UserError(`Invalid nmr config at ${configPath}: \`delegate\` must be an object`);
61
54
  }
62
55
  assertRecognizedKeys(delegate, RECOGNIZED_DELEGATE_KEYS, configPath, 'delegate.');
@@ -84,7 +77,7 @@ function validateHosts(value, configPath) {
84
77
  return [first, ...rest];
85
78
  }
86
79
  function validateUserConfig(value, configPath) {
87
- if (!isObject(value)) {
80
+ if (!isRecord(value)) {
88
81
  throw new UserError(`Invalid nmr config at ${configPath}: expected an object, got ${typeof value}`);
89
82
  }
90
83
  assertRecognizedKeys(value, RECOGNIZED_KEYS, configPath);