@williamthorsen/nmr 2.0.3 → 2.2.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 (79) hide show
  1. package/.meta/changelog.json +77 -0
  2. package/.readyup/kits/default.js +66 -35
  3. package/.readyup/manifest.json +38 -39
  4. package/CHANGELOG.md +26 -0
  5. package/README.md +8 -1
  6. package/dist/esm/assertResolvableCommandLists.d.ts +18 -0
  7. package/dist/esm/{check-cache/config-check-cache.js → assertResolvableCommandLists.js} +20 -12
  8. package/dist/esm/check-cache/check-cache-entries.js +1 -1
  9. package/dist/esm/check-cache/check-cache-gate.js +1 -1
  10. package/dist/esm/check-cache/check-cache-record.js +1 -1
  11. package/dist/esm/check-cache/check-cache.js +1 -1
  12. package/dist/esm/commands/build.js +1 -1
  13. package/dist/esm/commands/fmt.js +1 -1
  14. package/dist/esm/commands/taze.js +2 -2
  15. package/dist/esm/config-validation.js +25 -3
  16. package/dist/esm/config.js +1 -1
  17. package/dist/esm/crossing-diagnostics.js +1 -1
  18. package/dist/esm/defineConfig.d.ts +1 -1
  19. package/dist/esm/delegation/delegate-host.js +10 -3
  20. package/dist/esm/delegation/host-filesystem.d.ts +24 -0
  21. package/dist/esm/delegation/host-filesystem.js +45 -0
  22. package/dist/esm/delegation/remote-probe.js +1 -1
  23. package/dist/esm/delegation/remote-run.d.ts +12 -6
  24. package/dist/esm/delegation/remote-run.js +30 -8
  25. package/dist/esm/delegation/removeStaleCheckouts.d.ts +33 -0
  26. package/dist/esm/delegation/removeStaleCheckouts.js +98 -0
  27. package/dist/esm/delegation/user-config.d.ts +6 -1
  28. package/dist/esm/delegation/user-config.js +14 -4
  29. package/dist/esm/{help.js → describeHelp.js} +1 -1
  30. package/dist/esm/fan-out.d.ts +7 -0
  31. package/dist/esm/fan-out.js +7 -2
  32. package/dist/esm/invocation.d.ts +1 -1
  33. package/dist/esm/machine-lock.d.ts +0 -4
  34. package/dist/esm/machine-lock.js +2 -2
  35. package/dist/esm/presentation.js +1 -1
  36. package/dist/esm/report-format.d.ts +2 -8
  37. package/dist/esm/report-format.js +8 -8
  38. package/dist/esm/resolve-scripts.js +1 -1
  39. package/dist/esm/route.js +1 -1
  40. package/dist/esm/run-chain.js +1 -1
  41. package/dist/esm/runCli.js +2 -2
  42. package/dist/esm/tiers.js +1 -1
  43. package/dist/esm/types.d.ts +12 -0
  44. package/dist/esm/verbosity.d.ts +2 -1
  45. package/dist/esm/verbosity.js +1 -1
  46. package/dist/esm/vitest.js +2 -2
  47. package/docs/delegate.md +22 -5
  48. package/docs/scripts.md +12 -0
  49. package/package.json +8 -8
  50. package/dist/esm/check-cache/config-check-cache.d.ts +0 -17
  51. /package/dist/esm/check-cache/{replay-assembly.d.ts → assembleReplay.d.ts} +0 -0
  52. /package/dist/esm/check-cache/{replay-assembly.js → assembleReplay.js} +0 -0
  53. /package/dist/esm/check-cache/{check-cache-delegation.d.ts → recordDelegatedPass.d.ts} +0 -0
  54. /package/dist/esm/check-cache/{check-cache-delegation.js → recordDelegatedPass.js} +0 -0
  55. /package/dist/esm/commands/{load-package-maturity-period.d.ts → loadPackageMaturityPeriod.d.ts} +0 -0
  56. /package/dist/esm/commands/{load-package-maturity-period.js → loadPackageMaturityPeriod.js} +0 -0
  57. /package/dist/esm/commands/{taze-passes.d.ts → planTazePasses.d.ts} +0 -0
  58. /package/dist/esm/commands/{taze-passes.js → planTazePasses.js} +0 -0
  59. /package/dist/esm/commands/{format-targets.d.ts → resolveFormatTargets.d.ts} +0 -0
  60. /package/dist/esm/commands/{format-targets.js → resolveFormatTargets.js} +0 -0
  61. /package/dist/esm/commands/{tsconfig-chain.d.ts → resolveTsconfigChain.d.ts} +0 -0
  62. /package/dist/esm/commands/{tsconfig-chain.js → resolveTsconfigChain.js} +0 -0
  63. /package/dist/esm/delegation/{remote-snapshot.d.ts → createSnapshot.d.ts} +0 -0
  64. /package/dist/esm/delegation/{remote-snapshot.js → createSnapshot.js} +0 -0
  65. /package/dist/esm/delegation/{remote-probe-host.d.ts → probeHostLocks.d.ts} +0 -0
  66. /package/dist/esm/delegation/{remote-probe-host.js → probeHostLocks.js} +0 -0
  67. /package/dist/esm/delegation/{delegation-target.d.ts → resolveDelegationTarget.d.ts} +0 -0
  68. /package/dist/esm/delegation/{delegation-target.js → resolveDelegationTarget.js} +0 -0
  69. /package/dist/esm/{help.d.ts → describeHelp.d.ts} +0 -0
  70. /package/dist/esm/helpers/{hook-name.d.ts → isHookName.d.ts} +0 -0
  71. /package/dist/esm/helpers/{hook-name.js → isHookName.js} +0 -0
  72. /package/dist/esm/helpers/{type-guards.d.ts → isStringRecord.d.ts} +0 -0
  73. /package/dist/esm/helpers/{type-guards.js → isStringRecord.js} +0 -0
  74. /package/dist/esm/{git-ignored-paths.d.ts → listGitIgnoredPaths.d.ts} +0 -0
  75. /package/dist/esm/{git-ignored-paths.js → listGitIgnoredPaths.js} +0 -0
  76. /package/dist/esm/{bin-path.d.ts → prependBinDirs.d.ts} +0 -0
  77. /package/dist/esm/{bin-path.js → prependBinDirs.js} +0 -0
  78. /package/dist/esm/{workspace-globs.d.ts → toWorkspacePackageGlobs.d.ts} +0 -0
  79. /package/dist/esm/{workspace-globs.js → toWorkspacePackageGlobs.js} +0 -0
