@williamthorsen/nmr 0.51.0 → 0.53.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 (45) hide show
  1. package/.meta/changelog.json +97 -0
  2. package/.readyup/kits/default.js +29 -27
  3. package/.readyup/manifest.json +31 -23
  4. package/CHANGELOG.md +32 -0
  5. package/README.md +13 -6
  6. package/bin/nmr-actionlint.js +11 -0
  7. package/dist/esm/check-cache-gate.d.ts +1 -1
  8. package/dist/esm/check-cache-gate.js +16 -4
  9. package/dist/esm/check-cache.js +1 -0
  10. package/dist/esm/cli-actionlint.d.ts +1 -0
  11. package/dist/esm/cli-actionlint.js +12 -0
  12. package/dist/esm/commands/actionlint.d.ts +23 -0
  13. package/dist/esm/commands/actionlint.js +31 -0
  14. package/dist/esm/commands/build-output.d.ts +13 -0
  15. package/dist/esm/commands/build-output.js +30 -1
  16. package/dist/esm/commands/build.js +12 -3
  17. package/dist/esm/commands/clean.d.ts +1 -1
  18. package/dist/esm/commands/clean.js +2 -1
  19. package/dist/esm/commands/fmt.d.ts +2 -1
  20. package/dist/esm/commands/fmt.js +7 -2
  21. package/dist/esm/commands/run-vitest.js +2 -0
  22. package/dist/esm/commands/workflow-tools.d.ts +39 -0
  23. package/dist/esm/commands/workflow-tools.js +140 -0
  24. package/dist/esm/default-scripts.js +5 -3
  25. package/dist/esm/delegate-host.js +3 -3
  26. package/dist/esm/glyphs.d.ts +1 -1
  27. package/dist/esm/glyphs.js +1 -0
  28. package/dist/esm/remote-host-session.js +60 -0
  29. package/dist/esm/remote-run.js +5 -5
  30. package/dist/esm/remote-snapshot.js +2 -2
  31. package/dist/esm/runner.d.ts +7 -0
  32. package/dist/esm/runner.js +37 -3
  33. package/dist/esm/stated-outcome.d.ts +37 -0
  34. package/dist/esm/stated-outcome.js +60 -0
  35. package/dist/esm/taze.d.ts +9 -7
  36. package/dist/esm/taze.js +0 -1
  37. package/dist/esm/verdict.d.ts +2 -0
  38. package/dist/esm/verdict.js +15 -3
  39. package/docs/check-cache.md +1 -1
  40. package/docs/delegate.md +4 -4
  41. package/docs/reporting.md +5 -1
  42. package/docs/scripts.md +9 -1
  43. package/docs/upgrades.md +6 -4
  44. package/docs/utilities.md +14 -3
  45. package/package.json +8 -7
