@vzn/vx 0.0.639 → 0.0.640
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 +1 -1
- package/docs/caching.md +111 -83
- package/docs/cli.md +70 -40
- package/docs/execution.md +4 -4
- package/docs/features.md +9 -8
- package/docs/patterns.md +1 -1
- package/docs/schema.md +235 -180
- package/docs/security.md +1 -1
- package/package.json +5 -5
- package/schemas/show.json +14 -14
- package/skills/vx/SKILL.md +4 -3
- package/src/cache/cache.ts +4 -1
- package/src/cache/file-hashes.ts +7 -12
- package/src/cache/fs-clock.ts +62 -0
- package/src/cache/git-inputs.ts +2 -2
- package/src/cache/index.ts +3 -0
- package/src/cache/inputs.ts +154 -83
- package/src/cache/key-fold.ts +23 -3
- package/src/cache/layer.ts +8 -7
- package/src/cache/linked-inputs.ts +87 -0
- package/src/cli/foreign-flags.ts +1 -1
- package/src/cli/init.ts +23 -11
- package/src/cli/last.ts +24 -1
- package/src/cli/plugin-templates.ts +2 -2
- package/src/cli/select.ts +16 -6
- package/src/cli/show.ts +9 -6
- package/src/cli/upgrade.ts +95 -19
- package/src/cli/watch-filter.ts +22 -6
- package/src/cli/watch-judge.ts +49 -5
- package/src/cli/watch-set.ts +18 -8
- package/src/cli/watch.ts +74 -2
- package/src/cli/why.ts +5 -3
- package/src/config.ts +19 -15
- package/src/exec/executor.ts +4 -4
- package/src/graph/dependency-spec.ts +2 -2
- package/src/graph/folds.ts +236 -0
- package/src/graph/index.ts +8 -1
- package/src/graph/scheduler.ts +41 -15
- package/src/graph/task-graph.ts +135 -73
- package/src/index.ts +1 -1
- package/src/orchestrator/affected-tasks.ts +20 -19
- package/src/orchestrator/download-policy.ts +16 -22
- package/src/orchestrator/excluded-keys.ts +4 -5
- package/src/orchestrator/execute-task.ts +29 -25
- package/src/orchestrator/index.ts +1 -0
- package/src/orchestrator/keyed-projects.ts +12 -51
- package/src/orchestrator/local-shortcircuit.ts +1 -1
- package/src/orchestrator/persistent-task.ts +3 -3
- package/src/orchestrator/placement.ts +2 -2
- package/src/orchestrator/plan.ts +1 -1
- package/src/orchestrator/plugin-host.ts +62 -22
- package/src/orchestrator/plugin.ts +12 -2
- package/src/orchestrator/prepare.ts +12 -5
- package/src/orchestrator/projects.ts +7 -10
- package/src/orchestrator/run-report.ts +124 -46
- package/src/orchestrator/run-steps.ts +57 -0
- package/src/orchestrator/run.ts +51 -21
- package/src/orchestrator/sandbox-fix.ts +1 -1
- package/src/orchestrator/sandbox-request.ts +2 -2
- package/src/orchestrator/stable-keys.ts +59 -26
- package/src/orchestrator/steps.ts +26 -6
- package/src/orchestrator/task-hash.ts +31 -9
- package/src/orchestrator/telemetry-host.ts +3 -3
- package/src/orchestrator/telemetry.ts +9 -4
- package/src/orchestrator/upstream.ts +4 -181
- package/src/report/framed-output.ts +1 -1
- package/src/report/status-line.ts +7 -3
- package/src/settings.ts +11 -17
- package/src/util/paths.ts +1 -1
- package/src/workspace/affected.ts +81 -2
- package/src/workspace/config-imports.ts +1 -1
- package/src/workspace/config-schema.ts +142 -143
- package/src/workspace/lockfile.ts +1 -1
- package/src/workspace/migration.ts +19 -3
package/src/cli/init.ts
CHANGED
|
@@ -13,7 +13,7 @@ import { gitTop, workspaceFileWritten } from '../handoff.js'
|
|
|
13
13
|
import { flagHint, formatValue, refuse, seeHelp } from './help.js'
|
|
14
14
|
import { lockAfterMigration } from './lock.js'
|
|
15
15
|
import { PLUGIN_TEMPLATES } from './plugin-templates.js'
|
|
16
|
-
import { isCompiledBinary } from './upgrade.js'
|
|
16
|
+
import { isCompiledBinary, targetVersion } from './upgrade.js'
|
|
17
17
|
import { VERSION } from '../version.js'
|
|
18
18
|
import { DOT_VX_GITATTRIBUTES, DOT_VX_GITIGNORE, ui, UserError, sayNotice } from '../util/index.js'
|
|
19
19
|
import {
|
|
@@ -217,8 +217,11 @@ async function starter(root: string, noGit: boolean, args: InitArgs): Promise<nu
|
|
|
217
217
|
}
|
|
218
218
|
|
|
219
219
|
/**
|
|
220
|
-
* A repo with a package.json is `@vzn/vx-migrate`'s
|
|
221
|
-
*
|
|
220
|
+
* A repo with a package.json is `@vzn/vx-migrate`'s, at the latest
|
|
221
|
+
* release: it installs @vzn/vx and every @vzn/vx-* plugin the root lists
|
|
222
|
+
* at its own version, so init always lands on latest with all of them
|
|
223
|
+
* together, as `vx upgrade` moves them (M, 2026-10-16: an installed 0.0.637
|
|
224
|
+
* pinned core back from 0.0.639). It finds the root itself, asks a
|
|
222
225
|
* terminal native or keep in a Turbo or Nx repo, and installs what it writes.
|
|
223
226
|
*/
|
|
224
227
|
async function migrate(dirs: readonly string[], args: InitArgs): Promise<number> {
|
|
@@ -229,21 +232,30 @@ async function migrate(dirs: readonly string[], args: InitArgs): Promise<number>
|
|
|
229
232
|
args.mode !== undefined && `--${args.mode}`,
|
|
230
233
|
args.json === true && '--format=json',
|
|
231
234
|
].filter((f): f is string => typeof f === 'string')
|
|
232
|
-
|
|
235
|
+
// An exact version: `bun x` may answer `@latest` from its cache.
|
|
236
|
+
const latest = VERSION === '0.0.0' ? 'latest' : await targetVersion(undefined, 'vx init')
|
|
237
|
+
return Bun.spawn(migrateCommand(dirs, flags, latest), {
|
|
233
238
|
stdio: ['inherit', 'inherit', 'inherit'],
|
|
234
239
|
// A compiled vx is a Bun runtime only when told so.
|
|
235
240
|
env: isCompiledBinary() ? { ...process.env, BUN_BE_BUN: '1' } : process.env,
|
|
236
241
|
}).exited
|
|
237
242
|
}
|
|
238
243
|
|
|
239
|
-
export function migrateCommand(
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
244
|
+
export function migrateCommand(
|
|
245
|
+
dirs: readonly string[],
|
|
246
|
+
flags: readonly string[],
|
|
247
|
+
latest: string,
|
|
248
|
+
running = VERSION,
|
|
249
|
+
): string[] {
|
|
250
|
+
// A source checkout (0.0.0, which npm never had) runs the vx-migrate
|
|
251
|
+
// beside it; a release never runs an installed one.
|
|
252
|
+
if (running === '0.0.0') {
|
|
253
|
+
for (const d of dirs) {
|
|
254
|
+
const installed = path.join(d, 'node_modules', '@vzn', 'vx-migrate', 'src', 'bin.ts')
|
|
255
|
+
if (existsSync(installed)) return [process.execPath, '--no-install', installed, ...flags]
|
|
256
|
+
}
|
|
243
257
|
}
|
|
244
|
-
// A source checkout is 0.0.0, which npm never had.
|
|
245
|
-
const version = VERSION === '0.0.0' ? 'latest' : VERSION
|
|
246
258
|
// Bun's own install output ("Slow filesystem detected…") in a pnpm or npm
|
|
247
259
|
// repo reads as a stranger speaking (Growth's first-run funnel F2).
|
|
248
|
-
return [process.execPath, 'x', '--silent', `@vzn/vx-migrate@${
|
|
260
|
+
return [process.execPath, 'x', '--silent', `@vzn/vx-migrate@${latest}`, ...flags]
|
|
249
261
|
}
|
package/src/cli/last.ts
CHANGED
|
@@ -22,6 +22,8 @@ import {
|
|
|
22
22
|
exitSignal,
|
|
23
23
|
resolveRunId,
|
|
24
24
|
runFailures,
|
|
25
|
+
type RunStep,
|
|
26
|
+
runSteps,
|
|
25
27
|
shortRunId,
|
|
26
28
|
taskLog,
|
|
27
29
|
} from '../orchestrator/index.js'
|
|
@@ -328,6 +330,22 @@ function reasonParts(t: RunSummaryRow): string {
|
|
|
328
330
|
/** Hit rows shown before the rest fold — the slowest restores. */
|
|
329
331
|
const HITS_SHOWN = 16
|
|
330
332
|
|
|
333
|
+
/**
|
|
334
|
+
* The steps the run did not wait on (`vx:prefetch`), which its own output
|
|
335
|
+
* never showed (M, 2026-10-16): here for debugging, marked `internal`.
|
|
336
|
+
*/
|
|
337
|
+
function formatStepRows(steps: readonly RunStep[]): string[] {
|
|
338
|
+
if (steps.length === 0) return []
|
|
339
|
+
const idW = Math.max(...steps.map((s) => s.id.length), 4)
|
|
340
|
+
return [
|
|
341
|
+
'',
|
|
342
|
+
...steps.map(
|
|
343
|
+
(s) =>
|
|
344
|
+
` ${(s.ok ? 'success' : 'failed').padEnd(17)} ${s.id.padEnd(idW)} ${fmtMs(s.durationMs).padStart(8)} internal`,
|
|
345
|
+
),
|
|
346
|
+
]
|
|
347
|
+
}
|
|
348
|
+
|
|
331
349
|
/**
|
|
332
350
|
* The per-task rows: failures first, then what executed or was skipped,
|
|
333
351
|
* then cache hits. Hits are the noise of a warm run — 996 of a thousand
|
|
@@ -336,6 +354,7 @@ const HITS_SHOWN = 16
|
|
|
336
354
|
* count; the ones shown are the slowest restores, the one thing a hit's
|
|
337
355
|
* row tells. `--format json` lists every row.
|
|
338
356
|
*/
|
|
357
|
+
|
|
339
358
|
export function formatTaskRows(tasks: readonly RunSummaryRow[]): string[] {
|
|
340
359
|
if (tasks.length === 0) return []
|
|
341
360
|
const failed = tasks.filter((t) => t.status === 'failed')
|
|
@@ -545,7 +564,10 @@ export async function lastCmd(args: readonly string[]): Promise<number> {
|
|
|
545
564
|
...(f.fixedIn !== undefined ? { fixedIn: f.fixedIn } : {}),
|
|
546
565
|
}
|
|
547
566
|
})
|
|
548
|
-
|
|
567
|
+
const steps = runSteps(cacheDir, inv.runId)
|
|
568
|
+
process.stdout.write(
|
|
569
|
+
`${JSON.stringify({ invocation: inv, tasks, ...(steps.length > 0 ? { steps } : {}) })}\n`,
|
|
570
|
+
)
|
|
549
571
|
return 0
|
|
550
572
|
}
|
|
551
573
|
|
|
@@ -563,6 +585,7 @@ export async function lastCmd(args: readonly string[]): Promise<number> {
|
|
|
563
585
|
`${inv.failedCount > 0 ? ` · ${inv.failedCount} failed` : ''}`,
|
|
564
586
|
)
|
|
565
587
|
lines.push(...formatTaskRows(detail?.tasks ?? []))
|
|
588
|
+
lines.push(...formatStepRows(runSteps(cacheDir, inv.runId)))
|
|
566
589
|
// The next command after reading a failed run, with the arguments the
|
|
567
590
|
// run forwarded to the tasks that got them (a failure under `-- --shard
|
|
568
591
|
// 2` is that shard's); a dependency never got them, so its line has none.
|
|
@@ -11,7 +11,7 @@ export const PLUGIN_TEMPLATES: Readonly<Record<string, { plugin: string; test: s
|
|
|
11
11
|
cache: {
|
|
12
12
|
plugin:
|
|
13
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
|
|
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 inputs: { files: ['package.json'] }, cache: { 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:
|
|
@@ -31,7 +31,7 @@ export const PLUGIN_TEMPLATES: Readonly<Record<string, { plugin: string; test: s
|
|
|
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 // 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
|
|
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 inputs: { files: ['package.json'] }, cache: { 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:
|
package/src/cli/select.ts
CHANGED
|
@@ -51,7 +51,7 @@ import {
|
|
|
51
51
|
import { resolveSetting } from '../settings.js'
|
|
52
52
|
|
|
53
53
|
/**
|
|
54
|
-
* The projects whose tasks declare
|
|
54
|
+
* The projects whose tasks declare an `inputs.workspaceFiles` glob
|
|
55
55
|
* matching a changed path, one inside another project included (item
|
|
56
56
|
* 954): `--affected` selects them, since the glob is that task's input. Through the run
|
|
57
57
|
* path's staged load, so a glob a `project` plugin gave a config-less
|
|
@@ -71,10 +71,8 @@ export async function workspaceGlobOwners(
|
|
|
71
71
|
): Promise<string[]> {
|
|
72
72
|
const declaresMatch = (config: ProjectConfig): boolean => {
|
|
73
73
|
for (const task of Object.values(config.tasks ?? {})) {
|
|
74
|
-
|
|
75
|
-
if (
|
|
76
|
-
if (changed.some((rel) => declaresInput(cache, null, rel))) return true
|
|
77
|
-
if (nested.some((dir) => workspaceFilesReachInto(cache, dir))) return true
|
|
74
|
+
if (changed.some((rel) => declaresInput(task, null, rel))) return true
|
|
75
|
+
if (nested.some((dir) => workspaceFilesReachInto(task, dir))) return true
|
|
78
76
|
}
|
|
79
77
|
return false
|
|
80
78
|
}
|
|
@@ -335,7 +333,19 @@ export async function resolveFilters(
|
|
|
335
333
|
workspaceGlobOwners: (changed: readonly string[], nested: readonly string[]) =>
|
|
336
334
|
workspaceGlobOwners(root, projects, changed, load, stagedOnce, nested),
|
|
337
335
|
fingerprintClaims: () => workspaceFingerprintClaims(root, projects, load),
|
|
338
|
-
...(git !== undefined
|
|
336
|
+
...(git !== undefined
|
|
337
|
+
? {
|
|
338
|
+
untracked: async () => (await git.start()).untracked,
|
|
339
|
+
// A scoped enumeration lists only its projects: null asks git.
|
|
340
|
+
links: async () => {
|
|
341
|
+
const g = await git.start()
|
|
342
|
+
if (!g.scopes.includes('.')) return null
|
|
343
|
+
const out: string[] = []
|
|
344
|
+
for (const [rel, oid] of g.trusted) if (oid.startsWith('120000:')) out.push(rel)
|
|
345
|
+
return out
|
|
346
|
+
},
|
|
347
|
+
}
|
|
348
|
+
: {}),
|
|
339
349
|
}
|
|
340
350
|
let names: Set<string>
|
|
341
351
|
if (f.raw === affected) {
|
package/src/cli/show.ts
CHANGED
|
@@ -466,14 +466,17 @@ function taskRows(raw: TaskConfig): ui.FieldRow[] {
|
|
|
466
466
|
)
|
|
467
467
|
}
|
|
468
468
|
add('interactive', exec?.interactive === undefined ? undefined : exec.interactive ? 'yes' : 'no')
|
|
469
|
+
const inputs = task.inputs
|
|
470
|
+
if (inputs !== undefined) {
|
|
471
|
+
add('inputs.files', list(inputs.files))
|
|
472
|
+
add('inputs.workspaceFiles', list(inputs.workspaceFiles))
|
|
473
|
+
add('inputs.env', list(inputs.env))
|
|
474
|
+
add('inputs.tasks', list(inputs.tasks))
|
|
475
|
+
add('inputs.runtime', list(inputs.runtime))
|
|
476
|
+
add('inputs.workspaceRuntime', list(inputs.workspaceRuntime))
|
|
477
|
+
}
|
|
469
478
|
const cache = task.cache
|
|
470
479
|
if (cache !== undefined) {
|
|
471
|
-
add('inputs.files', list(cache.inputs.files))
|
|
472
|
-
add('inputs.workspaceFiles', list(cache.inputs.workspaceFiles))
|
|
473
|
-
add('inputs.env', list(cache.inputs.env))
|
|
474
|
-
add('inputs.tasks', list(cache.inputs.tasks))
|
|
475
|
-
add('inputs.runtime', list(cache.inputs.runtime))
|
|
476
|
-
add('inputs.workspaceRuntime', list(cache.inputs.workspaceRuntime))
|
|
477
480
|
add('outputs.files', list(cache.outputs.files))
|
|
478
481
|
add('outputs.workspaceFiles', list(cache.outputs.workspaceFiles))
|
|
479
482
|
}
|
package/src/cli/upgrade.ts
CHANGED
|
@@ -112,13 +112,18 @@ export function versionSpec(tag: string | undefined): string {
|
|
|
112
112
|
* as an internal error with a stack (a network namespace without a
|
|
113
113
|
* route, 2026-09-16). One line, the host and the reason.
|
|
114
114
|
*/
|
|
115
|
-
async function fetchOrRefuse(
|
|
115
|
+
async function fetchOrRefuse(
|
|
116
|
+
url: string,
|
|
117
|
+
init: RequestInit,
|
|
118
|
+
what: string,
|
|
119
|
+
verb = 'vx upgrade',
|
|
120
|
+
): Promise<Response> {
|
|
116
121
|
try {
|
|
117
122
|
return await fetch(url, init)
|
|
118
123
|
} catch (err) {
|
|
119
124
|
const message = err instanceof Error ? err.message : String(err)
|
|
120
125
|
throw new UserError(
|
|
121
|
-
|
|
126
|
+
`${verb}: could not reach ${new URL(url).host} to ${what} (${message}) — check the network or the proxy and re-run`,
|
|
122
127
|
)
|
|
123
128
|
}
|
|
124
129
|
}
|
|
@@ -130,33 +135,43 @@ async function fetchOrRefuse(url: string, init: RequestInit, what: string): Prom
|
|
|
130
135
|
* connection was closed unexpectedly"), which `fetchOrRefuse` never sees
|
|
131
136
|
* and the CLI printed as an internal error with a stack.
|
|
132
137
|
*/
|
|
133
|
-
async function readOrRefuse<T>(
|
|
138
|
+
async function readOrRefuse<T>(
|
|
139
|
+
read: () => Promise<T>,
|
|
140
|
+
url: string,
|
|
141
|
+
what: string,
|
|
142
|
+
verb = 'vx upgrade',
|
|
143
|
+
): Promise<T> {
|
|
134
144
|
try {
|
|
135
145
|
return await read()
|
|
136
146
|
} catch (err) {
|
|
137
147
|
const message = err instanceof Error ? err.message : String(err)
|
|
138
148
|
throw new UserError(
|
|
139
|
-
|
|
149
|
+
`${verb}: could not ${what} from ${new URL(url).host} (${message}) — nothing replaced; check the network or the proxy and re-run`,
|
|
140
150
|
)
|
|
141
151
|
}
|
|
142
152
|
}
|
|
143
153
|
|
|
144
154
|
/** The registry's document for `pkg@spec` (`latest` or a version). */
|
|
145
|
-
export async function fetchManifest(
|
|
155
|
+
export async function fetchManifest(
|
|
156
|
+
pkg: string,
|
|
157
|
+
spec: string,
|
|
158
|
+
verb = 'vx upgrade',
|
|
159
|
+
): Promise<unknown> {
|
|
146
160
|
const url = `${REGISTRY}/${pkg}/${spec}`
|
|
147
161
|
const res = await fetchOrRefuse(
|
|
148
162
|
url,
|
|
149
163
|
{ redirect: 'follow', headers: { Accept: 'application/json', 'User-Agent': `vx/${VERSION}` } },
|
|
150
164
|
'read the release',
|
|
165
|
+
verb,
|
|
151
166
|
)
|
|
152
167
|
if (!res.ok) {
|
|
153
168
|
throw new UserError(
|
|
154
|
-
|
|
169
|
+
`${verb}: could not read ${pkg}@${spec} from the npm registry (${res.status})${res.status === 404 && spec !== 'latest' ? ` — is ${spec} a published version?` : ''}`,
|
|
155
170
|
)
|
|
156
171
|
}
|
|
157
172
|
// A body that is not JSON (a captive portal's page, a proxy's error
|
|
158
173
|
// page served with a 200) is the same refusal as a cut one.
|
|
159
|
-
return await readOrRefuse(() => res.json(), url, 'read the release')
|
|
174
|
+
return await readOrRefuse(() => res.json(), url, 'read the release', verb)
|
|
160
175
|
}
|
|
161
176
|
|
|
162
177
|
/** What a verified registry document names: the version, its tarball and the signed integrity. */
|
|
@@ -395,6 +410,8 @@ export interface ManagedInstall {
|
|
|
395
410
|
cwd: string
|
|
396
411
|
/** The project field that lists `@vzn/vx`. */
|
|
397
412
|
field?: 'dependencies' | 'devDependencies' | 'optionalDependencies'
|
|
413
|
+
/** The `@vzn/vx-*` plugins the same package.json lists from the registry, moved with core. */
|
|
414
|
+
plugins?: string[]
|
|
398
415
|
}
|
|
399
416
|
|
|
400
417
|
const FIELDS = ['devDependencies', 'dependencies', 'optionalDependencies'] as const
|
|
@@ -417,6 +434,45 @@ function listingField(dir: string): ManagedInstall['field'] | undefined {
|
|
|
417
434
|
})
|
|
418
435
|
}
|
|
419
436
|
|
|
437
|
+
/** A release's platform binary, which the launcher pulls in itself: no plugin. */
|
|
438
|
+
const PLATFORM = /^@vzn\/vx-(?:darwin|linux)-(?:x64|arm64)$/
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* The `@vzn/vx-*` plugins `dir`'s package.json lists from the registry
|
|
442
|
+
* (a `workspace:` or `file:` link is the repo's own). Every @vzn package
|
|
443
|
+
* releases together, so they move with core in its one install (M,
|
|
444
|
+
* 2026-10-16); one in another field would move fields under core's flag,
|
|
445
|
+
* so it is refused.
|
|
446
|
+
*/
|
|
447
|
+
function pluginsBeside(dir: string, field: NonNullable<ManagedInstall['field']>): string[] {
|
|
448
|
+
const pkg = readJson(path.join(dir, 'package.json'))
|
|
449
|
+
const plugins: string[] = []
|
|
450
|
+
for (const f of FIELDS) {
|
|
451
|
+
const deps = pkg?.[f]
|
|
452
|
+
if (typeof deps !== 'object' || deps === null) continue
|
|
453
|
+
for (const [name, spec] of Object.entries(deps as Record<string, unknown>)) {
|
|
454
|
+
if (!name.startsWith(`${PACKAGE}-`) || PLATFORM.test(name)) continue
|
|
455
|
+
if (typeof spec !== 'string' || /^(?:workspace|file|link|portal):/.test(spec)) continue
|
|
456
|
+
if (f !== field) {
|
|
457
|
+
throw new UserError(
|
|
458
|
+
`vx upgrade: ${name} is in ${f} and ${PACKAGE} in ${field}; list them in one field so one install moves them together`,
|
|
459
|
+
)
|
|
460
|
+
}
|
|
461
|
+
plugins.push(name)
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
return plugins.sort()
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/** The version of `pkg` installed where `dir` resolves it, if any. */
|
|
468
|
+
function installedVersion(dir: string, pkg: string): string | undefined {
|
|
469
|
+
for (let d = dir; ; d = path.dirname(d)) {
|
|
470
|
+
const v = readJson(path.join(d, 'node_modules', pkg, 'package.json'))?.['version']
|
|
471
|
+
if (typeof v === 'string') return v
|
|
472
|
+
if (path.dirname(d) === d) return undefined
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
420
476
|
/** The project's package manager: its lockfile, then `packageManager`, then the tree's shape. */
|
|
421
477
|
function projectManager(dir: string, execPath: string): ManagedInstall['manager'] {
|
|
422
478
|
for (let d = dir; ; d = path.dirname(d)) {
|
|
@@ -463,12 +519,15 @@ export function managedInstall(execPath: string, cwd: string): ManagedInstall |
|
|
|
463
519
|
if (/\/yarn\/global\//.test(p)) return { manager: 'yarn', scope: 'global', cwd: root }
|
|
464
520
|
const field = listingField(root)
|
|
465
521
|
if (field !== undefined) {
|
|
466
|
-
|
|
522
|
+
const plugins = pluginsBeside(root, field)
|
|
523
|
+
return { manager: projectManager(root, execPath), scope: 'project', cwd: root, field, plugins }
|
|
467
524
|
}
|
|
468
525
|
for (let d = path.resolve(cwd); d.startsWith(root); d = path.dirname(d)) {
|
|
469
526
|
const f = listingField(d)
|
|
470
|
-
if (f !== undefined)
|
|
471
|
-
|
|
527
|
+
if (f !== undefined) {
|
|
528
|
+
const plugins = pluginsBeside(d, f)
|
|
529
|
+
return { manager: projectManager(d, execPath), scope: 'project', cwd: d, field: f, plugins }
|
|
530
|
+
}
|
|
472
531
|
if (path.dirname(d) === d) break
|
|
473
532
|
}
|
|
474
533
|
// npm's global tree: `<prefix>/lib/node_modules` (nvm, volta, Homebrew alike).
|
|
@@ -503,7 +562,8 @@ export function upgradeArgv(install: ManagedInstall, version: string): string[]
|
|
|
503
562
|
bun: { devDependencies: '--dev', dependencies: '', optionalDependencies: '--optional' },
|
|
504
563
|
yarn: { devDependencies: '--dev', dependencies: '', optionalDependencies: '--optional' },
|
|
505
564
|
}[manager][field]
|
|
506
|
-
const
|
|
565
|
+
const specs = [spec, ...(install.plugins ?? []).map((p) => `${p}@${version}`)]
|
|
566
|
+
const argv = manager === 'npm' ? ['npm', 'install', ...specs] : [manager, 'add', ...specs]
|
|
507
567
|
if (flag !== '') argv.push(flag)
|
|
508
568
|
// pnpm refuses an `add` at a workspace's root without saying it means the root.
|
|
509
569
|
if (manager === 'pnpm' && existsSync(path.join(install.cwd, 'pnpm-workspace.yaml')))
|
|
@@ -511,13 +571,17 @@ export function upgradeArgv(install: ManagedInstall, version: string): string[]
|
|
|
511
571
|
return argv
|
|
512
572
|
}
|
|
513
573
|
|
|
514
|
-
/**
|
|
515
|
-
|
|
574
|
+
/**
|
|
575
|
+
* The version `tag` names, or the registry's latest: read for the "already
|
|
576
|
+
* at" check, and by `vx init`, which always installs the latest release.
|
|
577
|
+
*/
|
|
578
|
+
export async function targetVersion(tag: string | undefined, verb = 'vx upgrade'): Promise<string> {
|
|
516
579
|
const spec = versionSpec(tag)
|
|
517
580
|
if (spec !== 'latest') return spec
|
|
518
|
-
const v = ((await fetchManifest(PACKAGE, 'latest')) as { version?: unknown } | null)
|
|
581
|
+
const v = ((await fetchManifest(PACKAGE, 'latest', verb)) as { version?: unknown } | null)
|
|
582
|
+
?.version
|
|
519
583
|
if (typeof v !== 'string' || !/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(v)) {
|
|
520
|
-
throw new UserError(
|
|
584
|
+
throw new UserError(`${verb}: the npm registry named no latest version of ${PACKAGE}`)
|
|
521
585
|
}
|
|
522
586
|
return v
|
|
523
587
|
}
|
|
@@ -527,13 +591,14 @@ async function targetVersion(tag: string | undefined): Promise<string> {
|
|
|
527
591
|
* `version`; vx never installs one. `step` names the install when a run
|
|
528
592
|
* moves two (`project `, `global `).
|
|
529
593
|
*/
|
|
530
|
-
async function upgradeManaged(
|
|
594
|
+
export async function upgradeManaged(
|
|
531
595
|
install: ManagedInstall,
|
|
532
596
|
version: string,
|
|
533
597
|
current = VERSION,
|
|
534
598
|
step = '',
|
|
535
599
|
): Promise<void> {
|
|
536
|
-
|
|
600
|
+
const plugins = install.plugins ?? []
|
|
601
|
+
if (version === current && plugins.every((p) => installedVersion(install.cwd, p) === version)) {
|
|
537
602
|
ui.print('out', ui.status('info', 'vx upgrade', `${step}already at ${current}`))
|
|
538
603
|
return
|
|
539
604
|
}
|
|
@@ -547,7 +612,11 @@ async function upgradeManaged(
|
|
|
547
612
|
}
|
|
548
613
|
ui.print(
|
|
549
614
|
'out',
|
|
550
|
-
ui.status(
|
|
615
|
+
ui.status(
|
|
616
|
+
'info',
|
|
617
|
+
'vx upgrade',
|
|
618
|
+
`${step}${version === current ? 'plugins' : current} → ${version}`,
|
|
619
|
+
),
|
|
551
620
|
ui.text([ui.cmd(argv.join(' ')), ui.dim(` in ${install.cwd}`)]),
|
|
552
621
|
)
|
|
553
622
|
const proc = Bun.spawnSync({
|
|
@@ -557,7 +626,14 @@ async function upgradeManaged(
|
|
|
557
626
|
})
|
|
558
627
|
if (proc.exitCode !== 0) {
|
|
559
628
|
throw new UserError(
|
|
560
|
-
`vx upgrade: ${argv.join(' ')} failed (exit ${proc.exitCode}) — vx changed nothing itself
|
|
629
|
+
`vx upgrade: ${argv.join(' ')} failed (exit ${proc.exitCode}) — vx changed nothing itself${
|
|
630
|
+
// The install is pinned, so pnpm refuses a release younger than
|
|
631
|
+
// its minimumReleaseAge instead of mixing releases; its words
|
|
632
|
+
// print above, so the way out is named (as vx-migrate does).
|
|
633
|
+
install.manager === 'pnpm'
|
|
634
|
+
? `; if pnpm refused a version published within its minimumReleaseAge, list ${[PACKAGE, ...plugins].join(', ')} under minimumReleaseAgeExclude in pnpm-workspace.yaml`
|
|
635
|
+
: ''
|
|
636
|
+
}`,
|
|
561
637
|
)
|
|
562
638
|
}
|
|
563
639
|
}
|
package/src/cli/watch-filter.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// rules, git-ignored paths, the root arm's filter) and which files reshape
|
|
3
3
|
// the watched set. Pure decisions over paths; the loop acts on them.
|
|
4
4
|
|
|
5
|
+
import fs from 'node:fs'
|
|
5
6
|
import path from 'node:path'
|
|
6
7
|
import {
|
|
7
8
|
executablePath,
|
|
@@ -369,12 +370,14 @@ export function gitSpeller(root: string): (p: string) => string {
|
|
|
369
370
|
* a project moved away whole is one event on its directory): what existed
|
|
370
371
|
* when watch armed, so a path born and gone since is told from a deletion
|
|
371
372
|
* (watch-judge.ts). `tracked` is the files git tracks: a user edits those,
|
|
372
|
-
* so a server cannot be blamed for one.
|
|
373
|
+
* so a server cannot be blamed for one. `links` is the symlinks among the
|
|
374
|
+
* files: a key reads what each points to (cache/linked-inputs.ts), so watch
|
|
375
|
+
* arms that too. Undefined when git cannot answer:
|
|
373
376
|
* no inventory, every gone path is a deletion as before.
|
|
374
377
|
*/
|
|
375
378
|
export function gitFiles(
|
|
376
379
|
workspaceRoot: string,
|
|
377
|
-
): { listed: Set<string>; tracked: Set<string> } | undefined {
|
|
380
|
+
): { listed: Set<string>; tracked: Set<string>; links: Set<string> } | undefined {
|
|
378
381
|
let proc: ReturnType<typeof Bun.spawnSync>
|
|
379
382
|
try {
|
|
380
383
|
proc = Bun.spawnSync({
|
|
@@ -383,6 +386,7 @@ export function gitFiles(
|
|
|
383
386
|
'ls-files',
|
|
384
387
|
'-z',
|
|
385
388
|
'-t',
|
|
389
|
+
'-s',
|
|
386
390
|
'--cached',
|
|
387
391
|
'--others',
|
|
388
392
|
'--exclude-standard',
|
|
@@ -398,18 +402,30 @@ export function gitFiles(
|
|
|
398
402
|
if (proc.exitCode !== 0) return undefined
|
|
399
403
|
const files = new Set<string>()
|
|
400
404
|
const tracked = new Set<string>()
|
|
405
|
+
const links = new Set<string>()
|
|
401
406
|
for (const entry of new TextDecoder('utf-8', { ignoreBOM: true })
|
|
402
407
|
.decode(proc.stdout)
|
|
403
408
|
.split('\0')) {
|
|
404
409
|
if (entry.length === 0) continue
|
|
405
|
-
// `-t` prefixes a tag and a space; `?` is untracked.
|
|
406
|
-
|
|
410
|
+
// `-t` prefixes a tag and a space; `?` is untracked, a bare path. A
|
|
411
|
+
// tracked entry is `-s`'s `<mode> <oid> <stage>\t<path>` after the tag.
|
|
412
|
+
const untracked = entry[0] === '?'
|
|
413
|
+
const p = untracked ? entry.slice(2) : entry.slice(entry.indexOf('\t') + 1)
|
|
407
414
|
// An untracked nested repository is listed as `dir/`.
|
|
408
415
|
let abs = path.join(workspaceRoot, p.endsWith('/') ? p.slice(0, -1) : p)
|
|
409
416
|
files.add(abs)
|
|
410
|
-
if (
|
|
417
|
+
if (!untracked) tracked.add(abs)
|
|
418
|
+
if (untracked ? isLink(abs) : entry.startsWith('120000 ', 2)) links.add(abs)
|
|
411
419
|
for (abs = path.dirname(abs); abs !== workspaceRoot && !files.has(abs); abs = path.dirname(abs))
|
|
412
420
|
files.add(abs)
|
|
413
421
|
}
|
|
414
|
-
return { listed: files, tracked }
|
|
422
|
+
return { listed: files, tracked, links }
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function isLink(abs: string): boolean {
|
|
426
|
+
try {
|
|
427
|
+
return fs.lstatSync(abs).isSymbolicLink()
|
|
428
|
+
} catch {
|
|
429
|
+
return false
|
|
430
|
+
}
|
|
415
431
|
}
|
package/src/cli/watch-judge.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
import fs from 'node:fs'
|
|
6
6
|
import path from 'node:path'
|
|
7
|
+
import { filesBehind } from '../cache/index.js'
|
|
7
8
|
import { xxh3 } from '../util/index.js'
|
|
8
9
|
import { gitIgnored, gitSpeller } from './watch-filter.js'
|
|
9
10
|
import { modifiedBefore } from './watch-fs.js'
|
|
@@ -13,9 +14,10 @@ const ABSENT = -1n
|
|
|
13
14
|
function settledState(abs: string): bigint {
|
|
14
15
|
let st: fs.Stats
|
|
15
16
|
try {
|
|
16
|
-
// A link is its target string, as the key folds
|
|
17
|
-
// a retarget to equal bytes as "the same"
|
|
18
|
-
if (fs.lstatSync(abs).isSymbolicLink())
|
|
17
|
+
// A link is its target string and what it points to, as the key folds
|
|
18
|
+
// it: following it alone saw a retarget to equal bytes as "the same".
|
|
19
|
+
if (fs.lstatSync(abs).isSymbolicLink())
|
|
20
|
+
return xxh3(`link:${fs.readlinkSync(abs)}\0${behindState(abs)}`)
|
|
19
21
|
st = fs.statSync(abs)
|
|
20
22
|
} catch {
|
|
21
23
|
return ABSENT
|
|
@@ -50,6 +52,34 @@ function settledState(abs: string): bigint {
|
|
|
50
52
|
return xxh3(Buffer.from(entries.join('\n')))
|
|
51
53
|
}
|
|
52
54
|
|
|
55
|
+
/**
|
|
56
|
+
* What a link points to, as change detection reads it: a file's bytes and
|
|
57
|
+
* mode, a directory's files by size and times (cache/linked-inputs.ts keys
|
|
58
|
+
* their bytes), nothing for a dangling link.
|
|
59
|
+
*/
|
|
60
|
+
function behindState(link: string): string {
|
|
61
|
+
let real: string
|
|
62
|
+
let st: fs.Stats
|
|
63
|
+
try {
|
|
64
|
+
real = fs.realpathSync(link)
|
|
65
|
+
st = fs.statSync(real)
|
|
66
|
+
} catch {
|
|
67
|
+
return ''
|
|
68
|
+
}
|
|
69
|
+
if (st.isFile()) return `${xxh3(fs.readFileSync(real))}:${st.mode & 0o7777}`
|
|
70
|
+
if (!st.isDirectory()) return ''
|
|
71
|
+
return filesBehind(real)
|
|
72
|
+
.map((rel) => {
|
|
73
|
+
try {
|
|
74
|
+
const f = fs.statSync(path.join(real, rel))
|
|
75
|
+
return `${rel}\0${f.size}\0${f.mtimeMs}\0${f.ctimeMs}`
|
|
76
|
+
} catch {
|
|
77
|
+
return rel
|
|
78
|
+
}
|
|
79
|
+
})
|
|
80
|
+
.join('\n')
|
|
81
|
+
}
|
|
82
|
+
|
|
53
83
|
/** What the judgement reads from the loop, which owns it and may replace it. */
|
|
54
84
|
export interface JudgeContext {
|
|
55
85
|
workspaceRoot: string
|
|
@@ -59,6 +89,8 @@ export interface JudgeContext {
|
|
|
59
89
|
held(): boolean
|
|
60
90
|
/** Projects whose uncached task may read a git-ignored file. */
|
|
61
91
|
uncached(): ReadonlySet<string>
|
|
92
|
+
/** Paths a task's inputs name outright: a git-ignored file at or under one is an input. */
|
|
93
|
+
namedIgnored?(): readonly string[]
|
|
62
94
|
/** Whether `abs`, under the project at `ownDir`, lies in a project nested there (`makeFence`). */
|
|
63
95
|
fenced?(ownDir: string, abs: string): boolean
|
|
64
96
|
/** The files git listed at the arm; absent when git could not answer. */
|
|
@@ -113,6 +145,15 @@ export class ChangeJudge {
|
|
|
113
145
|
|
|
114
146
|
constructor(private readonly ctx: JudgeContext) {}
|
|
115
147
|
|
|
148
|
+
/**
|
|
149
|
+
* Record a path's state as it is, if this loop has not: a link armed for
|
|
150
|
+
* what it points to, whose own times say "before the arm" whatever
|
|
151
|
+
* changed behind it, so its first sighting compares against this.
|
|
152
|
+
*/
|
|
153
|
+
seed(abs: string): void {
|
|
154
|
+
if (!this.lastState.has(abs)) this.lastState.set(abs, settledState(abs))
|
|
155
|
+
}
|
|
156
|
+
|
|
116
157
|
// A path this loop has never judged is a change only if it moved since
|
|
117
158
|
// the watchers went live (its mtime or ctime, `modifiedBefore`). macOS delivers the initial run's own writes
|
|
118
159
|
// AFTER the arm (CI, 2026-09-11: `app dist; re-running...` with no edit
|
|
@@ -192,10 +233,13 @@ export class ChangeJudge {
|
|
|
192
233
|
// user wrote it; one the last cycle wrote (the pid file an uncached
|
|
193
234
|
// task rewrites every run, the loop the filter exists for) stays out,
|
|
194
235
|
// as does one in a project nested under it, which it does not own.
|
|
236
|
+
// So is one a task's inputs name outright (`.env.local`, `dist/**`).
|
|
195
237
|
const editForUncached = (p: string): boolean =>
|
|
196
|
-
[...this.ctx.uncached()].some(
|
|
238
|
+
([...this.ctx.uncached()].some(
|
|
197
239
|
(dir) => p.startsWith(dir + path.sep) && this.ctx.fenced?.(dir, p) !== true,
|
|
198
|
-
)
|
|
240
|
+
) ||
|
|
241
|
+
(this.ctx.namedIgnored?.() ?? []).some((n) => p === n || p.startsWith(n + path.sep))) &&
|
|
242
|
+
!this.writtenDuringLastCycle(p)
|
|
199
243
|
// A path still there names the cycle before a gone one: an editor or
|
|
200
244
|
// `sed -i` saving through a temporary file fires that file first, and
|
|
201
245
|
// the cycle was announced by a name already renamed away.
|