@@ -4,7 +4,8 @@ import { isRecord, UserError } from '@williamthorsen/nmr-core';
4
4
  import { assertRecognizedKeys } from "../config-validation.js";
5
5
  import { importConfigDefault } from "../helpers/importConfigDefault.js";
6
6
  import { resolveXdgBaseDir } from "../helpers/resolveXdgBaseDir.js";
7
- export const DELEGATE_WHEN_VALUES = ['agent', 'always'];
7
+ const DELEGATE_WHEN_VALUES = ['agent', 'always'];
8
+ export const DEFAULT_REMOVE_CHECKOUTS_AFTER_DAYS = 7;
8
9
  export function formatDisplayPath(filePath, env) {
9
10
  const home = env['HOME'];
10
11
  if (home === undefined || !path.isAbsolute(home)) {
@@ -30,7 +31,7 @@ export function resolveUserConfigPath(env) {
30
31
  }
31
32
  const USER_CONFIG_RELATIVE_PATH = path.join('nmr', 'config.ts');
32
33
  const RECOGNIZED_KEYS = ['delegate'];
33
- const RECOGNIZED_DELEGATE_KEYS = ['hosts', 'when'];
34
+ const RECOGNIZED_DELEGATE_KEYS = ['hosts', 'removeCheckoutsAfterDays', 'when'];
34
35
  function isDelegateWhen(value) {
35
36
  const values = DELEGATE_WHEN_VALUES;
36
37
  return values.includes(value);
@@ -45,16 +46,18 @@ function validateDelegateField(value, configPath) {
45
46
  }
46
47
  assertRecognizedKeys(delegate, RECOGNIZED_DELEGATE_KEYS, configPath, 'delegate.');
47
48
  const hosts = validateHosts(delegate['hosts'], configPath);
49
+ const removeCheckoutsAfterDays = validateRemoveCheckoutsAfterDays(delegate['removeCheckoutsAfterDays'], configPath);
50
+ const removal = removeCheckoutsAfterDays === undefined ? {} : { removeCheckoutsAfterDays };
48
51
  const when = delegate['when'];
49
52
  if (when === undefined) {
50
- return { hosts };
53
+ return { hosts, ...removal };
51
54
  }
52
55
  if (!isDelegateWhen(when)) {
53
56
  const renderedValue = typeof when === 'string' ? when : JSON.stringify(when);
54
57
  throw new UserError(`Invalid nmr config at ${configPath}: \`delegate.when\` is \`${renderedValue}\`, which is not one of: ` +
55
58
  DELEGATE_WHEN_VALUES.join(', '));
56
59
  }
57
- return { hosts, when };
60
+ return { hosts, ...removal, when };
58
61
  }
59
62
  function validateHosts(value, configPath) {
60
63
  const names = Array.isArray(value) && value.every((host) => typeof host === 'string')
@@ -67,6 +70,13 @@ function validateHosts(value, configPath) {
67
70
  }
68
71
  return [first, ...rest];
69
72
  }
73
+ function validateRemoveCheckoutsAfterDays(value, configPath) {
74
+ if (value === undefined || value === false || (typeof value === 'number' && Number.isFinite(value) && value > 0)) {
75
+ return value;
76
+ }
77
+ throw new UserError(`Invalid nmr config at ${configPath}: \`delegate.removeCheckoutsAfterDays\` must be a positive number of days, ` +
78
+ 'or `false` to keep every checkout');
79
+ }
70
80
  function validateUserConfig(value, configPath) {
71
81
  if (!isRecord(value)) {
72
82
  throw new UserError(`Invalid nmr config at ${configPath}: expected an object, got ${typeof value}`);
@@ -1,5 +1,5 @@
1
1
  import { DELEGATE_ENV_VAR } from "./delegation/remote-run.js";
2
- import { isHookName } from "./helpers/hook-name.js";
2
+ import { isHookName } from "./helpers/isHookName.js";
3
3
  import { OUTPUT_STYLE_ENV_VAR } from "./output-style.js";
4
4
  import { buildRootRegistry, buildWorkspaceRegistry, describeScript, isSelfReferential, readPackageJsonScripts, } from "./resolver.js";
5
5
  const LIFECYCLE_SCRIPT_NAMES = new Set([
@@ -5,6 +5,7 @@ import { type RunCliResult } from './invocation.js';
5
5
  import type { ParsedArgs } from './nmr-flags.js';
6
6
  import type { RunSteps, RunStepsOptions } from './runner.js';
7
7
  import type { Step } from './steps.js';
8
+ import type { NmrConfig } from './types.js';
8
9
  /**
9
10
  * Composes the delegation that an `-F` or `-R` calls for, or `undefined` when the invocation runs here.
10
11
  *
@@ -15,10 +16,16 @@ import type { Step } from './steps.js';
15
16
  *
16
17
  * A `--log` fan-out surveys the scopes selected, so a scope with nothing to show is a gap in the survey rather
17
18
  * than a failure of it, however that scope was selected.
19
+ *
20
+ * pnpm runs the selected packages in topological order unless told otherwise, which holds each package's run until
21
+ * its workspace dependencies have finished theirs. Only a command that reads what its dependencies' runs produce
22
+ * needs that, so the order is kept for `build` and for the commands in `fanOut.extraSortedCommands`, and every
23
+ * other fan-out runs with `--no-sort`.
18
24
  */
19
25
  export declare function composeDelegation(options: {
20
26
  childEnv: NodeJS.ProcessEnv;
21
27
  command: string;
28
+ config: NmrConfig;
22
29
  parsedArgs: ParsedArgs;
23
30
  }): {
24
31
  env: NodeJS.ProcessEnv;
@@ -6,8 +6,9 @@ import { RUN_IF_PRESENT_ENV_VAR } from "./invocation.js";
6
6
  import { buildRootRegistry, buildWorkspaceRegistry, readPackageJsonScripts } from "./resolver.js";
7
7
  import { readWorkspacePackageNames, resolveWorkspace } from "./workspace.js";
8
8
  const RECURSIVE_REJECTION = '-R/--recursive did not match any workspace:';
9
+ const DEFAULT_SORTED_COMMANDS = new Set(['build']);
9
10
  export function composeDelegation(options) {
10
- const { childEnv, command, parsedArgs } = options;
11
+ const { childEnv, command, config, parsedArgs } = options;
11
12
  let scope;
12
13
  let shouldRunIfPresent = parsedArgs.shouldPrintLog;
13
14
  if (parsedArgs.filter) {
@@ -20,12 +21,13 @@ export function composeDelegation(options) {
20
21
  if (scope === undefined) {
21
22
  return undefined;
22
23
  }
24
+ const sortFlags = isSortedCommand(command, config) ? [] : ['--no-sort'];
23
25
  const flags = parsedArgs.shouldPrintLog ? ['--log'] : [];
24
26
  return {
25
27
  env: shouldRunIfPresent ? { ...childEnv, [RUN_IF_PRESENT_ENV_VAR]: '1' } : childEnv,
26
28
  step: {
27
29
  kind: 'structural',
28
- argv: ['pnpm', ...scope, 'exec', 'nmr', ...flags, command, ...parsedArgs.passthrough],
30
+ argv: ['pnpm', ...scope, ...sortFlags, 'exec', 'nmr', ...flags, command, ...parsedArgs.passthrough],
29
31
  },
30
32
  };
31
33
  }
@@ -136,6 +138,9 @@ function formatEmptyFilterError(pattern, names) {
136
138
  function formatFilterRejection(pattern) {
137
139
  return `-F/--filter did not match any workspace: \`${pattern}\`.`;
138
140
  }
141
+ function isSortedCommand(command, config) {
142
+ return DEFAULT_SORTED_COMMANDS.has(command) || (config.fanOut?.extraSortedCommands ?? []).includes(command);
143
+ }
139
144
  function suggestWorkspaceNames(pattern, names) {
140
145
  if (names.length === 0) {
141
146
  return '';
@@ -2,7 +2,7 @@ import type { Writable } from 'node:stream';
2
2
  import { type StreamStyles } from '@williamthorsen/nmr-core';
3
3
  import type { TreeReader, TreeSnapshot } from './check-cache/check-cache.js';
4
4
  import type { ResolvedContext } from './context.js';
5
- import type { DelegationTarget } from './delegation/delegation-target.js';
5
+ import type { DelegationTarget } from './delegation/resolveDelegationTarget.js';
6
6
  import type { ReadFilterSelection } from './helpers/filter-selection.js';
7
7
  import type { ParsedArgs } from './nmr-flags.js';
8
8
  import type { ReportFormat } from './report-format.js';
@@ -1,7 +1,5 @@
1
1
  import type { Writable } from 'node:stream';
2
2
  import { type OutputStyle } from '@williamthorsen/nmr-core';
3
- /** How often a holder refreshes the lock file's mtime. */
4
- export declare const HEARTBEAT_INTERVAL_MS = 10000;
5
3
  /**
6
4
  * The commands that take the machine lock: the ones whose memory peak makes two concurrent runs slower than two in
7
5
  * sequence. `test:watch` is absent because it runs until it is stopped, and holding the lock would block every other
@@ -16,8 +14,6 @@ export declare const LOCK_FILE = "heavy.lock";
16
14
  export declare const LOCK_TOKEN_ENV_VAR = "NMR_MACHINE_LOCK_TOKEN";
17
15
  /** Set to `1` to run without the lock, for the whole chain. */
18
16
  export declare const NO_LOCK_ENV_VAR = "NMR_NO_LOCK";
19
- /** How often a waiter checks the lock again. */
20
- export declare const POLL_INTERVAL_MS = 1000;
21
17
  /**
22
18
  * Age at which an unrefreshed lock file is abandoned. It covers a holder whose pid was reused by another process,
23
19
  * and a file left half-written, neither of which a liveness check detects.
@@ -8,7 +8,7 @@ import { isRecord, STATUS_GLYPHS } from '@williamthorsen/nmr-core';
8
8
  import { isEnvSwitchOn } from "./env-switch.js";
9
9
  const RECLAIM_GUARD = 'heavy.lock.reclaim';
10
10
  const RECLAIM_GUARD_STALE_AFTER_MS = 10_000;
11
- export const HEARTBEAT_INTERVAL_MS = 10_000;
11
+ const HEARTBEAT_INTERVAL_MS = 10_000;
12
12
  export const HEAVY_COMMANDS = new Set([
13
13
  'check',
14
14
  'check:strict',
@@ -28,7 +28,7 @@ export const LOCK_DIR_ENV_VAR = 'NMR_LOCK_DIR';
28
28
  export const LOCK_FILE = 'heavy.lock';
29
29
  export const LOCK_TOKEN_ENV_VAR = 'NMR_MACHINE_LOCK_TOKEN';
30
30
  export const NO_LOCK_ENV_VAR = 'NMR_NO_LOCK';
31
- export const POLL_INTERVAL_MS = 1000;
31
+ const POLL_INTERVAL_MS = 1000;
32
32
  export const STALE_AFTER_MS = 60_000;
33
33
  export async function acquireMachineLock(options) {
34
34
  const { command, dir, env, stderr } = options;
@@ -1,5 +1,5 @@
1
1
  import { describeInvalidOutputStyle } from '@williamthorsen/nmr-core';
2
- import { isHookName } from "./helpers/hook-name.js";
2
+ import { isHookName } from "./helpers/isHookName.js";
3
3
  import { resolveOutputStyles } from "./output-style.js";
4
4
  import { readReportFormatEnv } from "./report-format.js";
5
5
  import { readVerbosityEnv, resolveVerbosity, } from "./verbosity.js";
@@ -1,10 +1,3 @@
1
- /**
2
- * Renders the rejection of a value off the format ladder, naming where it was written. Every source that
3
- * accepts a format renders through this, so two of them can never come to name different ladders.
4
- */
5
- export declare function formatReportFormatRejection(source: string, value: string): string;
6
- /** Narrows a raw value to a point on the format ladder. */
7
- export declare function isReportFormat(value: string): value is ReportFormat;
8
1
  /**
9
2
  * Reads the format that the environment names. An unrecognized value is returned as an error, which keeps a
10
3
  * misspelling from going unnoticed for the life of the shell.
@@ -22,7 +15,7 @@ export declare const REPORT_FORMAT_ENV_VAR = "NMR_REPORT_FORMAT";
22
15
  * The points on the format ladder. `text` is a named point, so that one invocation can override a format
23
16
  * exported for the shell in either direction.
24
17
  */
25
- export declare const REPORT_FORMATS: readonly ["text", "json"];
18
+ declare const REPORT_FORMATS: readonly ["text", "json"];
26
19
  /** How nmr renders its own verdicts, which is never the output of the commands that it runs. */
27
20
  export type ReportFormat = (typeof REPORT_FORMATS)[number];
28
21
  /** The format that the environment names, if any, or the message naming why its value could not be read. */
@@ -44,3 +37,4 @@ export interface ResolveReportFormatOptions {
44
37
  envFormat: ReportFormat | undefined;
45
38
  hasJsonFlag: boolean;
46
39
  }
40
+ export {};
@@ -1,10 +1,3 @@
1
- export function formatReportFormatRejection(source, value) {
2
- return `${source} is \`${value}\`, which is not one of: ${REPORT_FORMATS.join(', ')}`;
3
- }
4
- export function isReportFormat(value) {
5
- const names = REPORT_FORMATS;
6
- return names.includes(value);
7
- }
8
1
  export function readReportFormatEnv(env) {
9
2
  const rawValue = env[REPORT_FORMAT_ENV_VAR];
10
3
  if (rawValue === undefined || rawValue === '') {
@@ -16,10 +9,17 @@ export function readReportFormatEnv(env) {
16
9
  return { ok: true, format: rawValue };
17
10
  }
18
11
  export const REPORT_FORMAT_ENV_VAR = 'NMR_REPORT_FORMAT';
19
- export const REPORT_FORMATS = ['text', 'json'];
12
+ const REPORT_FORMATS = ['text', 'json'];
20
13
  export function resolveReportFormat(options) {
21
14
  const { envFormat, hasJsonFlag } = options;
22
15
  if (hasJsonFlag)
23
16
  return 'json';
24
17
  return envFormat ?? 'text';
25
18
  }
19
+ function formatReportFormatRejection(source, value) {
20
+ return `${source} is \`${value}\`, which is not one of: ${REPORT_FORMATS.join(', ')}`;
21
+ }
22
+ function isReportFormat(value) {
23
+ const names = REPORT_FORMATS;
24
+ return names.includes(value);
25
+ }
@@ -1,6 +1,6 @@
1
1
  import { buildRootScripts, workspaceScripts } from "./default-scripts.js";
2
+ import { toWorkspacePackageGlobs } from "./toWorkspacePackageGlobs.js";
2
3
  import { resolveWorkspace } from "./workspace.js";
3
- import { toWorkspacePackageGlobs } from "./workspace-globs.js";
4
4
  export function getDefaultWorkspaceScripts() {
5
5
  return { ...workspaceScripts };
6
6
  }
package/dist/esm/route.js CHANGED
@@ -1,9 +1,9 @@
1
1
  import path from 'node:path';
2
2
  import { createCli, UsageError } from '@williamthorsen/nmr-core/cli';
3
3
  import { DEBUG_ENV_VAR, NO_CACHE_ENV_VAR } from "./check-cache/check-cache.js";
4
+ import { describeHelp } from "./describeHelp.js";
4
5
  import { readEnvSwitch, readEnvSwitches } from "./env-switch.js";
5
6
  import { listDefinedCommands } from "./fan-out.js";
6
- import { describeHelp } from "./help.js";
7
7
  import { RUN_IF_PRESENT_ENV_VAR } from "./invocation.js";
8
8
  import { NO_LOCK_ENV_VAR } from "./machine-lock.js";
9
9
  import { NMR_FLAGS, toParsedArgs } from "./nmr-flags.js";
@@ -4,7 +4,7 @@ import { NMR_BIN } from "./bins.js";
4
4
  import { resolveCacheKey } from "./check-cache/check-cache-gate.js";
5
5
  import { reportNmrCrossing } from "./crossing-diagnostics.js";
6
6
  import { NMR_GLYPHS } from "./glyphs.js";
7
- import { isHookName } from "./helpers/hook-name.js";
7
+ import { isHookName } from "./helpers/isHookName.js";
8
8
  import { quoteForShell } from "./helpers/quoteForShell.js";
9
9
  import { wrapWithHooks } from "./hooks.js";
10
10
  import { USAGE_EXIT_CODE } from "./invocation.js";
@@ -1,6 +1,5 @@
1
1
  import { reportError } from '@williamthorsen/nmr-core';
2
2
  import { runCli as runRouter } from '@williamthorsen/nmr-core/cli';
3
- import { prependBinDirs } from "./bin-path.js";
4
3
  import { NMR_BIN } from "./bins.js";
5
4
  import { GIT_TREE_READER, NO_CACHE_ENV_VAR, resolveRunId, RUN_ID_ENV_VAR, } from "./check-cache/check-cache.js";
6
5
  import { openGate, runGated } from "./check-cache/check-cache-gate.js";
@@ -17,6 +16,7 @@ import { readFilterSelection as readFilterSelectionDefault, } from "./helpers/fi
17
16
  import { RUN_IF_PRESENT_ENV_VAR, USAGE_EXIT_CODE, VERSION, } from "./invocation.js";
18
17
  import { formatMisplacedFlagHint } from "./invocation-args.js";
19
18
  import { readInvocationFlags } from "./nmr-flags.js";
19
+ import { prependBinDirs } from "./prependBinDirs.js";
20
20
  import { reportVerdict } from "./presentation.js";
21
21
  import { buildRootRegistry, buildWorkspaceRegistry, resolveScript } from "./resolver.js";
22
22
  import { buildRoot, buildVersionRoot } from "./route.js";
@@ -104,7 +104,7 @@ async function planRun(invocation, target) {
104
104
  stderr,
105
105
  env: childEnv,
106
106
  };
107
- const delegation = composeDelegation({ childEnv, command, parsedArgs });
107
+ const delegation = composeDelegation({ childEnv, command, config: context.config, parsedArgs });
108
108
  if (delegation !== undefined) {
109
109
  const lock = await takeMachineLock(invocation);
110
110
  let result;
package/dist/esm/tiers.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { readdirSync } from 'node:fs';
2
2
  import path from 'node:path';
3
- import { listGitIgnoredPaths } from "./git-ignored-paths.js";
3
+ import { listGitIgnoredPaths } from "./listGitIgnoredPaths.js";
4
4
  const TEST_DIR = '__tests__';
5
5
  const TEST_EXTENSIONS = '{ts,tsx}';
6
6
  const TEST_GLOB_PREFIX = `**/${TEST_DIR}/**`;
@@ -28,6 +28,17 @@ export interface CheckCacheConfig {
28
28
  */
29
29
  extraCommands?: string[];
30
30
  }
31
+ /** Fan-out settings honored at the monorepo root. */
32
+ export interface FanOutConfig {
33
+ /**
34
+ * Command names added to the set whose `-R` and `-F` fan-outs run packages in topological order, which holds
35
+ * `build` by default; every other fan-out starts each package without waiting for its workspace dependencies.
36
+ * Extends rather than replaces, so that declaring one command cannot drop `build`. List a command whose run in a
37
+ * package reads what the same command produces in that package's workspace dependencies. Every name has to
38
+ * resolve to a command, because a misspelt entry would otherwise be inert.
39
+ */
40
+ extraSortedCommands?: string[];
41
+ }
31
42
  /** Self-hosted runner settings honored at the monorepo root, read by nmr's readyup kit. */
32
43
  export interface SelfHostedRunnerConfig {
33
44
  /**
@@ -59,6 +70,7 @@ export interface NmrConfig {
59
70
  * config says. A delegation flag still sends a run elsewhere.
60
71
  */
61
72
  delegate?: false;
73
+ fanOut?: FanOutConfig;
62
74
  output?: OutputConfig;
63
75
  workspaceScripts?: Record<string, ScriptValue>;
64
76
  rootScripts?: Record<string, ScriptValue>;
@@ -9,7 +9,7 @@ export declare const AGENT_ENV_VARS: readonly ["CLAUDECODE", "ROVODEV_CLI", "ROV
9
9
  * The points on the loudness ladder. Both are spelled out rather than leaving `full` implicit in the variable's
10
10
  * absence, so that a repo-level default is overridable from the environment in either direction.
11
11
  */
12
- export declare const COMMAND_VERBOSITIES: readonly ["full", "quiet"];
12
+ declare const COMMAND_VERBOSITIES: readonly ["full", "quiet"];
13
13
  /**
14
14
  * Passes the resolved verbosity down the chain of spawned processes, so that `-q` applies to every process rather
15
15
  * than only the first. The pass key does not include it: It changes what a run prints and never what a command
@@ -65,3 +65,4 @@ export type VerbosityRead = {
65
65
  ok: false;
66
66
  error: string;
67
67
  };
68
+ export {};
@@ -1,5 +1,5 @@
1
1
  export const AGENT_ENV_VARS = ['CLAUDECODE', 'ROVODEV_CLI', 'ROVO_CLI'];
2
- export const COMMAND_VERBOSITIES = ['full', 'quiet'];
2
+ const COMMAND_VERBOSITIES = ['full', 'quiet'];
3
3
  export const COMMAND_VERBOSITY_ENV_VAR = 'NMR_COMMAND_VERBOSITY';
4
4
  export function formatVerbosityRejection(source, value) {
5
5
  return `${source} is \`${value}\`, which is not one of: ${COMMAND_VERBOSITIES.join(', ')}`;
@@ -3,12 +3,12 @@ import path from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
4
  import { isRecord } from '@williamthorsen/nmr-core';
5
5
  import { defaultExclude, mergeConfig } from 'vitest/config';
6
- import { listGitIgnoredPaths } from "./git-ignored-paths.js";
6
+ import { listGitIgnoredPaths } from "./listGitIgnoredPaths.js";
7
7
  import { ALL_TEST_PATTERNS, buildTierPatterns, TEST_COLLECTION_EXCLUDE, TIER_NAMES } from "./tiers.js";
8
+ import { toWorkspacePackageGlobs } from "./toWorkspacePackageGlobs.js";
8
9
  import { deriveDefaultMaxWorkers, readMaxWorkersFlag } from "./vitest-max-workers.js";
9
10
  import { createSourceResolutionPlugin } from "./vitest-source-resolution.js";
10
11
  import { getWorkspacePackageDirs } from "./workspace.js";
11
- import { toWorkspacePackageGlobs } from "./workspace-globs.js";
12
12
  const [RESIDUAL_TIER, ...NAMED_TIERS] = TIER_NAMES;
13
13
  const TIERED_PATTERNS = NAMED_TIERS.flatMap(buildTierPatterns);
14
14
  const TIER_TIMEOUT_MS = 30_000;
package/docs/delegate.md CHANGED
@@ -12,18 +12,19 @@ A machine that runs several worktrees' checks at once runs out of memory, and th
12
12
 
13
13
  ## The user config
14
14
 
15
- `~/.config/nmr/config.ts`, or `$XDG_CONFIG_HOME/nmr/config.ts` when `XDG_CONFIG_HOME` is set, declares this machine's hosts and when to delegate to them without being asked. It honors one key, `delegate`:
15
+ `~/.config/nmr/config.ts`, or `$XDG_CONFIG_HOME/nmr/config.ts` when `XDG_CONFIG_HOME` is set, declares this machine's hosts, when to delegate to them without being asked, and how long a host keeps an unused checkout. It honors one key, `delegate`:
16
16
 
17
17
  ```ts
18
18
  export default {
19
- delegate: { hosts: ['imac2', 'imac3'], when: 'agent' },
19
+ delegate: { hosts: ['imac2', 'imac3'], removeCheckoutsAfterDays: 14, when: 'agent' },
20
20
  };
21
21
  ```
22
22
 
23
23
  - **`hosts`** lists the hosts in order of preference. It is required whenever `delegate` is present.
24
+ - **`removeCheckoutsAfterDays`** sets how long a checkout on a host may go unused before a delegation from this machine removes it, in days; `false` keeps every checkout. It defaults to 7. See [unused checkouts](#unused-checkouts).
24
25
  - **`when`** turns on delegation by default: `'agent'` for every run whose environment contains an agent-harness marker, the variables that also select [quiet output](reporting.md#configuring), extended by the repo's `output.extraAgentEnvVars`; `'always'` for every run. Without `when`, nmr delegates only on request, and `hosts` serves `--delegate`.
25
26
 
26
- nmr reads the file only when a decision reaches it, and an unrecognized key, a malformed `hosts`, or an unknown `when` fails that run with an error naming the file and the key. The file cannot import anything from a repo, so it is a plain object rather than a `defineConfig` call. When a directory above it contains a `package.json` without `"type"`, such as a stray `~/package.json`, Node warns on every load that the file's module type is not specified; a `~/.config/nmr/package.json` containing `{ "type": "module" }` silences it.
27
+ nmr reads the file when a run reaches rule 7 of [choosing where a command runs](#choosing-where-a-command-runs), and for every delegation, however it was requested, to learn `removeCheckoutsAfterDays`; a run that an earlier rule keeps here does not read it. An unrecognized key, a malformed `hosts`, or an unknown `when` fails that run with an error naming the file and the key. The file cannot import anything from a repo, so it is a plain object rather than a `defineConfig` call. When a directory above it contains a `package.json` without `"type"`, such as a stray `~/package.json`, Node warns on every load that the file's module type is not specified; a `~/.config/nmr/package.json` containing `{ "type": "module" }` silences it.
27
28
 
28
29
  A pass that a host earns satisfies a later delegation to that host, but not a run here: Under `when`, a run that `--no-delegate` keeps here re-runs a check that the host already passed. See [consulting the cache first](#consulting-the-cache-first).
29
30
 
@@ -100,11 +101,21 @@ $ nmr --delegate-to imac2 ci
100
101
 
101
102
  nmr records the working tree as it is on disk as a commit: tracked changes, staged or not, and untracked files that git does not ignore. This is the tree that a local run checks. The commit is built in a temporary index, so the repository's index, refs, and stash do not change.
102
103
 
103
- nmr pushes the commit to a checkout on the host under `~/.cache/nmr/delegate/`, without running the repository's `pre-push` hook, then runs `pnpm install --frozen-lockfile` and the command from the directory that corresponds to the local one, with nmr's own flags as they were given. The host keeps one checkout for each repository on each machine that delegates to it, so concurrent delegations from different worktrees do not share one, and `node_modules` and build output persist between runs. When the workspace packages' `bin` entries differ from those of the checkout's last successful install, nmr first removes pnpm's workspace state, so that the install links the new bins, which an unchanged lockfile would otherwise let pnpm skip; that install reruns the workspace's `prepare` scripts. nmr does not remove these checkouts.
104
+ nmr pushes the commit to a checkout on the host under `~/.cache/nmr/delegate/`, without running the repository's `pre-push` hook, then runs `pnpm install --frozen-lockfile` and the command from the directory that corresponds to the local one, with nmr's own flags as they were given. The host keeps one checkout for each worktree on each machine that delegates to it, so concurrent delegations from different worktrees do not share one, and `node_modules` and build output persist between runs. When the workspace packages' `bin` entries differ from those of the checkout's last successful install, nmr first removes pnpm's workspace state, so that the install links the new bins, which an unchanged lockfile would otherwise let pnpm skip; that install reruns the workspace's `prepare` scripts.
104
105
 
105
106
  - **Ignored files stay here.** A command that needs a gitignored file, such as a local `.env`, does not find it on the host.
106
107
  - **A repository with submodules cannot be delegated**, nor can a tree containing a nested repository, because the snapshot does not include their content. A delegation flag refuses such a tree; under `NMR_DELEGATE` or the user config, the command runs here, and nmr prints one line saying why.
107
108
 
109
+ ### Unused checkouts
110
+
111
+ A worktree adds a checkout on each host to which it delegates, with its own `node_modules`, so the checkouts accumulate as worktrees come and go. Before it pushes, each delegation removes the host's other checkouts that have not been used for longer than the delegating machine's `removeCheckoutsAfterDays`, whichever machine created them:
112
+
113
+ - **A checkout's last use** is the last delegation to it. A checkout made by an nmr that does not record its use is dated by its last check out.
114
+ - **A checkout in use is kept**: one whose lock, described below, has a live holder, whatever its age. The delegation's own checkout is never removed.
115
+ - **The delegation does not wait for the deletion.** It moves each checkout aside at once and deletes it in the background, so a delegation that starts later creates a fresh checkout and installs it from scratch.
116
+ - **The removal runs before the push**, so a host whose filesystem the checkouts have filled recovers without anyone deleting files by hand: The delegation that removes the checkouts may still fail, and the next one finds the room. Removal never fails a delegation.
117
+ - **Each machine applies its own age.** A machine whose user config says `false` removes nothing, while another machine delegating to the same host still removes the checkouts that are older than its own age.
118
+
108
119
  Delegations from the same worktree share its checkout on the host, so they run one at a time. A delegation that starts while another holds the checkout waits for it, saying so on stderr, then runs:
109
120
 
110
121
  ```console
@@ -128,7 +139,13 @@ A failure of the delegation itself, rather than of the command, exits 255, the s
128
139
  ❌ my-repo: ci: failed on 📡 imac2 in 10.0s (exit 255) — could not prepare imac2
129
140
  ```
130
141
 
131
- The steps are `prepare`, which reaches the host and creates the checkout, `push`, `check out`, and `install`. A connection lost while the command runs is reported as `lost connection to <host>`.
142
+ The steps are `prepare`, which reaches the host, removes its [unused checkouts](#unused-checkouts), and creates the checkout, `push`, `check out`, and `install`. A connection lost while the command runs is reported as `lost connection to <host>`.
143
+
144
+ A step that fails with `No space left on device` reports which resource the host's filesystem has run out of, because a write fails the same way for both:
145
+
146
+ ```console
147
+ ❌ my-repo: ci: failed on 📡 imac2 in 3.1s (exit 255) — could not push to imac2: the host's filesystem has run out of inodes
148
+ ```
132
149
 
133
150
  ### When no host answers
134
151
 
package/docs/scripts.md CHANGED
@@ -41,6 +41,17 @@ A script value is a string or an array of steps. Only the built-in defaults and
41
41
 
42
42
  Each array element is a command name, optionally preceded by nmr's own flags, split on whitespace: `-R test` runs the element in every package, the way `nmr -R test` does. An element containing a quoted argument or shell syntax is rejected when the config loads: Name that command as a script of its own and reference the name.
43
43
 
44
+ #### Package order in a fan-out
45
+
46
+ An `-R` or `-F` fan-out of `build` runs the packages in topological order, so that each package builds after its workspace dependencies. Every other fan-out passes `--no-sort` to pnpm and starts each package's command without waiting for its dependencies to finish theirs: `test`, the `test:*` commands, and `typecheck` read the `dist` that an earlier `build` wrote, not anything that the same command writes in another package. A command that does read what its dependencies' runs of it produce, such as a code generator whose output a dependent package imports, keeps the order once `fanOut.extraSortedCommands` lists it:
47
+
48
+ ```ts
49
+ // .config/nmr.config.ts
50
+ export default defineConfig({ fanOut: { extraSortedCommands: ['generate'] } });
51
+ ```
52
+
53
+ The list adds to `build` and cannot remove it, and a name in it that does not resolve to any command fails when the config loads.
54
+
44
55
  #### Running a step in the background
45
56
 
46
57
  A composite runs its steps in series. An element declared with `shouldRunInBackground` starts and is left running while the steps after it run:
@@ -125,6 +136,7 @@ A minimal `.config/nmr.config.ts` is in the [README](../README.md#configuration)
125
136
  | `delegate` | `false` | Keeps every check on the machine that has the tree, whatever `NMR_DELEGATE` or the [user config](delegate.md#the-user-config) says; a delegation flag still sends a run elsewhere (root config only) |
126
137
  | `workspaceScripts` | `Record<string, ScriptValue>` | Scripts added or overridden in the workspace registry (tier 2) |
127
138
  | `rootScripts` | `Record<string, ScriptValue>` | Scripts added or overridden in the root registry (tier 2) |
139
+ | `fanOut` | `{ extraSortedCommands?: string[] }` | Commands whose fan-out runs packages in topological order, besides `build`; see [package order in a fan-out](#package-order-in-a-fan-out) (root config only) |
128
140
  | `output` | `{ commandVerbosity?: CommandVerbosity; extraAgentEnvVars?: string[] }` | How loudly runs report command output; see [configuring](reporting.md#configuring) |
129
141
  | `selfHostedRunner` | `{ exempt: string }` | Why a qualifying repo keeps CI off the self-hosted runners; stops [nmr's readyup kit](../README.md#conformance-checks) from recommending `CI_RUNS_ON` (root config only). The reason is structural: a circular dependency, a platform or capacity the runners lack, or network reach. A missing tool is installed by a workflow step instead |
130
142
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@williamthorsen/nmr",
3
- "version": "2.0.3",
3
+ "version": "2.2.0",
4
4
  "private": false,
5
5
  "description": "Context-aware script runner for pnpm monorepos",
6
6
  "keywords": [
@@ -81,22 +81,22 @@
81
81
  ],
82
82
  "dependencies": {
83
83
  "@williamthorsen/nmr-core": "0.16.2",
84
- "@williamthorsen/toolbelt.errors": "0.9.1",
85
- "@williamthorsen/toolbelt.filesystem": "0.10.1",
84
+ "@williamthorsen/toolbelt.errors": "0.10.0",
85
+ "@williamthorsen/toolbelt.filesystem": "0.10.2",
86
86
  "glob": "14.0.4",
87
- "prettier": "3.9.10",
87
+ "prettier": "3.10.0",
88
88
  "prettier-plugin-sh": "0.20.2",
89
89
  "taze": "21.3.0",
90
90
  "unconfig": "7.5.0"
91
91
  },
92
92
  "devDependencies": {
93
- "@williamthorsen/toolbelt.testing": "0.8.1",
94
- "@williamthorsen/toolbelt.vitest": "0.13.3",
93
+ "@williamthorsen/toolbelt.testing": "0.8.2",
94
+ "@williamthorsen/toolbelt.vitest": "0.13.4",
95
95
  "esbuild": "0.28.2",
96
- "readyup": "0.45.1",
96
+ "readyup": "0.46.1",
97
97
  "tinyglobby": "0.2.17",
98
98
  "typescript": "6.0.3",
99
- "vite": "8.3.7",
99
+ "vite": "8.3.8",
100
100
  "vitest": "5.1.0",
101
101
  "yaml": "2.9.1"
102
102
  },
@@ -1,17 +0,0 @@
1
- import type { NmrConfig } from '../types.js';
2
- /**
3
- * Rejects a `checkCache` name that does not resolve to any command. Both fields are read by name alone, so a
4
- * misspelt entry is inert: The command that it meant to name goes on running, indistinguishable from one that
5
- * cannot be cached at all.
6
- *
7
- * Because the test is resolvability rather than membership of the cacheable set, an `excludeCommands` entry
8
- * written against a name that a later release moves out of the defaults remains valid.
9
- *
10
- * Reads nothing while every name resolves in the merged registries, and reads a package's own `scripts` only
11
- * once a name is missing from them: Those are a resolution tier of their own; a name declared only there is
12
- * valid.
13
- *
14
- * Does nothing outside a monorepo root, where `checkCache` is a key that this tier does not honor. `assertTierKeys`
15
- * reports that, and of the two messages it is the one that names the real mistake.
16
- */
17
- export declare function assertResolvableCheckCacheCommands(config: NmrConfig, configPath: string, baseDir: string): void;
File without changes
File without changes
File without changes