@williamthorsen/nmr 0.53.1 → 0.54.1
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 +49 -0
- package/.readyup/kits/default.js +10 -10
- package/.readyup/manifest.json +19 -19
- package/CHANGELOG.md +15 -0
- package/README.md +12 -3
- package/bin/ensure-prepublish-hooks.js +6 -2
- package/bin/nmr-actionlint.js +6 -2
- package/bin/nmr-clean.js +6 -2
- package/bin/nmr-compile.js +6 -2
- package/bin/nmr-fmt.js +6 -2
- package/bin/nmr-report-catalog.js +6 -2
- package/bin/nmr-report-overrides.js +6 -2
- package/bin/nmr-taze.js +6 -2
- package/bin/nmr-vitest.js +6 -2
- package/bin/nmr.js +6 -2
- package/dist/esm/child-process.js +1 -1
- package/dist/esm/cli-actionlint.js +2 -3
- package/dist/esm/cli-build.js +2 -3
- package/dist/esm/cli-clean.js +2 -3
- package/dist/esm/cli-ensure-prepublish-hooks.js +2 -3
- package/dist/esm/cli-fmt.js +8 -1
- package/dist/esm/cli-report-catalog.js +2 -3
- package/dist/esm/cli-report-overrides.js +2 -3
- package/dist/esm/cli-taze.js +4 -4
- package/dist/esm/cli-vitest.js +8 -1
- package/dist/esm/cli.js +2 -2
- package/dist/esm/commands/actionlint.js +2 -1
- package/dist/esm/commands/build.js +9 -6
- package/dist/esm/commands/clean.js +2 -2
- package/dist/esm/commands/compiler.js +3 -3
- package/dist/esm/commands/ensure-prepublish-hooks.js +10 -3
- package/dist/esm/commands/node-pin.d.ts +30 -0
- package/dist/esm/commands/node-pin.js +127 -0
- package/dist/esm/commands/report-overrides.js +1 -2
- package/dist/esm/commands/rewrite-specifiers.js +3 -2
- package/dist/esm/commands/taze.d.ts +10 -0
- package/dist/esm/commands/taze.js +15 -0
- package/dist/esm/commands/tsconfig-chain.js +4 -3
- package/dist/esm/commands/workflow-tools.js +10 -8
- package/dist/esm/config-check-cache.js +1 -1
- package/dist/esm/config-validation.js +1 -1
- package/dist/esm/config.js +1 -1
- package/dist/esm/crossing-diagnostics.js +1 -2
- package/dist/esm/delegation-target.d.ts +5 -4
- package/dist/esm/delegation-target.js +15 -0
- package/dist/esm/entry-chain.js +1 -1
- package/dist/esm/glyphs.d.ts +1 -1
- package/dist/esm/glyphs.js +1 -0
- package/dist/esm/helpers/deriveExcerpt.js +1 -1
- package/dist/esm/helpers/duration.js +1 -1
- package/dist/esm/helpers/package-json.js +1 -1
- package/dist/esm/machine-lock.js +1 -1
- package/dist/esm/pass-manifest.js +2 -3
- package/dist/esm/remote-host-session.js +2 -2
- package/dist/esm/resolveBinStyles.js +1 -2
- package/dist/esm/user-config.js +1 -1
- package/dist/esm/vitest-source-resolution.js +1 -1
- package/dist/esm/workspace.js +1 -1
- package/docs/delegate.md +7 -5
- package/docs/upgrades.md +19 -2
- package/package.json +6 -6
- package/dist/esm/UserError.d.ts +0 -8
- package/dist/esm/UserError.js +0 -6
- package/dist/esm/reportCliFailure.d.ts +0 -9
- package/dist/esm/reportCliFailure.js +0 -11
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { spawnSync } from 'node:child_process';
|
|
2
2
|
import fs from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
+
import { UserError } from '@williamthorsen/nmr-core';
|
|
4
5
|
import { describeSignalExitCode } from "../child-process.js";
|
|
5
6
|
import { ensureTool } from "./workflow-tools.js";
|
|
6
7
|
const WORKFLOWS_DIR = path.join('.github', 'workflows');
|
|
@@ -15,7 +16,7 @@ export async function runActionlint(monorepoRoot, args, options = {}) {
|
|
|
15
16
|
const shellcheckPath = await ensure('shellcheck', cacheRoot);
|
|
16
17
|
const outcome = (options.spawn ?? spawnInherited)(actionlintPath, buildActionlintArgs(shellcheckPath, args), monorepoRoot);
|
|
17
18
|
if (outcome.error !== undefined) {
|
|
18
|
-
throw new
|
|
19
|
+
throw new UserError(`Cannot run actionlint: ${outcome.error.message}`, { cause: outcome.error });
|
|
19
20
|
}
|
|
20
21
|
if (outcome.signal !== null) {
|
|
21
22
|
return describeSignalExitCode(outcome.signal);
|
|
@@ -2,7 +2,8 @@ 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, startTimer, STATUS_GLYPHS, writeCacheEntry, } from '@williamthorsen/nmr-core';
|
|
5
|
+
import { formatGlyphLine, readCacheEntry, startTimer, STATUS_GLYPHS, UserError, writeCacheEntry, } from '@williamthorsen/nmr-core';
|
|
6
|
+
import { describeError } from '@williamthorsen/toolbelt.errors';
|
|
6
7
|
import { glob } from 'glob';
|
|
7
8
|
import { NMR_GLYPHS } from "../glyphs.js";
|
|
8
9
|
import { resolveConfigPath } from "../helpers/config-path.js";
|
|
@@ -91,7 +92,7 @@ async function emitPackage(packageDir, entryPoints, outdir) {
|
|
|
91
92
|
}
|
|
92
93
|
function assertEmitSucceeded(result) {
|
|
93
94
|
if (result.emitSkipped) {
|
|
94
|
-
throw new
|
|
95
|
+
throw new UserError(`nmr-compile: emit failed.\n${formatDiagnostics(result.diagnostics)}`);
|
|
95
96
|
}
|
|
96
97
|
}
|
|
97
98
|
function createCachingCompilerHost(compilerOptions) {
|
|
@@ -126,7 +127,7 @@ function resolveEmitDir(packageDir, outdir) {
|
|
|
126
127
|
if (relativePathFromPackage === '' ||
|
|
127
128
|
relativePathFromPackage.startsWith('..') ||
|
|
128
129
|
path.isAbsolute(relativePathFromPackage)) {
|
|
129
|
-
throw new
|
|
130
|
+
throw new UserError(`nmr-compile: refusing to build into '${outdir}', which does not resolve inside the package. ` +
|
|
130
131
|
'The build replaces its output directory on each emit, so the directory must be below the package root.');
|
|
131
132
|
}
|
|
132
133
|
return resolvedDir;
|
|
@@ -143,7 +144,9 @@ async function swapIntoPlace(emitDir, scratchDirs) {
|
|
|
143
144
|
if (hadPreviousOutput && !existsSync(emitDir)) {
|
|
144
145
|
await rename(scratchDirs.previousDir, emitDir);
|
|
145
146
|
}
|
|
146
|
-
throw error
|
|
147
|
+
throw new UserError(`nmr-compile: failed to move the built output into ${emitDir}: ${describeError(error)}`, {
|
|
148
|
+
cause: error,
|
|
149
|
+
});
|
|
147
150
|
}
|
|
148
151
|
await discardScratchDirs(scratchDirs);
|
|
149
152
|
}
|
|
@@ -152,11 +155,11 @@ function synthesizeCompilerOptions(packageDir, outdir) {
|
|
|
152
155
|
const configPath = path.join(packageDir, 'tsconfig.json');
|
|
153
156
|
const configFile = ts.readConfigFile(configPath, (fileName) => ts.sys.readFile(fileName));
|
|
154
157
|
if (configFile.error) {
|
|
155
|
-
throw new
|
|
158
|
+
throw new UserError(`nmr-compile: failed to read ${configPath}.\n${formatDiagnostics([configFile.error])}`);
|
|
156
159
|
}
|
|
157
160
|
const parsedConfig = ts.parseJsonConfigFileContent(configFile.config, ts.sys, packageDir);
|
|
158
161
|
if (parsedConfig.errors.length > 0) {
|
|
159
|
-
throw new
|
|
162
|
+
throw new UserError(`nmr-compile: failed to parse ${configPath}.\n${formatDiagnostics(parsedConfig.errors)}`);
|
|
160
163
|
}
|
|
161
164
|
const resolvedOutDir = path.resolve(packageDir, outdir);
|
|
162
165
|
return {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { rm } from 'node:fs/promises';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
-
import { formatGlyphLine } from '@williamthorsen/nmr-core';
|
|
3
|
+
import { formatGlyphLine, UserError } from '@williamthorsen/nmr-core';
|
|
4
4
|
import { removeCheckCache } from "../check-cache-entries.js";
|
|
5
5
|
import { loadRootConfig } from "../config.js";
|
|
6
6
|
import { findContainingPackageDir } from "../context.js";
|
|
@@ -71,7 +71,7 @@ async function sweepWorkspace(monorepoRoot, workspacePackageDirs, style) {
|
|
|
71
71
|
channels: { stderr: resolveChannel(process.stderr, false), stdout: resolveChannel(process.stdout, false) },
|
|
72
72
|
});
|
|
73
73
|
if (exitCode !== 0) {
|
|
74
|
-
throw new
|
|
74
|
+
throw new UserError(`nmr-clean: \`${command}\` failed in ${path.basename(packageDir)} with exit code ${exitCode}.`);
|
|
75
75
|
}
|
|
76
76
|
cleanedCount++;
|
|
77
77
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { hasErrnoCode } from '@williamthorsen/nmr-core';
|
|
1
|
+
import { hasErrnoCode, UserError } from '@williamthorsen/nmr-core';
|
|
2
2
|
const MINIMUM_TYPESCRIPT_MAJOR = 5;
|
|
3
3
|
const MINIMUM_TYPESCRIPT_MINOR = 7;
|
|
4
4
|
const UNSUPPORTED_TYPESCRIPT_MAJOR = 7;
|
|
@@ -14,7 +14,7 @@ export function assertSupportedTypeScript() {
|
|
|
14
14
|
const minor = minorPart === undefined ? 0 : Number(minorPart);
|
|
15
15
|
const tooOld = major < MINIMUM_TYPESCRIPT_MAJOR || (major === MINIMUM_TYPESCRIPT_MAJOR && minor < MINIMUM_TYPESCRIPT_MINOR);
|
|
16
16
|
if (tooOld || major >= UNSUPPORTED_TYPESCRIPT_MAJOR) {
|
|
17
|
-
throw new
|
|
17
|
+
throw new UserError(`nmr-compile requires TypeScript ${SUPPORTED_TYPESCRIPT_RANGE}, but found ` +
|
|
18
18
|
`${typeof version === 'string' ? version : 'a version without the compiler API'}. ${INSTALL_TYPESCRIPT_GUIDANCE}`);
|
|
19
19
|
}
|
|
20
20
|
}
|
|
@@ -38,7 +38,7 @@ export async function loadCompiler(loadTypeScript = importTypeScript) {
|
|
|
38
38
|
}
|
|
39
39
|
catch (error) {
|
|
40
40
|
if (hasErrnoCode(error, 'ERR_MODULE_NOT_FOUND') && error.message.includes("'typescript'")) {
|
|
41
|
-
throw new
|
|
41
|
+
throw new UserError(`nmr-compile requires TypeScript ${SUPPORTED_TYPESCRIPT_RANGE}, but 'typescript' could not be resolved. ` +
|
|
42
42
|
INSTALL_TYPESCRIPT_GUIDANCE, { cause: error });
|
|
43
43
|
}
|
|
44
44
|
throw error;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { readFileSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
-
import { STATUS_GLYPHS } from '@williamthorsen/nmr-core';
|
|
3
|
+
import { STATUS_GLYPHS, UserError } from '@williamthorsen/nmr-core';
|
|
4
|
+
import { describeError } from '@williamthorsen/toolbelt.errors';
|
|
4
5
|
import { readPackageJson } from "../helpers/package-json.js";
|
|
5
6
|
import { reportClosing } from "../helpers/reportClosing.js";
|
|
6
7
|
import { isObject } from "../helpers/type-guards.js";
|
|
@@ -75,9 +76,15 @@ export function reportPrepublishHooks(result, hookCommand, style) {
|
|
|
75
76
|
function addPrepublishOnly(packageDir, command) {
|
|
76
77
|
const filePath = path.join(packageDir, 'package.json');
|
|
77
78
|
const rawText = readFileSync(filePath, 'utf8');
|
|
78
|
-
|
|
79
|
+
let parsedManifest;
|
|
80
|
+
try {
|
|
81
|
+
parsedManifest = JSON.parse(rawText);
|
|
82
|
+
}
|
|
83
|
+
catch (error) {
|
|
84
|
+
throw new UserError(`Invalid package.json in ${packageDir}: ${describeError(error)}`, { cause: error });
|
|
85
|
+
}
|
|
79
86
|
if (!isObject(parsedManifest)) {
|
|
80
|
-
throw new
|
|
87
|
+
throw new UserError(`Invalid package.json in ${packageDir}: expected an object`);
|
|
81
88
|
}
|
|
82
89
|
const scripts = isObject(parsedManifest['scripts']) ? parsedManifest['scripts'] : {};
|
|
83
90
|
scripts['prepublishOnly'] = command;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { Writable } from 'node:stream';
|
|
2
|
+
import { type OutputStyle } from '@williamthorsen/nmr-core';
|
|
3
|
+
/** Node's release index, which lists every release with its version and its UTC release day. */
|
|
4
|
+
export declare const NODE_RELEASE_INDEX_URL = "https://nodejs.org/dist/index.json";
|
|
5
|
+
export interface CheckNodePinOptions {
|
|
6
|
+
/** Returns the parsed release index. Injected by tests; defaults to fetching `NODE_RELEASE_INDEX_URL`. */
|
|
7
|
+
fetchReleases?: () => Promise<unknown>;
|
|
8
|
+
/** Suppresses the report, but not the warnings. */
|
|
9
|
+
isSilent: boolean;
|
|
10
|
+
/** The current time, against which the soak is measured. Injected by tests. */
|
|
11
|
+
now?: Date;
|
|
12
|
+
shouldWrite: boolean;
|
|
13
|
+
/** Stream that the report and the warnings are written to. Defaults to `process.stderr`. */
|
|
14
|
+
stderr?: Writable;
|
|
15
|
+
style: OutputStyle;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Reports the newest release in the major line of the `nodejs` pin in `dir`'s `.tool-versions` that is newer than
|
|
19
|
+
* the pin and at least 24 hours old, and rewrites the pin to it when `shouldWrite` is set.
|
|
20
|
+
*
|
|
21
|
+
* Reports nothing when the file or its `nodejs` entry is absent, or when the pin is current. Every failure is a
|
|
22
|
+
* warning, so that the check never fails the upgrade that runs it.
|
|
23
|
+
*/
|
|
24
|
+
export declare function checkNodePin(dir: string, options: CheckNodePinOptions): Promise<void>;
|
|
25
|
+
/**
|
|
26
|
+
* Returns the newest plain `X.Y.Z` release in `pin`'s major line that is newer than `pin` and whose UTC release day
|
|
27
|
+
* began at least two days before `now`, or `undefined` when none is. Ignores an entry that lacks a plain version or
|
|
28
|
+
* a `YYYY-MM-DD` date.
|
|
29
|
+
*/
|
|
30
|
+
export declare function selectNodeUpgrade(pin: string, releases: readonly unknown[], now: Date): string | undefined;
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import process from 'node:process';
|
|
4
|
+
import { formatGlyphLine } from '@williamthorsen/nmr-core';
|
|
5
|
+
import { describeError } from '@williamthorsen/toolbelt.errors';
|
|
6
|
+
import { NMR_GLYPHS } from "../glyphs.js";
|
|
7
|
+
import { isObject } from "../helpers/type-guards.js";
|
|
8
|
+
export const NODE_RELEASE_INDEX_URL = 'https://nodejs.org/dist/index.json';
|
|
9
|
+
const TOOL_VERSIONS_FILE = '.tool-versions';
|
|
10
|
+
const SOAK_MS = 2 * 24 * 60 * 60 * 1000;
|
|
11
|
+
const FETCH_TIMEOUT_MS = 10_000;
|
|
12
|
+
const NODEJS_ENTRY_PATTERN = /^[ \t]*nodejs[ \t]+([^\s#]+)/m;
|
|
13
|
+
const PLAIN_VERSION_PATTERN = /^\d+\.\d+\.\d+$/;
|
|
14
|
+
const RELEASE_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
|
|
15
|
+
export async function checkNodePin(dir, options) {
|
|
16
|
+
const stderr = options.stderr ?? process.stderr;
|
|
17
|
+
function report(message) {
|
|
18
|
+
stderr.write(`${formatGlyphLine(NMR_GLYPHS, options.style, 'pin', message)}\n`);
|
|
19
|
+
}
|
|
20
|
+
function warn(detail) {
|
|
21
|
+
report(`WARN: Could not check the nodejs pin: ${detail}`);
|
|
22
|
+
}
|
|
23
|
+
const filePath = path.join(dir, TOOL_VERSIONS_FILE);
|
|
24
|
+
let content;
|
|
25
|
+
try {
|
|
26
|
+
content = fs.readFileSync(filePath, 'utf8');
|
|
27
|
+
}
|
|
28
|
+
catch (error) {
|
|
29
|
+
if (isMissingFileError(error))
|
|
30
|
+
return;
|
|
31
|
+
warn(describeError(error));
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
const pin = findNodePin(content);
|
|
35
|
+
if (pin === undefined)
|
|
36
|
+
return;
|
|
37
|
+
if (!PLAIN_VERSION_PATTERN.test(pin.version)) {
|
|
38
|
+
if (!options.isSilent) {
|
|
39
|
+
report(`Skipped the nodejs pin '${pin.version}' in ${TOOL_VERSIONS_FILE}: Only an X.Y.Z version is checked.`);
|
|
40
|
+
}
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
let releases;
|
|
44
|
+
try {
|
|
45
|
+
releases = await (options.fetchReleases ?? fetchNodeReleaseIndex)();
|
|
46
|
+
}
|
|
47
|
+
catch (error) {
|
|
48
|
+
warn(`${NODE_RELEASE_INDEX_URL} is unreachable (${describeError(error)})`);
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
if (!Array.isArray(releases)) {
|
|
52
|
+
warn(`${NODE_RELEASE_INDEX_URL} did not return a list of releases`);
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
const target = selectNodeUpgrade(pin.version, releases, options.now ?? new Date());
|
|
56
|
+
if (target === undefined)
|
|
57
|
+
return;
|
|
58
|
+
if (!options.shouldWrite) {
|
|
59
|
+
if (!options.isSilent) {
|
|
60
|
+
report(`nodejs ${pin.version} → ${target} is available. Run with --write to update ${TOOL_VERSIONS_FILE}.`);
|
|
61
|
+
}
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
const updated = content.slice(0, pin.start) + target + content.slice(pin.start + pin.version.length);
|
|
65
|
+
try {
|
|
66
|
+
fs.writeFileSync(filePath, updated);
|
|
67
|
+
}
|
|
68
|
+
catch (error) {
|
|
69
|
+
warn(describeError(error));
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
if (!options.isSilent)
|
|
73
|
+
report(`Updated nodejs ${pin.version} → ${target} in ${TOOL_VERSIONS_FILE}.`);
|
|
74
|
+
}
|
|
75
|
+
export function selectNodeUpgrade(pin, releases, now) {
|
|
76
|
+
const pinParts = parseVersion(pin);
|
|
77
|
+
let best;
|
|
78
|
+
let bestVersion;
|
|
79
|
+
for (const release of releases) {
|
|
80
|
+
if (!isObject(release))
|
|
81
|
+
continue;
|
|
82
|
+
const { date, version } = release;
|
|
83
|
+
if (typeof version !== 'string' || typeof date !== 'string' || !RELEASE_DATE_PATTERN.test(date))
|
|
84
|
+
continue;
|
|
85
|
+
const bareVersion = version.replace(/^v/, '');
|
|
86
|
+
if (!PLAIN_VERSION_PATTERN.test(bareVersion))
|
|
87
|
+
continue;
|
|
88
|
+
const parts = parseVersion(bareVersion);
|
|
89
|
+
if (parts[0] !== pinParts[0] || compareVersions(parts, pinParts) <= 0)
|
|
90
|
+
continue;
|
|
91
|
+
if (Date.parse(`${date}T00:00:00Z`) + SOAK_MS > now.getTime())
|
|
92
|
+
continue;
|
|
93
|
+
if (best !== undefined && compareVersions(parts, best) <= 0)
|
|
94
|
+
continue;
|
|
95
|
+
best = parts;
|
|
96
|
+
bestVersion = bareVersion;
|
|
97
|
+
}
|
|
98
|
+
return bestVersion;
|
|
99
|
+
}
|
|
100
|
+
function compareVersions(a, b) {
|
|
101
|
+
for (let index = 0; index < 3; index++) {
|
|
102
|
+
const difference = (a[index] ?? 0) - (b[index] ?? 0);
|
|
103
|
+
if (difference !== 0)
|
|
104
|
+
return difference;
|
|
105
|
+
}
|
|
106
|
+
return 0;
|
|
107
|
+
}
|
|
108
|
+
async function fetchNodeReleaseIndex() {
|
|
109
|
+
const response = await fetch(NODE_RELEASE_INDEX_URL, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
|
|
110
|
+
if (!response.ok)
|
|
111
|
+
throw new Error(`HTTP ${response.status}`);
|
|
112
|
+
const body = await response.json();
|
|
113
|
+
return body;
|
|
114
|
+
}
|
|
115
|
+
function findNodePin(content) {
|
|
116
|
+
const match = NODEJS_ENTRY_PATTERN.exec(content);
|
|
117
|
+
const version = match?.[1];
|
|
118
|
+
if (match === null || version === undefined)
|
|
119
|
+
return undefined;
|
|
120
|
+
return { start: match.index + match[0].length - version.length, version };
|
|
121
|
+
}
|
|
122
|
+
function isMissingFileError(error) {
|
|
123
|
+
return isObject(error) && error['code'] === 'ENOENT';
|
|
124
|
+
}
|
|
125
|
+
function parseVersion(version) {
|
|
126
|
+
return version.split('.').map(Number);
|
|
127
|
+
}
|
|
@@ -1,8 +1,7 @@
|
|
|
1
|
-
import { formatGlyphLine } from '@williamthorsen/nmr-core';
|
|
1
|
+
import { formatGlyphLine, UserError } from '@williamthorsen/nmr-core';
|
|
2
2
|
import { NMR_GLYPHS } from "../glyphs.js";
|
|
3
3
|
import { getPnpmOverrides, readPackageJson } from "../helpers/package-json.js";
|
|
4
4
|
import { reportClosing } from "../helpers/reportClosing.js";
|
|
5
|
-
import { UserError } from "../UserError.js";
|
|
6
5
|
import { readWorkspaceOverrides } from "../workspace.js";
|
|
7
6
|
export function reportOverrides(monorepoRoot, style) {
|
|
8
7
|
const declaredOverrides = listEntries(readWorkspaceOverrides(monorepoRoot));
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
|
+
import { UserError } from '@williamthorsen/nmr-core';
|
|
2
3
|
import { getCompiler } from "./compiler.js";
|
|
3
4
|
const TS_EXTENSIONS = ['.ts', '.tsx'];
|
|
4
5
|
const JS_EXTENSION = '.js';
|
|
@@ -88,7 +89,7 @@ function resolveSpecifierReplacement(specifier, sourceContainingFile, compilerOp
|
|
|
88
89
|
const ts = getCompiler();
|
|
89
90
|
const resolvedModule = ts.resolveModuleName(specifier, sourceContainingFile, compilerOptions, ts.sys).resolvedModule;
|
|
90
91
|
if (!resolvedModule) {
|
|
91
|
-
throw new
|
|
92
|
+
throw new UserError(`nmr-compile: could not resolve aliased import '${specifier}' from ${sourceContainingFile}. ` +
|
|
92
93
|
`Verify the tsconfig 'paths' mapping and that the target file exists.`);
|
|
93
94
|
}
|
|
94
95
|
if (!isWithin(sourceRoot, resolvedModule.resolvedFileName)) {
|
|
@@ -97,7 +98,7 @@ function resolveSpecifierReplacement(specifier, sourceContainingFile, compilerOp
|
|
|
97
98
|
if (bareResolvedModule) {
|
|
98
99
|
return undefined;
|
|
99
100
|
}
|
|
100
|
-
throw new
|
|
101
|
+
throw new UserError(`nmr-compile: aliased import '${specifier}' from ${sourceContainingFile} resolves to ` +
|
|
101
102
|
`${resolvedModule.resolvedFileName}, outside the package source root ${sourceRoot}, and does not resolve ` +
|
|
102
103
|
`the way Node will at runtime, which ignores tsconfig 'paths', 'baseUrl', and 'rootDirs'. The ` +
|
|
103
104
|
`emitted specifier would fail at runtime; re-anchor the alias inside the package.`);
|
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
import type { Writable } from 'node:stream';
|
|
2
|
+
import { type OutputStyle } from '@williamthorsen/nmr-core';
|
|
3
|
+
import { type CheckNodePinOptions } from './node-pin.js';
|
|
2
4
|
export interface SpawnOutcome {
|
|
3
5
|
status: number | null;
|
|
4
6
|
error?: Error | undefined;
|
|
5
7
|
}
|
|
6
8
|
export interface RunTazeOptions {
|
|
9
|
+
/** Checks the `.tool-versions` Node pin in a directory. Injected by tests. */
|
|
10
|
+
checkNodePin?: (dir: string, options: CheckNodePinOptions) => Promise<void>;
|
|
7
11
|
/** Returns the `packageMaturityPeriod` that the taze config governing a directory declares. Injected by tests. */
|
|
8
12
|
loadConfig?: (cwd: string) => Promise<unknown>;
|
|
9
13
|
/** Resolves the absolute path of taze's CLI entry. Injected by tests. */
|
|
@@ -12,6 +16,8 @@ export interface RunTazeOptions {
|
|
|
12
16
|
spawn?: (nodePath: string, argv: string[]) => SpawnOutcome;
|
|
13
17
|
/** Stream that error output and pass headings are written to. Defaults to `process.stderr`. */
|
|
14
18
|
stderr?: Writable;
|
|
19
|
+
/** Style of `stderr`, in which the Node pin check reports. Defaults to `'plain'`. */
|
|
20
|
+
stderrStyle?: OutputStyle;
|
|
15
21
|
}
|
|
16
22
|
/**
|
|
17
23
|
* Runs taze from nmr's own dependency tree, forwarding `args`, and returns its exit code.
|
|
@@ -24,6 +30,10 @@ export interface RunTazeOptions {
|
|
|
24
30
|
* root `node_modules/.bin`. This launcher runs taze without that bin: pnpm links nmr's own bins into the
|
|
25
31
|
* consumer root, and nmr resolves taze from the tree that it does control.
|
|
26
32
|
*
|
|
33
|
+
* Before the passes, checks the `nodejs` pin in the `.tool-versions` of the directory from which taze resolves its
|
|
34
|
+
* config, reporting a newer release in the pin's major line and rewriting the pin under `--write`. The check only
|
|
35
|
+
* warns on failure, so it does not affect the exit code.
|
|
36
|
+
*
|
|
27
37
|
* Every pass also receives a request timeout, unless `args` sets one. Neither other site can declare it: Because
|
|
28
38
|
* an invocation's trailing arguments are appended to the resolved command string, the same flag in the `upgrade`
|
|
29
39
|
* script would be doubled by `nmr upgrade --request-timeout ...`, and taze's parser collects the pair into an
|
|
@@ -5,12 +5,14 @@ import { fileURLToPath } from 'node:url';
|
|
|
5
5
|
import { reportError } from '@williamthorsen/nmr-core';
|
|
6
6
|
import { describeError } from '@williamthorsen/toolbelt.errors';
|
|
7
7
|
import { loadPackageMaturityPeriod } from "./load-package-maturity-period.js";
|
|
8
|
+
import { checkNodePin as checkNodePinDefault } from "./node-pin.js";
|
|
8
9
|
import { isSilent, readFlag } from "./taze-args.js";
|
|
9
10
|
import { planTazePasses } from "./taze-passes.js";
|
|
10
11
|
const TAZE_CLI_SPECIFIER = 'taze/cli';
|
|
11
12
|
const REQUEST_TIMEOUT_FLAG = '--request-timeout';
|
|
12
13
|
const REQUEST_TIMEOUT_FLAGS = [REQUEST_TIMEOUT_FLAG, '--requestTimeout'];
|
|
13
14
|
const REQUEST_TIMEOUT_MS = 30_000;
|
|
15
|
+
const NODE_PIN_SKIP_FLAGS = ['--no-node-version', '--help', '-h', '--version', '-v'];
|
|
14
16
|
export async function runTaze(args, options = {}) {
|
|
15
17
|
const stderr = options.stderr ?? process.stderr;
|
|
16
18
|
const resolveCliPath = options.resolveCliPath ?? resolveTazeCliPath;
|
|
@@ -33,6 +35,19 @@ export async function runTaze(args, options = {}) {
|
|
|
33
35
|
reportError(`Could not read the taze config: ${describeError(error)}`, stderr);
|
|
34
36
|
return 1;
|
|
35
37
|
}
|
|
38
|
+
if (readFlag(args, NODE_PIN_SKIP_FLAGS) === undefined) {
|
|
39
|
+
try {
|
|
40
|
+
await (options.checkNodePin ?? checkNodePinDefault)(resolveConfigDir(args), {
|
|
41
|
+
isSilent: isSilent(args),
|
|
42
|
+
shouldWrite: readFlag(args, ['--write', '-w']) !== undefined,
|
|
43
|
+
stderr,
|
|
44
|
+
style: options.stderrStyle ?? 'plain',
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
catch (error) {
|
|
48
|
+
reportError(`Could not check the nodejs pin: ${describeError(error)}`, stderr);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
36
51
|
const showHeadings = !isSilent(args);
|
|
37
52
|
let exitCode = 0;
|
|
38
53
|
for (const pass of passes) {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
|
+
import { UserError } from '@williamthorsen/nmr-core';
|
|
2
3
|
import { formatDiagnostics, getCompiler } from "./compiler.js";
|
|
3
4
|
import { isRelativeSpecifier } from "./rewrite-specifiers.js";
|
|
4
5
|
export function resolveTsconfigChain(packageDir, configFileName = 'tsconfig.json') {
|
|
@@ -14,7 +15,7 @@ export function resolveTsconfigChain(packageDir, configFileName = 'tsconfig.json
|
|
|
14
15
|
resolvedChain.push(normalizedPath);
|
|
15
16
|
const configFile = ts.readConfigFile(normalizedPath, (fileName) => ts.sys.readFile(fileName));
|
|
16
17
|
if (configFile.error) {
|
|
17
|
-
throw new
|
|
18
|
+
throw new UserError(`nmr-compile: failed to read ${normalizedPath}.\n${formatDiagnostics([configFile.error])}`);
|
|
18
19
|
}
|
|
19
20
|
for (const entry of normalizeExtendsField(configFile.config)) {
|
|
20
21
|
walk(resolveExtendsTarget(entry, normalizedPath));
|
|
@@ -47,12 +48,12 @@ function resolveExtendsTarget(extendsEntry, fromConfigPath) {
|
|
|
47
48
|
if (ts.sys.fileExists(withJsonExtension)) {
|
|
48
49
|
return withJsonExtension;
|
|
49
50
|
}
|
|
50
|
-
throw new
|
|
51
|
+
throw new UserError(`nmr-compile: ${fromConfigPath} extends '${extendsEntry}', which does not exist.`);
|
|
51
52
|
}
|
|
52
53
|
const resolvedPath = resolvePackageSpecifier(extendsEntry, fromConfigPath) ??
|
|
53
54
|
resolvePackageSpecifier(`${extendsEntry}/tsconfig.json`, fromConfigPath);
|
|
54
55
|
if (resolvedPath === undefined) {
|
|
55
|
-
throw new
|
|
56
|
+
throw new UserError(`nmr-compile: ${fromConfigPath} extends '${extendsEntry}', which does not resolve to a file.`);
|
|
56
57
|
}
|
|
57
58
|
return resolvedPath;
|
|
58
59
|
}
|
|
@@ -3,8 +3,8 @@ import { createHash } from 'node:crypto';
|
|
|
3
3
|
import fs from 'node:fs';
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import process from 'node:process';
|
|
6
|
+
import { UserError } from '@williamthorsen/nmr-core';
|
|
6
7
|
import { describeError } from '@williamthorsen/toolbelt.errors';
|
|
7
|
-
import { UserError } from "../UserError.js";
|
|
8
8
|
const SUPPORTED_PLATFORMS = ['darwin-arm64', 'darwin-x64', 'linux-arm64', 'linux-x64'];
|
|
9
9
|
const DOWNLOAD_TIMEOUT_MS = 60_000;
|
|
10
10
|
const ACTIONLINT_RELEASE = 'https://github.com/rhysd/actionlint/releases/download/v1.7.12';
|
|
@@ -71,7 +71,7 @@ export async function ensureTool(tool, options) {
|
|
|
71
71
|
const archive = await download(asset.url, options.fetch ?? globalThis.fetch);
|
|
72
72
|
const actualSha256 = createHash('sha256').update(archive).digest('hex');
|
|
73
73
|
if (actualSha256 !== asset.sha256) {
|
|
74
|
-
throw new
|
|
74
|
+
throw new UserError(`Checksum mismatch for ${asset.url}: expected sha256 ${asset.sha256}, got ${actualSha256}`);
|
|
75
75
|
}
|
|
76
76
|
const archivePath = path.join(scratchDir, 'archive.tar.gz');
|
|
77
77
|
fs.writeFileSync(archivePath, archive);
|
|
@@ -103,7 +103,7 @@ function createCacheRoot(cacheRoot) {
|
|
|
103
103
|
fs.mkdirSync(cacheRoot, { recursive: true });
|
|
104
104
|
}
|
|
105
105
|
catch (error) {
|
|
106
|
-
throw new
|
|
106
|
+
throw new UserError(`Cannot create the tool cache at ${cacheRoot}: ${describeError(error)}`, { cause: error });
|
|
107
107
|
}
|
|
108
108
|
}
|
|
109
109
|
async function download(url, fetchImpl) {
|
|
@@ -112,20 +112,20 @@ async function download(url, fetchImpl) {
|
|
|
112
112
|
response = await fetchImpl(url, { signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS) });
|
|
113
113
|
}
|
|
114
114
|
catch (error) {
|
|
115
|
-
throw new
|
|
115
|
+
throw new UserError(`Cannot download ${url}: ${describeError(error)}`, { cause: error });
|
|
116
116
|
}
|
|
117
117
|
if (!response.ok) {
|
|
118
|
-
throw new
|
|
118
|
+
throw new UserError(`Cannot download ${url}: HTTP ${response.status}`);
|
|
119
119
|
}
|
|
120
120
|
return Buffer.from(await response.arrayBuffer());
|
|
121
121
|
}
|
|
122
122
|
function extractMember(archivePath, extractDir, member) {
|
|
123
123
|
const result = spawnSync('tar', ['-xzf', archivePath, '-C', extractDir, member], { encoding: 'utf8' });
|
|
124
124
|
if (result.error !== undefined) {
|
|
125
|
-
throw new
|
|
125
|
+
throw new UserError(`Cannot run tar to extract ${member}: ${result.error.message}`, { cause: result.error });
|
|
126
126
|
}
|
|
127
127
|
if (result.status !== 0) {
|
|
128
|
-
throw new
|
|
128
|
+
throw new UserError(`tar could not extract ${member} from ${archivePath}: ${result.stderr.trim()}`);
|
|
129
129
|
}
|
|
130
130
|
}
|
|
131
131
|
function publishStage(stageDir, targetDir, binaryPath) {
|
|
@@ -134,7 +134,9 @@ function publishStage(stageDir, targetDir, binaryPath) {
|
|
|
134
134
|
}
|
|
135
135
|
catch (error) {
|
|
136
136
|
if (!fs.existsSync(binaryPath)) {
|
|
137
|
-
throw error
|
|
137
|
+
throw new UserError(`Cannot move the downloaded tool into ${targetDir}: ${describeError(error)}`, {
|
|
138
|
+
cause: error,
|
|
139
|
+
});
|
|
138
140
|
}
|
|
139
141
|
}
|
|
140
142
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
+
import { UserError } from '@williamthorsen/nmr-core';
|
|
1
2
|
import { findClosestName } from "./helpers/findClosestName.js";
|
|
2
3
|
import { isHookName } from "./helpers/hook-name.js";
|
|
3
4
|
import { buildRootRegistry, buildWorkspaceRegistry, readPackageJsonScripts } from "./resolver.js";
|
|
4
|
-
import { UserError } from "./UserError.js";
|
|
5
5
|
import { getWorkspacePackageDirs, isMonorepoRoot } from "./workspace.js";
|
|
6
6
|
export function assertResolvableCheckCacheCommands(config, configPath, baseDir) {
|
|
7
7
|
const { checkCache } = config;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
+
import { UserError } from '@williamthorsen/nmr-core';
|
|
1
2
|
import { assertResolvableCheckCacheCommands } from "./config-check-cache.js";
|
|
2
3
|
import { isObject, isStringRecord } from "./helpers/type-guards.js";
|
|
3
4
|
import { findUnexpressibleToken } from "./steps.js";
|
|
4
|
-
import { UserError } from "./UserError.js";
|
|
5
5
|
import { formatVerbosityRejection, isCommandVerbosity } from "./verbosity.js";
|
|
6
6
|
export const CONFIG_TIERS = {
|
|
7
7
|
root: {
|
package/dist/esm/config.js
CHANGED
|
@@ -8,6 +8,7 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
|
|
|
8
8
|
};
|
|
9
9
|
import { existsSync } from 'node:fs';
|
|
10
10
|
import { pathToFileURL } from 'node:url';
|
|
11
|
+
import { UserError } from '@williamthorsen/nmr-core';
|
|
11
12
|
import { CONFIG_TIERS, validateConfig } from "./config-validation.js";
|
|
12
13
|
import { resolveConfigPath } from "./helpers/config-path.js";
|
|
13
14
|
import { escapeControlCharacters } from "./helpers/escapeControlCharacters.js";
|
|
@@ -16,7 +17,6 @@ import { isObject } from "./helpers/type-guards.js";
|
|
|
16
17
|
import { getDefaultRootScripts, getDefaultWorkspaceScripts } from "./resolve-scripts.js";
|
|
17
18
|
import { describeScript } from "./resolver.js";
|
|
18
19
|
import { readSelfReference, readStepListSelfReference } from "./steps.js";
|
|
19
|
-
import { UserError } from "./UserError.js";
|
|
20
20
|
const SCRIPT_FIELDS = [
|
|
21
21
|
{ field: 'rootScripts', scope: 'root' },
|
|
22
22
|
{ field: 'workspaceScripts', scope: 'workspaceRegistry' },
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
|
-
import { STATUS_GLYPHS } from '@williamthorsen/nmr-core';
|
|
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
5
|
import { isHookName } from "./helpers/hook-name.js";
|
|
@@ -7,7 +7,6 @@ import { resolvePackageJsonPath } from "./helpers/package-json.js";
|
|
|
7
7
|
import { hasRunnableHook } from "./hooks.js";
|
|
8
8
|
import { expandScript, findChainedSelfReference, readPackageJsonScripts, } from "./resolver.js";
|
|
9
9
|
import { findNmrCrossing, readSoleNmrCommand, renderChain } from "./steps.js";
|
|
10
|
-
import { UserError } from "./UserError.js";
|
|
11
10
|
const CROSSING_CONSEQUENCE = "so nmr handles the nested run's output as a tool's.";
|
|
12
11
|
export function assertNoSelfReference(options) {
|
|
13
12
|
const { anchorDir, command, isReading, isWorkspaceRoot, monorepoRoot, registry } = options;
|
|
@@ -27,10 +27,11 @@ export type DelegationTarget = {
|
|
|
27
27
|
* 2. `--delegate-to` runs it on the hosts that it names, and `--delegate` on `NMR_DELEGATE`'s, or else the user
|
|
28
28
|
* config's.
|
|
29
29
|
* 3. A repo config declaring `delegate: false` runs it here.
|
|
30
|
-
* 4. `
|
|
31
|
-
* 5. `NMR_DELEGATE`
|
|
32
|
-
* 6.
|
|
33
|
-
* 7.
|
|
30
|
+
* 4. A package install hook, which `npm_lifecycle_event` names, runs it here.
|
|
31
|
+
* 5. `NMR_DELEGATE` naming hosts runs it on them.
|
|
32
|
+
* 6. `NMR_DELEGATE` set without a host runs it here, which is how a step keeps to where its parent runs.
|
|
33
|
+
* 7. The user config's `when` matching runs it on the config's hosts, unless `CI` is set or the invocation fans out.
|
|
34
|
+
* 8. Otherwise, it runs here.
|
|
34
35
|
*
|
|
35
36
|
* A command that rewrites the tree, or that runs until it is stopped, cannot be delegated, and neither can `--log`,
|
|
36
37
|
* which reads this machine's recordings. Asked for by a flag, such an invocation is refused; asked for by the
|
|
@@ -16,6 +16,9 @@ export async function resolveDelegationTarget(options) {
|
|
|
16
16
|
if (isRepoOptedOut) {
|
|
17
17
|
return { kind: 'local' };
|
|
18
18
|
}
|
|
19
|
+
if (isInstallHook(env)) {
|
|
20
|
+
return { kind: 'local' };
|
|
21
|
+
}
|
|
19
22
|
if (envHosts !== undefined) {
|
|
20
23
|
return isDelegable ? { kind: 'remote', hosts: envHosts, source: 'env' } : { kind: 'local' };
|
|
21
24
|
}
|
|
@@ -33,6 +36,15 @@ export async function resolveDelegationTarget(options) {
|
|
|
33
36
|
return { kind: 'remote', hosts: delegate.hosts, source: 'config', configPath: loaded.path, when: delegate.when };
|
|
34
37
|
}
|
|
35
38
|
const DEFAULT_USER_CONFIG_PATH = '~/.config/nmr/config.ts';
|
|
39
|
+
const INSTALL_HOOKS = new Set([
|
|
40
|
+
'dependencies',
|
|
41
|
+
'install',
|
|
42
|
+
'postinstall',
|
|
43
|
+
'postprepare',
|
|
44
|
+
'preinstall',
|
|
45
|
+
'prepare',
|
|
46
|
+
'preprepare',
|
|
47
|
+
]);
|
|
36
48
|
const NON_DELEGABLE_BASE_COMMANDS = ['build', 'clean', 'compile', 'fix', 'fmt', 'lint', 'test:watch', 'upgrade'];
|
|
37
49
|
const NON_DELEGABLE_COMMANDS = new Set([
|
|
38
50
|
...NON_DELEGABLE_BASE_COMMANDS,
|
|
@@ -41,6 +53,9 @@ const NON_DELEGABLE_COMMANDS = new Set([
|
|
|
41
53
|
function hasValue(value) {
|
|
42
54
|
return value !== undefined && value !== '';
|
|
43
55
|
}
|
|
56
|
+
function isInstallHook(env) {
|
|
57
|
+
return INSTALL_HOOKS.has(env['npm_lifecycle_event'] ?? '');
|
|
58
|
+
}
|
|
44
59
|
function parseHostList(value) {
|
|
45
60
|
const names = value.split(',').map((host) => host.trim());
|
|
46
61
|
const [first, ...rest] = new Set(names.filter((host) => host !== ''));
|
package/dist/esm/entry-chain.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { realpathSync } from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
+
import { UserError } from '@williamthorsen/nmr-core';
|
|
3
4
|
import { isObject } from "./helpers/type-guards.js";
|
|
4
|
-
import { UserError } from "./UserError.js";
|
|
5
5
|
export const ENTRY_CHAIN_ENV_VAR = 'NMR_ENTRY_CHAIN';
|
|
6
6
|
export function enterChain(options) {
|
|
7
7
|
const { anchorDir, command, env, monorepoRoot } = options;
|
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" | "host" | "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" | "pin" | "recording", import("@williamthorsen/toolbelt.terminal/candidate").Glyph>>>>;
|
|
9
9
|
export type NmrGlyphName = keyof (typeof NMR_GLYPHS)['rich'];
|
package/dist/esm/glyphs.js
CHANGED
|
@@ -6,5 +6,6 @@ export const NMR_GLYPHS = defineGlyphSet({
|
|
|
6
6
|
noop: { plain: 'NOOP', rich: '\u{26AA}' },
|
|
7
7
|
overrides: { plain: '', rich: '\u{1F512}' },
|
|
8
8
|
package: { plain: '', rich: '\u{1F4E6}' },
|
|
9
|
+
pin: { plain: '', rich: '\u{1F4CC}' },
|
|
9
10
|
recording: { plain: '', rich: '\u{1F4BE}' },
|
|
10
11
|
});
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { clampToBytes } from "./clampToBytes.js";
|
|
2
2
|
import { cleanTranscript } from "./transcript.js";
|
|
3
3
|
const MAX_BLOCK_LINES = 8;
|
|
4
|
-
export const MAX_EXCERPT_BYTES =
|
|
4
|
+
export const MAX_EXCERPT_BYTES = 2048;
|
|
5
5
|
const NON_RULE_CHARACTER = /[^-=_~*+.|:#]/u;
|
|
6
6
|
export function deriveExcerpt(transcript) {
|
|
7
7
|
const lines = cleanTranscript(transcript).split('\n');
|