@vzn/vx 0.0.636 → 0.0.637
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/README.md +39 -11
- package/docs/caching.md +213 -207
- package/docs/cli.md +699 -655
- package/docs/execution.md +67 -75
- package/docs/features.md +44 -40
- package/docs/patterns.md +26 -25
- package/docs/schema.md +376 -198
- package/docs/security.md +76 -39
- package/launcher.sh +43 -0
- package/package.json +5 -5
- package/schemas/cache.json +41 -19
- package/schemas/info.json +23 -11
- package/schemas/last.json +48 -2
- package/schemas/lock.json +1 -1
- package/schemas/plan.json +2 -2
- package/schemas/show.json +5 -3
- package/schemas/why.json +15 -1
- package/skills/vx/SKILL.md +16 -12
- package/src/bin.ts +17 -2
- package/src/cache/archive.ts +11 -0
- package/src/cache/cache.ts +105 -53
- package/src/cache/git-inputs.ts +277 -63
- package/src/cache/index.ts +7 -1
- package/src/cache/inputs.ts +125 -35
- package/src/cache/key-fold.ts +8 -7
- package/src/cache/layer.ts +8 -17
- package/src/cache/layered-cache.ts +16 -102
- package/src/cache/policy.ts +25 -29
- package/src/cache/run-history.ts +36 -14
- package/src/cache/schema.ts +21 -23
- package/src/cache/scoped-cache.ts +224 -0
- package/src/cli/cache.ts +150 -28
- package/src/cli/completions.ts +2 -2
- package/src/cli/core-alias.ts +1 -1
- package/src/cli/foreign-flags.ts +13 -12
- package/src/cli/help.ts +94 -63
- package/src/cli/index.ts +42 -13
- package/src/cli/info.ts +38 -23
- package/src/cli/init.ts +115 -406
- package/src/cli/last.ts +145 -4
- package/src/cli/lock.ts +333 -111
- package/src/cli/plan-format.ts +2 -2
- package/src/cli/plugin-commands.ts +5 -9
- package/src/cli/plugin-templates.ts +10 -10
- package/src/cli/run.ts +103 -149
- package/src/cli/select.ts +33 -39
- package/src/cli/show.ts +17 -19
- package/src/cli/task-verb.ts +2 -9
- package/src/cli/upgrade.ts +402 -145
- package/src/cli/watch-cycle.ts +9 -2
- package/src/cli/watch-filter.ts +33 -60
- package/src/cli/watch-fs.ts +29 -16
- package/src/cli/watch-judge.ts +12 -3
- package/src/cli/watch-set.ts +16 -42
- package/src/cli/watch.ts +188 -215
- package/src/cli/why.ts +28 -17
- package/src/cli/workspace-config.ts +31 -68
- package/src/config.ts +169 -70
- package/src/exec/env.ts +1 -31
- package/src/exec/executor.ts +1 -5
- package/src/exec/kill-tree.ts +2 -1
- package/src/exec/sandbox-runtime.ts +12 -1
- package/src/exec/sandbox-violations.ts +9 -1
- package/src/graph/dependency-spec.ts +1 -1
- package/src/graph/index.ts +1 -0
- package/src/graph/priorities.ts +1 -1
- package/src/graph/scheduler.ts +19 -7
- package/src/graph/task-graph.ts +44 -20
- package/src/handoff.ts +169 -0
- package/src/index.ts +24 -57
- package/src/orchestrator/affected-tasks.ts +7 -5
- package/src/orchestrator/command-windows.ts +44 -0
- package/src/orchestrator/deferred-outputs.ts +12 -10
- package/src/orchestrator/doctor.ts +98 -110
- package/src/orchestrator/download-policy.ts +3 -3
- package/src/orchestrator/events.ts +19 -3
- package/src/orchestrator/excluded-keys.ts +0 -2
- package/src/orchestrator/execute-task.ts +158 -63
- package/src/orchestrator/fingerprint-watch.ts +18 -15
- package/src/orchestrator/forecast.ts +10 -2
- package/src/orchestrator/framed-output.ts +8 -3
- package/src/orchestrator/graph-stage.ts +5 -5
- package/src/orchestrator/history.ts +70 -30
- package/src/orchestrator/hit-restore.ts +15 -3
- package/src/orchestrator/index.ts +18 -4
- package/src/orchestrator/local-shortcircuit.ts +2 -1
- package/src/orchestrator/lockfile-claim.ts +79 -20
- package/src/orchestrator/logger.ts +71 -39
- package/src/orchestrator/metrics.ts +71 -6
- package/src/orchestrator/miss-save.ts +3 -0
- package/src/orchestrator/options.ts +42 -29
- package/src/orchestrator/output-host.ts +72 -0
- package/src/orchestrator/plan.ts +6 -8
- package/src/orchestrator/plugin-host.ts +87 -63
- package/src/orchestrator/plugin.ts +94 -60
- package/src/orchestrator/prepare.ts +113 -72
- package/src/orchestrator/projects.ts +102 -99
- package/src/orchestrator/remote-prefetch.ts +2 -1
- package/src/orchestrator/run-context.ts +25 -6
- package/src/orchestrator/run-failures.ts +17 -8
- package/src/orchestrator/run-lock.ts +1 -1
- package/src/orchestrator/run-report.ts +9 -9
- package/src/orchestrator/run-settings.ts +48 -0
- package/src/orchestrator/run.ts +121 -95
- package/src/orchestrator/sandbox-fix.ts +39 -20
- package/src/orchestrator/sandbox-request.ts +84 -13
- package/src/orchestrator/signals.ts +1 -1
- package/src/orchestrator/stable-keys.ts +13 -11
- package/src/orchestrator/status-line.ts +48 -6
- package/src/orchestrator/summary.ts +46 -62
- package/src/orchestrator/task-hash.ts +12 -83
- package/src/orchestrator/telemetry.ts +3 -3
- package/src/settings.ts +417 -0
- package/src/util/errors.ts +1 -1
- package/src/util/essential-env.ts +31 -0
- package/src/util/hide-env.ts +107 -0
- package/src/util/index.ts +8 -2
- package/src/util/paths.ts +10 -0
- package/src/util/settle.ts +49 -30
- package/src/util/size.ts +23 -4
- package/src/util/timing.ts +3 -2
- package/src/util/ulid.ts +1 -1
- package/src/util/verbs.ts +0 -15
- package/src/workspace/affected.ts +77 -222
- package/src/workspace/config-eval.ts +15 -20
- package/src/workspace/config-imports.ts +78 -3
- package/src/workspace/config-schema.ts +209 -268
- package/src/workspace/dependency-rules.ts +57 -0
- package/src/workspace/filter.ts +2 -2
- package/src/workspace/fingerprint.ts +11 -121
- package/src/workspace/index.ts +20 -27
- package/src/workspace/load-reads.ts +4 -4
- package/src/workspace/lockfile.ts +96 -140
- package/src/workspace/migration.ts +242 -146
- package/src/workspace/package-graph.ts +30 -216
- package/src/workspace/project-loader.ts +80 -285
- package/src/workspace/repo-id.ts +2 -1
- package/src/workspace/workspace.ts +362 -831
- package/src/cache/config-evals.ts +0 -130
- package/src/workspace/config-cache.ts +0 -661
- package/src/workspace/migrate-scripts.ts +0 -1121
|
@@ -6,12 +6,12 @@ export const PLUGIN_TEMPLATES: Readonly<Record<string, { plugin: string; test: s
|
|
|
6
6
|
admit: {
|
|
7
7
|
plugin:
|
|
8
8
|
"// `admit`: whether a ready task starts now, beside what runs here. At most\n// one task of the named `task` runs at a time (say, one that holds a port).\n// Synchronous and cheap: it is asked at every dispatch.\nimport { definePlugin, type VxPlugin } from '@vzn/vx'\n\nexport function oneAtATime(task: string): VxPlugin {\n const named = (id: string): boolean => id.endsWith(`#${task}`)\n return definePlugin(import.meta, {\n admit(node, ctx) {\n return !named(node.id) || !ctx.running.some((r) => named(r.id))\n },\n })\n}\n",
|
|
9
|
-
test: "// A test for the example `admit` 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-admit-'))\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'),
|
|
9
|
+
test: "// A test for the example `admit` 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-admit-'))\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 // Core reads no package.json: the config names the project.\n await writeFile(\n path.join(dir, 'vx.config.mjs'),\n config.replace('export default {', `export default { name: ${JSON.stringify(name)},`),\n )\n return dir\n}\n\n/**\n * A `.vx/workspace.mjs` declaring `call`, e.g. `oneAtATime()`, from the plugin file\n * beside this test, then `vx lock`: a run reads project configs only from the lock.\n */\nasync function workspace(call: string): Promise<void> {\n const from = JSON.stringify(path.join(import.meta.dir, 'admit.ts'))\n await Bun.write(\n path.join(root, '.vx/workspace.mjs'),\n `import { oneAtATime } from ${from}\\nexport default { plugins: [${call}] }\\n`,\n )\n const vx = path.join(path.dirname(new URL(import.meta.resolve('@vzn/vx')).pathname), 'bin.ts')\n const lock = Bun.spawnSync({ cmd: [process.execPath, vx, 'lock'], cwd: root })\n expect([lock.exitCode, lock.stderr.toString()]).toEqual([0, ''])\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('two tasks of the named task never overlap, even with two workers', async () => {\n for (const name of ['a', 'b']) {\n await pkg(name, `export default { tasks: { serve: { exec: { command: 'sleep 0.2' } } } }`)\n }\n await workspace(`oneAtATime('serve')`)\n const order: string[] = []\n await run({ cwd: root, tasks: ['serve'], log: log(order), handleSignals: false, concurrency: 2 })\n // Each start is followed by its own end: no second start in between.\n expect(order).toHaveLength(4)\n expect(order[1]!.replace('end', 'start')).toBe(order[0]!)\n expect(order[3]!.replace('end', 'start')).toBe(order[2]!)\n}, 20_000)\n",
|
|
10
10
|
},
|
|
11
11
|
cache: {
|
|
12
12
|
plugin:
|
|
13
|
-
"// `cache`: where artifacts live. A directory stands in for a remote store;\n//
|
|
14
|
-
test: "// A test for the example `cache` 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-cache-'))\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'),
|
|
13
|
+
"// `cache`: where artifacts live. A directory stands in for a remote store;\n// core puts it behind the local cache, so a remote error is a miss and a\n// warning, never a failed run.\nimport { link, mkdir, unlink } from 'node:fs/promises'\nimport path from 'node:path'\nimport { definePlugin, type RemoteCacheLayer, type VxPlugin } from '@vzn/vx'\n\nclass DirRemote implements RemoteCacheLayer {\n constructor(readonly endpoint: string) {}\n async has(hash: string): Promise<boolean> {\n return Bun.file(path.join(this.endpoint, hash)).exists()\n }\n async get(hash: string) {\n const file = Bun.file(path.join(this.endpoint, hash))\n return (await file.exists()) ? { body: file, durationMs: undefined } : null\n }\n async put(hash: string, body: Blob): Promise<void> {\n await mkdir(this.endpoint, { recursive: true })\n // Written aside, then linked: on a shared directory a rename can swap\n // the file under a reader, while a link never replaces an existing one.\n // EEXIST is success: the same hash means the same bytes.\n const tmp = path.join(this.endpoint, `.${hash}.${process.pid}`)\n await Bun.write(tmp, body)\n try {\n await link(tmp, path.join(this.endpoint, hash))\n } catch (err) {\n if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err\n } finally {\n await unlink(tmp)\n }\n }\n}\n\nexport function dirCache(dir: string): VxPlugin {\n return definePlugin(import.meta, {\n cache() {\n return new DirRemote(dir)\n },\n })\n}\n",
|
|
14
|
+
test: "// A test for the example `cache` 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-cache-'))\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 // Core reads no package.json: the config names the project.\n await writeFile(\n path.join(dir, 'vx.config.mjs'),\n config.replace('export default {', `export default { name: ${JSON.stringify(name)},`),\n )\n return dir\n}\n\n/**\n * A `.vx/workspace.mjs` declaring `call`, e.g. `dirCache()`, from the plugin file\n * beside this test, then `vx lock`: a run reads project configs only from the lock.\n */\nasync function workspace(call: string): Promise<void> {\n const from = JSON.stringify(path.join(import.meta.dir, 'cache.ts'))\n await Bun.write(\n path.join(root, '.vx/workspace.mjs'),\n `import { dirCache } from ${from}\\nexport default { plugins: [${call}] }\\n`,\n )\n const vx = path.join(path.dirname(new URL(import.meta.resolve('@vzn/vx')).pathname), 'bin.ts')\n const lock = Bun.spawnSync({ cmd: [process.execPath, vx, 'lock'], cwd: root })\n expect([lock.exitCode, lock.stderr.toString()]).toEqual([0, ''])\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('a run on an empty local cache restores from the directory', async () => {\n const dir = await pkg(\n 'a',\n `export default { tasks: { build: {\n exec: { command: 'mkdir -p dist && echo built > dist/out.txt' },\n cache: { inputs: { files: ['package.json'] }, outputs: { files: ['dist/**'] } },\n } } }`,\n )\n await workspace(`dirCache(${JSON.stringify(path.join(root, 'remote'))})`)\n const first = await run({ cwd: root, tasks: ['build'], log: log(), handleSignals: false })\n expect(first.outcomes.map((o) => o.status)).toEqual(['success'])\n await rm(path.join(root, '.vx', 'cache'), { recursive: true, force: true })\n await rm(path.join(dir, 'dist'), { recursive: true, force: true })\n const second = await run({ cwd: root, tasks: ['build'], log: log(), handleSignals: false })\n expect(second.outcomes.map((o) => o.status)).toEqual(['cache-hit-remote'])\n expect(await Bun.file(path.join(dir, 'dist', 'out.txt')).text()).toBe('built\\n')\n}, 20_000)\n",
|
|
15
15
|
},
|
|
16
16
|
commands: {
|
|
17
17
|
plugin:
|
|
@@ -21,31 +21,31 @@ export const PLUGIN_TEMPLATES: Readonly<Record<string, { plugin: string; test: s
|
|
|
21
21
|
executor: {
|
|
22
22
|
plugin:
|
|
23
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'),
|
|
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 // Core reads no package.json: the config names the project.\n await writeFile(\n path.join(dir, 'vx.config.mjs'),\n config.replace('export default {', `export default { name: ${JSON.stringify(name)},`),\n )\n return dir\n}\n\n/**\n * A `.vx/workspace.mjs` declaring `call`, e.g. `shellExecutor()`, from the plugin file\n * beside this test, then `vx lock`: a run reads project configs only from the lock.\n */\nasync function workspace(call: string): Promise<void> {\n const from = JSON.stringify(path.join(import.meta.dir, 'executor.ts'))\n await Bun.write(\n path.join(root, '.vx/workspace.mjs'),\n `import { shellExecutor } from ${from}\\nexport default { plugins: [${call}] }\\n`,\n )\n const vx = path.join(path.dirname(new URL(import.meta.resolve('@vzn/vx')).pathname), 'bin.ts')\n const lock = Bun.spawnSync({ cmd: [process.execPath, vx, 'lock'], cwd: root })\n expect([lock.exitCode, lock.stderr.toString()]).toEqual([0, ''])\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: '300ms' } },\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:
|
|
28
28
|
"// `graph`: the run's edges. Every task named `task` waits for the one\n// before it in id order, so they run one after another across projects.\n// Core checks the edited graph again (a missing dep, a cycle).\nimport { definePlugin, type VxPlugin } from '@vzn/vx'\n\nexport function chain(task: string): VxPlugin {\n return definePlugin(import.meta, {\n graph(nodes) {\n const ids = [...nodes.keys()].filter((id) => id.endsWith(`#${task}`)).sort()\n for (const [i, id] of ids.entries()) {\n const prev = ids[i - 1]\n const node = nodes.get(id)!\n if (prev !== undefined && !node.deps.includes(prev)) node.deps.push(prev)\n }\n },\n })\n}\n",
|
|
29
|
-
test: "// A test for the example `graph` 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 { planRun, type Logger } from '@vzn/vx'\n\nlet root: string\nbeforeEach(async () => {\n root = await mkdtemp(path.join(os.tmpdir(), 'vx-plugin-graph-'))\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'),
|
|
29
|
+
test: "// A test for the example `graph` 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 { planRun, type Logger } from '@vzn/vx'\n\nlet root: string\nbeforeEach(async () => {\n root = await mkdtemp(path.join(os.tmpdir(), 'vx-plugin-graph-'))\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 // Core reads no package.json: the config names the project.\n await writeFile(\n path.join(dir, 'vx.config.mjs'),\n config.replace('export default {', `export default { name: ${JSON.stringify(name)},`),\n )\n return dir\n}\n\n/**\n * A `.vx/workspace.mjs` declaring `call`, e.g. `chain()`, from the plugin file\n * beside this test, then `vx lock`: a run reads project configs only from the lock.\n */\nasync function workspace(call: string): Promise<void> {\n const from = JSON.stringify(path.join(import.meta.dir, 'graph.ts'))\n await Bun.write(\n path.join(root, '.vx/workspace.mjs'),\n `import { chain } from ${from}\\nexport default { plugins: [${call}] }\\n`,\n )\n const vx = path.join(path.dirname(new URL(import.meta.resolve('@vzn/vx')).pathname), 'bin.ts')\n const lock = Bun.spawnSync({ cmd: [process.execPath, vx, 'lock'], cwd: root })\n expect([lock.exitCode, lock.stderr.toString()]).toEqual([0, ''])\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('each named task waits on the one before it in id order', async () => {\n for (const name of ['a', 'b', 'c']) {\n await pkg(name, `export default { tasks: { build: { exec: { command: 'true' } } } }`)\n }\n await workspace(`chain('build')`)\n const plan = await planRun({ cwd: root, tasks: ['build'], log: log() })\n expect(plan.tasks.map((t) => `${t.node.id} <- ${t.deps.join(',')}`).sort()).toEqual([\n 'a#build <- ',\n 'b#build <- a#build',\n 'c#build <- b#build',\n ])\n}, 20_000)\n",
|
|
30
30
|
},
|
|
31
31
|
key: {
|
|
32
32
|
plugin:
|
|
33
33
|
"// `key`: extra cache-key material. The value of each named environment\n// variable is folded into every cached task's key and named in `vx why`,\n// so a change to it is a miss. An unset variable folds as ''.\nimport { definePlugin, type VxPlugin } from '@vzn/vx'\n\nexport function envKey(names: readonly string[]): VxPlugin {\n return definePlugin(import.meta, {\n key() {\n return Object.fromEntries(names.map((n) => [n, process.env[n] ?? '']))\n },\n })\n}\n",
|
|
34
|
-
test: "// A test for the example `key` 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 { planRun, type Logger } from '@vzn/vx'\n\nlet root: string\nbeforeEach(async () => {\n root = await mkdtemp(path.join(os.tmpdir(), 'vx-plugin-key-'))\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'),
|
|
34
|
+
test: "// A test for the example `key` 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 { planRun, type Logger } from '@vzn/vx'\n\nlet root: string\nbeforeEach(async () => {\n root = await mkdtemp(path.join(os.tmpdir(), 'vx-plugin-key-'))\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 // Core reads no package.json: the config names the project.\n await writeFile(\n path.join(dir, 'vx.config.mjs'),\n config.replace('export default {', `export default { name: ${JSON.stringify(name)},`),\n )\n return dir\n}\n\n/**\n * A `.vx/workspace.mjs` declaring `call`, e.g. `envKey()`, from the plugin file\n * beside this test, then `vx lock`: a run reads project configs only from the lock.\n */\nasync function workspace(call: string): Promise<void> {\n const from = JSON.stringify(path.join(import.meta.dir, 'key.ts'))\n await Bun.write(\n path.join(root, '.vx/workspace.mjs'),\n `import { envKey } from ${from}\\nexport default { plugins: [${call}] }\\n`,\n )\n const vx = path.join(path.dirname(new URL(import.meta.resolve('@vzn/vx')).pathname), 'bin.ts')\n const lock = Bun.spawnSync({ cmd: [process.execPath, vx, 'lock'], cwd: root })\n expect([lock.exitCode, lock.stderr.toString()]).toEqual([0, ''])\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(\"the variable's value moves the key; the same value keeps it\", async () => {\n await pkg(\n 'a',\n `export default { tasks: { build: {\n exec: { command: 'true' },\n cache: { inputs: { files: ['package.json'] }, outputs: { files: [] } },\n } } }`,\n )\n await workspace(`envKey(['EXAMPLE_FLAG'])`)\n const hash = async (value: string): Promise<string | undefined> => {\n process.env['EXAMPLE_FLAG'] = value\n try {\n const plan = await planRun({ cwd: root, tasks: ['build'], log: log() })\n return plan.tasks[0]!.hash\n } finally {\n delete process.env['EXAMPLE_FLAG']\n }\n }\n const one = await hash('1')\n expect(one).toBeDefined()\n expect(await hash('1')).toBe(one)\n expect(await hash('2')).not.toBe(one)\n}, 20_000)\n",
|
|
35
35
|
},
|
|
36
36
|
project: {
|
|
37
37
|
plugin:
|
|
38
|
-
"// `project`: a project's tasks. Every package.json
|
|
39
|
-
test: "// A test for the example `project` 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-project-'))\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'),
|
|
38
|
+
"// `project`: a project's tasks. Every script in the project's package.json\n// becomes a task of the same name that runs it, unless the project already\n// declares one. Core reads no package.json, so the plugin does. Core\n// re-validates each edit as it would the user's own config.\nimport { definePlugin, type VxPlugin } from '@vzn/vx'\n\nexport function scriptTasks(): VxPlugin {\n return definePlugin(import.meta, {\n async project(config, ctx) {\n const pkg: unknown = await Bun.file(`${ctx.dir}/package.json`)\n .json()\n .catch(() => null)\n const scripts = (pkg as { scripts?: unknown } | null)?.scripts\n if (scripts === null || typeof scripts !== 'object') return\n config.tasks ??= {}\n for (const [name, command] of Object.entries(scripts)) {\n if (typeof command !== 'string' || config.tasks[name] !== undefined) continue\n config.tasks[name] = { exec: { command } }\n }\n },\n })\n}\n",
|
|
39
|
+
test: "// A test for the example `project` 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-project-'))\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 // Core reads no package.json: the config names the project.\n await writeFile(\n path.join(dir, 'vx.config.mjs'),\n config.replace('export default {', `export default { name: ${JSON.stringify(name)},`),\n )\n return dir\n}\n\n/**\n * A `.vx/workspace.mjs` declaring `call`, e.g. `scriptTasks()`, from the plugin file\n * beside this test, then `vx lock`: a run reads project configs only from the lock.\n */\nasync function workspace(call: string): Promise<void> {\n const from = JSON.stringify(path.join(import.meta.dir, 'project.ts'))\n await Bun.write(\n path.join(root, '.vx/workspace.mjs'),\n `import { scriptTasks } from ${from}\\nexport default { plugins: [${call}] }\\n`,\n )\n const vx = path.join(path.dirname(new URL(import.meta.resolve('@vzn/vx')).pathname), 'bin.ts')\n const lock = Bun.spawnSync({ cmd: [process.execPath, vx, 'lock'], cwd: root })\n expect([lock.exitCode, lock.stderr.toString()]).toEqual([0, ''])\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('a package.json script runs as a task; a declared task wins', async () => {\n const dir = await pkg(\n 'a',\n `export default { tasks: { build: { exec: { command: 'echo declared > build.txt' } } } }`,\n { hello: 'echo hi > hello.txt', build: 'echo script > build.txt' },\n )\n await workspace('scriptTasks()')\n const r = await run({ cwd: root, tasks: ['hello', 'build'], log: log(), handleSignals: false })\n expect(r.outcomes.map((o) => `${o.node.id} ${o.status}`).sort()).toEqual([\n 'a#build success',\n 'a#hello success',\n ])\n expect(await Bun.file(path.join(dir, 'hello.txt')).text()).toBe('hi\\n')\n expect(await Bun.file(path.join(dir, 'build.txt')).text()).toBe('declared\\n')\n}, 20_000)\n",
|
|
40
40
|
},
|
|
41
41
|
schedule: {
|
|
42
42
|
plugin:
|
|
43
43
|
"// `schedule`: which ready task runs first. Tasks named here outrank the rest;\n// earlier in the list, higher.\nimport { definePlugin, type VxPlugin } from '@vzn/vx'\n\nexport function prioritize(ids: readonly string[]): VxPlugin {\n return definePlugin(import.meta, {\n schedule(nodes) {\n const weights = new Map<string, number>()\n for (const [i, id] of ids.entries()) if (nodes.has(id)) weights.set(id, 1e6 - i)\n return weights\n },\n })\n}\n",
|
|
44
|
-
test: "// A test for the example `schedule` 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-schedule-'))\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'),
|
|
44
|
+
test: "// A test for the example `schedule` 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-schedule-'))\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 // Core reads no package.json: the config names the project.\n await writeFile(\n path.join(dir, 'vx.config.mjs'),\n config.replace('export default {', `export default { name: ${JSON.stringify(name)},`),\n )\n return dir\n}\n\n/**\n * A `.vx/workspace.mjs` declaring `call`, e.g. `prioritize()`, from the plugin file\n * beside this test, then `vx lock`: a run reads project configs only from the lock.\n */\nasync function workspace(call: string): Promise<void> {\n const from = JSON.stringify(path.join(import.meta.dir, 'schedule.ts'))\n await Bun.write(\n path.join(root, '.vx/workspace.mjs'),\n `import { prioritize } from ${from}\\nexport default { plugins: [${call}] }\\n`,\n )\n const vx = path.join(path.dirname(new URL(import.meta.resolve('@vzn/vx')).pathname), 'bin.ts')\n const lock = Bun.spawnSync({ cmd: [process.execPath, vx, 'lock'], cwd: root })\n expect([lock.exitCode, lock.stderr.toString()]).toEqual([0, ''])\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('the named task starts first on one worker, whichever it is', async () => {\n for (const name of ['a', 'b']) {\n await pkg(name, `export default { tasks: { build: { exec: { command: 'true' } } } }`)\n }\n const first = async (id: string): Promise<string | undefined> => {\n await workspace(`prioritize([${JSON.stringify(id)}])`)\n const order: string[] = []\n await run({\n cwd: root,\n tasks: ['build'],\n log: log(order),\n handleSignals: false,\n concurrency: 1,\n })\n return order[0]\n }\n expect(await first('b#build')).toBe('start b#build')\n expect(await first('a#build')).toBe('start a#build')\n}, 20_000)\n",
|
|
45
45
|
},
|
|
46
46
|
telemetry: {
|
|
47
47
|
plugin:
|
|
48
48
|
"// `telemetry`: who observes. One JSON line per run, appended to a file.\n// Observability never breaks a run: a sink that throws is switched off,\n// and `flush` is where slow I/O goes (core bounds it).\nimport { appendFile } from 'node:fs/promises'\nimport { definePlugin, type VxPlugin } from '@vzn/vx'\n\nexport function jsonlTelemetry(file: string): VxPlugin {\n return definePlugin(import.meta, {\n telemetry() {\n const lines: string[] = []\n return {\n onRunSummary(s) {\n lines.push(\n JSON.stringify({ tasks: s.taskCount, failed: s.failedCount, cached: s.hitCount }),\n )\n },\n async flush() {\n if (lines.length > 0) await appendFile(file, lines.splice(0).join('\\n') + '\\n')\n },\n }\n },\n })\n}\n",
|
|
49
|
-
test: "// A test for the example `telemetry` 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-telemetry-'))\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'),
|
|
49
|
+
test: "// A test for the example `telemetry` 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-telemetry-'))\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 // Core reads no package.json: the config names the project.\n await writeFile(\n path.join(dir, 'vx.config.mjs'),\n config.replace('export default {', `export default { name: ${JSON.stringify(name)},`),\n )\n return dir\n}\n\n/**\n * A `.vx/workspace.mjs` declaring `call`, e.g. `jsonlTelemetry()`, from the plugin file\n * beside this test, then `vx lock`: a run reads project configs only from the lock.\n */\nasync function workspace(call: string): Promise<void> {\n const from = JSON.stringify(path.join(import.meta.dir, 'telemetry.ts'))\n await Bun.write(\n path.join(root, '.vx/workspace.mjs'),\n `import { jsonlTelemetry } from ${from}\\nexport default { plugins: [${call}] }\\n`,\n )\n const vx = path.join(path.dirname(new URL(import.meta.resolve('@vzn/vx')).pathname), 'bin.ts')\n const lock = Bun.spawnSync({ cmd: [process.execPath, vx, 'lock'], cwd: root })\n expect([lock.exitCode, lock.stderr.toString()]).toEqual([0, ''])\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('appends one line per run', async () => {\n await pkg('a', `export default { tasks: { hello: { exec: { command: 'true' } } } }`)\n const file = path.join(root, 'runs.jsonl')\n await workspace(`jsonlTelemetry(${JSON.stringify(file)})`)\n await run({ cwd: root, tasks: ['hello'], log: log(), handleSignals: false })\n await run({ cwd: root, tasks: ['hello'], log: log(), handleSignals: false })\n expect(await Bun.file(file).text()).toBe(\n '{\"tasks\":1,\"failed\":0,\"cached\":0}\\n{\"tasks\":1,\"failed\":0,\"cached\":0}\\n',\n )\n}, 20_000)\n",
|
|
50
50
|
},
|
|
51
51
|
}
|
package/src/cli/run.ts
CHANGED
|
@@ -14,6 +14,8 @@ import {
|
|
|
14
14
|
} from '../orchestrator/index.js'
|
|
15
15
|
import type { ContinueMode, TaskOutcome } from '../graph/index.js'
|
|
16
16
|
import {
|
|
17
|
+
applyCacheFlags,
|
|
18
|
+
type CacheFlags,
|
|
17
19
|
type CachePolicy,
|
|
18
20
|
FULL_CACHE_POLICY,
|
|
19
21
|
gitRefusal,
|
|
@@ -21,7 +23,8 @@ import {
|
|
|
21
23
|
} from '../cache/index.js'
|
|
22
24
|
import { affectedFilterFor, findCwdSelection, pickTask, resolveFilters } from './select.js'
|
|
23
25
|
import { nxTargetHint, taskNamesHere } from './task-verb.js'
|
|
24
|
-
import {
|
|
26
|
+
import { parseDecimalInt } from '../util/index.js'
|
|
27
|
+
import { kindHint, parseSettingValue, type Setting, SETTINGS, settingRefusal } from '../settings.js'
|
|
25
28
|
import { formatGraphDot, formatPlanJson, formatPlanText } from './plan-format.js'
|
|
26
29
|
|
|
27
30
|
export interface RunArgs {
|
|
@@ -42,32 +45,29 @@ export interface RunArgs {
|
|
|
42
45
|
* `[...names]` → drop only these specific dep names.
|
|
43
46
|
*/
|
|
44
47
|
excludeDependencies: 'all' | string[]
|
|
45
|
-
concurrency: number | undefined
|
|
46
48
|
/**
|
|
47
|
-
*
|
|
49
|
+
* The settings a flag set, by path (`run.timeout` → ms): each flag
|
|
50
|
+
* src/settings.ts names for the verb, parsed by its kind.
|
|
51
|
+
*/
|
|
52
|
+
settings: Record<string, unknown>
|
|
53
|
+
/**
|
|
54
|
+
* Resolved 4-axis cache policy after applying `--cache-policy` / `--no-cache`
|
|
48
55
|
* / `--force` in precedence order. Defaults to all-on.
|
|
49
56
|
*/
|
|
50
57
|
cache: CachePolicy
|
|
51
|
-
/**
|
|
58
|
+
/**
|
|
59
|
+
* Neither `--cache-policy` nor `VX_CACHE_POLICY` named a spec: the run starts from the
|
|
60
|
+
* workspace's `cache.policy` and applies these on top.
|
|
61
|
+
*/
|
|
62
|
+
cacheFlags?: CacheFlags
|
|
63
|
+
/** The policy named a remote axis (`remote:r`, `:w`, `:rw`): the run says so when no plugin serves one. */
|
|
52
64
|
remoteRequested?: boolean
|
|
53
65
|
/** `--cache-dir <path>`: override the cache directory (cwd-relative). */
|
|
54
66
|
cacheDir: string | undefined
|
|
55
|
-
/** `--cache-scope <name>`: this run's `
|
|
67
|
+
/** `--cache-scope <name>`: this run's `cache.scope`, above `VX_CACHE_SCOPE` and the workspace's. */
|
|
56
68
|
cacheScope?: string
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
* `--retry <n>` / `--retry=<n>`: run-level retry default for tasks
|
|
60
|
-
* without their own `exec.retries`. Undefined when not passed.
|
|
61
|
-
*/
|
|
62
|
-
retries: number | undefined
|
|
63
|
-
/**
|
|
64
|
-
* `--timeout <ms>` / `--timeout=<ms>`: run-level default task timeout for
|
|
65
|
-
* tasks without their own `exec.timeout`. Sits above the `VX_TASK_TIMEOUT`
|
|
66
|
-
* env and workspace `timeout` defaults. Undefined when not passed.
|
|
67
|
-
*/
|
|
68
|
-
timeout: number | undefined
|
|
69
|
-
outputLogs?: 'full' | 'errors-only' | 'none' | 'hash-only'
|
|
70
|
-
download?: 'all' | 'toplevel' | 'none'
|
|
69
|
+
/** `vx watch --poll` (`watch.poll`). */
|
|
70
|
+
poll?: true
|
|
71
71
|
forwardArgs: string[]
|
|
72
72
|
verbosity: number
|
|
73
73
|
dry: 'text' | 'json' | undefined
|
|
@@ -100,25 +100,42 @@ export interface RunArgs {
|
|
|
100
100
|
// quoted literal, so the doc-drift test does not count it as an accepted flag.
|
|
101
101
|
const RETIRED_EXCLUDE_DEPENDENCIES = /^--excludeDependencies(=|$)/
|
|
102
102
|
|
|
103
|
-
/**
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
103
|
+
/** `name` when `a` is it, bare or with `=`. */
|
|
104
|
+
function flagOf(a: string | undefined, name: string): string | undefined {
|
|
105
|
+
return a === name || a?.startsWith(`${name}=`) ? name : undefined
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// The settings parsed by their kind alone; the cache flags, `--continue`
|
|
109
|
+
// and `--cache-dir` keep their own branches above.
|
|
110
|
+
const GENERIC_FLAGS = SETTINGS.filter(
|
|
111
|
+
(s) =>
|
|
112
|
+
s.flag?.verb === 'run' &&
|
|
113
|
+
!['run.continue', 'cache.dir', 'cache.policy', 'cache.scope'].includes(s.path),
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
/** The setting a flag names. */
|
|
117
|
+
function settingFlagOf(a: string): { s: Setting; name: string } | undefined {
|
|
118
|
+
for (const s of GENERIC_FLAGS) {
|
|
119
|
+
const name = s.flag!.name
|
|
120
|
+
if (flagOf(a, name) !== undefined) return { s, name }
|
|
117
121
|
}
|
|
118
|
-
|
|
119
|
-
return n === null || n < 1 ? null : n
|
|
122
|
+
return undefined
|
|
120
123
|
}
|
|
121
124
|
|
|
125
|
+
/** Each setting a flag sets, and the RunOptions field it lands in. */
|
|
126
|
+
const SETTING_OPTIONS = [
|
|
127
|
+
['run.concurrency', 'concurrency'],
|
|
128
|
+
['run.schedule', 'schedule'],
|
|
129
|
+
['run.branch', 'branch'],
|
|
130
|
+
['run.timeout', 'timeout'],
|
|
131
|
+
['run.retries', 'retries'],
|
|
132
|
+
['run.outputLogs', 'outputLogs'],
|
|
133
|
+
['run.killGrace', 'killGrace'],
|
|
134
|
+
['run.teardownTimeout', 'teardownTimeout'],
|
|
135
|
+
['run.readyNotice', 'readyNotice'],
|
|
136
|
+
['cache.download', 'download'],
|
|
137
|
+
] as const satisfies readonly (readonly [string, keyof RunOptions])[]
|
|
138
|
+
|
|
122
139
|
/** `verb` is the one being parsed for: `vx watch` reads `vx run`'s flags, and
|
|
123
140
|
* its flag hints named `vx run` (M-58). The caller appends the help pointer. */
|
|
124
141
|
export function parseRunArgs(rawArgs: readonly string[], verb: 'run' | 'watch' = 'run'): RunArgs {
|
|
@@ -127,12 +144,9 @@ export function parseRunArgs(rawArgs: readonly string[], verb: 'run' | 'watch' =
|
|
|
127
144
|
filters: [],
|
|
128
145
|
all: false,
|
|
129
146
|
excludeDependencies: [],
|
|
130
|
-
|
|
147
|
+
settings: {},
|
|
131
148
|
cache: { ...FULL_CACHE_POLICY },
|
|
132
149
|
cacheDir: undefined,
|
|
133
|
-
frozen: false,
|
|
134
|
-
retries: undefined,
|
|
135
|
-
timeout: undefined,
|
|
136
150
|
forwardArgs: [],
|
|
137
151
|
verbosity: 0,
|
|
138
152
|
dry: undefined,
|
|
@@ -155,9 +169,9 @@ export function parseRunArgs(rawArgs: readonly string[], verb: 'run' | 'watch' =
|
|
|
155
169
|
out.forwardArgs = sepIdx === -1 ? [] : args.slice(sepIdx + 1)
|
|
156
170
|
|
|
157
171
|
// Cache policy is resolved AFTER the loop in precedence order:
|
|
158
|
-
// start all-true → apply each `--cache` spec → `--no-cache` forces
|
|
172
|
+
// start all-true → apply each `--cache-policy` spec → `--no-cache` forces
|
|
159
173
|
// all false → `--force` forces both reads false. So `--no-cache`
|
|
160
|
-
// beats `--force`, and `--cache` is the base both may override.
|
|
174
|
+
// beats `--force`, and `--cache-policy` is the base both may override.
|
|
161
175
|
let cachePolicy: CachePolicy = { ...FULL_CACHE_POLICY }
|
|
162
176
|
let cacheSpecError: string | undefined
|
|
163
177
|
let cacheFlag = false
|
|
@@ -175,20 +189,6 @@ export function parseRunArgs(rawArgs: readonly string[], verb: 'run' | 'watch' =
|
|
|
175
189
|
error: `--filter requires a value (a project name, glob or path, e.g. --filter app)`,
|
|
176
190
|
}
|
|
177
191
|
out.filters.push(v)
|
|
178
|
-
} else if (a === '--concurrency' || a?.startsWith('--concurrency=')) {
|
|
179
|
-
const v = a === '--concurrency' ? before[++i] : a.slice('--concurrency='.length)
|
|
180
|
-
if (v === undefined)
|
|
181
|
-
return {
|
|
182
|
-
...out,
|
|
183
|
-
error: `--concurrency requires a value (a positive integer, or a share of the cores such as 50%)`,
|
|
184
|
-
}
|
|
185
|
-
const n = parseConcurrency(v)
|
|
186
|
-
if (n === null)
|
|
187
|
-
return {
|
|
188
|
-
...out,
|
|
189
|
-
error: `--concurrency must be a positive integer, or a share of the cores such as 50% (got ${v})`,
|
|
190
|
-
}
|
|
191
|
-
out.concurrency = n
|
|
192
192
|
} else if (a === '--all') {
|
|
193
193
|
out.all = true
|
|
194
194
|
} else if (RETIRED_EXCLUDE_DEPENDENCIES.test(a ?? '')) {
|
|
@@ -204,7 +204,7 @@ export function parseRunArgs(rawArgs: readonly string[], verb: 'run' | 'watch' =
|
|
|
204
204
|
// bare flag) and "drop none" (an empty list) are equally defensible
|
|
205
205
|
// readings, and silently picking either does the opposite of what
|
|
206
206
|
// half the callers mean. Reject and name both explicit forms; the
|
|
207
|
-
// sibling value flags (--
|
|
207
|
+
// sibling value flags (--retries=, --timeout=, --cache-dir=)
|
|
208
208
|
// reject an empty `=` value the same way.
|
|
209
209
|
if (raw === '') {
|
|
210
210
|
return {
|
|
@@ -216,60 +216,6 @@ export function parseRunArgs(rawArgs: readonly string[], verb: 'run' | 'watch' =
|
|
|
216
216
|
.split(',')
|
|
217
217
|
.map((s) => s.trim())
|
|
218
218
|
.filter((s) => s.length > 0)
|
|
219
|
-
} else if (a === '--frozen') {
|
|
220
|
-
out.frozen = true
|
|
221
|
-
} else if (a === '--retry' || a?.startsWith('--retry=')) {
|
|
222
|
-
const v = a === '--retry' ? before[++i] : a.slice('--retry='.length)
|
|
223
|
-
if (v === undefined)
|
|
224
|
-
return { ...out, error: `--retry requires a value (a non-negative integer)` }
|
|
225
|
-
const n = parseDecimalInt(v)
|
|
226
|
-
if (n === null) {
|
|
227
|
-
return { ...out, error: `--retry must be a non-negative integer (got ${v})` }
|
|
228
|
-
}
|
|
229
|
-
out.retries = n
|
|
230
|
-
} else if (a === '--timeout' || a?.startsWith('--timeout=')) {
|
|
231
|
-
const v = a === '--timeout' ? before[++i] : a.slice('--timeout='.length)
|
|
232
|
-
if (v === undefined)
|
|
233
|
-
return { ...out, error: `--timeout requires a value (a positive integer, in ms)` }
|
|
234
|
-
const n = parseDecimalInt(v)
|
|
235
|
-
if (n !== null && n > MAX_TIMEOUT_MS) {
|
|
236
|
-
// Not "effectively no limit" — setTimeout reduces a delay past 2^31-1
|
|
237
|
-
// to 1 ms, so this would kill every task the moment it spawned.
|
|
238
|
-
return {
|
|
239
|
-
...out,
|
|
240
|
-
error:
|
|
241
|
-
`--timeout ${n} exceeds the maximum timer delay (${MAX_TIMEOUT_MS} ms, ~24.8 days); ` +
|
|
242
|
-
`larger values are silently reduced to 1 ms. Omit --timeout for no limit.`,
|
|
243
|
-
}
|
|
244
|
-
}
|
|
245
|
-
if (n === null || n <= 0) {
|
|
246
|
-
return { ...out, error: `--timeout must be a positive integer in ms (got ${v})` }
|
|
247
|
-
}
|
|
248
|
-
out.timeout = n
|
|
249
|
-
} else if (a === '--output-logs' || a?.startsWith('--output-logs=')) {
|
|
250
|
-
const v = a === '--output-logs' ? before[++i] : a.slice('--output-logs='.length)
|
|
251
|
-
if (v === undefined || v === '') {
|
|
252
|
-
return {
|
|
253
|
-
...out,
|
|
254
|
-
error: `--output-logs requires a value (full, errors-only, hash-only, or none)`,
|
|
255
|
-
}
|
|
256
|
-
}
|
|
257
|
-
if (v !== 'full' && v !== 'errors-only' && v !== 'none' && v !== 'hash-only') {
|
|
258
|
-
return {
|
|
259
|
-
...out,
|
|
260
|
-
error: `--output-logs must be full, errors-only, hash-only, or none (got ${v})`,
|
|
261
|
-
}
|
|
262
|
-
}
|
|
263
|
-
out.outputLogs = v
|
|
264
|
-
} else if (a === '--download' || a?.startsWith('--download=')) {
|
|
265
|
-
const v = a === '--download' ? before[++i] : a.slice('--download='.length)
|
|
266
|
-
if (v === undefined || v === '') {
|
|
267
|
-
return { ...out, error: `--download requires a value (all, toplevel, or none)` }
|
|
268
|
-
}
|
|
269
|
-
if (v !== 'all' && v !== 'toplevel' && v !== 'none') {
|
|
270
|
-
return { ...out, error: `--download must be all, toplevel, or none (got ${v})` }
|
|
271
|
-
}
|
|
272
|
-
out.download = v
|
|
273
219
|
} else if (a === '--cache-dir' || a?.startsWith('--cache-dir=')) {
|
|
274
220
|
const v = a === '--cache-dir' ? before[++i] : a.slice('--cache-dir='.length)
|
|
275
221
|
if (v === undefined || v === '') return { ...out, error: `--cache-dir requires a path` }
|
|
@@ -290,15 +236,16 @@ export function parseRunArgs(rawArgs: readonly string[], verb: 'run' | 'watch' =
|
|
|
290
236
|
if (v === undefined || !isCacheScope(v))
|
|
291
237
|
return { ...out, error: cacheScopeError('--cache-scope') }
|
|
292
238
|
out.cacheScope = v
|
|
293
|
-
} else if (a
|
|
239
|
+
} else if (flagOf(a, '--cache-policy')) {
|
|
294
240
|
cacheFlag = true
|
|
295
|
-
const
|
|
241
|
+
const name = flagOf(a, '--cache-policy')!
|
|
242
|
+
const v = a === name ? before[++i] : a!.slice(name.length + 1)
|
|
296
243
|
if (v === undefined)
|
|
297
244
|
return {
|
|
298
245
|
...out,
|
|
299
|
-
error:
|
|
246
|
+
error: `${name} requires a value (a spec like local:r, local:rw, remote:, or local:,remote:rw)`,
|
|
300
247
|
}
|
|
301
|
-
// A spec with no segment at all applies NOTHING, so `--cache=`
|
|
248
|
+
// A spec with no segment at all applies NOTHING, so `--cache-policy=`
|
|
302
249
|
// (an empty shell var, or someone reaching for "no cache") left
|
|
303
250
|
// all four axes ON — the opposite of the intent. Reject it; an
|
|
304
251
|
// empty flag list for a NAMED layer (`local:`) stays valid and
|
|
@@ -306,14 +253,14 @@ export function parseRunArgs(rawArgs: readonly string[], verb: 'run' | 'watch' =
|
|
|
306
253
|
if (!v.split(',').some((seg) => seg.trim().length > 0)) {
|
|
307
254
|
return {
|
|
308
255
|
...out,
|
|
309
|
-
error:
|
|
256
|
+
error: `${name} needs a spec like local:r, local:rw, remote:, or local:,remote:rw — pass --no-cache to disable every axis`,
|
|
310
257
|
}
|
|
311
258
|
}
|
|
312
259
|
try {
|
|
313
260
|
cachePolicy = parseCachePolicy(v, cachePolicy)
|
|
314
261
|
if (/remote:[rw]/.test(v)) out.remoteRequested = true
|
|
315
262
|
} catch (err) {
|
|
316
|
-
cacheSpecError = err instanceof Error ? err.message : String(err)
|
|
263
|
+
cacheSpecError = `--cache-policy: ${err instanceof Error ? err.message : String(err)}`
|
|
317
264
|
}
|
|
318
265
|
} else if (a === '--no-cache') {
|
|
319
266
|
noCacheFlag = true
|
|
@@ -406,6 +353,16 @@ export function parseRunArgs(rawArgs: readonly string[], verb: 'run' | 'watch' =
|
|
|
406
353
|
return { ...out, error: `--report must be markdown (got ${fmt})` }
|
|
407
354
|
}
|
|
408
355
|
out.report = fmt
|
|
356
|
+
} else if (verb === 'watch' && a === '--poll') {
|
|
357
|
+
out.poll = true
|
|
358
|
+
} else if (a !== undefined && settingFlagOf(a) !== undefined) {
|
|
359
|
+
const { s: set, name } = settingFlagOf(a)!
|
|
360
|
+
const v = a === name ? before[++i] : a.slice(name.length + 1)
|
|
361
|
+
if (v === undefined || v === '')
|
|
362
|
+
return { ...out, error: `${name} requires a value (${kindHint(set.kind)})` }
|
|
363
|
+
const value = parseSettingValue(set.kind, v)
|
|
364
|
+
if (value === null) return { ...out, error: settingRefusal(set.path, name, v) }
|
|
365
|
+
out.settings[set.path] = value
|
|
409
366
|
} else if (a !== undefined && a.startsWith('-')) {
|
|
410
367
|
return { ...out, error: `unknown flag: ${a}${flagHint(verb, a)}` }
|
|
411
368
|
} else if (a !== undefined) {
|
|
@@ -416,30 +373,32 @@ export function parseRunArgs(rawArgs: readonly string[], verb: 'run' | 'watch' =
|
|
|
416
373
|
if (cacheSpecError !== undefined) {
|
|
417
374
|
return { ...out, error: cacheSpecError }
|
|
418
375
|
}
|
|
419
|
-
// `
|
|
420
|
-
const envSpec = process.env['
|
|
376
|
+
// `VX_CACHE_POLICY` is the spec a run takes when no flag names one.
|
|
377
|
+
const envSpec = process.env['VX_CACHE_POLICY'] || undefined
|
|
421
378
|
if (!cacheFlag && envSpec !== undefined) {
|
|
422
379
|
if (!envSpec.split(',').some((seg) => seg.trim().length > 0))
|
|
423
380
|
return {
|
|
424
381
|
...out,
|
|
425
|
-
error: `
|
|
382
|
+
error: `VX_CACHE_POLICY needs a spec like local:r, local:rw, remote:, or local:,remote:rw`,
|
|
426
383
|
}
|
|
427
384
|
try {
|
|
428
385
|
cachePolicy = parseCachePolicy(envSpec, cachePolicy)
|
|
429
386
|
} catch (err) {
|
|
430
|
-
return {
|
|
387
|
+
return {
|
|
388
|
+
...out,
|
|
389
|
+
error: `VX_CACHE_POLICY: ${err instanceof Error ? err.message : String(err)}`,
|
|
390
|
+
}
|
|
431
391
|
}
|
|
432
392
|
if (/remote:[rw]/.test(envSpec)) out.remoteRequested = true
|
|
433
393
|
}
|
|
434
|
-
//
|
|
435
|
-
//
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
if (forceFlag) {
|
|
440
|
-
cachePolicy = { ...cachePolicy, localRead: false, remoteRead: false }
|
|
394
|
+
// --no-cache (all off) beats --force (reads off, writes kept), both
|
|
395
|
+
// layered on top of the spec; with none named, on the workspace's.
|
|
396
|
+
const flags: CacheFlags = {
|
|
397
|
+
...(noCacheFlag ? { noCache: true } : {}),
|
|
398
|
+
...(forceFlag ? { force: true } : {}),
|
|
441
399
|
}
|
|
442
|
-
out.cache = cachePolicy
|
|
400
|
+
out.cache = applyCacheFlags(cachePolicy, flags)
|
|
401
|
+
if (!cacheFlag && envSpec === undefined) out.cacheFlags = flags
|
|
443
402
|
|
|
444
403
|
if (out.dry !== undefined && out.graph !== undefined) {
|
|
445
404
|
return { ...out, error: '--dry and --graph are mutually exclusive' }
|
|
@@ -495,9 +454,6 @@ export function detectFlow(
|
|
|
495
454
|
}
|
|
496
455
|
|
|
497
456
|
/** `CI=0` / `CI=false` are not CI, as the output flow reads them. */
|
|
498
|
-
function onCI(v: string | undefined): boolean {
|
|
499
|
-
return v !== undefined && v !== '' && v !== '0' && v !== 'false'
|
|
500
|
-
}
|
|
501
457
|
|
|
502
458
|
/** The filters a run's scope comes from: `--filter`s, with `--affected` as its `...[<base>]`. */
|
|
503
459
|
async function scopeFilters(
|
|
@@ -585,10 +541,7 @@ export async function resolveRunOptions(
|
|
|
585
541
|
? await resolveFilters(
|
|
586
542
|
cwd,
|
|
587
543
|
filterStrings,
|
|
588
|
-
{
|
|
589
|
-
...(parsed.cacheDir !== undefined ? { cacheDir: parsed.cacheDir } : {}),
|
|
590
|
-
...(parsed.frozen ? { frozen: true } : {}),
|
|
591
|
-
},
|
|
544
|
+
parsed.cacheDir !== undefined ? { cacheDir: parsed.cacheDir } : {},
|
|
592
545
|
affectedFilter,
|
|
593
546
|
)
|
|
594
547
|
: undefined
|
|
@@ -633,19 +586,18 @@ export async function resolveRunOptions(
|
|
|
633
586
|
const opts: RunOptions = {
|
|
634
587
|
cwd,
|
|
635
588
|
tasks: [...tasks],
|
|
636
|
-
|
|
589
|
+
...(parsed.cacheFlags !== undefined
|
|
590
|
+
? { cacheFlags: parsed.cacheFlags }
|
|
591
|
+
: { cache: parsed.cache }),
|
|
637
592
|
...(parsed.remoteRequested ? { remoteRequested: true } : {}),
|
|
638
593
|
...(parsed.cacheScope !== undefined ? { cacheScope: parsed.cacheScope } : {}),
|
|
639
594
|
// A laptop reads the shared cache and never writes it (owner,
|
|
640
|
-
// 2026-10-03); naming the remote in `--cache` is asking to.
|
|
595
|
+
// 2026-10-03); naming the remote in `--cache-policy` is asking to.
|
|
641
596
|
...(!parsed.remoteRequested && !onCI(process.env['CI'])
|
|
642
|
-
? {
|
|
597
|
+
? { remoteReadOnlyByDefault: true }
|
|
643
598
|
: {}),
|
|
644
599
|
flow: detectFlow(parsed),
|
|
645
|
-
...(parsed.frozen ? { frozen: true } : {}),
|
|
646
|
-
...(parsed.outputLogs !== undefined ? { outputLogs: parsed.outputLogs } : {}),
|
|
647
600
|
...(parsed.verbosity > 0 ? { summaryTable: true } : {}),
|
|
648
|
-
...(parsed.download !== undefined ? { download: parsed.download } : {}),
|
|
649
601
|
...(parsed.continueMode !== undefined ? { continueMode: parsed.continueMode } : {}),
|
|
650
602
|
forwardArgs: parsed.forwardArgs,
|
|
651
603
|
// isatty, not process.stdin: touching the stream opens it, and a config
|
|
@@ -663,10 +615,10 @@ export async function resolveRunOptions(
|
|
|
663
615
|
if (outright !== undefined) opts.selectedOutright = outright
|
|
664
616
|
if (staged !== undefined) opts.staged = staged
|
|
665
617
|
if (discovered !== undefined) opts.discovered = discovered
|
|
666
|
-
if (parsed.retries !== undefined) opts.retries = parsed.retries
|
|
667
|
-
if (parsed.timeout !== undefined) opts.timeout = parsed.timeout
|
|
668
618
|
if (parsed.cacheDir !== undefined) opts.cacheDir = parsed.cacheDir
|
|
669
|
-
|
|
619
|
+
for (const [path, field] of SETTING_OPTIONS) {
|
|
620
|
+
if (parsed.settings[path] !== undefined) Object.assign(opts, { [field]: parsed.settings[path] })
|
|
621
|
+
}
|
|
670
622
|
if (parsed.summarize !== undefined) opts.summarize = parsed.summarize
|
|
671
623
|
if (parsed.format === 'json') opts.json = true
|
|
672
624
|
if (parsed.profile !== undefined) opts.profile = parsed.profile
|
|
@@ -716,10 +668,7 @@ export async function runCmd(args: readonly string[]): Promise<number> {
|
|
|
716
668
|
process.stderr.write(`${refusal.message}\n`)
|
|
717
669
|
return 1
|
|
718
670
|
}
|
|
719
|
-
const load = {
|
|
720
|
-
...(parsed.cacheDir !== undefined ? { cacheDir: parsed.cacheDir } : {}),
|
|
721
|
-
...(parsed.frozen ? { frozen: true } : {}),
|
|
722
|
-
}
|
|
671
|
+
const load = parsed.cacheDir !== undefined ? { cacheDir: parsed.cacheDir } : {}
|
|
723
672
|
// `--filter` / `--affected` scope the menu, as they scope a bare task:
|
|
724
673
|
// the menu listed every project and the anchored pick ran outside it.
|
|
725
674
|
const scope = await scopeFilters(parsed, cwd)
|
|
@@ -868,6 +817,11 @@ export async function runCmd(args: readonly string[]): Promise<number> {
|
|
|
868
817
|
}
|
|
869
818
|
}
|
|
870
819
|
}
|
|
871
|
-
if (summary.json !== undefined) process.stdout.write(`${JSON.stringify(summary.json
|
|
820
|
+
if (summary.json !== undefined) process.stdout.write(`${JSON.stringify(summary.json)}\n`)
|
|
872
821
|
return summary.ok ? 0 : 1
|
|
873
822
|
}
|
|
823
|
+
|
|
824
|
+
/** CI=0 and CI=false are "not CI": several vendors set CI=true. */
|
|
825
|
+
function onCI(v: string | undefined): boolean {
|
|
826
|
+
return v !== undefined && v !== '' && v !== '0' && v !== 'false'
|
|
827
|
+
}
|