@@ -0,0 +1,31 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { describeSignalExitCode } from "../child-process.js";
5
+ import { ensureTool } from "./workflow-tools.js";
6
+ const WORKFLOWS_DIR = path.join('.github', 'workflows');
7
+ export async function runActionlint(monorepoRoot, args, options = {}) {
8
+ if (!fs.existsSync(path.join(monorepoRoot, WORKFLOWS_DIR))) {
9
+ console.info(`No workflows to lint: ${WORKFLOWS_DIR}/ does not exist.`);
10
+ return 0;
11
+ }
12
+ const cacheRoot = options.cacheRoot ?? path.join(monorepoRoot, 'node_modules', '.cache', 'nmr-tools');
13
+ const ensure = options.ensure ?? ((tool, root) => ensureTool(tool, { cacheRoot: root }));
14
+ const actionlintPath = await ensure('actionlint', cacheRoot);
15
+ const shellcheckPath = await ensure('shellcheck', cacheRoot);
16
+ const outcome = (options.spawn ?? spawnInherited)(actionlintPath, buildActionlintArgs(shellcheckPath, args), monorepoRoot);
17
+ if (outcome.error !== undefined) {
18
+ throw new Error(`Cannot run actionlint: ${outcome.error.message}`, { cause: outcome.error });
19
+ }
20
+ if (outcome.signal !== null) {
21
+ return describeSignalExitCode(outcome.signal);
22
+ }
23
+ return outcome.status ?? 1;
24
+ }
25
+ export function buildActionlintArgs(shellcheckPath, args) {
26
+ return [`-shellcheck=${shellcheckPath}`, '-pyflakes=', ...args];
27
+ }
28
+ function spawnInherited(file, args, cwd) {
29
+ const result = spawnSync(file, args, { cwd, stdio: 'inherit' });
30
+ return { error: result.error, signal: result.signal, status: result.status };
31
+ }
@@ -44,6 +44,11 @@ export declare function hasExpectedBuildOutput(packageDir: string, outdir: strin
44
44
  * current one.
45
45
  */
46
46
  export declare function readBuildDigest(packageDir: string): Promise<string | undefined>;
47
+ /**
48
+ * Returns how long the emit took, in milliseconds, for the build that recorded `digest`, or `undefined` when no
49
+ * duration was recorded for that digest.
50
+ */
51
+ export declare function readEmitDuration(packageDir: string, digest: string): Promise<number | undefined>;
47
52
  /**
48
53
  * Resolves the absolute path of a package's build-cache file, one entry in the shared store. The package
49
54
  * directory is the entry's scope, so the store keys the file to it and packages sharing a hoisted
@@ -54,6 +59,12 @@ export declare function readBuildDigest(packageDir: string): Promise<string | un
54
59
  * to the digest. Changing any of them leaves every entry on disk unreachable.
55
60
  */
56
61
  export declare function resolveBuildCachePath(packageDir: string): string;
62
+ /**
63
+ * Resolves the absolute path of the entry that records how long a package's last emit took. It is a separate file in
64
+ * the build-cache entry's scope, so that a value varying from run to run never enters the digest that
65
+ * `readBuildDigest` returns.
66
+ */
67
+ export declare function resolveEmitDurationPath(packageDir: string): string;
57
68
  /**
58
69
  * Resolves the two scratch directories through which a build publishes: `staging`, which the emit is written to,
59
70
  * and `previous`, which the outgoing output is renamed aside to. Both are siblings of the emit directory, so a
@@ -80,3 +91,5 @@ export declare function resolveScratchDirs(emitDir: string): ScratchDirs;
80
91
  * the pre-rebuild binary keeps the old fingerprint and rebuilds on the next run.
81
92
  */
82
93
  export declare function resolveToolchainFingerprint(packageDir: string, selfDir?: string): Promise<string>;
94
+ /** Records how long the emit took for the build whose digest is `digest`. */
95
+ export declare function writeEmitDuration(packageDir: string, digest: string, emitMs: number): Promise<void>;
@@ -1,6 +1,6 @@
1
1
  import { existsSync, readdirSync, realpathSync } from 'node:fs';
2
2
  import path from 'node:path';
3
- import { findPackageRoot, readCacheEntry, resolveCacheEntryPath } from '@williamthorsen/nmr-core';
3
+ import { findPackageRoot, readCacheEntry, readGuardedCacheEntry, resolveCacheEntryPath, writeGuardedCacheEntry, } from '@williamthorsen/nmr-core';
4
4
  import { glob } from 'glob';
5
5
  import { readPackageJson } from "../helpers/package-json.js";
6
6
  export const DEFAULT_ENTRY_GLOBS = ['src/**/*.{ts,tsx}'];
@@ -27,6 +27,10 @@ export function hasExpectedBuildOutput(packageDir, outdir, entryPoints) {
27
27
  export async function readBuildDigest(packageDir) {
28
28
  return readCacheEntry(resolveBuildCachePath(packageDir));
29
29
  }
30
+ export async function readEmitDuration(packageDir, digest) {
31
+ const entry = await readGuardedCacheEntry(resolveEmitDurationPath(packageDir), digest, isEmitDurationEntry);
32
+ return entry?.emitMs;
33
+ }
30
34
  export function resolveBuildCachePath(packageDir) {
31
35
  const absolutePackageDir = path.resolve(packageDir);
32
36
  return resolveCacheEntryPath({
@@ -36,6 +40,15 @@ export function resolveBuildCachePath(packageDir) {
36
40
  extension: '.hash',
37
41
  });
38
42
  }
43
+ export function resolveEmitDurationPath(packageDir) {
44
+ const absolutePackageDir = path.resolve(packageDir);
45
+ return resolveCacheEntryPath({
46
+ tool: BUILD_CACHE_TOOL,
47
+ scopeDir: absolutePackageDir,
48
+ slug: path.basename(absolutePackageDir),
49
+ extension: '.duration.json',
50
+ });
51
+ }
39
52
  export function resolveScratchDirs(emitDir) {
40
53
  const parent = path.dirname(emitDir);
41
54
  const name = path.basename(emitDir);
@@ -53,6 +66,22 @@ export async function resolveToolchainFingerprint(packageDir, selfDir = SELF_DIR
53
66
  }
54
67
  return readPackageJson(selfDir).version ?? '';
55
68
  }
69
+ export async function writeEmitDuration(packageDir, digest, emitMs) {
70
+ const entry = { emitMs };
71
+ try {
72
+ await writeGuardedCacheEntry(resolveEmitDurationPath(packageDir), digest, entry);
73
+ }
74
+ catch {
75
+ }
76
+ }
77
+ function isEmitDurationEntry(value) {
78
+ return (typeof value === 'object' &&
79
+ value !== null &&
80
+ 'emitMs' in value &&
81
+ typeof value.emitMs === 'number' &&
82
+ Number.isFinite(value.emitMs) &&
83
+ value.emitMs >= 0);
84
+ }
56
85
  function isSameDir(left, right) {
57
86
  return resolveRealPath(left) === resolveRealPath(right);
58
87
  }
@@ -2,11 +2,13 @@ import { createHash } from 'node:crypto';
2
2
  import { existsSync } from 'node:fs';
3
3
  import { readFile, rename, rm } from 'node:fs/promises';
4
4
  import path from 'node:path';
5
- import { formatGlyphLine, readCacheEntry, STATUS_GLYPHS, writeCacheEntry, } from '@williamthorsen/nmr-core';
5
+ import { formatGlyphLine, readCacheEntry, startTimer, STATUS_GLYPHS, writeCacheEntry, } from '@williamthorsen/nmr-core';
6
6
  import { glob } from 'glob';
7
7
  import { NMR_GLYPHS } from "../glyphs.js";
8
8
  import { resolveConfigPath } from "../helpers/config-path.js";
9
- import { DEFAULT_ENTRY_GLOBS, DEFAULT_IGNORE_PATTERNS, DEFAULT_OUTDIR, hasExpectedBuildOutput, resolveBuildCachePath, resolveScratchDirs, resolveToolchainFingerprint, } from "./build-output.js";
9
+ import { formatSaving } from "../helpers/duration.js";
10
+ import { writeStatedOutcome } from "../stated-outcome.js";
11
+ import { DEFAULT_ENTRY_GLOBS, DEFAULT_IGNORE_PATTERNS, DEFAULT_OUTDIR, hasExpectedBuildOutput, readEmitDuration, resolveBuildCachePath, resolveScratchDirs, resolveToolchainFingerprint, writeEmitDuration, } from "./build-output.js";
10
12
  import { assertSupportedTypeScript, formatDiagnostics, getCompiler, loadCompiler } from "./compiler.js";
11
13
  import { isWithin, rewriteSpecifiers } from "./rewrite-specifiers.js";
12
14
  import { resolveTsconfigChain } from "./tsconfig-chain.js";
@@ -37,8 +39,11 @@ export async function buildPackage(packageDir, options) {
37
39
  await discardScratchDirs(resolveScratchDirs(emitDir));
38
40
  return;
39
41
  }
42
+ const readEmitMs = startTimer();
40
43
  const emittedFileCount = await emitPackage(packageDir, entryPoints, outdir);
44
+ const emitMs = readEmitMs();
41
45
  await writeCacheEntry(cachePath, currentHash);
46
+ await writeEmitDuration(packageDir, currentHash, emitMs);
42
47
  console.info(formatGlyphLine(NMR_GLYPHS, options.style, 'package', `${path.basename(packageDir)}: ${describeEmit(emittedFileCount, outdir)}`));
43
48
  }
44
49
  export async function computeBuildHash(packageDir, files, emitConfig, toolchain) {
@@ -184,7 +189,11 @@ async function detectBuildChanges(packageDir, files, emitConfig, toolchain, cach
184
189
  const currentHash = await computeBuildHash(packageDir, files, emitConfig, toolchain);
185
190
  if (previousHash === currentHash) {
186
191
  if (isOutputPresent) {
187
- console.info(`${STATUS_GLYPHS[style].skipped.text} ${packageName}: No changes detected. Skipping build.`);
192
+ const savedMs = await readEmitDuration(packageDir, currentHash);
193
+ await writeStatedOutcome(process.env, { kind: 'build-unchanged', ...(savedMs !== undefined && { savedMs }) });
194
+ const saving = formatSaving(savedMs ?? 0);
195
+ const savingClause = saving === undefined ? '' : `, ${saving}`;
196
+ console.info(`${STATUS_GLYPHS[style].skipped.text} ${packageName}: No changes detected. Skipping build${savingClause}.`);
188
197
  return { changed: false, currentHash };
189
198
  }
190
199
  console.info(formatGlyphLine(NMR_GLYPHS, style, 'package', `${packageName}: Build output is missing. Rebuilding.`));
@@ -1,6 +1,6 @@
1
1
  import { type OutputStyle } from '@williamthorsen/nmr-core';
2
2
  /**
3
- * Removes a package's build output and the build-cache entry that describes it, so the package does not keep
3
+ * Removes a package's build output and the build-cache entries that describe it, so the package does not keep
4
4
  * any state that would make the next build skip. Removal is idempotent: An already-clean package is a silent
5
5
  * no-op.
6
6
  */
@@ -11,13 +11,14 @@ import { applyDevBin, buildWorkspaceRegistry, resolveScript } from "../resolver.
11
11
  import { runCommand } from "../runner.js";
12
12
  import { renderChain } from "../steps.js";
13
13
  import { findMonorepoRoot, getWorkspacePackageDirs } from "../workspace.js";
14
- import { resolveBuildCachePath } from "./build-output.js";
14
+ import { resolveBuildCachePath, resolveEmitDurationPath } from "./build-output.js";
15
15
  const CLEAN_COMMAND = 'clean';
16
16
  const BUILT_IN_CLEAN = 'nmr-clean';
17
17
  const OUTPUT_ROOT = 'dist';
18
18
  export async function cleanPackage(packageDir, style) {
19
19
  await rm(path.resolve(packageDir, OUTPUT_ROOT), { recursive: true, force: true });
20
20
  await rm(resolveBuildCachePath(packageDir), { force: true });
21
+ await rm(resolveEmitDurationPath(packageDir), { force: true });
21
22
  console.info(formatGlyphLine(NMR_GLYPHS, style, 'clean', `${path.basename(packageDir)}: Removed build output and cache.`));
22
23
  }
23
24
  export async function runClean(cwd, style) {
@@ -6,7 +6,8 @@ export type FormatMode = 'check' | 'write';
6
6
  *
7
7
  * Trailing arguments are git pathspecs, not Prettier flags; an unrecognized option is rejected rather
8
8
  * than passed along, since git would read it as a pathspec matching nothing and the run would report
9
- * success over an empty selection.
9
+ * success over an empty selection. Every argument after `--` is a pathspec, even one that starts
10
+ * with `-`.
10
11
  */
11
12
  export declare function runFmt(argv: string[], cwd?: string): Promise<number>;
12
13
  /** @internal */
@@ -13,7 +13,7 @@ import { resolveFormatTargets } from "./format-targets.js";
13
13
  const PRETTIER_PACKAGE = 'prettier';
14
14
  const DEFAULT_CONFIG_PATH = fileURLToPath(new URL(`../prettier-default.config${path.extname(fileURLToPath(import.meta.url))}`, import.meta.url));
15
15
  const ARGUMENT_BUDGET_BYTES = 100_000;
16
- const USAGE = 'Usage: nmr-fmt (--check | --write) [--no-cache] [pathspec...]';
16
+ const USAGE = 'Usage: nmr-fmt (--check | --write) [--no-cache] [--] [pathspec...]';
17
17
  const MODE_ARGS = {
18
18
  check: ['--check'],
19
19
  write: ['--list-different', '--write'],
@@ -79,7 +79,11 @@ function parseFmtArgs(argv) {
79
79
  let mode;
80
80
  let shouldBypassCache = false;
81
81
  const pathspecs = [];
82
- for (const arg of argv) {
82
+ for (const [index, arg] of argv.entries()) {
83
+ if (arg === '--') {
84
+ pathspecs.push(...argv.slice(index + 1));
85
+ break;
86
+ }
83
87
  if (arg === '--no-cache') {
84
88
  shouldBypassCache = true;
85
89
  continue;
@@ -147,6 +151,7 @@ export function runPrettier(options) {
147
151
  ? []
148
152
  : ['--cache', '--cache-strategy', 'content', '--cache-location', cacheLocation]),
149
153
  ...MODE_ARGS[mode],
154
+ '--',
150
155
  ];
151
156
  let firstFailureStatus = 0;
152
157
  for (const batch of batchWithinBudget(files, budgetBytes)) {
@@ -7,6 +7,7 @@ import { reportError } from '@williamthorsen/nmr-core';
7
7
  import { findMonorepoRoot as findMonorepoRootOrNothing } from '@williamthorsen/nmr-core/workspace';
8
8
  import { describeSignalExitCode } from "../child-process.js";
9
9
  import { isObject } from "../helpers/type-guards.js";
10
+ import { omitOutcomeFile } from "../stated-outcome.js";
10
11
  const VITEST_PACKAGE = 'vitest';
11
12
  const SUPPORTED_VITEST_RANGE = '>=4.0.0 <6';
12
13
  const CONFIG_EXTENSIONS = ['ts', 'mts', 'cts', 'js', 'mjs', 'cjs'];
@@ -18,6 +19,7 @@ export function runNmrVitest(argv, cwd = process.cwd()) {
18
19
  }
19
20
  const result = spawnSync(process.execPath, [cli.cliPath, ...chooseVitestConfigArgs(argv, cwd)], {
20
21
  cwd,
22
+ env: omitOutcomeFile(process.env),
21
23
  stdio: 'inherit',
22
24
  });
23
25
  if (result.error) {
@@ -0,0 +1,39 @@
1
+ /** A program that `nmr-actionlint` downloads rather than takes from `PATH`. */
2
+ export type WorkflowTool = 'actionlint' | 'shellcheck';
3
+ /** A supported pair of `process.platform` and `process.arch`, joined by a hyphen. */
4
+ export type ToolPlatform = 'darwin-arm64' | 'darwin-x64' | 'linux-arm64' | 'linux-x64';
5
+ /** One release of a tool: where each platform's archive is, its SHA-256, and the binary's path inside it. */
6
+ export interface ToolPin {
7
+ archiveMember: string;
8
+ assets: Record<ToolPlatform, {
9
+ sha256: string;
10
+ url: string;
11
+ }>;
12
+ version: string;
13
+ }
14
+ export type ToolPins = Record<WorkflowTool, ToolPin>;
15
+ /** The subset of `fetch` that the download uses. */
16
+ export type FetchFunction = (url: string, init?: RequestInit) => Promise<Response>;
17
+ export interface EnsureToolOptions {
18
+ /** Directory under which each tool version is cached in a directory of its own. */
19
+ cacheRoot: string;
20
+ arch?: string;
21
+ fetch?: FetchFunction;
22
+ pins?: ToolPins;
23
+ platform?: NodeJS.Platform;
24
+ }
25
+ /**
26
+ * The pinned releases. To move a pin, change its version and URLs and take each SHA-256 from the release's `digest`
27
+ * as `gh api repos/{owner}/{repo}/releases/tags/{tag}` reports it, confirming actionlint's against the release's
28
+ * `checksums.txt`. The pins move only with a release of nmr.
29
+ */
30
+ export declare const TOOL_PINS: ToolPins;
31
+ /**
32
+ * Returns the path of the tool's pinned binary, downloading, verifying, and caching it first on a cache miss.
33
+ *
34
+ * A failure at any step leaves the cache without the tool, and a concurrent run never sees a partial binary: The
35
+ * binary is assembled in a scratch directory beside its target and renamed into place only once verified.
36
+ */
37
+ export declare function ensureTool(tool: WorkflowTool, options: EnsureToolOptions): Promise<string>;
38
+ /** Maps Node's platform and architecture onto a supported pair, rejecting every other pair. */
39
+ export declare function resolveToolPlatform(platform: NodeJS.Platform, arch: string): ToolPlatform;
@@ -0,0 +1,140 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { createHash } from 'node:crypto';
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import process from 'node:process';
6
+ import { describeError } from '@williamthorsen/toolbelt.errors';
7
+ import { UserError } from "../UserError.js";
8
+ const SUPPORTED_PLATFORMS = ['darwin-arm64', 'darwin-x64', 'linux-arm64', 'linux-x64'];
9
+ const DOWNLOAD_TIMEOUT_MS = 60_000;
10
+ const ACTIONLINT_RELEASE = 'https://github.com/rhysd/actionlint/releases/download/v1.7.12';
11
+ const SHELLCHECK_RELEASE = 'https://github.com/koalaman/shellcheck/releases/download/v0.11.0';
12
+ export const TOOL_PINS = {
13
+ actionlint: {
14
+ archiveMember: 'actionlint',
15
+ assets: {
16
+ 'darwin-arm64': {
17
+ sha256: 'aba9ced2dee8d27fecca3dc7feb1a7f9a52caefa1eb46f3271ea66b6e0e6953f',
18
+ url: `${ACTIONLINT_RELEASE}/actionlint_1.7.12_darwin_arm64.tar.gz`,
19
+ },
20
+ 'darwin-x64': {
21
+ sha256: '5b44c3bc2255115c9b69e30efc0fecdf498fdb63c5d58e17084fd5f16324c644',
22
+ url: `${ACTIONLINT_RELEASE}/actionlint_1.7.12_darwin_amd64.tar.gz`,
23
+ },
24
+ 'linux-arm64': {
25
+ sha256: '325e971b6ba9bfa504672e29be93c24981eeb1c07576d730e9f7c8805afff0c6',
26
+ url: `${ACTIONLINT_RELEASE}/actionlint_1.7.12_linux_arm64.tar.gz`,
27
+ },
28
+ 'linux-x64': {
29
+ sha256: '8aca8db96f1b94770f1b0d72b6dddcb1ebb8123cb3712530b08cc387b349a3d8',
30
+ url: `${ACTIONLINT_RELEASE}/actionlint_1.7.12_linux_amd64.tar.gz`,
31
+ },
32
+ },
33
+ version: '1.7.12',
34
+ },
35
+ shellcheck: {
36
+ archiveMember: 'shellcheck-v0.11.0/shellcheck',
37
+ assets: {
38
+ 'darwin-arm64': {
39
+ sha256: '339b930feb1ea764467013cc1f72d09cd6b869ebf1013296ba9055ab2ffbd26f',
40
+ url: `${SHELLCHECK_RELEASE}/shellcheck-v0.11.0.darwin.aarch64.tar.gz`,
41
+ },
42
+ 'darwin-x64': {
43
+ sha256: 'c2c15e08df0e8fbc374c335b230a7ee958c313fa5714817a59aa59f1aa594f51',
44
+ url: `${SHELLCHECK_RELEASE}/shellcheck-v0.11.0.darwin.x86_64.tar.gz`,
45
+ },
46
+ 'linux-arm64': {
47
+ sha256: '68a8133197a50beb8803f8d42f9908d1af1c5540d4bb05fdfca8c1fa47decefc',
48
+ url: `${SHELLCHECK_RELEASE}/shellcheck-v0.11.0.linux.aarch64.tar.gz`,
49
+ },
50
+ 'linux-x64': {
51
+ sha256: 'b7af85e41cc99489dcc21d66c6d5f3685138f06d34651e6d34b42ec6d54fe6f6',
52
+ url: `${SHELLCHECK_RELEASE}/shellcheck-v0.11.0.linux.x86_64.tar.gz`,
53
+ },
54
+ },
55
+ version: '0.11.0',
56
+ },
57
+ };
58
+ export async function ensureTool(tool, options) {
59
+ const pin = (options.pins ?? TOOL_PINS)[tool];
60
+ const asset = pin.assets[resolveToolPlatform(options.platform ?? process.platform, options.arch ?? process.arch)];
61
+ const targetDir = path.join(options.cacheRoot, `${tool}-${pin.version}`);
62
+ const binaryPath = path.join(targetDir, tool);
63
+ if (fs.existsSync(binaryPath)) {
64
+ return binaryPath;
65
+ }
66
+ createCacheRoot(options.cacheRoot);
67
+ const scratchDir = `${targetDir}.tmp-${process.pid}`;
68
+ fs.rmSync(scratchDir, { force: true, recursive: true });
69
+ fs.mkdirSync(scratchDir);
70
+ try {
71
+ const archive = await download(asset.url, options.fetch ?? globalThis.fetch);
72
+ const actualSha256 = createHash('sha256').update(archive).digest('hex');
73
+ if (actualSha256 !== asset.sha256) {
74
+ throw new Error(`Checksum mismatch for ${asset.url}: expected sha256 ${asset.sha256}, got ${actualSha256}`);
75
+ }
76
+ const archivePath = path.join(scratchDir, 'archive.tar.gz');
77
+ fs.writeFileSync(archivePath, archive);
78
+ const extractDir = path.join(scratchDir, 'extract');
79
+ fs.mkdirSync(extractDir);
80
+ extractMember(archivePath, extractDir, pin.archiveMember);
81
+ const stageDir = path.join(scratchDir, 'stage');
82
+ fs.mkdirSync(stageDir);
83
+ const stagedBinary = path.join(stageDir, tool);
84
+ fs.renameSync(path.join(extractDir, pin.archiveMember), stagedBinary);
85
+ fs.chmodSync(stagedBinary, 0o755);
86
+ publishStage(stageDir, targetDir, binaryPath);
87
+ }
88
+ finally {
89
+ fs.rmSync(scratchDir, { force: true, recursive: true });
90
+ }
91
+ return binaryPath;
92
+ }
93
+ export function resolveToolPlatform(platform, arch) {
94
+ const key = `${platform}-${arch}`;
95
+ const supported = SUPPORTED_PLATFORMS.find((candidate) => candidate === key);
96
+ if (supported === undefined) {
97
+ throw new UserError(`Workflow linting does not support ${key}; the supported platforms are ${SUPPORTED_PLATFORMS.join(', ')}`);
98
+ }
99
+ return supported;
100
+ }
101
+ function createCacheRoot(cacheRoot) {
102
+ try {
103
+ fs.mkdirSync(cacheRoot, { recursive: true });
104
+ }
105
+ catch (error) {
106
+ throw new Error(`Cannot create the tool cache at ${cacheRoot}`, { cause: error });
107
+ }
108
+ }
109
+ async function download(url, fetchImpl) {
110
+ let response;
111
+ try {
112
+ response = await fetchImpl(url, { signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS) });
113
+ }
114
+ catch (error) {
115
+ throw new Error(`Cannot download ${url}: ${describeError(error)}`, { cause: error });
116
+ }
117
+ if (!response.ok) {
118
+ throw new Error(`Cannot download ${url}: HTTP ${response.status}`);
119
+ }
120
+ return Buffer.from(await response.arrayBuffer());
121
+ }
122
+ function extractMember(archivePath, extractDir, member) {
123
+ const result = spawnSync('tar', ['-xzf', archivePath, '-C', extractDir, member], { encoding: 'utf8' });
124
+ if (result.error !== undefined) {
125
+ throw new Error(`Cannot run tar to extract ${member}: ${result.error.message}`, { cause: result.error });
126
+ }
127
+ if (result.status !== 0) {
128
+ throw new Error(`tar could not extract ${member} from ${archivePath}: ${result.stderr.trim()}`);
129
+ }
130
+ }
131
+ function publishStage(stageDir, targetDir, binaryPath) {
132
+ try {
133
+ fs.renameSync(stageDir, targetDir);
134
+ }
135
+ catch (error) {
136
+ if (!fs.existsSync(binaryPath)) {
137
+ throw error;
138
+ }
139
+ }
140
+ }
@@ -4,6 +4,7 @@ const TYPECHECK_STEP = { run: 'typecheck', shouldDeclineArguments: true };
4
4
  const ROOT_TYPECHECK_STEP = { run: 'root:typecheck', shouldDeclineArguments: true };
5
5
  const LINT_CHECK_STEP = { run: 'lint:check', shouldRunInBackground: true };
6
6
  const LINT_STRICT_STEP = { run: 'lint:strict', shouldRunInBackground: true };
7
+ const LINT_WORKFLOWS_STEP = { run: 'lint:workflows', shouldDeclineArguments: true };
7
8
  export const workspaceScripts = {
8
9
  build: ['compile'],
9
10
  check: [LINT_CHECK_STEP, TYPECHECK_STEP, 'fmt:check', 'test'],
@@ -35,8 +36,8 @@ export function buildRootScripts(workspaceGlobs) {
35
36
  'audit:dev': 'pnpm exec v11y --dev',
36
37
  'audit:prod': 'pnpm exec v11y --prod',
37
38
  build: ['-R build'],
38
- check: [LINT_CHECK_STEP, TYPECHECK_STEP, 'fmt:check', 'test'],
39
- 'check:strict': [LINT_STRICT_STEP, TYPECHECK_STEP, 'fmt:check', 'test'],
39
+ check: [LINT_CHECK_STEP, LINT_WORKFLOWS_STEP, TYPECHECK_STEP, 'fmt:check', 'test'],
40
+ 'check:strict': [LINT_STRICT_STEP, LINT_WORKFLOWS_STEP, TYPECHECK_STEP, 'fmt:check', 'test'],
40
41
  ci: [{ run: 'build', shouldDeclineArguments: true }, 'check:strict'],
41
42
  clean: 'nmr-clean',
42
43
  fix: ['lint', 'fmt'],
@@ -46,9 +47,10 @@ export function buildRootScripts(workspaceGlobs) {
46
47
  lint: 'eslint --fix .',
47
48
  'lint:check': 'eslint .',
48
49
  'lint:strict': 'strict-lint',
50
+ 'lint:workflows': 'nmr-actionlint',
49
51
  prepush: [{ run: 'audit', shouldDeclineArguments: true }, 'ci'],
50
52
  'report-overrides': 'nmr-report-overrides',
51
- 'root:check': [ROOT_TYPECHECK_STEP, 'fmt:check', 'root:lint:check', 'root:test'],
53
+ 'root:check': [ROOT_TYPECHECK_STEP, 'fmt:check', 'root:lint:check', LINT_WORKFLOWS_STEP, 'root:test'],
52
54
  'root:lint': `eslint --fix ${ignorePatterns}.`,
53
55
  'root:lint:check': `eslint ${ignorePatterns}.`,
54
56
  'root:lint:strict': `strict-lint ${ignorePatterns}.`,
@@ -1,6 +1,6 @@
1
1
  import os from 'node:os';
2
2
  import { Writable } from 'node:stream';
3
- import { reportError } from '@williamthorsen/nmr-core';
3
+ import { reportError, startTimer } from '@williamthorsen/nmr-core';
4
4
  import { NO_CACHE_ENV_VAR, resolveRunId, writeDebugNote } from "./check-cache.js";
5
5
  import { buildChildEnv, buildRemoteEnv } from "./child-env.js";
6
6
  import { resolveDelegationTarget } from "./delegation-target.js";
@@ -111,7 +111,7 @@ async function delegate(options) {
111
111
  const { invocation, remoteEnv, target } = options;
112
112
  const { command, context, cwd, env, parsedArgs, stderr, stdout } = invocation;
113
113
  const listedHosts = target.hosts.join(', ');
114
- const startedAtMs = Date.now();
114
+ const readElapsedMs = startTimer();
115
115
  const snapshot = createSnapshot(cwd, env);
116
116
  if (!snapshot.ok && snapshot.isUnsupported) {
117
117
  if (target.source === 'flag') {
@@ -144,7 +144,7 @@ async function delegate(options) {
144
144
  stdout: createTee(stdout, retained.stdout),
145
145
  })
146
146
  : { ok: false, message: 'could not snapshot the working tree', output: snapshot.reason, waitedMs: 0 };
147
- const durationMs = Date.now() - startedAtMs - result.waitedMs;
147
+ const durationMs = readElapsedMs() - result.waitedMs;
148
148
  const ran = {
149
149
  kind: 'ran',
150
150
  durationMs,
@@ -5,5 +5,5 @@
5
5
  * `noop` is a neutral circle: Nothing ran because the repo asked for nothing to run, which is neither a
6
6
  * failure nor a block.
7
7
  */
8
- export declare const NMR_GLYPHS: Readonly<Record<"plain" | "rich", Readonly<Record<"overrides" | "clean" | "catalog" | "noop" | "package" | "recording", import("@williamthorsen/toolbelt.terminal/candidate").Glyph>>>>;
8
+ export declare const NMR_GLYPHS: Readonly<Record<"plain" | "rich", Readonly<Record<"overrides" | "clean" | "host" | "catalog" | "noop" | "package" | "recording", import("@williamthorsen/toolbelt.terminal/candidate").Glyph>>>>;
9
9
  export type NmrGlyphName = keyof (typeof NMR_GLYPHS)['rich'];
@@ -2,6 +2,7 @@ import { defineGlyphSet } from '@williamthorsen/nmr-core';
2
2
  export const NMR_GLYPHS = defineGlyphSet({
3
3
  catalog: { plain: '', rich: '\u{1F4DA}' },
4
4
  clean: { plain: '', rich: '\u{1F9F9}' },
5
+ host: { plain: '', rich: '\u{1F4E1}' },
5
6
  noop: { plain: 'NOOP', rich: '\u{26AA}' },
6
7
  overrides: { plain: '', rich: '\u{1F512}' },
7
8
  package: { plain: '', rich: '\u{1F4E6}' },
@@ -14,6 +14,8 @@ export function runHostSession(params) {
14
14
  const COMMAND_NOT_FOUND_EXIT_CODE = 127;
15
15
  const DELEGATION_FAILURE_EXIT_CODE = 255;
16
16
  const STOP_GRACE_MS = 5_000;
17
+ const NODE_MODULES_DIR = path.join(params.rootDir, 'node_modules');
18
+ const BINS_RECORD_PATH = path.join(NODE_MODULES_DIR, '.nmr-delegate-bins');
17
19
  const token = crypto.randomUUID();
18
20
  let isHolding = false;
19
21
  let activeChild;
@@ -36,6 +38,10 @@ export function runHostSession(params) {
36
38
  return;
37
39
  }
38
40
  }
41
+ const binsFingerprint = readBinsFingerprint();
42
+ if (binsFingerprint === undefined || readBinsRecord() !== binsFingerprint) {
43
+ removeWorkspaceState();
44
+ }
39
45
  const install = await runPhase('pnpm', ['install', '--frozen-lockfile'], params.rootDir, {
40
46
  ...process.env,
41
47
  CI: 'true',
@@ -46,6 +52,8 @@ export function runHostSession(params) {
46
52
  fail('install', install);
47
53
  return;
48
54
  }
55
+ if (binsFingerprint !== undefined)
56
+ writeBinsRecord(binsFingerprint);
49
57
  emit({ kind: 'run' });
50
58
  fs.mkdirSync(params.runDir, { recursive: true });
51
59
  fs.mkdirSync(params.passManifestDir, { recursive: true });
@@ -146,6 +154,42 @@ export function runHostSession(params) {
146
154
  return true;
147
155
  return state.holder !== undefined && isProcessGone(state.holder.pid);
148
156
  }
157
+ function readBinsFingerprint() {
158
+ let listing;
159
+ try {
160
+ listing = childProcess.execFileSync('git', ['ls-files', '-z', '--', '*package.json'], {
161
+ cwd: params.rootDir,
162
+ encoding: 'utf8',
163
+ stdio: ['ignore', 'pipe', 'ignore'],
164
+ });
165
+ }
166
+ catch {
167
+ return undefined;
168
+ }
169
+ const lines = listing
170
+ .split('\0')
171
+ .filter((file) => path.basename(file) === 'package.json')
172
+ .toSorted()
173
+ .map((file) => {
174
+ try {
175
+ const manifest = JSON.parse(fs.readFileSync(path.join(params.rootDir, file), 'utf8'));
176
+ const fields = typeof manifest === 'object' && manifest !== null ? { ...manifest } : {};
177
+ return JSON.stringify([file, fields['name'], fields['bin']]);
178
+ }
179
+ catch {
180
+ return JSON.stringify([file]);
181
+ }
182
+ });
183
+ return crypto.createHash('sha256').update(lines.join('\n')).digest('hex');
184
+ }
185
+ function readBinsRecord() {
186
+ try {
187
+ return fs.readFileSync(BINS_RECORD_PATH, 'utf8');
188
+ }
189
+ catch {
190
+ return undefined;
191
+ }
192
+ }
149
193
  function readErrorCode(error) {
150
194
  return typeof error === 'object' && error !== null && 'code' in error ? error.code : undefined;
151
195
  }
@@ -218,6 +262,14 @@ export function runHostSession(params) {
218
262
  fs.rmSync(LOCK_PATH, { force: true });
219
263
  }
220
264
  }
265
+ function removeWorkspaceState() {
266
+ const names = fs.existsSync(NODE_MODULES_DIR) ? fs.readdirSync(NODE_MODULES_DIR) : [];
267
+ for (const name of names) {
268
+ if (name.startsWith('.pnpm-workspace-state') && name.endsWith('.json')) {
269
+ fs.rmSync(path.join(NODE_MODULES_DIR, name), { force: true });
270
+ }
271
+ }
272
+ }
221
273
  function runPhase(file, args, cwd, env) {
222
274
  const head = [];
223
275
  const tail = [];
@@ -274,4 +326,12 @@ export function runHostSession(params) {
274
326
  catch {
275
327
  }
276
328
  }
329
+ function writeBinsRecord(fingerprint) {
330
+ try {
331
+ fs.mkdirSync(NODE_MODULES_DIR, { recursive: true });
332
+ fs.writeFileSync(BINS_RECORD_PATH, fingerprint);
333
+ }
334
+ catch {
335
+ }
336
+ }
277
337
  }
@@ -3,7 +3,7 @@ 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 { STATUS_GLYPHS } from '@williamthorsen/nmr-core';
6
+ import { 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";
@@ -74,18 +74,18 @@ export async function runRemotely(options) {
74
74
  const marker = `__NMR_DELEGATE_${nonce}__`;
75
75
  const statusFile = `.git/nmr-run-status-${nonce}`;
76
76
  const passes = [];
77
- let waitStartedAtMs;
77
+ let readWaitedMs;
78
78
  let waitedMs = 0;
79
79
  let failure;
80
80
  const demultiplexer = createSessionDemultiplexer({
81
81
  marker,
82
82
  onRecord(record) {
83
83
  if (record.kind === 'waiting') {
84
- waitStartedAtMs ??= Date.now();
84
+ readWaitedMs ??= startTimer();
85
85
  stderr.write(`${formatDelegationWaitingLine(record.holder, host)}\n`);
86
86
  }
87
- else if (record.kind === 'locked' && waitStartedAtMs !== undefined) {
88
- waitedMs = Date.now() - waitStartedAtMs;
87
+ else if (record.kind === 'locked' && readWaitedMs !== undefined) {
88
+ waitedMs = readWaitedMs();
89
89
  }
90
90
  else if (record.kind === 'failed') {
91
91
  failure = record;
@@ -2,7 +2,7 @@ import { spawnSync } from 'node:child_process';
2
2
  import fs from 'node:fs';
3
3
  import os from 'node:os';
4
4
  import path from 'node:path';
5
- import { GIT_OUTPUT_LIMIT } from '@williamthorsen/nmr-core';
5
+ import { GIT_OUTPUT_LIMIT_BYTES } from '@williamthorsen/nmr-core';
6
6
  export function createSnapshot(cwd, env) {
7
7
  const toplevelResult = runGit(['rev-parse', '--show-toplevel'], cwd, env);
8
8
  if (!toplevelResult.ok) {
@@ -69,7 +69,7 @@ function copyIndex(source, destination) {
69
69
  fs.utimesSync(destination, atime, mtime);
70
70
  }
71
71
  function runGit(args, cwd, env) {
72
- const result = spawnSync('git', args, { cwd, encoding: 'utf8', env, maxBuffer: GIT_OUTPUT_LIMIT });
72
+ const result = spawnSync('git', args, { cwd, encoding: 'utf8', env, maxBuffer: GIT_OUTPUT_LIMIT_BYTES });
73
73
  if (result.error !== undefined) {
74
74
  return { ok: false, error: result.error.message };
75
75
  }