@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
@@ -1,7 +1,7 @@
1
1
  import path from 'node:path';
2
2
  import { isRecord, readCacheEntry, readJsonCacheEntry, removeCacheDir, removeCacheEntry, resolveCacheEntryPath, writeCacheEntry, } from '@williamthorsen/nmr-core';
3
3
  import { describeError } from '@williamthorsen/toolbelt.errors';
4
- import { isStringRecord } from "../helpers/type-guards.js";
4
+ import { isStringRecord } from "../helpers/isStringRecord.js";
5
5
  import { writeDebugNote } from "./check-cache.js";
6
6
  const CACHE_TOOL = 'nmr-check';
7
7
  const UNSAFE_SLUG_CHARACTERS = /[^\w.-]+/g;
@@ -7,11 +7,11 @@ import { resolveChannel } from "../output-channel.js";
7
7
  import { splitSegments } from "../shell-syntax.js";
8
8
  import { formatMisplacedNoCacheWarning, isCacheableCommand, resolveTreeSnapshot, writeDebugNote, } from "./check-cache.js";
9
9
  import { findStaleBuildOutput, readBuildOutputState } from "./check-cache-build-output.js";
10
- import { recordDelegatedPass } from "./check-cache-delegation.js";
11
10
  import { certifyRetention, readCheckCacheEntry } from "./check-cache-entries.js";
12
11
  import { computeCacheKey, computeRetentionKey } from "./check-cache-key.js";
13
12
  import { recordPass } from "./check-cache-record.js";
14
13
  import { writePassManifestEntry } from "./pass-manifest.js";
