@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.
- package/.meta/changelog.json +97 -0
- package/.readyup/kits/default.js +29 -27
- package/.readyup/manifest.json +31 -23
- package/CHANGELOG.md +32 -0
- package/README.md +13 -6
- package/bin/nmr-actionlint.js +11 -0
- package/dist/esm/check-cache-gate.d.ts +1 -1
- package/dist/esm/check-cache-gate.js +16 -4
- package/dist/esm/check-cache.js +1 -0
- package/dist/esm/cli-actionlint.d.ts +1 -0
- package/dist/esm/cli-actionlint.js +12 -0
- package/dist/esm/commands/actionlint.d.ts +23 -0
- package/dist/esm/commands/actionlint.js +31 -0
- package/dist/esm/commands/build-output.d.ts +13 -0
- package/dist/esm/commands/build-output.js +30 -1
- package/dist/esm/commands/build.js +12 -3
- package/dist/esm/commands/clean.d.ts +1 -1
- package/dist/esm/commands/clean.js +2 -1
- package/dist/esm/commands/fmt.d.ts +2 -1
- package/dist/esm/commands/fmt.js +7 -2
- package/dist/esm/commands/run-vitest.js +2 -0
- package/dist/esm/commands/workflow-tools.d.ts +39 -0
- package/dist/esm/commands/workflow-tools.js +140 -0
- package/dist/esm/default-scripts.js +5 -3
- package/dist/esm/delegate-host.js +3 -3
- package/dist/esm/glyphs.d.ts +1 -1
- package/dist/esm/glyphs.js +1 -0
- package/dist/esm/remote-host-session.js +60 -0
- package/dist/esm/remote-run.js +5 -5
- package/dist/esm/remote-snapshot.js +2 -2
- package/dist/esm/runner.d.ts +7 -0
- package/dist/esm/runner.js +37 -3
- package/dist/esm/stated-outcome.d.ts +37 -0
- package/dist/esm/stated-outcome.js +60 -0
- package/dist/esm/taze.d.ts +9 -7
- package/dist/esm/taze.js +0 -1
- package/dist/esm/verdict.d.ts +2 -0
- package/dist/esm/verdict.js +15 -3
- package/docs/check-cache.md +1 -1
- package/docs/delegate.md +4 -4
- package/docs/reporting.md +5 -1
- package/docs/scripts.md +9 -1
- package/docs/upgrades.md +6 -4
- package/docs/utilities.md +14 -3
- 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 {
|
|
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
|
-
|
|
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
|
|
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 */
|
package/dist/esm/commands/fmt.js
CHANGED
|
@@ -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
|
|
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 =
|
|
147
|
+
const durationMs = readElapsedMs() - result.waitedMs;
|
|
148
148
|
const ran = {
|
|
149
149
|
kind: 'ran',
|
|
150
150
|
durationMs,
|
package/dist/esm/glyphs.d.ts
CHANGED
|
@@ -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'];
|
package/dist/esm/glyphs.js
CHANGED
|
@@ -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
|
}
|
package/dist/esm/remote-run.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
84
|
+
readWaitedMs ??= startTimer();
|
|
85
85
|
stderr.write(`${formatDelegationWaitingLine(record.holder, host)}\n`);
|
|
86
86
|
}
|
|
87
|
-
else if (record.kind === 'locked' &&
|
|
88
|
-
waitedMs =
|
|
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 {
|
|
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:
|
|
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
|
}
|