@vzn/vx 0.0.594 → 0.0.595
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/package.json +5 -5
- package/src/bin.ts +26 -11
- package/src/cache/archive.ts +2 -2
- package/src/cache/cache.ts +96 -90
- package/src/cache/config-evals.ts +19 -4
- package/src/cache/git-inputs.ts +19 -8
- package/src/cache/inputs.ts +110 -37
- package/src/cache/layered-cache.ts +25 -2
- package/src/cache/tar-stream.ts +1 -1
- package/src/cli/cache.ts +1 -11
- package/src/cli/completions.ts +42 -6
- package/src/cli/foreign-flags.ts +25 -4
- package/src/cli/help.ts +5 -3
- package/src/cli/init.ts +1 -1
- package/src/cli/plan-format.ts +9 -0
- package/src/cli/plugin-templates.ts +2 -2
- package/src/cli/run.ts +29 -27
- package/src/cli/select.ts +30 -3
- package/src/cli/show.ts +42 -3
- package/src/cli/watch-filter.ts +14 -1
- package/src/cli/watch-fs.ts +15 -7
- package/src/cli/watch-judge.ts +1 -1
- package/src/cli/watch.ts +18 -11
- package/src/config.ts +5 -5
- package/src/exec/env.ts +5 -0
- package/src/exec/index.ts +1 -0
- package/src/exec/proc-sample.ts +5 -1
- package/src/exec/runner.ts +98 -40
- package/src/exec/sandbox-paths.ts +11 -5
- package/src/exec/sandbox-runtime.ts +20 -4
- package/src/graph/index.ts +1 -0
- package/src/graph/scheduler.ts +1 -1
- package/src/index.ts +6 -0
- package/src/orchestrator/execute-task.ts +29 -7
- package/src/orchestrator/hit-restore.ts +6 -3
- package/src/orchestrator/local-shortcircuit.ts +4 -1
- package/src/orchestrator/lockfile-claim.ts +43 -46
- package/src/orchestrator/plan.ts +25 -1
- package/src/orchestrator/plugin-host.ts +25 -3
- package/src/orchestrator/plugin.ts +2 -1
- package/src/orchestrator/run.ts +52 -8
- package/src/orchestrator/signals.ts +5 -2
- package/src/orchestrator/summary.ts +14 -1
- package/src/util/index.ts +7 -1
- package/src/util/secret-mask.ts +36 -0
- package/src/util/settle.ts +15 -0
- package/src/workspace/affected.ts +30 -8
- package/src/workspace/config-cache.ts +25 -4
- package/src/workspace/config-imports.ts +1 -1
- package/src/workspace/config-schema.ts +48 -6
- package/src/workspace/migrate-scripts.ts +1 -1
- package/src/workspace/package-graph.ts +15 -3
- package/src/workspace/project-loader.ts +72 -0
- package/src/workspace/workspace.ts +82 -11
|
@@ -20,8 +20,8 @@ export const PLUGIN_TEMPLATES: Readonly<Record<string, { plugin: string; test: s
|
|
|
20
20
|
},
|
|
21
21
|
executor: {
|
|
22
22
|
plugin:
|
|
23
|
-
"// `executor`: where a task's command runs. This one runs it here, in a\n// shell of its own, and stops the shell's whole process group when core\n// aborts the request (a stop, or `exec.timeout`): core cannot reach a\n// process an executor spawned.\nimport { definePlugin, type TaskExecutor, type VxPlugin } from '@vzn/vx'\n\nexport function shellExecutor(): VxPlugin {\n const executor: TaskExecutor = {\n name: 'shell',\n async execute(req) {\n const started = Date.now()\n // vx's own `sh`: a bare name is looked up on `req.env`'s PATH, which\n // leads with the project's node_modules/.bin, where a dependency's\n // `sh` would run every command.\n const sh = Bun.which('sh') ?? '/bin/sh'\n const child = Bun.spawn([sh, '-c',
|
|
24
|
-
test: "// A test for the example `executor` plugin beside it: a throwaway workspace\n// declares the plugin and vx's own run() drives it. `bun test` runs it.\nimport { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'\nimport os from 'node:os'\nimport path from 'node:path'\nimport { afterEach, beforeEach, expect, it } from 'bun:test'\nimport { run, type Logger } from '@vzn/vx'\n\nlet root: string\nbeforeEach(async () => {\n root = await mkdtemp(path.join(os.tmpdir(), 'vx-plugin-executor-'))\n await writeFile(path.join(root, 'package.json'), JSON.stringify({ name: 'ws', private: true }))\n await writeFile(path.join(root, 'pnpm-workspace.yaml'), 'packages:\\n - \"packages/*\"\\n')\n Bun.spawnSync({ cmd: ['git', 'init', '-q'], cwd: root })\n})\nafterEach(async () => {\n await rm(root, { recursive: true, force: true })\n})\n\n/** A package `name` whose `vx.config.mjs` is `config`. */\nasync function pkg(\n name: string,\n config: string,\n scripts?: Record<string, string>,\n): Promise<string> {\n const dir = path.join(root, 'packages', name)\n await mkdir(dir, { recursive: true })\n await writeFile(\n path.join(dir, 'package.json'),\n JSON.stringify({ name, version: '1.0.0', ...(scripts ? { scripts } : {}) }),\n )\n await writeFile(path.join(dir, 'vx.config.mjs'), config)\n return dir\n}\n\n/** A `vx.workspace.mjs` declaring `call`, e.g. `shellExecutor()`, from the plugin file beside this test. */\nasync function workspace(call: string): Promise<void> {\n const from = JSON.stringify(path.join(import.meta.dir, 'executor.ts'))\n await writeFile(\n path.join(root, 'vx.workspace.mjs'),\n `import { shellExecutor } from ${from}\\nexport default { plugins: [${call}] }\\n`,\n )\n}\n\nfunction log(order: string[] = []): Logger {\n return {\n status() {},\n taskStart(node) {\n order.push(`start ${node.id}`)\n },\n taskStdout() {},\n taskStderr() {},\n taskComplete(node) {\n order.push(`end ${node.id}`)\n },\n }\n}\n\nit('runs the command and says where; stops it on exec.timeout', async () => {\n const dir = await pkg(\n 'a',\n `export default { tasks: {\n hello: { exec: { command: 'echo hi > out.txt' } },\n slow: { exec: { command: 'exec sleep 30', timeout: 300 } },\n } }`,\n )\n await workspace('shellExecutor()')\n const ok = await run({ cwd: root, tasks: ['hello'], log: log(), handleSignals: false })\n expect(ok.outcomes.map((o) => [o.node.id, o.status, o.where])).toEqual([\n ['a#hello', 'success', 'shell'],\n ])\n expect(await Bun.file(path.join(dir, 'out.txt')).text()).toBe('hi\\n')\n const started = Date.now()\n const slow = await run({ cwd: root, tasks: ['slow'], log: log(), handleSignals: false })\n expect(slow.outcomes.map((o) => [o.node.id, o.status, o.timedOut === true])).toEqual([\n ['a#slow', 'failed', true],\n ])\n expect(Date.now() - started).toBeLessThan(10_000)\n}, 20_000)\n\nit(\"runs the command through this machine's sh, not a project's node_modules/.bin sh\", async () => {\n const dir = await pkg(\n 'a',\n `export default { tasks: { hello: { exec: { command: 'echo real' } } } }`,\n )\n await mkdir(path.join(dir, 'node_modules', '.bin'), { recursive: true })\n await writeFile(path.join(dir, 'node_modules', '.bin', 'sh'), '#!/bin/sh\\necho planted\\n', {\n mode: 0o755,\n })\n await workspace('shellExecutor()')\n const out: string[] = []\n const r = await run({\n cwd: root,\n tasks: ['hello'],\n log: { ...log(), taskStdout: (_n, chunk) => void out.push(chunk) },\n handleSignals: false,\n })\n expect([r.outcomes.map((o) => o.status), out.join('')]).toEqual([['success'], 'real\\n'])\n}, 20_000)\n",
|
|
23
|
+
"// `executor`: where a task's command runs. This one runs it here, in a\n// shell of its own, and stops the shell's whole process group when core\n// aborts the request (a stop, or `exec.timeout`): core cannot reach a\n// process an executor spawned.\nimport { definePlugin, withForwardArgs, type TaskExecutor, type VxPlugin } from '@vzn/vx'\n\nexport function shellExecutor(): VxPlugin {\n const executor: TaskExecutor = {\n name: 'shell',\n async execute(req) {\n const started = Date.now()\n // vx's own `sh`: a bare name is looked up on `req.env`'s PATH, which\n // leads with the project's node_modules/.bin, where a dependency's\n // `sh` would run every command.\n const sh = Bun.which('sh') ?? '/bin/sh'\n // The args after `--`, quoted and placed as the local executor places\n // them (before a trailing comment, never on a heredoc's terminator).\n const command = withForwardArgs(req.command, req.forwardArgs)\n const child = Bun.spawn([sh, '-c', command], {\n cwd: req.cwd,\n env: req.env,\n stdout: 'pipe',\n stderr: 'pipe',\n detached: true,\n })\n const stop = (): void => {\n try {\n process.kill(-child.pid, 'SIGTERM')\n } catch {\n // already gone\n }\n }\n req.signal?.addEventListener('abort', stop, { once: true })\n const drain = async (stream: ReadableStream<Uint8Array>, on: (s: string) => void) => {\n const decoder = new TextDecoder()\n let all = ''\n for await (const chunk of stream) {\n const text = decoder.decode(chunk, { stream: true })\n all += text\n on(text)\n }\n return all\n }\n const [stdout, stderr, exitCode] = await Promise.all([\n drain(child.stdout, req.onStdout),\n drain(child.stderr, req.onStderr),\n child.exited,\n ])\n req.signal?.removeEventListener('abort', stop)\n return {\n exitCode,\n durationMs: Date.now() - started,\n stdout,\n stderr,\n violations: [],\n where: 'shell',\n }\n },\n }\n return definePlugin(import.meta, { executor: () => executor })\n}\n",
|
|
24
|
+
test: "// A test for the example `executor` plugin beside it: a throwaway workspace\n// declares the plugin and vx's own run() drives it. `bun test` runs it.\nimport { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'\nimport os from 'node:os'\nimport path from 'node:path'\nimport { afterEach, beforeEach, expect, it } from 'bun:test'\nimport { run, type Logger } from '@vzn/vx'\n\nlet root: string\nbeforeEach(async () => {\n root = await mkdtemp(path.join(os.tmpdir(), 'vx-plugin-executor-'))\n await writeFile(path.join(root, 'package.json'), JSON.stringify({ name: 'ws', private: true }))\n await writeFile(path.join(root, 'pnpm-workspace.yaml'), 'packages:\\n - \"packages/*\"\\n')\n Bun.spawnSync({ cmd: ['git', 'init', '-q'], cwd: root })\n})\nafterEach(async () => {\n await rm(root, { recursive: true, force: true })\n})\n\n/** A package `name` whose `vx.config.mjs` is `config`. */\nasync function pkg(\n name: string,\n config: string,\n scripts?: Record<string, string>,\n): Promise<string> {\n const dir = path.join(root, 'packages', name)\n await mkdir(dir, { recursive: true })\n await writeFile(\n path.join(dir, 'package.json'),\n JSON.stringify({ name, version: '1.0.0', ...(scripts ? { scripts } : {}) }),\n )\n await writeFile(path.join(dir, 'vx.config.mjs'), config)\n return dir\n}\n\n/** A `vx.workspace.mjs` declaring `call`, e.g. `shellExecutor()`, from the plugin file beside this test. */\nasync function workspace(call: string): Promise<void> {\n const from = JSON.stringify(path.join(import.meta.dir, 'executor.ts'))\n await writeFile(\n path.join(root, 'vx.workspace.mjs'),\n `import { shellExecutor } from ${from}\\nexport default { plugins: [${call}] }\\n`,\n )\n}\n\nfunction log(order: string[] = []): Logger {\n return {\n status() {},\n taskStart(node) {\n order.push(`start ${node.id}`)\n },\n taskStdout() {},\n taskStderr() {},\n taskComplete(node) {\n order.push(`end ${node.id}`)\n },\n }\n}\n\nit('runs the command and says where; stops it on exec.timeout', async () => {\n const dir = await pkg(\n 'a',\n `export default { tasks: {\n hello: { exec: { command: 'echo hi > out.txt' } },\n slow: { exec: { command: 'exec sleep 30', timeout: 300 } },\n } }`,\n )\n await workspace('shellExecutor()')\n const ok = await run({ cwd: root, tasks: ['hello'], log: log(), handleSignals: false })\n expect(ok.outcomes.map((o) => [o.node.id, o.status, o.where])).toEqual([\n ['a#hello', 'success', 'shell'],\n ])\n expect(await Bun.file(path.join(dir, 'out.txt')).text()).toBe('hi\\n')\n const started = Date.now()\n const slow = await run({ cwd: root, tasks: ['slow'], log: log(), handleSignals: false })\n expect(slow.outcomes.map((o) => [o.node.id, o.status, o.timedOut === true])).toEqual([\n ['a#slow', 'failed', true],\n ])\n expect(Date.now() - started).toBeLessThan(10_000)\n}, 20_000)\n\nit(\"runs the command through this machine's sh, not a project's node_modules/.bin sh\", async () => {\n const dir = await pkg(\n 'a',\n `export default { tasks: { hello: { exec: { command: 'echo real' } } } }`,\n )\n await mkdir(path.join(dir, 'node_modules', '.bin'), { recursive: true })\n await writeFile(path.join(dir, 'node_modules', '.bin', 'sh'), '#!/bin/sh\\necho planted\\n', {\n mode: 0o755,\n })\n await workspace('shellExecutor()')\n const out: string[] = []\n const r = await run({\n cwd: root,\n tasks: ['hello'],\n log: { ...log(), taskStdout: (_n, chunk) => void out.push(chunk) },\n handleSignals: false,\n })\n expect([r.outcomes.map((o) => o.status), out.join('')]).toEqual([['success'], 'real\\n'])\n}, 20_000)\n\nit('puts the args after -- on the command as the local executor does', async () => {\n await pkg(\n 'a',\n `export default { tasks: { say: { exec: { command: \"printf '<%s>' # each arg\" } } } }`,\n )\n await workspace('shellExecutor()')\n const out: string[] = []\n const r = await run({\n cwd: root,\n tasks: ['say'],\n forwardArgs: [\"it's\", 'a b'],\n log: { ...log(), taskStdout: (_n, chunk) => void out.push(chunk) },\n handleSignals: false,\n })\n expect([r.outcomes.map((o) => o.status), out.join('')]).toEqual([['success'], \"<it's><a b>\"])\n}, 20_000)\n",
|
|
25
25
|
},
|
|
26
26
|
graph: {
|
|
27
27
|
plugin:
|
package/src/cli/run.ts
CHANGED
|
@@ -3,7 +3,6 @@ import path from 'node:path'
|
|
|
3
3
|
import { isatty } from 'node:tty'
|
|
4
4
|
import { translateForeign } from './foreign-flags.js'
|
|
5
5
|
import { flagHint, seeHelp } from './help.js'
|
|
6
|
-
import { defaultAffectedBase, findWorkspaceRoot } from '../workspace/index.js'
|
|
7
6
|
import {
|
|
8
7
|
planRun,
|
|
9
8
|
formatRunReportMarkdown,
|
|
@@ -15,10 +14,9 @@ import {
|
|
|
15
14
|
} from '../orchestrator/index.js'
|
|
16
15
|
import type { ContinueMode } from '../graph/index.js'
|
|
17
16
|
import { type CachePolicy, FULL_CACHE_POLICY, parseCachePolicy } from '../cache/index.js'
|
|
18
|
-
import { findCwdSelection, pickTask, resolveFilters } from './select.js'
|
|
17
|
+
import { affectedFilterFor, findCwdSelection, pickTask, resolveFilters } from './select.js'
|
|
19
18
|
import { nxTargetHint, taskNamesHere } from './task-verb.js'
|
|
20
|
-
import {
|
|
21
|
-
import { MAX_TIMEOUT_MS, isUserError, parseDecimalInt, machineParallelism } from '../util/index.js'
|
|
19
|
+
import { MAX_TIMEOUT_MS, parseDecimalInt, machineParallelism } from '../util/index.js'
|
|
22
20
|
import { formatGraphDot, formatPlanJson, formatPlanText } from './plan-format.js'
|
|
23
21
|
|
|
24
22
|
export interface RunArgs {
|
|
@@ -113,7 +111,7 @@ export function parseConcurrency(v: string, cpus = machineParallelism()): number
|
|
|
113
111
|
}
|
|
114
112
|
|
|
115
113
|
/** `verb` is the one being parsed for: `vx watch` reads `vx run`'s flags, and
|
|
116
|
-
* its
|
|
114
|
+
* its flag hints named `vx run` (M-58). The caller appends the help pointer. */
|
|
117
115
|
export function parseRunArgs(rawArgs: readonly string[], verb: 'run' | 'watch' = 'run'): RunArgs {
|
|
118
116
|
const out: RunArgs = {
|
|
119
117
|
tasks: [],
|
|
@@ -185,7 +183,7 @@ export function parseRunArgs(rawArgs: readonly string[], verb: 'run' | 'watch' =
|
|
|
185
183
|
} else if (RETIRED_EXCLUDE_DEPENDENCIES.test(a ?? '')) {
|
|
186
184
|
return {
|
|
187
185
|
...out,
|
|
188
|
-
error: `unknown flag: ${a} (the flag is --exclude-dependencies)
|
|
186
|
+
error: `unknown flag: ${a} (the flag is --exclude-dependencies)`,
|
|
189
187
|
}
|
|
190
188
|
} else if (a === '--exclude-dependencies') {
|
|
191
189
|
out.excludeDependencies = 'all'
|
|
@@ -215,7 +213,7 @@ export function parseRunArgs(rawArgs: readonly string[], verb: 'run' | 'watch' =
|
|
|
215
213
|
return { ...out, error: `--retry requires a value (a non-negative integer)` }
|
|
216
214
|
const n = parseDecimalInt(v)
|
|
217
215
|
if (n === null) {
|
|
218
|
-
return { ...out, error: `--retry must be a non-negative integer
|
|
216
|
+
return { ...out, error: `--retry must be a non-negative integer (got ${v})` }
|
|
219
217
|
}
|
|
220
218
|
out.retries = n
|
|
221
219
|
} else if (a === '--timeout' || a?.startsWith('--timeout=')) {
|
|
@@ -234,19 +232,31 @@ export function parseRunArgs(rawArgs: readonly string[], verb: 'run' | 'watch' =
|
|
|
234
232
|
}
|
|
235
233
|
}
|
|
236
234
|
if (n === null || n <= 0) {
|
|
237
|
-
return { ...out, error: `--timeout must be a positive integer
|
|
235
|
+
return { ...out, error: `--timeout must be a positive integer in ms (got ${v})` }
|
|
238
236
|
}
|
|
239
237
|
out.timeout = n
|
|
240
238
|
} else if (a === '--output-logs' || a?.startsWith('--output-logs=')) {
|
|
241
239
|
const v = a === '--output-logs' ? before[++i] : a.slice('--output-logs='.length)
|
|
240
|
+
if (v === undefined || v === '') {
|
|
241
|
+
return {
|
|
242
|
+
...out,
|
|
243
|
+
error: `--output-logs requires a value (full, errors-only, hash-only, or none)`,
|
|
244
|
+
}
|
|
245
|
+
}
|
|
242
246
|
if (v !== 'full' && v !== 'errors-only' && v !== 'none' && v !== 'hash-only') {
|
|
243
|
-
return {
|
|
247
|
+
return {
|
|
248
|
+
...out,
|
|
249
|
+
error: `--output-logs must be full, errors-only, hash-only, or none (got ${v})`,
|
|
250
|
+
}
|
|
244
251
|
}
|
|
245
252
|
out.outputLogs = v
|
|
246
253
|
} else if (a === '--download' || a?.startsWith('--download=')) {
|
|
247
254
|
const v = a === '--download' ? before[++i] : a.slice('--download='.length)
|
|
255
|
+
if (v === undefined || v === '') {
|
|
256
|
+
return { ...out, error: `--download requires a value (all, toplevel, or none)` }
|
|
257
|
+
}
|
|
248
258
|
if (v !== 'all' && v !== 'toplevel' && v !== 'none') {
|
|
249
|
-
return { ...out, error: `--download must be all, toplevel, or none` }
|
|
259
|
+
return { ...out, error: `--download must be all, toplevel, or none (got ${v})` }
|
|
250
260
|
}
|
|
251
261
|
out.download = v
|
|
252
262
|
} else if (a === '--cache-dir' || a?.startsWith('--cache-dir=')) {
|
|
@@ -301,8 +311,11 @@ export function parseRunArgs(rawArgs: readonly string[], verb: 'run' | 'watch' =
|
|
|
301
311
|
out.continueMode = 'always'
|
|
302
312
|
} else if (a?.startsWith('--continue=')) {
|
|
303
313
|
const v = a.slice('--continue='.length)
|
|
314
|
+
if (v === '') {
|
|
315
|
+
return { ...out, error: `--continue= requires a mode (never, deps-ok, or always)` }
|
|
316
|
+
}
|
|
304
317
|
if (v !== 'never' && v !== 'deps-ok' && v !== 'always') {
|
|
305
|
-
return { ...out, error: `--continue must be never, deps-ok, or always` }
|
|
318
|
+
return { ...out, error: `--continue must be never, deps-ok, or always (got ${v})` }
|
|
306
319
|
}
|
|
307
320
|
out.continueMode = v
|
|
308
321
|
} else if (a === '--verbosity' || a?.startsWith('--verbosity=')) {
|
|
@@ -366,7 +379,7 @@ export function parseRunArgs(rawArgs: readonly string[], verb: 'run' | 'watch' =
|
|
|
366
379
|
}
|
|
367
380
|
out.report = fmt
|
|
368
381
|
} else if (a !== undefined && a.startsWith('-')) {
|
|
369
|
-
return { ...out, error: `unknown flag: ${a}${flagHint(verb, a)}
|
|
382
|
+
return { ...out, error: `unknown flag: ${a}${flagHint(verb, a)}` }
|
|
370
383
|
} else if (a !== undefined) {
|
|
371
384
|
out.tasks.push(a)
|
|
372
385
|
}
|
|
@@ -446,20 +459,9 @@ async function scopeFilters(
|
|
|
446
459
|
const filterStrings = [...parsed.filters]
|
|
447
460
|
let affectedFilter: string | undefined
|
|
448
461
|
if (parsed.affected !== undefined) {
|
|
449
|
-
const
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
// nx.json's `defaultBase` or `TURBO_SCM_BASE` — comes before the guess.
|
|
453
|
-
if (base === '') base = (await loadCliWorkspace(root)).workspaceConfig?.affectedBase ?? ''
|
|
454
|
-
if (base === '') {
|
|
455
|
-
try {
|
|
456
|
-
base = await defaultAffectedBase(root)
|
|
457
|
-
} catch (err) {
|
|
458
|
-
if (!isUserError(err)) throw err
|
|
459
|
-
return { error: err.message }
|
|
460
|
-
}
|
|
461
|
-
}
|
|
462
|
-
affectedFilter = `...[${base}]`
|
|
462
|
+
const f = await affectedFilterFor(cwd, parsed.affected)
|
|
463
|
+
if (typeof f === 'object') return f
|
|
464
|
+
affectedFilter = f
|
|
463
465
|
filterStrings.unshift(affectedFilter)
|
|
464
466
|
}
|
|
465
467
|
return { filterStrings, ...(affectedFilter !== undefined ? { affectedFilter } : {}) }
|
|
@@ -612,7 +614,7 @@ const TASKS_SHOWN = 12
|
|
|
612
614
|
export async function runCmd(args: readonly string[]): Promise<number> {
|
|
613
615
|
const parsed = parseRunArgs(args)
|
|
614
616
|
if (parsed.error) {
|
|
615
|
-
process.stderr.write(`vx run: ${parsed.error}\n`)
|
|
617
|
+
process.stderr.write(`vx run: ${parsed.error}${seeHelp('run')}\n`)
|
|
616
618
|
return 1
|
|
617
619
|
}
|
|
618
620
|
|
package/src/cli/select.ts
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
refIsHead,
|
|
13
13
|
applyFilters,
|
|
14
14
|
buildPackageGraph,
|
|
15
|
+
defaultAffectedBase,
|
|
15
16
|
findWorkspaceRoot,
|
|
16
17
|
type LoadReads,
|
|
17
18
|
type FingerprintClaims,
|
|
@@ -26,7 +27,7 @@ import type { ProjectConfig } from '../config.js'
|
|
|
26
27
|
import type { ProjectEntry } from '../workspace/index.js'
|
|
27
28
|
import { parseDependencySpec } from '../graph/index.js'
|
|
28
29
|
import { declaresInput } from '../cache/index.js'
|
|
29
|
-
import { listed, maskedLine, nearest, UserError } from '../util/index.js'
|
|
30
|
+
import { isUserError, listed, maskedLine, nearest, UserError } from '../util/index.js'
|
|
30
31
|
import {
|
|
31
32
|
claimedAffected,
|
|
32
33
|
fingerprintClaims,
|
|
@@ -228,6 +229,30 @@ export function taskEdgesFrom(staged: ReadonlyMap<string, ProjectEntry>): Map<st
|
|
|
228
229
|
return out
|
|
229
230
|
}
|
|
230
231
|
|
|
232
|
+
/**
|
|
233
|
+
* The filter `--affected[=<base>]` stands for: `...[<base>]`, the changed
|
|
234
|
+
* projects and their dependents. An empty base is the workspace's
|
|
235
|
+
* `affectedBase` (or a plugin's `config` stage, from nx.json's
|
|
236
|
+
* `defaultBase` or `TURBO_SCM_BASE`), then the guess.
|
|
237
|
+
*/
|
|
238
|
+
export async function affectedFilterFor(
|
|
239
|
+
cwd: string,
|
|
240
|
+
affected: string,
|
|
241
|
+
): Promise<string | { error: string }> {
|
|
242
|
+
const root = await findWorkspaceRoot(cwd)
|
|
243
|
+
let base = affected
|
|
244
|
+
if (base === '') base = (await loadCliWorkspace(root)).workspaceConfig?.affectedBase ?? ''
|
|
245
|
+
if (base === '') {
|
|
246
|
+
try {
|
|
247
|
+
base = await defaultAffectedBase(root)
|
|
248
|
+
} catch (err) {
|
|
249
|
+
if (!isUserError(err)) throw err
|
|
250
|
+
return { error: err.message }
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
return `...[${base}]`
|
|
254
|
+
}
|
|
255
|
+
|
|
231
256
|
export async function resolveFilters(
|
|
232
257
|
cwd: string,
|
|
233
258
|
raw: string[],
|
|
@@ -471,7 +496,9 @@ export async function pickTask(
|
|
|
471
496
|
)
|
|
472
497
|
return null
|
|
473
498
|
}
|
|
474
|
-
|
|
499
|
+
// The menu is a conversation with the terminal, not the run's output: on
|
|
500
|
+
// stdout, `vx run > out.txt` put it in the file and asked a blank screen.
|
|
501
|
+
const out = io.output ?? process.stderr
|
|
475
502
|
const numW = String(entries.length).length
|
|
476
503
|
const idW = Math.max(...entries.map((e) => `${e.project}#${e.task}`.length))
|
|
477
504
|
out.write('Tasks:\n')
|
|
@@ -486,7 +513,7 @@ export async function pickTask(
|
|
|
486
513
|
const readline = await import('node:readline/promises')
|
|
487
514
|
const rl = readline.createInterface({
|
|
488
515
|
input: io.input ?? process.stdin,
|
|
489
|
-
output:
|
|
516
|
+
output: out,
|
|
490
517
|
})
|
|
491
518
|
// On a terminal readline takes Ctrl-C and Ctrl-D itself, raw, and
|
|
492
519
|
// rejects the pending question with an AbortError — which reached the
|
package/src/cli/show.ts
CHANGED
|
@@ -12,6 +12,7 @@ import { declaredTask } from '../graph/index.js'
|
|
|
12
12
|
import { isDefaultBuild } from '../orchestrator/index.js'
|
|
13
13
|
import { flagHint, formatValue, seeHelp } from './help.js'
|
|
14
14
|
import { listed, nearMatches, relPosix, secretMask, UserError } from '../util/index.js'
|
|
15
|
+
import { affectedFilterFor, resolveFilters } from './select.js'
|
|
15
16
|
import { discoverCliProjects, loadCliProjects } from './workspace-config.js'
|
|
16
17
|
import {
|
|
17
18
|
findWorkspaceRoot,
|
|
@@ -24,16 +25,27 @@ import {
|
|
|
24
25
|
interface ShowArgs {
|
|
25
26
|
target?: string
|
|
26
27
|
format: 'pretty' | 'json'
|
|
28
|
+
/** `--filter`, as `vx run` reads it: which projects the list shows. */
|
|
29
|
+
filters: string[]
|
|
30
|
+
/** `--affected[=<base>]`: `''` is the default base. */
|
|
31
|
+
affected?: string
|
|
27
32
|
error?: string
|
|
28
33
|
}
|
|
29
34
|
|
|
30
35
|
export function parseShowArgs(args: readonly string[]): ShowArgs {
|
|
31
|
-
const out: ShowArgs = { format: 'pretty' }
|
|
36
|
+
const out: ShowArgs = { format: 'pretty', filters: [] }
|
|
32
37
|
for (let i = 0; i < args.length; i++) {
|
|
33
38
|
const a = args[i]!
|
|
34
39
|
let format: string | undefined
|
|
35
40
|
if (a === '--format') format = args[++i] ?? ''
|
|
36
41
|
else if (a.startsWith('--format=')) format = a.slice('--format='.length)
|
|
42
|
+
else if (a === '--filter') {
|
|
43
|
+
const v = args[++i]
|
|
44
|
+
if (v === undefined) return { ...out, error: '--filter requires a value (a pattern)' }
|
|
45
|
+
out.filters.push(v)
|
|
46
|
+
} else if (a.startsWith('--filter=')) out.filters.push(a.slice('--filter='.length))
|
|
47
|
+
else if (a === '--affected') out.affected = ''
|
|
48
|
+
else if (a.startsWith('--affected=')) out.affected = a.slice('--affected='.length)
|
|
37
49
|
else if (a.startsWith('-'))
|
|
38
50
|
return { ...out, error: `unknown flag: ${a}${flagHint('show', a)}${seeHelp('show')}` }
|
|
39
51
|
else if (out.target === 'project')
|
|
@@ -103,10 +115,35 @@ export async function showCmd(args: readonly string[]): Promise<number> {
|
|
|
103
115
|
const bareTask = projectName !== undefined && taskName === undefined && !byName.has(projectName)
|
|
104
116
|
const scope = projectName === undefined || bareTask ? 'all' : [projectName]
|
|
105
117
|
|
|
118
|
+
// `turbo ls --filter` / `nx show projects --affected`: the list, and a
|
|
119
|
+
// task's projects, narrowed by the selection `vx run` would make.
|
|
120
|
+
let selected: Set<string> | undefined
|
|
121
|
+
if (parsed.filters.length > 0 || parsed.affected !== undefined) {
|
|
122
|
+
if (scope !== 'all') {
|
|
123
|
+
throw new UserError(
|
|
124
|
+
'vx show: --filter and --affected narrow a list: `vx show` or `vx show <task>`',
|
|
125
|
+
)
|
|
126
|
+
}
|
|
127
|
+
const raw = [...parsed.filters]
|
|
128
|
+
let affected: string | undefined
|
|
129
|
+
if (parsed.affected !== undefined) {
|
|
130
|
+
const f = await affectedFilterFor(process.cwd(), parsed.affected)
|
|
131
|
+
if (typeof f === 'object') throw new UserError(`vx show: ${f.error}`)
|
|
132
|
+
affected = f
|
|
133
|
+
raw.unshift(f)
|
|
134
|
+
}
|
|
135
|
+
const r = await resolveFilters(process.cwd(), raw, { noCreate: true }, affected)
|
|
136
|
+
if ('error' in r) throw new UserError(`vx show: ${r.error}`)
|
|
137
|
+
if ('empty' in r) process.stderr.write(`vx show: ${r.empty}\n`)
|
|
138
|
+
selected = new Set('empty' in r ? [] : r.names)
|
|
139
|
+
}
|
|
140
|
+
const inSelection = (name: string): boolean => selected === undefined || selected.has(name)
|
|
141
|
+
|
|
106
142
|
const projects = await loadCliProjects(root, metas, scope, { noCreate: true, closure: true })
|
|
107
143
|
|
|
108
144
|
if (parsed.target === undefined) {
|
|
109
|
-
|
|
145
|
+
const shown = metas.filter((m) => inSelection(m.name))
|
|
146
|
+
process.stdout.write(renderList(root, shown, projects, parsed.format))
|
|
110
147
|
return 0
|
|
111
148
|
}
|
|
112
149
|
|
|
@@ -124,7 +161,8 @@ export async function showCmd(args: readonly string[]): Promise<number> {
|
|
|
124
161
|
`vx show: unknown project or task: "${projectName}"${nx}${suggest(projectName!, [...byName.keys(), ...names], '', 'projects and tasks')}`,
|
|
125
162
|
)
|
|
126
163
|
}
|
|
127
|
-
|
|
164
|
+
const shown = declaring.filter((p) => inSelection(p.name))
|
|
165
|
+
process.stdout.write(renderTaskAcross(root, shown, projectName!, parsed.format))
|
|
128
166
|
return 0
|
|
129
167
|
}
|
|
130
168
|
|
|
@@ -239,6 +277,7 @@ function renderList(
|
|
|
239
277
|
2,
|
|
240
278
|
)}\n`
|
|
241
279
|
}
|
|
280
|
+
if (rows.length === 0) return ''
|
|
242
281
|
const nameW = Math.max(...rows.map((r) => r.name.length))
|
|
243
282
|
const dirW = Math.max(...rows.map((r) => r.dir.length))
|
|
244
283
|
const lines = rows.map((r) => {
|
package/src/cli/watch-filter.ts
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
isLiteralPattern,
|
|
9
9
|
normalizeGlob,
|
|
10
10
|
outputMatcher,
|
|
11
|
+
realPath,
|
|
11
12
|
staticPrefix,
|
|
12
13
|
taskGlob,
|
|
13
14
|
} from '../util/index.js'
|
|
@@ -54,7 +55,9 @@ export function makeWatchIgnore(
|
|
|
54
55
|
outputs: ReadonlyMap<string, readonly string[]> = new Map(),
|
|
55
56
|
inputs: ReadonlyMap<string, ReadonlyArray<readonly string[]>> = new Map(),
|
|
56
57
|
): (base: string, filename: string) => boolean {
|
|
57
|
-
|
|
58
|
+
// Watchers report under the real root; a cache dir named through a link
|
|
59
|
+
// (`VX_CACHE_DIR`, macOS `/var` -> `/private/var`) is matched as its target.
|
|
60
|
+
const cacheAbs = realThrough(path.resolve(cacheDir))
|
|
58
61
|
// A task's own outputs are not edits: without this every cycle that
|
|
59
62
|
// writes `dist/` (or `out.txt`) re-runs once more, reporting
|
|
60
63
|
// "up-to-date" for the trouble. Matched under the directory the globs
|
|
@@ -132,6 +135,16 @@ export function makeWatchIgnore(
|
|
|
132
135
|
}
|
|
133
136
|
}
|
|
134
137
|
|
|
138
|
+
/** `p` with its longest existing prefix realpath'd: the cache dir may not exist yet. */
|
|
139
|
+
function realThrough(p: string): string {
|
|
140
|
+
try {
|
|
141
|
+
return realPath(p)
|
|
142
|
+
} catch {
|
|
143
|
+
const up = path.dirname(p)
|
|
144
|
+
return up === p ? p : path.join(realThrough(up), path.basename(p))
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
135
148
|
/**
|
|
136
149
|
* The literal directory a glob's matches live under (`''` when the glob
|
|
137
150
|
* starts with a pattern, or negates). A literal entry is a file or its
|
package/src/cli/watch-fs.ts
CHANGED
|
@@ -77,10 +77,10 @@ export function pollWatcher(
|
|
|
77
77
|
*/
|
|
78
78
|
skipDir: (rel: string) => boolean = (rel) => POLL_SKIP.has(path.basename(rel)),
|
|
79
79
|
): WatchHandle {
|
|
80
|
-
let previous = new Map<string,
|
|
80
|
+
let previous = new Map<string, string>()
|
|
81
81
|
let first = true
|
|
82
82
|
const scan = (): void => {
|
|
83
|
-
const current = new Map<string,
|
|
83
|
+
const current = new Map<string, string>()
|
|
84
84
|
const walk = (abs: string, rel: string): void => {
|
|
85
85
|
let entries: fs.Dirent[]
|
|
86
86
|
try {
|
|
@@ -95,15 +95,23 @@ export function pollWatcher(
|
|
|
95
95
|
if (recursive && !skipDir(childRel)) walk(path.join(abs, e.name), childRel)
|
|
96
96
|
continue
|
|
97
97
|
}
|
|
98
|
-
|
|
98
|
+
// A link is an input as its target string (watch-judge.ts), so that
|
|
99
|
+
// string is sampled, never the target's times: a retarget inside
|
|
100
|
+
// one clock tick carries the same times.
|
|
101
|
+
const link = e.isSymbolicLink()
|
|
102
|
+
if (!e.isFile() && !link) continue
|
|
99
103
|
try {
|
|
100
104
|
// The later of the two clocks, as `modifiedBefore` reads them: a
|
|
101
105
|
// replacement that carries the old file's mtime (`cp -p`, `rsync
|
|
102
106
|
// -a`, `mv` of a file stamped the same) moved nothing under mtime
|
|
103
107
|
// alone, and the poller never ran it where the native watcher did.
|
|
104
108
|
// A rename or a write moves ctime, and no process can set it.
|
|
105
|
-
const
|
|
106
|
-
current.set(childRel,
|
|
109
|
+
const p = path.join(abs, e.name)
|
|
110
|
+
if (link) current.set(childRel, `link:${fs.readlinkSync(p)}`)
|
|
111
|
+
else {
|
|
112
|
+
const st = fs.statSync(p)
|
|
113
|
+
current.set(childRel, String(Math.max(st.mtimeMs, st.ctimeMs)))
|
|
114
|
+
}
|
|
107
115
|
} catch {
|
|
108
116
|
// raced with a delete; the next scan settles it
|
|
109
117
|
}
|
|
@@ -111,8 +119,8 @@ export function pollWatcher(
|
|
|
111
119
|
}
|
|
112
120
|
walk(dir, '')
|
|
113
121
|
if (!first) {
|
|
114
|
-
for (const [rel,
|
|
115
|
-
if (previous.get(rel) !==
|
|
122
|
+
for (const [rel, stamp] of current) {
|
|
123
|
+
if (previous.get(rel) !== stamp) onEvent(rel)
|
|
116
124
|
}
|
|
117
125
|
for (const rel of previous.keys()) {
|
|
118
126
|
if (!current.has(rel)) onEvent(rel)
|
package/src/cli/watch-judge.ts
CHANGED
|
@@ -77,7 +77,7 @@ export class ChangeJudge {
|
|
|
77
77
|
// are judged one window after the run ends, all together — an edit made
|
|
78
78
|
// meanwhile still differs from what the loop last saw and re-runs.
|
|
79
79
|
readonly pending = new Map<string, string>()
|
|
80
|
-
/** The last cycle's
|
|
80
|
+
/** The last cycle's window, its start on the mtime clock (`fsClockNow`); a write inside it is the run's own. */
|
|
81
81
|
lastCycle: { start: number; end: number } | undefined
|
|
82
82
|
// Declared outputs are ignored by PATH above. A task with no `cache`
|
|
83
83
|
// block declares none and still writes into its project, and the
|
package/src/cli/watch.ts
CHANGED
|
@@ -86,7 +86,7 @@ export function watchRefusal(parsed: RunArgs): string | null {
|
|
|
86
86
|
export async function watchCmd(args: readonly string[]): Promise<number> {
|
|
87
87
|
const parsed = parseRunArgs(args, 'watch')
|
|
88
88
|
if (parsed.error) {
|
|
89
|
-
process.stderr.write(`vx watch: ${parsed.error}\n`)
|
|
89
|
+
process.stderr.write(`vx watch: ${parsed.error}${seeHelp('watch')}\n`)
|
|
90
90
|
return 1
|
|
91
91
|
}
|
|
92
92
|
|
|
@@ -137,10 +137,13 @@ export async function watchCmd(args: readonly string[]): Promise<number> {
|
|
|
137
137
|
// worth; every cycle after an edit must load and list live.
|
|
138
138
|
delete opts.staged
|
|
139
139
|
delete opts.discovered
|
|
140
|
-
process.once('SIGINT', () =>
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
140
|
+
process.once('SIGINT', () => stop.abort('SIGINT'))
|
|
141
|
+
// Said once the loop is down, the last line: written as the signal
|
|
142
|
+
// landed, the aborted cycle's summary printed below it.
|
|
143
|
+
const stopped = (): number => {
|
|
144
|
+
if (stop.signal.reason === 'SIGINT') process.stdout.write('\nvx watch: stopped\n')
|
|
145
|
+
return 0
|
|
146
|
+
}
|
|
144
147
|
process.once('SIGTERM', () => stop.abort('SIGTERM'))
|
|
145
148
|
// A task runs in its own session (exec/kill-tree.ts): the terminal
|
|
146
149
|
// closing reaches the loop alone, and the loop passes it on.
|
|
@@ -206,7 +209,7 @@ export async function watchCmd(args: readonly string[]): Promise<number> {
|
|
|
206
209
|
}
|
|
207
210
|
if (stop.signal.aborted) {
|
|
208
211
|
await held?.stop(forwardedSignal(stop.signal.reason))
|
|
209
|
-
return
|
|
212
|
+
return stopped()
|
|
210
213
|
}
|
|
211
214
|
if (refusedToStart) return 1
|
|
212
215
|
// After the initial run, so a `pkg#task` naming no project gets the
|
|
@@ -234,7 +237,7 @@ export async function watchCmd(args: readonly string[]): Promise<number> {
|
|
|
234
237
|
...fingerprintClaims(plugins).keys(),
|
|
235
238
|
...(opts.frozen === true ? [LOCKFILE_NAME] : []),
|
|
236
239
|
])
|
|
237
|
-
|
|
240
|
+
await runWatchLoop({
|
|
238
241
|
opts,
|
|
239
242
|
held,
|
|
240
243
|
stop: stop.signal,
|
|
@@ -285,6 +288,7 @@ export async function watchCmd(args: readonly string[]): Promise<number> {
|
|
|
285
288
|
// The RESOLVED cache dir, not the `.vx` literal — see `makeWatchIgnore`.
|
|
286
289
|
cacheDir: opts.cacheDir ?? ws.cacheDir,
|
|
287
290
|
})
|
|
291
|
+
return stopped()
|
|
288
292
|
}
|
|
289
293
|
|
|
290
294
|
interface WatchLoopArgs {
|
|
@@ -345,7 +349,7 @@ interface Rediscovered {
|
|
|
345
349
|
fenceDirs: readonly string[]
|
|
346
350
|
}
|
|
347
351
|
|
|
348
|
-
async function runWatchLoop(args: WatchLoopArgs): Promise<
|
|
352
|
+
async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
|
|
349
353
|
const { opts, stop, workspaceRoot, projects, cacheDir } = args
|
|
350
354
|
// The watched set as of the last cycle: `rearm` replaces these after an
|
|
351
355
|
// event that can change it, and every filter below reads the current one.
|
|
@@ -403,7 +407,10 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<number> {
|
|
|
403
407
|
await held?.stop()
|
|
404
408
|
held = undefined
|
|
405
409
|
restartTimings()
|
|
406
|
-
|
|
410
|
+
// On the mtime clock, as the arm is: from `Date.now()` a write the
|
|
411
|
+
// run made within a tick of it carried an earlier mtime and read as
|
|
412
|
+
// an edit (WD-20). The end needs no stamp: an mtime never leads it.
|
|
413
|
+
const start = fsClockNow(cacheDir)
|
|
407
414
|
const cycle = await runOrchestrator(opts)
|
|
408
415
|
held = cycle.persistent
|
|
409
416
|
if (cycle.refused !== undefined) process.stderr.write(`vx watch: ${cycle.refused}\n`)
|
|
@@ -838,7 +845,7 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<number> {
|
|
|
838
845
|
|
|
839
846
|
process.stdout.write(`\n${watchingLine(projects.length)}; press Ctrl+C to stop\n`)
|
|
840
847
|
|
|
841
|
-
return await new Promise<
|
|
848
|
+
return await new Promise<void>((resolve) => {
|
|
842
849
|
const cleanup = async (): Promise<void> => {
|
|
843
850
|
pool.closeAll()
|
|
844
851
|
if (debounceTimer) clearTimeout(debounceTimer)
|
|
@@ -846,7 +853,7 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<number> {
|
|
|
846
853
|
// it has returned, so the process never exits over a live child.
|
|
847
854
|
await inFlight
|
|
848
855
|
await held?.stop(forwardedSignal(stop.reason))
|
|
849
|
-
resolve(
|
|
856
|
+
resolve()
|
|
850
857
|
}
|
|
851
858
|
if (stop.aborted) {
|
|
852
859
|
void cleanup()
|
package/src/config.ts
CHANGED
|
@@ -510,9 +510,9 @@ export interface CacheInputs {
|
|
|
510
510
|
* project files" (gitignore-aware), or narrow as needed. An empty
|
|
511
511
|
* array means no file inputs at all.
|
|
512
512
|
*
|
|
513
|
-
* Declared outputs and any nested-project
|
|
514
|
-
* automatically — a task cannot invalidate itself,
|
|
515
|
-
* across project boundaries.
|
|
513
|
+
* Declared outputs (both `outputs` fields) and any nested-project
|
|
514
|
+
* files are excluded automatically — a task cannot invalidate itself,
|
|
515
|
+
* and cannot read across project boundaries.
|
|
516
516
|
*/
|
|
517
517
|
files: string[]
|
|
518
518
|
/**
|
|
@@ -525,8 +525,8 @@ export interface CacheInputs {
|
|
|
525
525
|
* NO project-boundary rule applies: these globs may match files
|
|
526
526
|
* inside other projects' directories. Deliberate escape hatch —
|
|
527
527
|
* prefer project-relative `files` declarations where possible.
|
|
528
|
-
* Declared `outputs
|
|
529
|
-
* task cannot invalidate itself).
|
|
528
|
+
* Declared outputs (both `outputs` fields) are excluded
|
|
529
|
+
* automatically (a task cannot invalidate itself).
|
|
530
530
|
*/
|
|
531
531
|
workspaceFiles?: string[]
|
|
532
532
|
/**
|
package/src/exec/env.ts
CHANGED
|
@@ -33,6 +33,11 @@ export const ESSENTIAL_ENV: readonly string[] = [
|
|
|
33
33
|
'NO_COLOR',
|
|
34
34
|
'CI',
|
|
35
35
|
'NODE_OPTIONS',
|
|
36
|
+
// Where corepack and pnpm keep their own binaries, as HOME is: a machine
|
|
37
|
+
// path, never task output. Without them a corepack shim re-downloads its
|
|
38
|
+
// manager (offline or sandboxed: fails) and pnpm loses its global dir.
|
|
39
|
+
'COREPACK_HOME',
|
|
40
|
+
'PNPM_HOME',
|
|
36
41
|
]
|
|
37
42
|
|
|
38
43
|
/**
|
package/src/exec/index.ts
CHANGED
package/src/exec/proc-sample.ts
CHANGED
|
@@ -134,12 +134,16 @@ async function psTable(): Promise<Map<number, Proc> | undefined> {
|
|
|
134
134
|
return table
|
|
135
135
|
}
|
|
136
136
|
|
|
137
|
-
/**
|
|
137
|
+
/**
|
|
138
|
+
* `ps`'s `time`: `[[dd-]hh:]mm:ss[.cc]`, macOS printing hundredths after
|
|
139
|
+
* the locale's decimal point — `0:01,50` under a German or French locale.
|
|
140
|
+
*/
|
|
138
141
|
export function psTimeMs(s: string): number | undefined {
|
|
139
142
|
const dash = s.indexOf('-')
|
|
140
143
|
const days = dash === -1 ? 0 : Number(s.slice(0, dash))
|
|
141
144
|
const parts = s
|
|
142
145
|
.slice(dash + 1)
|
|
146
|
+
.replace(',', '.')
|
|
143
147
|
.split(':')
|
|
144
148
|
.map(Number)
|
|
145
149
|
if (parts.length < 2 || parts.some((n) => !Number.isFinite(n)) || !Number.isFinite(days)) {
|