14
+ import { recordDelegatedPass } from "./recordDelegatedPass.js";
15
15
  export function openGate(options) {
16
16
  const { command, config, env, monorepoRoot, passthrough, readTree, stderr, style } = options;
17
17
  if (!isCacheableCommand(config.checkCache, command)) {
@@ -4,13 +4,13 @@ import { isEnvSwitchOn } from "../env-switch.js";
4
4
  import { deriveExcerpt } from "../helpers/deriveExcerpt.js";
5
5
  import { composeTranscript } from "../helpers/transcript.js";
6
6
  import { RUN_IF_PRESENT_ENV_VAR, VERSION } from "../invocation.js";
7
+ import { assembleReplay } from "./assembleReplay.js";
7
8
  import { isCacheableCommand, resolveTreeSnapshot, writeDebugNote, } from "./check-cache.js";
8
9
  import { findStaleBuildOutput, readBuildOutputState } from "./check-cache-build-output.js";
9
10
  import { readCheckCacheEntry, recordTranscript, writeCheckCacheEntry, } from "./check-cache-entries.js";
10
11
  import { CURRENT_RUNTIME } from "./check-cache-key.js";
11
12
  import { writePassManifestEntry } from "./pass-manifest.js";
12
13
  import { renderRecording, renderRefusal, resolveRecording } from "./recording.js";
13
- import { assembleReplay } from "./replay-assembly.js";
14
14
  export async function recordHostPass(options) {
15
15
  const { env, host, key, ref, retention, snapshot, stderr } = options;
16
16
  const recorded = await readCheckCacheEntry(ref);
@@ -2,7 +2,7 @@ import { randomUUID } from 'node:crypto';
2
2
  import { realpathSync } from 'node:fs';
3
3
  import { hashWorkingTree, readHeadSha, STATUS_GLYPHS } from '@williamthorsen/nmr-core';
4
4
  import { isEnvSwitchOn } from "../env-switch.js";
5
- import { isHookName } from "../helpers/hook-name.js";
5
+ import { isHookName } from "../helpers/isHookName.js";
6
6
  export const GIT_TREE_READER = { hashWorkingTree, readHeadSha };
7
7
  export const DEBUG_ENV_VAR = 'NMR_DEBUG';
8
8
  export const DEFAULT_CACHEABLE_COMMANDS = [
@@ -8,8 +8,8 @@ import { writeStatedOutcome } from "../stated-outcome.js";
8
8
  import { DEFAULT_OUTDIR, hasExpectedBuildOutput, readEmitDuration, resolveBuildCachePath, resolveEntryPoints, resolveToolchainFingerprint, writeEmitDuration, } from "./build-output.js";
9
9
  import { assertSupportedTypeScript, formatDiagnostics, getCompiler, loadCompiler } from "./compiler.js";
10
10
  import { discardScratchDirs, publishOutput, removeScratchDirs } from "./publish-output.js";
11
+ import { resolveTsconfigChain } from "./resolveTsconfigChain.js";
11
12
  import { rewriteSpecifiers } from "./rewrite-specifiers.js";
12
- import { resolveTsconfigChain } from "./tsconfig-chain.js";
13
13
  const SOURCE_ROOT = 'src';
14
14
  export async function buildPackage(packageDir, options = {}) {
15
15
  await loadCompiler(options.loadTypeScript);
@@ -10,8 +10,8 @@ import { NMR_FMT_BIN } from "../bins.js";
10
10
  import { NO_CACHE_ENV_VAR } from "../check-cache/check-cache.js";
11
11
  import { readEnvSwitch } from "../env-switch.js";
12
12
  import { prepareFmtCache } from "./fmt-cache.js";
13
- import { resolveFormatTargets } from "./format-targets.js";
14
13
  import { BROKEN_MANIFEST_FIX, findPackageManifest, resolveDeclaredCli, } from "./package-manifest.js";
14
+ import { resolveFormatTargets } from "./resolveFormatTargets.js";
15
15
  import { resolveInheritedExitCode, spawnInherited } from "./spawn-inherited.js";
16
16
  const PRETTIER_PACKAGE = 'prettier';
17
17
  const DEFAULT_CONFIG_PATH = fileURLToPath(new URL(`../prettier-default.config${path.extname(fileURLToPath(import.meta.url))}`, import.meta.url));
@@ -4,11 +4,11 @@ import { fileURLToPath } from 'node:url';
4
4
  import { reportError } from '@williamthorsen/nmr-core';
5
5
  import { describeError } from '@williamthorsen/toolbelt.errors';
6
6
  import { NMR_TAZE_BIN } from "../bins.js";
7
- import { loadPackageMaturityPeriod } from "./load-package-maturity-period.js";
7
+ import { loadPackageMaturityPeriod } from "./loadPackageMaturityPeriod.js";
8
8
  import { checkNodePin as checkNodePinDefault } from "./node-pin.js";
9
+ import { planTazePasses } from "./planTazePasses.js";
9
10
  import { resolveInheritedExitCode, spawnInherited } from "./spawn-inherited.js";
10
11
  import { isSilent, readFlag } from "./taze-args.js";
11
- import { planTazePasses } from "./taze-passes.js";
12
12
  const TAZE_CLI_SPECIFIER = 'taze/cli';
13
13
  const REQUEST_TIMEOUT_FLAG = '--request-timeout';
14
14
  const REQUEST_TIMEOUT_FLAGS = [REQUEST_TIMEOUT_FLAG, '--requestTimeout'];
@@ -1,11 +1,11 @@
1
1
  import { isRecord, UserError } from '@williamthorsen/nmr-core';
2
- import { assertResolvableCheckCacheCommands } from "./check-cache/config-check-cache.js";
2
+ import { assertResolvableCommandLists } from "./assertResolvableCommandLists.js";
3
3
  import { findUnexpressibleToken } from "./steps.js";
4
4
  import { formatVerbosityRejection, isCommandVerbosity } from "./verbosity.js";
5
5
  export const CONFIG_TIERS = {
6
6
  root: {
7
7
  label: 'a monorepo-root config',
8
- honoredKeys: ['checkCache', 'delegate', 'output', 'rootScripts', 'selfHostedRunner', 'workspaceScripts'],
8
+ honoredKeys: ['checkCache', 'delegate', 'fanOut', 'output', 'rootScripts', 'selfHostedRunner', 'workspaceScripts'],
9
9
  elsewhere: "the package's own config",
10
10
  },
11
11
  workspace: {
@@ -17,6 +17,7 @@ export const CONFIG_TIERS = {
17
17
  const RECOGNIZED_KEYS = [...CONFIG_TIERS.root.honoredKeys, ...CONFIG_TIERS.workspace.honoredKeys];
18
18
  const RECOGNIZED_BUILD_KEYS = ['extraIgnorePatterns'];
19
19
  const RECOGNIZED_CHECK_CACHE_KEYS = ['enabled', 'excludeCommands', 'extraCommands'];
20
+ const RECOGNIZED_FAN_OUT_KEYS = ['extraSortedCommands'];
20
21
  const RECOGNIZED_OUTPUT_KEYS = ['commandVerbosity', 'extraAgentEnvVars'];
21
22
  const RECOGNIZED_SELF_HOSTED_RUNNER_KEYS = ['exempt'];
22
23
  const RECOGNIZED_STEP_KEYS = ['run', 'shouldDeclineArguments', 'shouldRunInBackground'];
@@ -43,6 +44,9 @@ export function validateConfig(value, configPath, baseDir) {
43
44
  config.checkCache = checkCache;
44
45
  if (value['delegate'] !== undefined)
45
46
  config.delegate = validateDelegateField(value['delegate'], configPath);
47
+ const fanOut = validateFanOutField(value, configPath);
48
+ if (fanOut)
49
+ config.fanOut = fanOut;
46
50
  const output = validateOutputField(value, configPath);
47
51
  if (output)
48
52
  config.output = output;
@@ -55,7 +59,7 @@ export function validateConfig(value, configPath, baseDir) {
55
59
  const selfHostedRunner = validateSelfHostedRunnerField(value, configPath);
56
60
  if (selfHostedRunner)
57
61
  config.selfHostedRunner = selfHostedRunner;
58
- assertResolvableCheckCacheCommands(config, configPath, baseDir);
62
+ assertResolvableCommandLists(config, configPath, baseDir);
59
63
  return config;
60
64
  }
61
65
  function assertNoRetiredKeys(value, retiredKeys, configPath, prefix) {
@@ -166,6 +170,24 @@ function validateDelegateField(delegate, configPath) {
166
170
  throw new UserError(`Invalid nmr config at ${configPath}: \`delegate\` accepts only \`false\`, which keeps the repo's checks on the ` +
167
171
  'machine that has the tree. Hosts and `when` belong in the user config, `~/.config/nmr/config.ts`.');
168
172
  }
173
+ function validateFanOutField(value, configPath) {
174
+ const fanOut = value['fanOut'];
175
+ if (fanOut === undefined) {
176
+ return undefined;
177
+ }
178
+ if (!isRecord(fanOut)) {
179
+ throw new UserError(`Invalid nmr config at ${configPath}: \`fanOut\` must be an object`);
180
+ }
181
+ assertRecognizedKeys(fanOut, RECOGNIZED_FAN_OUT_KEYS, configPath, 'fanOut.');
182
+ const extraSortedCommands = fanOut['extraSortedCommands'];
183
+ if (extraSortedCommands === undefined) {
184
+ return {};
185
+ }
186
+ if (!isStringArray(extraSortedCommands)) {
187
+ throw new UserError(`Invalid nmr config at ${configPath}: \`fanOut.extraSortedCommands\` must be a string[]`);
188
+ }
189
+ return { extraSortedCommands };
190
+ }
169
191
  function validateOutputField(value, configPath) {
170
192
  const output = value['output'];
171
193
  if (output === undefined) {
@@ -3,8 +3,8 @@ import { UserError } from '@williamthorsen/nmr-core';
3
3
  import { CONFIG_TIERS, validateConfig } from "./config-validation.js";
4
4
  import { resolveConfigPath } from "./helpers/config-path.js";
5
5
  import { escapeControlCharacters } from "./helpers/escapeControlCharacters.js";
6
- import { isHookName } from "./helpers/hook-name.js";
7
6
  import { importConfigDefault } from "./helpers/importConfigDefault.js";
7
+ import { isHookName } from "./helpers/isHookName.js";
8
8
  import { getDefaultRootScripts, getDefaultWorkspaceScripts } from "./resolve-scripts.js";
9
9
  import { describeScript } from "./resolver.js";
10
10
  import { readSelfReference, readStepListSelfReference } from "./steps.js";
@@ -2,7 +2,7 @@ import path from 'node:path';
2
2
  import { STATUS_GLYPHS, UserError } from '@williamthorsen/nmr-core';
3
3
  import { resolveConfigPath } from "./helpers/config-path.js";
4
4
  import { escapeControlCharacters } from "./helpers/escapeControlCharacters.js";
5
- import { isHookName } from "./helpers/hook-name.js";
5
+ import { isHookName } from "./helpers/isHookName.js";
6
6
  import { resolvePackageJsonPath } from "./helpers/package-json.js";
7
7
  import { hasRunnableHook } from "./hooks.js";
8
8
  import { expandScript, findChainedSelfReference, readPackageJsonScripts, } from "./resolver.js";
@@ -1,5 +1,5 @@
1
1
  import type { NmrConfig } from './types.js';
2
- export type { BuildConfig, CheckCacheConfig, CommandVerbosity, NmrConfig, OutputConfig, ScriptValue, SelfHostedRunnerConfig, StepSpec, } from './types.js';
2
+ export type { BuildConfig, CheckCacheConfig, CommandVerbosity, FanOutConfig, NmrConfig, OutputConfig, ScriptValue, SelfHostedRunnerConfig, StepSpec, } from './types.js';
3
3
  /**
4
4
  * Returns `config` unchanged, typing a configuration file's default export as `NmrConfig`.
5
5
  *
@@ -9,11 +9,11 @@ import { createBoundedBuffer } from "../helpers/createBoundedBuffer.js";
9
9
  import { USAGE_EXIT_CODE, } from "../invocation.js";
10
10
  import { isDelegationFlag } from "../invocation-args.js";
11
11
  import { reportVerdict } from "../presentation.js";
12
- import { resolveDelegationTarget } from "./delegation-target.js";
12
+ import { createSnapshot } from "./createSnapshot.js";
13
13
  import { chooseHost, probeHost } from "./remote-probe.js";
14
14
  import { DELEGATE_ENV_VAR, DELEGATE_FLAG, DELEGATE_TO_FLAG, DELEGATION_FAILURE_EXIT_CODE, deriveCheckoutPath, formatMisplacedDelegationWarning, readLastLine, runRemotely, } from "./remote-run.js";
15
- import { createSnapshot } from "./remote-snapshot.js";
16
- import { formatDisplayPath, loadUserConfig } from "./user-config.js";
15
+ import { resolveDelegationTarget } from "./resolveDelegationTarget.js";
16
+ import { DEFAULT_REMOVE_CHECKOUTS_AFTER_DAYS, formatDisplayPath, loadUserConfig } from "./user-config.js";
17
17
  export async function resolveExecutionTarget(invocation) {
18
18
  const { command, context, env, parsedArgs, stderr, styles } = invocation;
19
19
  if (parsedArgs.passthrough.some(isDelegationFlag)) {
@@ -69,6 +69,7 @@ export async function runPlanOnHost(plan, target) {
69
69
  target,
70
70
  });
71
71
  }
72
+ const MS_PER_DAY = 24 * 60 * 60 * 1000;
72
73
  const PROBE_RESULT_PHRASES = {
73
74
  busy: 'is busy',
74
75
  free: 'is free',
@@ -133,6 +134,7 @@ async function delegate(options) {
133
134
  }
134
135
  const { host } = choice;
135
136
  const retained = { stderr: createBoundedBuffer(), stdout: createBoundedBuffer() };
137
+ const removeCheckoutsAfterMs = snapshot.ok ? await readRemoveCheckoutsAfterMs(env) : null;
136
138
  const result = snapshot.ok
137
139
  ? await runRemotely({
138
140
  command,
@@ -142,6 +144,7 @@ async function delegate(options) {
142
144
  monorepoRoot: context.monorepoRoot,
143
145
  nmrArgs: composeForwardedArgs(command, parsedArgs),
144
146
  remoteEnv,
147
+ removeCheckoutsAfterMs,
145
148
  snapshot,
146
149
  stderr: createTee(stderr, retained.stderr),
147
150
  stdout: createTee(stdout, retained.stdout),
@@ -178,6 +181,10 @@ async function delegate(options) {
178
181
  function formatRunHereLine(command, listedHosts, reason) {
179
182
  return `nmr: running ${command} here rather than on ${listedHosts}: ${reason}\n`;
180
183
  }
184
+ async function readRemoveCheckoutsAfterMs(env) {
185
+ const days = (await loadUserConfig(env))?.config.delegate?.removeCheckoutsAfterDays ?? DEFAULT_REMOVE_CHECKOUTS_AFTER_DAYS;
186
+ return days === false ? null : days * MS_PER_DAY;
187
+ }
181
188
  async function resolveHost(hosts, toplevel, env, stderr) {
182
189
  const [first, ...rest] = hosts;
183
190
  if (rest.length === 0) {
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Appends to a failed step's message the resource that the host's filesystem has run out of, when the step's output
3
+ * reports a full filesystem and the counts that `runOnHost` reads say which. A write fails alike for lack of space and
4
+ * of inodes.
5
+ */
6
+ export declare function appendFilesystemReason(message: string, output: string, runOnHost: (script: string) => Promise<{
7
+ exitCode: number;
8
+ output: string;
9
+ }>): Promise<string>;
10
+ /** Composes the shell command that prints, on the host, the free counts of the filesystem that holds its home. */
11
+ export declare function composeFilesystemCheckScript(): string;
12
+ /**
13
+ * Names the resource that the host's filesystem has run out of, from what `composeFilesystemCheckScript` printed, or
14
+ * returns `undefined` when neither is exhausted or the output does not include the counts. A filesystem that reports
15
+ * a total of zero inodes, as btrfs does, allocates them as it goes, so it cannot run out of them.
16
+ */
17
+ export declare function describeExhaustedResource(output: string): string | undefined;
18
+ /**
19
+ * Runs on the host and prints, as one JSON line, the total and free inodes and the blocks available to this user on
20
+ * the filesystem that holds the home directory. It is sent as source, so it refers to nothing outside its own body.
21
+ *
22
+ * @internal
23
+ */
24
+ export declare function readHostFilesystem(): void;
@@ -0,0 +1,45 @@
1
+ import { quoteForShell } from "../helpers/quoteForShell.js";
2
+ export async function appendFilesystemReason(message, output, runOnHost) {
3
+ if (!FULL_FILESYSTEM_PATTERN.test(output))
4
+ return message;
5
+ const check = await runOnHost(composeFilesystemCheckScript());
6
+ const reason = check.exitCode === 0 ? describeExhaustedResource(check.output) : undefined;
7
+ return reason === undefined ? message : `${message}: ${reason}`;
8
+ }
9
+ export function composeFilesystemCheckScript() {
10
+ return `node -e ${quoteForShell(`(${readHostFilesystem.toString()})();`)}`;
11
+ }
12
+ export function describeExhaustedResource(output) {
13
+ const counts = output
14
+ .split('\n')
15
+ .map(parseCounts)
16
+ .findLast((value) => value !== undefined);
17
+ if (counts !== undefined && counts.files > 0 && counts.ffree === 0)
18
+ return "the host's filesystem has run out of inodes";
19
+ if (counts?.bavail === 0)
20
+ return "the host's filesystem has run out of space";
21
+ return undefined;
22
+ }
23
+ export function readHostFilesystem() {
24
+ const fs = process.getBuiltinModule('node:fs');
25
+ const os = process.getBuiltinModule('node:os');
26
+ const { bavail, ffree, files } = fs.statfsSync(os.homedir());
27
+ process.stdout.write(`${JSON.stringify({ bavail, ffree, files })}\n`);
28
+ }
29
+ const FULL_FILESYSTEM_PATTERN = /no space left on device|ENOSPC/i;
30
+ function parseCounts(line) {
31
+ try {
32
+ const value = JSON.parse(line);
33
+ if (typeof value !== 'object' || value === null)
34
+ return undefined;
35
+ if (!('bavail' in value) || !('ffree' in value) || !('files' in value))
36
+ return undefined;
37
+ const { bavail, ffree, files } = value;
38
+ return typeof bavail === 'number' && typeof ffree === 'number' && typeof files === 'number'
39
+ ? { bavail, ffree, files }
40
+ : undefined;
41
+ }
42
+ catch {
43
+ return undefined;
44
+ }
45
+ }
@@ -2,8 +2,8 @@ import path from 'node:path';
2
2
  import { isRecord } from '@williamthorsen/nmr-core';
3
3
  import { quoteForShell } from "../helpers/quoteForShell.js";
4
4
  import { LOCK_DIR_ENV_VAR, LOCK_FILE, STALE_AFTER_MS } from "../machine-lock.js";
5
+ import { probeHostLocks } from "./probeHostLocks.js";
5
6
  import { CHECKOUT_LOCK_PATH } from "./remote-host-session.js";
6
- import { probeHostLocks } from "./remote-probe-host.js";
7
7
  import { DELEGATION_FAILURE_EXIT_CODE, readLastLine, runSsh } from "./remote-run.js";
8
8
  export function chooseHost(options) {
9
9
  const { hosts, onSettled, probe } = options;
@@ -1,5 +1,7 @@
1
1
  import type { Writable } from 'node:stream';
2
2
  import { type OutputStyle } from '@williamthorsen/nmr-core';
3
+ /** The directory on the host, relative to its home directory, that contains one directory per checkout key. */
4
+ export declare const CHECKOUTS_DIR = ".cache/nmr/delegate";
3
5
  /** Names the hosts to which nmr delegates a command when the invocation does not say otherwise. */
4
6
  export declare const DELEGATE_ENV_VAR = "NMR_DELEGATE";
5
7
  /** The flag that runs the command on a host that `NMR_DELEGATE` or the user config names. */
@@ -56,9 +58,11 @@ export declare function composeGitSshCommand(env: NodeJS.ProcessEnv): string;
56
58
  * @internal
57
59
  */
58
60
  export declare function composeSshArgv(env: NodeJS.ProcessEnv, host: string, remoteCommand: string): string[];
61
+ /** Returns the key of the checkout on the host that serves the worktree at `toplevel` on the machine `hostname`. */
62
+ export declare function deriveCheckoutKey(hostname: string, toplevel: string): string;
59
63
  /**
60
- * Returns the checkout's path on the host, relative to its home directory: a key derived from this machine and the
61
- * repository, followed by the repository's own directory name, which nmr on the host reports as the root's scope.
64
+ * Returns the checkout's path on the host, relative to its home directory: the checkout's key, followed by the
65
+ * repository's own directory name, which nmr on the host reports as the root's scope.
62
66
  *
63
67
  * @internal
64
68
  */
@@ -74,11 +78,12 @@ export declare function readLastLine(output: string): string;
74
78
  * Runs an nmr invocation on `host` against a snapshot of the local working tree, streaming the command's output to
75
79
  * `stdout` and `stderr` and returning its exit status.
76
80
  *
77
- * The host keeps one checkout per local repository and host name, under `~/.cache/nmr/delegate/`, so concurrent
81
+ * The host keeps one checkout per worktree on each delegating machine, under `~/.cache/nmr/delegate/`, so concurrent
78
82
  * delegations from different worktrees do not share a checkout, and `node_modules` and build output persist
79
- * between runs. Check out, install, and run take one connection, whose session holds the checkout's lock, so that two
80
- * delegations from one worktree run one after the other; what a phase other than `run` prints is returned rather than
81
- * streamed, because it matters only when the phase fails.
83
+ * between runs. Before the push, the delegation removes the host's other checkouts that have not been used for
84
+ * longer than `removeCheckoutsAfterMs`, unless it is `null`. Check out, install, and run take one connection, whose
85
+ * session holds the checkout's lock, so that two delegations from one worktree run one after the other; what a phase
86
+ * other than `run` prints is returned rather than streamed, because it matters only when the phase fails.
82
87
  */
83
88
  export declare function runRemotely(options: {
84
89
  command: string;
@@ -88,6 +93,7 @@ export declare function runRemotely(options: {
88
93
  monorepoRoot: string;
89
94
  nmrArgs: readonly string[];
90
95
  remoteEnv: Readonly<Record<string, string>>;
96
+ removeCheckoutsAfterMs: number | null;
91
97
  snapshot: {
92
98
  sha: string;
93
99
  toplevel: string;
@@ -9,8 +9,11 @@ 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
11
  import { formatFlagSpelling } from "../nmr-flags.js";
12
+ import { appendFilesystemReason } from "./host-filesystem.js";
12
13
  import { CHECKOUT_LOCK_PATH } from "./remote-host-session.js";
13
14
  import { composeSessionScript, createSessionDemultiplexer, formatDelegationWaitingLine } from "./remote-session.js";
15
+ import { CHECKOUT_STAMP_PATH, composeCheckoutRemovalScript } from "./removeStaleCheckouts.js";
16
+ export const CHECKOUTS_DIR = '.cache/nmr/delegate';
14
17
  export const DELEGATE_ENV_VAR = 'NMR_DELEGATE';
15
18
  export const DELEGATE_FLAG = formatFlagSpelling('delegate');
16
19
  export const DELEGATE_TO_FLAG = formatFlagSpelling('delegateTo');
@@ -28,9 +31,11 @@ export function composeSshArgv(env, host, remoteCommand) {
28
31
  ? ['ssh', ...sshArgs]
29
32
  : ['/bin/sh', '-c', `${sshCommand} "$@"`, sshCommand, ...sshArgs];
30
33
  }
34
+ export function deriveCheckoutKey(hostname, toplevel) {
35
+ return createHash('sha256').update(`${hostname}\0${toplevel}`).digest('hex').slice(0, 16);
36
+ }
31
37
  export function deriveCheckoutPath(hostname, toplevel) {
32
- const key = createHash('sha256').update(`${hostname}\0${toplevel}`).digest('hex').slice(0, 16);
33
- return path.posix.join('.cache/nmr/delegate', key, path.basename(toplevel));
38
+ return path.posix.join(CHECKOUTS_DIR, deriveCheckoutKey(hostname, toplevel), path.basename(toplevel));
34
39
  }
35
40
  export function formatMisplacedDelegationWarning(command, style) {
36
41
  return (`${STATUS_GLYPHS[style].warning.text} ${DELEGATE_FLAG}, ${DELEGATE_TO_FLAG}, and ${NO_DELEGATE_FLAG} after the ` +
@@ -44,15 +49,30 @@ export function readLastLine(output) {
44
49
  .findLast((line) => line !== '') ?? '');
45
50
  }
46
51
  export async function runRemotely(options) {
47
- const { command, cwd, env, host, monorepoRoot, nmrArgs, remoteEnv, snapshot, stderr, stdout } = options;
52
+ const { command, cwd, env, host, monorepoRoot, nmrArgs, remoteEnv, removeCheckoutsAfterMs, snapshot, stderr, stdout, } = options;
48
53
  const checkoutPath = deriveCheckoutPath(os.hostname(), snapshot.toplevel);
49
54
  const checkoutDir = `"$HOME"/${quoteForShell(checkoutPath)}`;
50
- const prepare = await runSsh(host, `mkdir -p ${checkoutDir} && cd ${checkoutDir} && { [ -d .git ] || git init --quiet; }`, env);
55
+ function runOnHost(script) {
56
+ return runSsh(host, script, env);
57
+ }
58
+ const removal = composeCheckoutRemovalScript({
59
+ checkoutsDir: CHECKOUTS_DIR,
60
+ lockPath: CHECKOUT_LOCK_PATH,
61
+ ownKey: deriveCheckoutKey(os.hostname(), snapshot.toplevel),
62
+ removeAfterMs: removeCheckoutsAfterMs,
63
+ staleAfterMs: STALE_AFTER_MS,
64
+ stampPath: CHECKOUT_STAMP_PATH,
65
+ });
66
+ const prepare = await runSsh(host, `{ ${removal} || :; }; mkdir -p ${checkoutDir} && cd ${checkoutDir} && { [ -d .git ] || git init --quiet; } && ` +
67
+ `{ touch ${quoteForShell(CHECKOUT_STAMP_PATH)} || :; }`, env);
51
68
  if (prepare.exitCode !== 0) {
69
+ const isUnreachable = prepare.exitCode === DELEGATION_FAILURE_EXIT_CODE;
52
70
  return {
53
71
  ok: false,
54
- isUnreachable: prepare.exitCode === DELEGATION_FAILURE_EXIT_CODE,
55
- message: `could not prepare ${host}`,
72
+ isUnreachable,
73
+ message: isUnreachable
74
+ ? `could not prepare ${host}`
75
+ : await appendFilesystemReason(`could not prepare ${host}`, prepare.output, runOnHost),
56
76
  output: prepare.output,
57
77
  phase: 'prepare',
58
78
  waitedMs: 0,
@@ -69,7 +89,8 @@ export async function runRemotely(options) {
69
89
  `${snapshot.sha}:${SNAPSHOT_REF}`,
70
90
  ], { cwd: snapshot.toplevel, env: { ...env, [SSH_COMMAND_ENV_VAR]: composeGitSshCommand(env) } });
71
91
  if (push.exitCode !== 0) {
72
- return { ok: false, message: `could not push to ${host}`, output: push.output, phase: 'push', waitedMs: 0 };
92
+ const message = await appendFilesystemReason(`could not push to ${host}`, push.output, runOnHost);
93
+ return { ok: false, message, output: push.output, phase: 'push', waitedMs: 0 };
73
94
  }
74
95
  const nonce = randomUUID();
75
96
  const marker = `__NMR_DELEGATE_${nonce}__`;
@@ -127,7 +148,8 @@ export async function runRemotely(options) {
127
148
  demultiplexer.flush();
128
149
  if (failure !== undefined) {
129
150
  const output = [demultiplexer.readOutput(), failure.output].filter((text) => text !== '').join('\n');
130
- return { ok: false, message: describeFailedPhase(failure, host), output, phase: failure.phase, waitedMs };
151
+ const message = await appendFilesystemReason(describeFailedPhase(failure, host), output, runOnHost);
152
+ return { ok: false, message, output, phase: failure.phase, waitedMs };
131
153
  }
132
154
  if (!demultiplexer.hasStarted()) {
133
155
  return {
@@ -0,0 +1,33 @@
1
+ /** The file whose mtime records a checkout's last use, relative to the checkout. */
2
+ export declare const CHECKOUT_STAMP_PATH = ".git/nmr-delegate-used";
3
+ /** What the removal needs, sent to it as JSON. */
4
+ export interface CheckoutRemovalParams {
5
+ /** The directory that contains one directory per checkout key, relative to the host's home directory. */
6
+ checkoutsDir: string;
7
+ /** The checkout's lock file, relative to the checkout. */
8
+ lockPath: string;
9
+ /** The key of the delegation's own checkout, which is never removed. */
10
+ ownKey: string;
11
+ /** Age past which an unused checkout is removed, or `null` to keep every checkout. */
12
+ removeAfterMs: number | null;
13
+ /** Age at which an unrefreshed lock file is abandoned. */
14
+ staleAfterMs: number;
15
+ /** The file whose mtime records the checkout's last use, relative to the checkout. */
16
+ stampPath: string;
17
+ }
18
+ /** Composes the shell command that runs `removeStaleCheckouts` on the host. */
19
+ export declare function composeCheckoutRemovalScript(params: CheckoutRemovalParams): string;
20
+ /**
21
+ * Runs on the host and removes every checkout other than the delegation's own that has not been used for longer than
22
+ * `removeAfterMs` and whose lock does not have a live holder. A checkout's last use is the newest mtime of its stamp,
23
+ * `.git/index`, and `.git/HEAD`, so that a checkout made by an nmr that does not write the stamp still has one.
24
+ *
25
+ * Each checkout is renamed at once to a `.removing-` sibling, which needs no new inode, so that removal works on a
26
+ * filesystem without any free inodes and a delegation that starts later creates a fresh checkout. A detached `rm`
27
+ * then deletes every `.removing-` sibling, including those that an interrupted removal left, so that the delegation
28
+ * does not wait for it. An entry that cannot be read or moved is skipped. It is sent as source, so it refers to
29
+ * nothing outside its own body.
30
+ *
31
+ * @internal
32
+ */
33
+ export declare function removeStaleCheckouts(params: CheckoutRemovalParams): void;
@@ -0,0 +1,98 @@
1
+ import { quoteForShell } from "../helpers/quoteForShell.js";
2
+ export const CHECKOUT_STAMP_PATH = '.git/nmr-delegate-used';
3
+ export function composeCheckoutRemovalScript(params) {
4
+ const source = `(${removeStaleCheckouts.toString()})(JSON.parse(process.argv[1]));`;
5
+ return `node -e ${quoteForShell(source)} ${quoteForShell(JSON.stringify(params))}`;
6
+ }
7
+ export function removeStaleCheckouts(params) {
8
+ const fs = process.getBuiltinModule('node:fs');
9
+ const childProcess = process.getBuiltinModule('node:child_process');
10
+ const crypto = process.getBuiltinModule('node:crypto');
11
+ const os = process.getBuiltinModule('node:os');
12
+ const path = process.getBuiltinModule('node:path');
13
+ const removeAfterMs = params.removeAfterMs;
14
+ if (removeAfterMs === null)
15
+ return;
16
+ const rootDir = path.join(os.homedir(), params.checkoutsDir);
17
+ const REMOVING_PREFIX = '.removing-';
18
+ const now = Date.now();
19
+ for (const key of readNames(rootDir)) {
20
+ if (key.startsWith('.') || key === params.ownKey)
21
+ continue;
22
+ const keyDir = path.join(rootDir, key);
23
+ const checkouts = readNames(keyDir);
24
+ if (checkouts.length !== 1)
25
+ continue;
26
+ const checkoutDir = path.join(keyDir, checkouts[0] ?? '');
27
+ const lastUsedMs = readLastUsedMs(checkoutDir);
28
+ if (lastUsedMs === undefined || now - lastUsedMs <= removeAfterMs)
29
+ continue;
30
+ if (isLockHeld(path.join(checkoutDir, params.lockPath)))
31
+ continue;
32
+ try {
33
+ fs.renameSync(keyDir, path.join(rootDir, `${REMOVING_PREFIX}${crypto.randomUUID()}`));
34
+ }
35
+ catch {
36
+ }
37
+ }
38
+ const moved = readNames(rootDir)
39
+ .filter((name) => name.startsWith(REMOVING_PREFIX))
40
+ .map((name) => path.join(rootDir, name));
41
+ if (moved.length > 0) {
42
+ childProcess.spawn('rm', ['-rf', ...moved], { detached: true, stdio: 'ignore' }).unref();
43
+ }
44
+ function isLockHeld(lockPath) {
45
+ let content;
46
+ let mtimeMs;
47
+ try {
48
+ content = fs.readFileSync(lockPath, 'utf8');
49
+ ({ mtimeMs } = fs.statSync(lockPath));
50
+ }
51
+ catch {
52
+ return false;
53
+ }
54
+ if (Date.now() - mtimeMs > params.staleAfterMs)
55
+ return false;
56
+ const pid = readHolderPid(content);
57
+ return pid === undefined || !isProcessGone(pid);
58
+ }
59
+ function isProcessGone(pid) {
60
+ try {
61
+ process.kill(pid, 0);
62
+ return false;
63
+ }
64
+ catch (error) {
65
+ return typeof error === 'object' && error !== null && 'code' in error && error.code === 'ESRCH';
66
+ }
67
+ }
68
+ function readHolderPid(content) {
69
+ try {
70
+ const value = JSON.parse(content);
71
+ if (typeof value !== 'object' || value === null || !('pid' in value))
72
+ return undefined;
73
+ return typeof value.pid === 'number' ? value.pid : undefined;
74
+ }
75
+ catch {
76
+ return undefined;
77
+ }
78
+ }
79
+ function readLastUsedMs(checkoutDir) {
80
+ let newestMs;
81
+ for (const file of [params.stampPath, '.git/index', '.git/HEAD']) {
82
+ try {
83
+ newestMs = Math.max(newestMs ?? 0, fs.statSync(path.join(checkoutDir, file)).mtimeMs);
84
+ }
85
+ catch {
86
+ }
87
+ }
88
+ return newestMs;
89
+ }
90
+ function readNames(dir) {
91
+ try {
92
+ return fs.readdirSync(dir);
93
+ }
94
+ catch {
95
+ return [];
96
+ }
97
+ }
98
+ }
@@ -1,7 +1,9 @@
1
1
  import type { HostList } from './remote-run.js';
2
2
  /** The values that `delegate.when` accepts, each naming the runs that delegate without being asked. */
3
- export declare const DELEGATE_WHEN_VALUES: readonly ["agent", "always"];
3
+ declare const DELEGATE_WHEN_VALUES: readonly ["agent", "always"];
4
4
  export type DelegateWhen = (typeof DELEGATE_WHEN_VALUES)[number];
5
+ /** The age, in days, past which a delegation removes a host's unused checkouts when the user config does not set one. */
6
+ export declare const DEFAULT_REMOVE_CHECKOUTS_AFTER_DAYS = 7;
5
7
  /**
6
8
  * Renders a path for a message, with `~` standing for the home directory that `env` names when the path lies under
7
9
  * it.
@@ -30,5 +32,8 @@ export interface UserConfig {
30
32
  /** This machine's delegation hosts, and which runs delegate to them without being asked. */
31
33
  export interface UserDelegateConfig {
32
34
  hosts: HostList;
35
+ /** The age, in days, past which a delegation removes the host's unused checkouts; `false` keeps them. */
36
+ removeCheckoutsAfterDays?: number | false;
33
37
  when?: DelegateWhen;
34
38
  }
39
+ export {};