@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
|
@@ -1,16 +1,15 @@
|
|
|
1
1
|
// The migration seam: what any adoption tool needs once it has a plan —
|
|
2
2
|
// the plan's shape, TS emission, the overwrite guard, the writes and the
|
|
3
|
-
// report.
|
|
4
|
-
//
|
|
5
|
-
// tool wrote it. Core knows no source format here: a mapper returns a
|
|
3
|
+
// report. `@vzn/vx-migrate` (package.json scripts, Turbo, Nx) and any
|
|
4
|
+
// other adoption tool share it, so a generated config reads the same
|
|
5
|
+
// whichever tool wrote it. Core knows no source format here: a mapper returns a
|
|
6
6
|
// `MigrationPlan` and this file does the rest.
|
|
7
7
|
|
|
8
|
-
import { existsSync } from 'node:fs'
|
|
8
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
9
9
|
import { unlink } from 'node:fs/promises'
|
|
10
10
|
import path from 'node:path'
|
|
11
|
-
import {
|
|
12
|
-
import
|
|
13
|
-
import { loadWorkspace, reachesManifest, unreachedHint, unreachedPackages } from './workspace.js'
|
|
11
|
+
import { workspaceFileWritten } from '../handoff.js'
|
|
12
|
+
import { DOT_VX_GITIGNORE, executablePath, gitEnv, relPosix, UserError } from '../util/index.js'
|
|
14
13
|
import { WORKSPACE_CONFIG_FILENAMES } from './project-loader.js'
|
|
15
14
|
|
|
16
15
|
/**
|
|
@@ -29,19 +28,6 @@ export function quoteTsLiteral(s: string): string {
|
|
|
29
28
|
.replaceAll('\r', '\\r')}'`
|
|
30
29
|
}
|
|
31
30
|
|
|
32
|
-
/**
|
|
33
|
-
* Task names that conventionally never exit. Every mapper guesses the same
|
|
34
|
-
* way from a name — a source that SAYS a task is long-running (turbo's
|
|
35
|
-
* `persistent: true`, an Nx dev-server executor) is believed instead.
|
|
36
|
-
*/
|
|
37
|
-
export const PERSISTENT_TASK_NAMES: ReadonlySet<string> = new Set([
|
|
38
|
-
'dev',
|
|
39
|
-
'start',
|
|
40
|
-
'serve',
|
|
41
|
-
'watch',
|
|
42
|
-
'preview',
|
|
43
|
-
])
|
|
44
|
-
|
|
45
31
|
/**
|
|
46
32
|
* A package.json script with the `pre<name>` / `post<name>` hooks npm runs
|
|
47
33
|
* around it, as ONE sh command. Each part runs in its own subshell, so a
|
|
@@ -80,73 +66,6 @@ const QUOTE_ARGS =
|
|
|
80
66
|
`vx_r="$vx_r\${vx_s%%"$vx_q"*}'\\''"; vx_s=\${vx_s#*"$vx_q"};; *) break;; esac; done; ` +
|
|
81
67
|
`vx_a="$vx_a '$vx_r$vx_s'"; done`
|
|
82
68
|
|
|
83
|
-
/**
|
|
84
|
-
* Where a framework's build writes by default, for the cache TODO: the
|
|
85
|
-
* hint said `dist/**` to every build, `next build` included, which writes
|
|
86
|
-
* `.next`. Each tool's documented default; anything else keeps `dist/**`.
|
|
87
|
-
*/
|
|
88
|
-
const BUILD_OUTPUTS: readonly [RegExp, readonly string[]][] = [
|
|
89
|
-
[/\snext build\s/, ['.next/**', '!.next/cache/**']],
|
|
90
|
-
[/\snux[ti] build\s/, ['.output/**']],
|
|
91
|
-
[/\s(?:remix|react-router|react-scripts|docusaurus) build\s/, ['build/**']],
|
|
92
|
-
[/\sgatsby build\s/, ['public/**']],
|
|
93
|
-
[/\s(?:storybook build|build-storybook)\s/, ['storybook-static/**']],
|
|
94
|
-
]
|
|
95
|
-
|
|
96
|
-
/** A directory a TODO may name: relative, plain, no hidden or tool dir. */
|
|
97
|
-
const OUTPUT_DIR = /^(?:\.\/)?((?![./])[\w@-][\w@./-]*?)\/?$/
|
|
98
|
-
|
|
99
|
-
/**
|
|
100
|
-
* The directory the command says it writes, when no tool above names one:
|
|
101
|
-
* an out-dir flag (`tsc --outDir lib`, `babel -d lib`), else the
|
|
102
|
-
* directories it cleans first (`del-cli distribution`, `rimraf dist
|
|
103
|
-
* types`). ky's build writes `distribution/`, and the TODO said `dist/**`
|
|
104
|
-
* (D-90). A file (`tsconfig.tsbuildinfo`) and `node_modules` are no output.
|
|
105
|
-
*/
|
|
106
|
-
function namedOutputs(command: string): string[] | undefined {
|
|
107
|
-
const dir = (w: string | undefined): string | undefined => {
|
|
108
|
-
const m = w === undefined ? null : OUTPUT_DIR.exec(w.replace(/^(["'])(.*)\1$/, '$2'))
|
|
109
|
-
if (m === null) return undefined
|
|
110
|
-
const d = m[1]!
|
|
111
|
-
return /\.[a-z]+$/i.test(d) || d.split('/').includes('node_modules') ? undefined : d
|
|
112
|
-
}
|
|
113
|
-
const flag = /\s(?:--outDir|--out-dir|--outdir|-d)[\s=](\S+)/.exec(` ${command} `)
|
|
114
|
-
const out = dir(flag?.[1])
|
|
115
|
-
if (out !== undefined) return [`${out}/**`]
|
|
116
|
-
for (const segment of command.split(/[;&|()]+/)) {
|
|
117
|
-
const words = segment.trim().split(/\s+/)
|
|
118
|
-
const at =
|
|
119
|
-
words[0] === 'rimraf' || words[0] === 'del-cli' || words[0] === 'del'
|
|
120
|
-
? 1
|
|
121
|
-
: words[0] === 'rm' || (words[0] === 'shx' && words[1] === 'rm')
|
|
122
|
-
? words.indexOf('rm') + 1
|
|
123
|
-
: -1
|
|
124
|
-
if (at < 0) continue
|
|
125
|
-
const args = words.slice(at).filter((w) => !w.startsWith('-'))
|
|
126
|
-
// A dir the command makes again (kit's `rm -rf ./missing_ids && mkdir
|
|
127
|
-
// ./missing_ids && vite build`) is a scratch dir it prepares, not output.
|
|
128
|
-
const made = new Set([...command.matchAll(/\bmkdir\s+(?:-p\s+)?(\S+)/g)].map((m) => dir(m[1])))
|
|
129
|
-
const dirs = args
|
|
130
|
-
.map(dir)
|
|
131
|
-
.filter((d): d is string => d !== undefined)
|
|
132
|
-
.filter((d) => !made.has(d))
|
|
133
|
-
// Every cleaned path a directory vx can name, or the guess is not this.
|
|
134
|
-
return dirs.length > 0 && dirs.length === args.filter((w) => !/\.[a-z]+$/i.test(w)).length
|
|
135
|
-
? dirs.map((d) => `${d}/**`)
|
|
136
|
-
: undefined
|
|
137
|
-
}
|
|
138
|
-
return undefined
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
/** The cache block the task that builds `command` should declare, as a TODO on it. */
|
|
142
|
-
export function cacheTodo(command: string): string {
|
|
143
|
-
// Padded, separators as spaces: `next build && …` and `(next build)` match.
|
|
144
|
-
const words = ` ${command.replace(/[;&|()]/g, ' ')} `
|
|
145
|
-
const hit = BUILD_OUTPUTS.find(([re]) => re.test(words))
|
|
146
|
-
const outputs = (hit?.[1] ?? namedOutputs(command) ?? ['dist/**']).map((o) => `'${o}'`).join(', ')
|
|
147
|
-
return `cache: add \`cache: { inputs: { files: ['src/**'] }, outputs: { files: [${outputs}] } }\` with this package's real inputs and outputs — without it the task always runs and every file here, what it writes included, folds into the key its dependents fold; a block with EMPTY outputs would be a cached no-op, not an uncached task`
|
|
148
|
-
}
|
|
149
|
-
|
|
150
69
|
const isCacheTodo = (todo: string): boolean => todo.startsWith('cache: add `cache: {')
|
|
151
70
|
|
|
152
71
|
/** The one wording every mapper emits for a task it made persistent. */
|
|
@@ -230,9 +149,21 @@ export interface MigrationPlan {
|
|
|
230
149
|
notes: string[]
|
|
231
150
|
}
|
|
232
151
|
|
|
152
|
+
/**
|
|
153
|
+
* A project the adoption tool found: its name, directory and the vx config
|
|
154
|
+
* it already has. `packageJson.type` picks `vx.config.mts` for a CommonJS
|
|
155
|
+
* TypeScript package.
|
|
156
|
+
*/
|
|
157
|
+
export interface MigrationMember {
|
|
158
|
+
readonly name: string
|
|
159
|
+
readonly dir: string
|
|
160
|
+
readonly configPath: string | null
|
|
161
|
+
readonly packageJson?: object
|
|
162
|
+
}
|
|
163
|
+
|
|
233
164
|
export interface ApplyMigrationArgs {
|
|
234
165
|
root: string
|
|
235
|
-
metas: readonly
|
|
166
|
+
metas: readonly MigrationMember[]
|
|
236
167
|
plan: MigrationPlan
|
|
237
168
|
/** Named in the report and the generated header, e.g. `turbo.json`. */
|
|
238
169
|
source: string
|
|
@@ -241,9 +172,9 @@ export interface ApplyMigrationArgs {
|
|
|
241
172
|
dry: boolean
|
|
242
173
|
force: boolean
|
|
243
174
|
/**
|
|
244
|
-
* `vx init` on a workspace with no
|
|
245
|
-
* file, and a worked example); a
|
|
246
|
-
* error. Default false.
|
|
175
|
+
* A scripts migration (what `vx init` hands over) on a workspace with no
|
|
176
|
+
* scripts still has a job (the workspace file, and a worked example); a
|
|
177
|
+
* migration with nothing to convert is an error. Default false.
|
|
247
178
|
*/
|
|
248
179
|
init?: boolean
|
|
249
180
|
/** Report lines printed under the source line (e.g. "turbo.json found and not read"). */
|
|
@@ -254,6 +185,11 @@ export interface ApplyMigrationArgs {
|
|
|
254
185
|
* of them (M-59).
|
|
255
186
|
*/
|
|
256
187
|
unmapped?: boolean
|
|
188
|
+
/**
|
|
189
|
+
* Why an `init` plan is empty, when the tool knows (members its globs
|
|
190
|
+
* never reach): the report's one line in place of the example config.
|
|
191
|
+
*/
|
|
192
|
+
emptyReason?: string
|
|
257
193
|
/**
|
|
258
194
|
* `ts` (default) writes `vx.config.ts` with the type-only import and
|
|
259
195
|
* `satisfies`; `mjs` writes `vx.config.mjs` — the same object, untyped —
|
|
@@ -266,6 +202,21 @@ export interface ApplyMigrationArgs {
|
|
|
266
202
|
* of the files and the report, so an agent reads the plan as data.
|
|
267
203
|
*/
|
|
268
204
|
json?: boolean
|
|
205
|
+
/**
|
|
206
|
+
* Run after the files are written (not under `dry`): `vx lock`'s step, so
|
|
207
|
+
* the migrated workspace has its .vx/projects.lock.json and its graph rules checked
|
|
208
|
+
* (owner, 2026-10-14). Prints its own line. A refusal (a graph rule, a
|
|
209
|
+
* plugin not installed yet) is printed and the migration still succeeds:
|
|
210
|
+
* its files are written, and `vx lock` is the step to re-run.
|
|
211
|
+
*/
|
|
212
|
+
lock?: (root: string) => Promise<number>
|
|
213
|
+
/** What the written workspace file declares, e.g. `pnpm()` from `@vzn/vx-lockfile`. */
|
|
214
|
+
plugins?: readonly WorkspacePlugin[]
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export interface WorkspacePlugin {
|
|
218
|
+
readonly pkg: string
|
|
219
|
+
readonly factory: string
|
|
269
220
|
}
|
|
270
221
|
|
|
271
222
|
export type MigrationFormat = 'ts' | 'mjs'
|
|
@@ -279,15 +230,25 @@ export async function applyMigration(args: ApplyMigrationArgs): Promise<number>
|
|
|
279
230
|
const init = args.init === true
|
|
280
231
|
const format: MigrationFormat = args.format ?? 'ts'
|
|
281
232
|
const configName = `vx.config.${format}`
|
|
282
|
-
const workspaceName =
|
|
233
|
+
const workspaceName = `.vx/workspace.${format}`
|
|
283
234
|
const empty = plan.projects.length === 0
|
|
284
|
-
// `
|
|
235
|
+
// An `init` migration never overwrites a member's config: a half-adopted workspace
|
|
285
236
|
// keeps each one it has, by name, and gets the rest (M-52). It refused
|
|
286
237
|
// the whole workspace, and `--force` would have replaced the hand-written
|
|
287
238
|
// file; `--force` still replaces, as asked.
|
|
288
239
|
const kept: string[] = []
|
|
289
|
-
|
|
290
|
-
|
|
240
|
+
// Every project gets a config, a task-less one too (owner, 2026-10-14:
|
|
241
|
+
// "no skips. every project gets a config"): solidjs/solid's solid-ssr,
|
|
242
|
+
// with no script Turbo runs, was left with none and read as a project
|
|
243
|
+
// whose tasks are not locked.
|
|
244
|
+
const planned = new Set(plan.projects.map((p) => p.dir))
|
|
245
|
+
const unplanned: GeneratedProject[] = metas
|
|
246
|
+
.filter((m) => !planned.has(m.dir))
|
|
247
|
+
.map((m) => ({ name: m.name, dir: m.dir, importLines: [], tasks: [] }))
|
|
248
|
+
const projects = [...plan.projects, ...unplanned].filter((p) => {
|
|
249
|
+
// An empty config never replaces one a project has, --force or not.
|
|
250
|
+
if (p.tasks.length === 0) return !metas.some((m) => m.dir === p.dir && m.configPath)
|
|
251
|
+
if (!init || force) return true
|
|
291
252
|
const existing = metas.find((m) => m.dir === p.dir)?.configPath
|
|
292
253
|
if (!existing) return true
|
|
293
254
|
kept.push(relPosix(root, existing))
|
|
@@ -305,14 +266,14 @@ export async function applyMigration(args: ApplyMigrationArgs): Promise<number>
|
|
|
305
266
|
)
|
|
306
267
|
}
|
|
307
268
|
|
|
269
|
+
const names = new Map(projects.map((p) => [p.dir, projectConfigName(p.dir, metas, format)]))
|
|
308
270
|
const files: { relPath: string; abs: string; contents: string }[] = []
|
|
309
271
|
for (const p of projects) {
|
|
310
|
-
|
|
311
|
-
const abs = path.join(p.dir, configName)
|
|
272
|
+
const abs = path.join(p.dir, names.get(p.dir)!)
|
|
312
273
|
files.push({
|
|
313
274
|
relPath: relPosix(root, abs),
|
|
314
275
|
abs,
|
|
315
|
-
contents: renderConfigFile(p, format),
|
|
276
|
+
contents: renderConfigFile(ownConfigNamed(p, names.get(p.dir)!, format), format),
|
|
316
277
|
})
|
|
317
278
|
}
|
|
318
279
|
for (const f of plan.extraFiles) {
|
|
@@ -328,7 +289,17 @@ export async function applyMigration(args: ApplyMigrationArgs): Promise<number>
|
|
|
328
289
|
// A plan may bring its own (vx-migrate declares the plugins it detects).
|
|
329
290
|
if (!hasWorkspaceFile && !plan.extraFiles.some((f) => f.relPath === workspaceName)) {
|
|
330
291
|
const abs = path.join(root, workspaceName)
|
|
331
|
-
files.push({
|
|
292
|
+
files.push({
|
|
293
|
+
relPath: relPosix(root, abs),
|
|
294
|
+
abs,
|
|
295
|
+
contents: workspaceFile(format, args.plugins ?? []),
|
|
296
|
+
})
|
|
297
|
+
}
|
|
298
|
+
// `.vx/.gitignore` keeps the machine-local `.vx/cache` out of git and the
|
|
299
|
+
// workspace config and lock in; one already there is the user's.
|
|
300
|
+
const dotVxIgnore = path.join(root, '.vx', '.gitignore')
|
|
301
|
+
if (!(await Bun.file(dotVxIgnore).exists())) {
|
|
302
|
+
files.push({ relPath: '.vx/.gitignore', abs: dotVxIgnore, contents: DOT_VX_GITIGNORE })
|
|
332
303
|
}
|
|
333
304
|
|
|
334
305
|
if (!dry && !force) {
|
|
@@ -336,7 +307,6 @@ export async function applyMigration(args: ApplyMigrationArgs): Promise<number>
|
|
|
336
307
|
// A discovered project with ANY existing vx config (.ts/.mjs/.js) — refuse
|
|
337
308
|
// so we never shadow a hand-written config with a fresh .ts.
|
|
338
309
|
for (const p of projects) {
|
|
339
|
-
if (p.tasks.length === 0) continue
|
|
340
310
|
const meta = metas.find((m) => m.dir === p.dir)
|
|
341
311
|
if (meta?.configPath) conflicts.add(relPosix(root, meta.configPath))
|
|
342
312
|
}
|
|
@@ -361,9 +331,8 @@ export async function applyMigration(args: ApplyMigrationArgs): Promise<number>
|
|
|
361
331
|
// (item 1033).
|
|
362
332
|
const replaced: string[] = []
|
|
363
333
|
for (const p of projects) {
|
|
364
|
-
if (p.tasks.length === 0) continue
|
|
365
334
|
const existing = metas.find((m) => m.dir === p.dir)?.configPath
|
|
366
|
-
if (existing && existing !== path.join(p.dir,
|
|
335
|
+
if (existing && existing !== path.join(p.dir, names.get(p.dir)!)) replaced.push(existing)
|
|
367
336
|
}
|
|
368
337
|
if (dry && args.json !== true) {
|
|
369
338
|
for (const f of files) {
|
|
@@ -372,7 +341,16 @@ export async function applyMigration(args: ApplyMigrationArgs): Promise<number>
|
|
|
372
341
|
} else if (!dry) {
|
|
373
342
|
for (const f of files) await Bun.write(f.abs, f.contents)
|
|
374
343
|
for (const f of replaced) await unlink(f)
|
|
344
|
+
// The lock that follows loads its plugins from the file just written.
|
|
345
|
+
workspaceFileWritten()
|
|
375
346
|
}
|
|
347
|
+
const formatted = dry
|
|
348
|
+
? []
|
|
349
|
+
: formatWritten(
|
|
350
|
+
root,
|
|
351
|
+
// A formatter has no parser for `.vx/.gitignore`.
|
|
352
|
+
files.map((f) => f.relPath).filter((f) => /\.[cm]?[jt]s$/.test(f)),
|
|
353
|
+
)
|
|
376
354
|
|
|
377
355
|
// One reason, its tasks: on a Turbo template the same two-line cache TODO
|
|
378
356
|
// printed five times and the persistent one eight (the first-five-minutes
|
|
@@ -383,12 +361,7 @@ export async function applyMigration(args: ApplyMigrationArgs): Promise<number>
|
|
|
383
361
|
root,
|
|
384
362
|
files.map((f) => f.relPath),
|
|
385
363
|
)
|
|
386
|
-
const ignoredNote =
|
|
387
|
-
ignored.length === 0
|
|
388
|
-
? []
|
|
389
|
-
: [
|
|
390
|
-
`.gitignore drops ${ignored.length} file${ignored.length === 1 ? '' : 's'} this ${dry ? 'would write' : 'wrote'} (${ignored.slice(0, TODO_IDS_SHOWN).join(', ')}${ignored.length > TODO_IDS_SHOWN ? ', …' : ''}): add ${[...new Set(ignored.map((f) => `!${path.posix.basename(f)}`))].join(' and ')} to .gitignore, or commit them with git add -f`,
|
|
391
|
-
]
|
|
364
|
+
const ignoredNote = ignored.length === 0 ? [] : [ignoreAdvice(root, ignored, dry)]
|
|
392
365
|
const todos = new Map<string, string[]>()
|
|
393
366
|
let todoCount = 0
|
|
394
367
|
let clean = 0
|
|
@@ -406,24 +379,10 @@ export async function applyMigration(args: ApplyMigrationArgs): Promise<number>
|
|
|
406
379
|
}
|
|
407
380
|
}
|
|
408
381
|
const report: string[] = []
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
const workspace = empty ? await loadWorkspace(root) : undefined
|
|
412
|
-
const unreached = workspace === undefined ? [] : await unreachedPackages(workspace)
|
|
413
|
-
// Globs that reach no package: "no scripts" was true and named nothing
|
|
414
|
-
// to fix (M-42).
|
|
415
|
-
const globs = workspace?.packageGlobs.filter((g) => g !== '.') ?? []
|
|
416
|
-
// Asked of the globs, not of `metas`: a nameless or shared-name member
|
|
417
|
-
// is matched yet absent from `metas` (M-46).
|
|
418
|
-
const noMembers =
|
|
419
|
-
workspace !== undefined && globs.length > 0 && !(await reachesManifest(workspace))
|
|
420
|
-
? [
|
|
421
|
-
`the workspace globs (${globs.map((g) => `"${g}"`).join(', ')}) match no package.json: add a package under one, or fix the glob`,
|
|
422
|
-
]
|
|
423
|
-
: []
|
|
424
|
-
if (empty && unreached.length > 0) {
|
|
382
|
+
const emptyReason = empty ? args.emptyReason : undefined
|
|
383
|
+
if (emptyReason !== undefined) {
|
|
425
384
|
report.push(
|
|
426
|
-
`${verb}: ${
|
|
385
|
+
`${verb}: ${emptyReason}`,
|
|
427
386
|
hasWorkspaceFile
|
|
428
387
|
? `${workspaceName} already exists.`
|
|
429
388
|
: dry
|
|
@@ -435,7 +394,7 @@ export async function applyMigration(args: ApplyMigrationArgs): Promise<number>
|
|
|
435
394
|
args.unmapped === true
|
|
436
395
|
? `${verb}: no package.json scripts became tasks.`
|
|
437
396
|
: `${verb}: no package.json scripts to turn into tasks.`,
|
|
438
|
-
...[...
|
|
397
|
+
...[...(args.notes ?? []), ...ignoredNote].map((n) => `note: ${n}`),
|
|
439
398
|
hasWorkspaceFile
|
|
440
399
|
? `${workspaceName} already exists.`
|
|
441
400
|
: dry
|
|
@@ -449,8 +408,8 @@ export async function applyMigration(args: ApplyMigrationArgs): Promise<number>
|
|
|
449
408
|
.map((l) => ` ${l}`),
|
|
450
409
|
)
|
|
451
410
|
} else {
|
|
452
|
-
report.push(`${verb}: ${source} → ${configName}`)
|
|
453
|
-
for (const n of [...(args.notes ?? []), ...ignoredNote]) report.push(`note: ${n}`)
|
|
411
|
+
report.push(`${verb}: ${source} → ${[...new Set(names.values())].join(', ') || configName}`)
|
|
412
|
+
for (const n of [...(args.notes ?? []), ...ignoredNote, ...formatted]) report.push(`note: ${n}`)
|
|
454
413
|
for (const n of plan.headerNotes) report.push(`note: ${n}`)
|
|
455
414
|
report.push(
|
|
456
415
|
'',
|
|
@@ -468,7 +427,7 @@ export async function applyMigration(args: ApplyMigrationArgs): Promise<number>
|
|
|
468
427
|
report.push(...plan.notes)
|
|
469
428
|
if (files.length === 0) report.push(dry ? 'no files (dry run)' : 'no files written')
|
|
470
429
|
else report.push(dry ? 'files (dry run, nothing written):' : 'files written:')
|
|
471
|
-
|
|
430
|
+
report.push(...fileLines(files.map((f) => f.relPath)))
|
|
472
431
|
if (kept.length > 0) {
|
|
473
432
|
report.push('kept (each already has a vx config):')
|
|
474
433
|
for (const f of kept) report.push(` ${f}`)
|
|
@@ -491,10 +450,14 @@ export async function applyMigration(args: ApplyMigrationArgs): Promise<number>
|
|
|
491
450
|
// The line is the next thing to type, so it names what that run would
|
|
492
451
|
// refuse without: a git work tree (vx keys inputs on git's view) and,
|
|
493
452
|
// with no scripts mapped, a task to run.
|
|
494
|
-
const
|
|
453
|
+
const vx = vxInvocation(process.env['npm_config_user_agent'], installed)
|
|
454
|
+
const run = `${vx} run ${firstTask} --all`
|
|
455
|
+
// A run reads configs only from the lock; with no lock step here
|
|
456
|
+
// (`--no-install`), that run stopped with "no lock: run `vx lock`".
|
|
495
457
|
const steps = [
|
|
496
458
|
...(inGitWorkTree(root) ? [] : ['git init']),
|
|
497
459
|
...(empty ? [`declare a task as the example shows`] : []),
|
|
460
|
+
...(!dry && args.lock === undefined ? [`${vx} lock`] : []),
|
|
498
461
|
]
|
|
499
462
|
const next = steps.length === 0 ? run : `${steps.join(', ')}, then ${run}`
|
|
500
463
|
if (args.json === true) {
|
|
@@ -507,8 +470,7 @@ export async function applyMigration(args: ApplyMigrationArgs): Promise<number>
|
|
|
507
470
|
replaced: replaced.map((f) => relPosix(root, f)),
|
|
508
471
|
todos: [...todos].map(([reason, tasks]) => ({ reason, tasks })),
|
|
509
472
|
notes: [
|
|
510
|
-
...(
|
|
511
|
-
...(empty ? noMembers : []),
|
|
473
|
+
...(emptyReason === undefined ? [] : [emptyReason]),
|
|
512
474
|
...(args.notes ?? []),
|
|
513
475
|
...ignoredNote,
|
|
514
476
|
...plan.headerNotes,
|
|
@@ -519,17 +481,55 @@ export async function applyMigration(args: ApplyMigrationArgs): Promise<number>
|
|
|
519
481
|
)
|
|
520
482
|
return 0
|
|
521
483
|
}
|
|
522
|
-
|
|
523
|
-
|
|
484
|
+
if (dry || args.lock === undefined) {
|
|
485
|
+
report.push('', `next: ${next}`)
|
|
486
|
+
process.stdout.write(`${report.join('\n')}\n`)
|
|
487
|
+
return 0
|
|
488
|
+
}
|
|
489
|
+
process.stdout.write(`${report.join('\n')}\n\n`)
|
|
490
|
+
const locked = (await args.lock(root)) === 0
|
|
491
|
+
process.stdout.write(`\nnext: ${locked ? next : `fix the above and run vx lock, then ${next}`}\n`)
|
|
524
492
|
return 0
|
|
525
493
|
}
|
|
526
494
|
|
|
495
|
+
/**
|
|
496
|
+
* What to do about written files .gitignore drops. A `!file` line cures a
|
|
497
|
+
* file, but git re-includes nothing inside an ignored directory: an
|
|
498
|
+
* old-layout `/.vx/` line (when `.vx` held only the cache) hides the
|
|
499
|
+
* workspace config and lock, so that advice names the directory instead.
|
|
500
|
+
*/
|
|
501
|
+
function ignoreAdvice(root: string, ignored: readonly string[], dry: boolean): string {
|
|
502
|
+
const ancestors = (f: string): string[] =>
|
|
503
|
+
f
|
|
504
|
+
.split('/')
|
|
505
|
+
.slice(0, -1)
|
|
506
|
+
.map((_, i, parts) => `${parts.slice(0, i + 1).join('/')}/`)
|
|
507
|
+
const dirs = new Set(gitIgnored(root, [...new Set(ignored.flatMap(ancestors))]))
|
|
508
|
+
const blocking = new Set<string>()
|
|
509
|
+
const names = new Set<string>()
|
|
510
|
+
for (const f of ignored) {
|
|
511
|
+
const dir = ancestors(f).find((d) => dirs.has(d))
|
|
512
|
+
if (dir === undefined) names.add(`!${path.posix.basename(f)}`)
|
|
513
|
+
else blocking.add(dir)
|
|
514
|
+
}
|
|
515
|
+
const cures = [
|
|
516
|
+
...(names.size === 0 ? [] : [`add ${[...names].join(' and ')} to .gitignore`]),
|
|
517
|
+
...(blocking.size === 0
|
|
518
|
+
? []
|
|
519
|
+
: [
|
|
520
|
+
`stop ignoring ${[...blocking].join(' and ')} (a ! line cannot re-include a file inside an ignored directory)`,
|
|
521
|
+
]),
|
|
522
|
+
]
|
|
523
|
+
return `.gitignore drops ${ignored.length} file${ignored.length === 1 ? '' : 's'} this ${dry ? 'would write' : 'wrote'} (${ignored.slice(0, TODO_IDS_SHOWN).join(', ')}${ignored.length > TODO_IDS_SHOWN ? ', …' : ''}): ${cures.join(', ')}, or commit them with git add -f`
|
|
524
|
+
}
|
|
525
|
+
|
|
527
526
|
/** The paths (root-relative) git's ignore rules drop; none outside a work tree. */
|
|
528
527
|
function gitIgnored(root: string, relPaths: readonly string[]): string[] {
|
|
529
528
|
if (relPaths.length === 0) return []
|
|
530
529
|
try {
|
|
531
530
|
const r = Bun.spawnSync({
|
|
532
531
|
cmd: [executablePath('git'), 'check-ignore', '--stdin', '-z'],
|
|
532
|
+
env: gitEnv(),
|
|
533
533
|
cwd: root,
|
|
534
534
|
stdin: Buffer.from(relPaths.map((p) => `${p}\0`).join('')),
|
|
535
535
|
stdout: 'pipe',
|
|
@@ -551,6 +551,7 @@ function inGitWorkTree(root: string): boolean {
|
|
|
551
551
|
return (
|
|
552
552
|
Bun.spawnSync({
|
|
553
553
|
cmd: [executablePath('git'), 'rev-parse', '--is-inside-work-tree'],
|
|
554
|
+
env: gitEnv(),
|
|
554
555
|
cwd: root,
|
|
555
556
|
stdout: 'pipe',
|
|
556
557
|
stderr: 'ignore',
|
|
@@ -583,21 +584,22 @@ function nextTask(tasks: readonly GeneratedTask[]): string {
|
|
|
583
584
|
}
|
|
584
585
|
|
|
585
586
|
/**
|
|
586
|
-
* How the user runs vx, for the report's `next:` line. After `npx vx init`
|
|
587
|
+
* How the user runs vx, for the report's `next:` line. After `npx @vzn/vx init`
|
|
587
588
|
* or `bunx @vzn/vx init` a bare `vx` is on no PATH (the first-five-minutes
|
|
588
589
|
* walk, 2026-09-28), so the line names the runner that started this
|
|
589
590
|
* process — npx, pnpm, yarn and Bun each set `npm_config_user_agent` — and
|
|
590
|
-
* the spec it resolves
|
|
591
|
-
*
|
|
591
|
+
* the spec it resolves. npx and bunx always name the package, which runs
|
|
592
|
+
* the installed bin when there is one: their bare `vx` fetches an unrelated
|
|
593
|
+
* package named `vx` wherever vx is not installed. No
|
|
592
594
|
* runner (a global install, the compiled binary): `vx`.
|
|
593
595
|
*/
|
|
594
596
|
export function vxInvocation(userAgent: string | undefined, installed: boolean): string {
|
|
595
597
|
const runner = /^(npm|pnpm|yarn|bun)\//.exec(userAgent ?? '')?.[1]
|
|
596
598
|
switch (runner) {
|
|
597
599
|
case 'npm':
|
|
598
|
-
return
|
|
600
|
+
return 'npx @vzn/vx'
|
|
599
601
|
case 'bun':
|
|
600
|
-
return
|
|
602
|
+
return 'bunx @vzn/vx'
|
|
601
603
|
case 'pnpm':
|
|
602
604
|
return installed ? 'pnpm vx' : 'pnpm dlx @vzn/vx'
|
|
603
605
|
case 'yarn':
|
|
@@ -609,6 +611,16 @@ export function vxInvocation(userAgent: string | undefined, installed: boolean):
|
|
|
609
611
|
}
|
|
610
612
|
}
|
|
611
613
|
|
|
614
|
+
/** Files named one per line; past this, the root's and a count (TanStack form: 97). */
|
|
615
|
+
const FILES_SHOWN = 10
|
|
616
|
+
|
|
617
|
+
function fileLines(paths: readonly string[]): string[] {
|
|
618
|
+
if (paths.length <= FILES_SHOWN) return paths.map((p) => ` ${p}`)
|
|
619
|
+
const root = paths.filter((p) => !p.includes('/') || p.startsWith('.vx/'))
|
|
620
|
+
const more = paths.length - root.length
|
|
621
|
+
return [...root.map((p) => ` ${p}`), ` and ${more} more in package directories`]
|
|
622
|
+
}
|
|
623
|
+
|
|
612
624
|
/** Ids named per shared TODO; the files carry each one (sveltejs/kit: 92 persistent tasks). */
|
|
613
625
|
const TODO_IDS_SHOWN = 5
|
|
614
626
|
|
|
@@ -635,12 +647,12 @@ function exampleConfig(format: MigrationFormat): string {
|
|
|
635
647
|
// into every run — measured 2026-09-09 at ~17 ms on a two-package
|
|
636
648
|
// workspace, a fifth of the whole run — for an identity function.
|
|
637
649
|
// No explanatory comment: the file is the user's (owner, 2026-10-06).
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
650
|
+
function workspaceFile(format: MigrationFormat, plugins: readonly WorkspacePlugin[]): string {
|
|
651
|
+
const imports = plugins.map((p) => `import { ${p.factory} } from '${p.pkg}'\n`).join('')
|
|
652
|
+
const body = `export default { plugins: [${plugins.map((p) => `${p.factory}()`).join(', ')}] }`
|
|
641
653
|
return format === 'ts'
|
|
642
|
-
? `import type { WorkspaceConfig } from '@vzn/vx/config'\n\n${
|
|
643
|
-
: `${
|
|
654
|
+
? `import type { WorkspaceConfig } from '@vzn/vx/config'\n${imports}\n${body} satisfies WorkspaceConfig\n`
|
|
655
|
+
: `${imports}${imports === '' ? '' : '\n'}${body}\n`
|
|
644
656
|
}
|
|
645
657
|
|
|
646
658
|
const IDENT = /^[A-Za-z_$][\w$]*$/
|
|
@@ -680,7 +692,7 @@ function renderConfigFile(p: GeneratedProject, format: MigrationFormat): string
|
|
|
680
692
|
if (lines.length > 0) lines.push('')
|
|
681
693
|
lines.push('export default {')
|
|
682
694
|
if (p.tags !== undefined && p.tags.length > 0) lines.push(` tags: ${renderValue(p.tags, ' ')},`)
|
|
683
|
-
lines.push(' tasks: {')
|
|
695
|
+
lines.push(p.tasks.length === 0 ? ' tasks: {},' : ' tasks: {')
|
|
684
696
|
for (const t of p.tasks) {
|
|
685
697
|
// A reason can quote a manifest's own text (an Nx `env` key), and a
|
|
686
698
|
// line break there ended the comment: the rest was code in the file
|
|
@@ -709,6 +721,90 @@ function renderConfigFile(p: GeneratedProject, format: MigrationFormat): string
|
|
|
709
721
|
` ${key}: ${block === undefined ? rendered : rendered.replace('{\n', `{\n // ${block},\n`)},`,
|
|
710
722
|
)
|
|
711
723
|
}
|
|
712
|
-
lines.push(' },'
|
|
724
|
+
if (p.tasks.length > 0) lines.push(' },')
|
|
725
|
+
lines.push(format === 'ts' ? '} satisfies ProjectConfig' : '}', '')
|
|
713
726
|
return lines.join('\n')
|
|
714
727
|
}
|
|
728
|
+
|
|
729
|
+
/**
|
|
730
|
+
* `vx.config.mts` in a TypeScript package that is CommonJS: tsc (node16 /
|
|
731
|
+
* nodenext) reads a `.ts` there as CommonJS, and its type-only import of
|
|
732
|
+
* the ESM `@vzn/vx/config` fails (TS1479, TS1541: medusa's plugin builds).
|
|
733
|
+
*/
|
|
734
|
+
function projectConfigName(
|
|
735
|
+
dir: string,
|
|
736
|
+
metas: readonly MigrationMember[],
|
|
737
|
+
format: MigrationFormat,
|
|
738
|
+
): string {
|
|
739
|
+
if (format !== 'ts' || !existsSync(path.join(dir, 'tsconfig.json'))) return `vx.config.${format}`
|
|
740
|
+
const meta = metas.find((m) => m.dir === dir)
|
|
741
|
+
const manifest = (meta?.packageJson as { type?: unknown } | undefined) ?? readManifest(dir)
|
|
742
|
+
return manifest?.type === 'module' ? 'vx.config.ts' : 'vx.config.mts'
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
/**
|
|
746
|
+
* The plan takes the config back from an output as `!vx.config.<format>`;
|
|
747
|
+
* where the file written is the `.mts`, the take-back names it, or a `*`
|
|
748
|
+
* output covers it and `vx lock` refuses the config (medusa, 80 of 84).
|
|
749
|
+
*/
|
|
750
|
+
function ownConfigNamed(
|
|
751
|
+
p: GeneratedProject,
|
|
752
|
+
name: string,
|
|
753
|
+
format: MigrationFormat,
|
|
754
|
+
): GeneratedProject {
|
|
755
|
+
const planned = `!vx.config.${format}`
|
|
756
|
+
if (name === `vx.config.${format}`) return p
|
|
757
|
+
return {
|
|
758
|
+
...p,
|
|
759
|
+
tasks: p.tasks.map((t) => {
|
|
760
|
+
const outputs = (t.task?.['cache'] as { outputs?: { files?: unknown } } | undefined)?.outputs
|
|
761
|
+
if (!Array.isArray(outputs?.files) || !outputs.files.includes(planned)) return t
|
|
762
|
+
const cache = t.task!['cache'] as Record<string, unknown>
|
|
763
|
+
const files = outputs.files.map((f: unknown) => (f === planned ? `!${name}` : f))
|
|
764
|
+
return { ...t, task: { ...t.task, cache: { ...cache, outputs: { ...outputs, files } } } }
|
|
765
|
+
}),
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
function readManifest(dir: string): { type?: unknown } | undefined {
|
|
770
|
+
try {
|
|
771
|
+
return JSON.parse(readFileSync(path.join(dir, 'package.json'), 'utf8')) as { type?: unknown }
|
|
772
|
+
} catch {
|
|
773
|
+
return undefined
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
/** The formatters a repo installs at its root, each with its write command. */
|
|
778
|
+
const FORMATTERS: readonly (readonly [string, readonly string[]])[] = [
|
|
779
|
+
['prettier', ['--write']],
|
|
780
|
+
['biome', ['format', '--write']],
|
|
781
|
+
['oxfmt', []],
|
|
782
|
+
['dprint', ['fmt']],
|
|
783
|
+
]
|
|
784
|
+
|
|
785
|
+
/**
|
|
786
|
+
* Runs the repo's own formatter over the files just written, as Nx's
|
|
787
|
+
* generators do: create-t3-turbo's `prettier --check` failed on the quotes
|
|
788
|
+
* of a config vx wrote. Returns the report's note.
|
|
789
|
+
*/
|
|
790
|
+
function formatWritten(root: string, relPaths: readonly string[]): string[] {
|
|
791
|
+
if (relPaths.length === 0) return []
|
|
792
|
+
for (const [name, args] of FORMATTERS) {
|
|
793
|
+
const bin = path.join(root, 'node_modules', '.bin', name)
|
|
794
|
+
if (!existsSync(bin)) continue
|
|
795
|
+
const r = Bun.spawnSync({
|
|
796
|
+
cmd: [bin, ...args, ...relPaths],
|
|
797
|
+
cwd: root,
|
|
798
|
+
stdin: 'ignore',
|
|
799
|
+
stdout: 'pipe',
|
|
800
|
+
stderr: 'pipe',
|
|
801
|
+
timeout: 60_000,
|
|
802
|
+
})
|
|
803
|
+
if (r.exitCode === 0) {
|
|
804
|
+
return [`formatted ${relPaths.length} file${relPaths.length === 1 ? '' : 's'} with ${name}`]
|
|
805
|
+
}
|
|
806
|
+
const why = (r.stderr.toString() || r.stdout.toString()).trim().split('\n')[0] ?? ''
|
|
807
|
+
return [`${name} did not format the written files (exit ${r.exitCode ?? 'signal'}): ${why}`]
|
|
808
|
+
}
|
|
809
|
+
return []
|
|
810
|
+
}
|