@vzn/vx 0.0.636 → 0.0.638
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 +246 -213
- package/docs/cli.md +791 -705
- package/docs/execution.md +68 -76
- package/docs/features.md +47 -42
- package/docs/patterns.md +26 -25
- package/docs/schema.md +392 -205
- 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 +4 -4
- package/schemas/show.json +5 -3
- package/schemas/why.json +15 -1
- package/skills/vx/SKILL.md +28 -13
- package/src/bin.ts +30 -9
- package/src/cache/archive.ts +11 -0
- package/src/cache/cache.ts +124 -53
- package/src/cache/chained-cache.ts +4 -0
- package/src/cache/git-inputs.ts +277 -63
- package/src/cache/index.ts +15 -1
- package/src/cache/inputs.ts +102 -36
- package/src/cache/key-fold.ts +8 -7
- package/src/cache/layer.ts +15 -18
- package/src/cache/layered-cache.ts +20 -102
- package/src/cache/plugin-cache.ts +248 -0
- package/src/cache/policy.ts +32 -27
- package/src/cache/run-history.ts +104 -14
- package/src/cache/run-store.ts +77 -0
- package/src/cache/schema.ts +58 -31
- package/src/cache/scoped-cache.ts +237 -0
- package/src/cli/cache.ts +173 -34
- package/src/cli/completions.ts +11 -7
- package/src/cli/core-alias.ts +1 -1
- package/src/cli/foreign-flags.ts +13 -12
- package/src/cli/help.ts +110 -70
- package/src/cli/index.ts +67 -24
- package/src/cli/info.ts +43 -27
- package/src/cli/init.ts +115 -407
- package/src/cli/last.ts +157 -9
- package/src/cli/lock.ts +356 -122
- package/src/cli/plan-format.ts +7 -5
- package/src/cli/plugin-commands.ts +81 -14
- package/src/cli/plugin-templates.ts +11 -11
- package/src/cli/run.ts +116 -160
- package/src/cli/select.ts +50 -47
- package/src/cli/show.ts +83 -59
- package/src/cli/task-verb.ts +6 -13
- package/src/cli/upgrade.ts +415 -149
- package/src/cli/watch-cycle.ts +9 -2
- package/src/cli/watch-filter.ts +39 -61
- package/src/cli/watch-fs.ts +29 -16
- package/src/cli/watch-judge.ts +18 -4
- package/src/cli/watch-set.ts +16 -42
- package/src/cli/watch.ts +227 -230
- package/src/cli/why.ts +38 -18
- package/src/cli/workspace-config.ts +33 -70
- 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 +27 -7
- package/src/graph/task-graph.ts +76 -24
- package/src/handoff.ts +169 -0
- package/src/index.ts +35 -58
- package/src/orchestrator/affected-tasks.ts +15 -8
- 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 +147 -69
- package/src/orchestrator/failure-mode.ts +19 -10
- package/src/orchestrator/fingerprint-watch.ts +18 -15
- package/src/orchestrator/forecast.ts +10 -2
- package/src/orchestrator/framed-output.ts +9 -5
- package/src/orchestrator/graph-stage.ts +5 -5
- package/src/orchestrator/history.ts +126 -30
- package/src/orchestrator/hit-restore.ts +15 -3
- package/src/orchestrator/index.ts +20 -5
- package/src/orchestrator/local-shortcircuit.ts +2 -1
- package/src/orchestrator/lockfile-claim.ts +79 -20
- package/src/orchestrator/logger.ts +80 -41
- package/src/orchestrator/metrics.ts +78 -9
- 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 +100 -66
- package/src/orchestrator/plugin.ts +173 -67
- package/src/orchestrator/prepare.ts +163 -78
- package/src/orchestrator/projects.ts +105 -99
- package/src/orchestrator/remote-prefetch.ts +11 -6
- 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-records.ts +1 -1
- package/src/orchestrator/run-report.ts +9 -9
- package/src/orchestrator/run-settings.ts +48 -0
- package/src/orchestrator/run.ts +244 -119
- 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 +49 -7
- package/src/orchestrator/summary.ts +115 -106
- package/src/orchestrator/task-hash.ts +12 -83
- package/src/orchestrator/telemetry.ts +4 -4
- package/src/settings.ts +428 -0
- package/src/{orchestrator → util}/colors.ts +5 -0
- package/src/util/config-frame.ts +31 -2
- package/src/util/errors.ts +33 -1
- package/src/util/essential-env.ts +31 -0
- package/src/util/hide-env.ts +107 -0
- package/src/util/index.ts +16 -3
- package/src/util/paths.ts +10 -0
- package/src/util/refusal-notice.ts +111 -0
- package/src/util/report.ts +31 -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/ui.ts +186 -0
- package/src/util/ulid.ts +1 -1
- package/src/util/verbs.ts +0 -15
- package/src/workspace/affected.ts +84 -232
- 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 +65 -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 +307 -215
- 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 +363 -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,16 @@
|
|
|
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 {
|
|
11
|
+
import { workspaceFileWritten } from '../handoff.js'
|
|
12
|
+
import { DOT_VX_GITIGNORE, executablePath, gitEnv, relPosix, UserError, ui } from '../util/index.js'
|
|
13
|
+
import { VERSION } from '../version.js'
|
|
14
14
|
import { WORKSPACE_CONFIG_FILENAMES } from './project-loader.js'
|
|
15
15
|
|
|
16
16
|
/**
|
|
@@ -29,19 +29,6 @@ export function quoteTsLiteral(s: string): string {
|
|
|
29
29
|
.replaceAll('\r', '\\r')}'`
|
|
30
30
|
}
|
|
31
31
|
|
|
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
32
|
/**
|
|
46
33
|
* A package.json script with the `pre<name>` / `post<name>` hooks npm runs
|
|
47
34
|
* around it, as ONE sh command. Each part runs in its own subshell, so a
|
|
@@ -80,73 +67,6 @@ const QUOTE_ARGS =
|
|
|
80
67
|
`vx_r="$vx_r\${vx_s%%"$vx_q"*}'\\''"; vx_s=\${vx_s#*"$vx_q"};; *) break;; esac; done; ` +
|
|
81
68
|
`vx_a="$vx_a '$vx_r$vx_s'"; done`
|
|
82
69
|
|
|
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
70
|
const isCacheTodo = (todo: string): boolean => todo.startsWith('cache: add `cache: {')
|
|
151
71
|
|
|
152
72
|
/** The one wording every mapper emits for a task it made persistent. */
|
|
@@ -217,6 +137,8 @@ export interface GeneratedProject {
|
|
|
217
137
|
importLines: string[]
|
|
218
138
|
/** The project's `tags` (an Nx project's); none written when empty. */
|
|
219
139
|
tags?: readonly string[]
|
|
140
|
+
/** Writes `name`: no manifest names the project, so its path would. */
|
|
141
|
+
writeName?: boolean
|
|
220
142
|
tasks: GeneratedTask[]
|
|
221
143
|
}
|
|
222
144
|
|
|
@@ -230,9 +152,21 @@ export interface MigrationPlan {
|
|
|
230
152
|
notes: string[]
|
|
231
153
|
}
|
|
232
154
|
|
|
155
|
+
/**
|
|
156
|
+
* A project the adoption tool found: its name, directory and the vx config
|
|
157
|
+
* it already has. `packageJson.type` picks `vx.config.mts` for a CommonJS
|
|
158
|
+
* TypeScript package.
|
|
159
|
+
*/
|
|
160
|
+
export interface MigrationMember {
|
|
161
|
+
readonly name: string
|
|
162
|
+
readonly dir: string
|
|
163
|
+
readonly configPath: string | null
|
|
164
|
+
readonly packageJson?: object
|
|
165
|
+
}
|
|
166
|
+
|
|
233
167
|
export interface ApplyMigrationArgs {
|
|
234
168
|
root: string
|
|
235
|
-
metas: readonly
|
|
169
|
+
metas: readonly MigrationMember[]
|
|
236
170
|
plan: MigrationPlan
|
|
237
171
|
/** Named in the report and the generated header, e.g. `turbo.json`. */
|
|
238
172
|
source: string
|
|
@@ -241,12 +175,12 @@ export interface ApplyMigrationArgs {
|
|
|
241
175
|
dry: boolean
|
|
242
176
|
force: boolean
|
|
243
177
|
/**
|
|
244
|
-
* `vx init` on a workspace with no
|
|
245
|
-
* file, and a worked example); a
|
|
246
|
-
* error. Default false.
|
|
178
|
+
* A scripts migration (what `vx init` hands over) on a workspace with no
|
|
179
|
+
* scripts still has a job (the workspace file, and a worked example); a
|
|
180
|
+
* migration with nothing to convert is an error. Default false.
|
|
247
181
|
*/
|
|
248
182
|
init?: boolean
|
|
249
|
-
/**
|
|
183
|
+
/** The report's `note` lines (e.g. "turbo.json found and not read"). */
|
|
250
184
|
notes?: readonly string[]
|
|
251
185
|
/**
|
|
252
186
|
* Scripts exist but no project can carry them (a member without a
|
|
@@ -254,6 +188,11 @@ export interface ApplyMigrationArgs {
|
|
|
254
188
|
* of them (M-59).
|
|
255
189
|
*/
|
|
256
190
|
unmapped?: boolean
|
|
191
|
+
/**
|
|
192
|
+
* Why an `init` plan is empty, when the tool knows (members its globs
|
|
193
|
+
* never reach): the report's one line in place of the example config.
|
|
194
|
+
*/
|
|
195
|
+
emptyReason?: string
|
|
257
196
|
/**
|
|
258
197
|
* `ts` (default) writes `vx.config.ts` with the type-only import and
|
|
259
198
|
* `satisfies`; `mjs` writes `vx.config.mjs` — the same object, untyped —
|
|
@@ -266,6 +205,24 @@ export interface ApplyMigrationArgs {
|
|
|
266
205
|
* of the files and the report, so an agent reads the plan as data.
|
|
267
206
|
*/
|
|
268
207
|
json?: boolean
|
|
208
|
+
/**
|
|
209
|
+
* Run after the files are written (not under `dry`): `vx lock`'s step, so
|
|
210
|
+
* the migrated workspace has its .vx/projects.lock.json and its graph rules checked
|
|
211
|
+
* (owner, 2026-10-14). On success `said` is its line, shown as the
|
|
212
|
+
* report's `lock` row; a refusal (a graph rule, a plugin not installed
|
|
213
|
+
* yet) prints above the report and the migration still succeeds: its
|
|
214
|
+
* files are written, and `vx lock` is the step to re-run.
|
|
215
|
+
*/
|
|
216
|
+
lock?: (root: string) => Promise<{ code: number; said: string }>
|
|
217
|
+
/** Rows of what the tool did before the write, e.g. `['ran', 'bun add -d …']`. */
|
|
218
|
+
steps?: readonly (readonly [label: string, value: string])[]
|
|
219
|
+
/** What the written workspace file declares, e.g. `pnpm()` from `@vzn/vx-lockfile`. */
|
|
220
|
+
plugins?: readonly WorkspacePlugin[]
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export interface WorkspacePlugin {
|
|
224
|
+
readonly pkg: string
|
|
225
|
+
readonly factory: string
|
|
269
226
|
}
|
|
270
227
|
|
|
271
228
|
export type MigrationFormat = 'ts' | 'mjs'
|
|
@@ -279,15 +236,25 @@ export async function applyMigration(args: ApplyMigrationArgs): Promise<number>
|
|
|
279
236
|
const init = args.init === true
|
|
280
237
|
const format: MigrationFormat = args.format ?? 'ts'
|
|
281
238
|
const configName = `vx.config.${format}`
|
|
282
|
-
const workspaceName =
|
|
239
|
+
const workspaceName = `.vx/workspace.${format}`
|
|
283
240
|
const empty = plan.projects.length === 0
|
|
284
|
-
// `
|
|
241
|
+
// An `init` migration never overwrites a member's config: a half-adopted workspace
|
|
285
242
|
// keeps each one it has, by name, and gets the rest (M-52). It refused
|
|
286
243
|
// the whole workspace, and `--force` would have replaced the hand-written
|
|
287
244
|
// file; `--force` still replaces, as asked.
|
|
288
245
|
const kept: string[] = []
|
|
289
|
-
|
|
290
|
-
|
|
246
|
+
// Every project gets a config, a task-less one too (owner, 2026-10-14:
|
|
247
|
+
// "no skips. every project gets a config"): solidjs/solid's solid-ssr,
|
|
248
|
+
// with no script Turbo runs, was left with none and read as a project
|
|
249
|
+
// whose tasks are not locked.
|
|
250
|
+
const planned = new Set(plan.projects.map((p) => p.dir))
|
|
251
|
+
const unplanned: GeneratedProject[] = metas
|
|
252
|
+
.filter((m) => !planned.has(m.dir))
|
|
253
|
+
.map((m) => ({ name: m.name, dir: m.dir, importLines: [], tasks: [] }))
|
|
254
|
+
const projects = [...plan.projects, ...unplanned].filter((p) => {
|
|
255
|
+
// An empty config never replaces one a project has, --force or not.
|
|
256
|
+
if (p.tasks.length === 0) return !metas.some((m) => m.dir === p.dir && m.configPath)
|
|
257
|
+
if (!init || force) return true
|
|
291
258
|
const existing = metas.find((m) => m.dir === p.dir)?.configPath
|
|
292
259
|
if (!existing) return true
|
|
293
260
|
kept.push(relPosix(root, existing))
|
|
@@ -305,14 +272,14 @@ export async function applyMigration(args: ApplyMigrationArgs): Promise<number>
|
|
|
305
272
|
)
|
|
306
273
|
}
|
|
307
274
|
|
|
275
|
+
const names = new Map(projects.map((p) => [p.dir, projectConfigName(p.dir, metas, format)]))
|
|
308
276
|
const files: { relPath: string; abs: string; contents: string }[] = []
|
|
309
277
|
for (const p of projects) {
|
|
310
|
-
|
|
311
|
-
const abs = path.join(p.dir, configName)
|
|
278
|
+
const abs = path.join(p.dir, names.get(p.dir)!)
|
|
312
279
|
files.push({
|
|
313
280
|
relPath: relPosix(root, abs),
|
|
314
281
|
abs,
|
|
315
|
-
contents: renderConfigFile(p, format),
|
|
282
|
+
contents: renderConfigFile(ownConfigNamed(p, names.get(p.dir)!, format), format),
|
|
316
283
|
})
|
|
317
284
|
}
|
|
318
285
|
for (const f of plan.extraFiles) {
|
|
@@ -328,7 +295,17 @@ export async function applyMigration(args: ApplyMigrationArgs): Promise<number>
|
|
|
328
295
|
// A plan may bring its own (vx-migrate declares the plugins it detects).
|
|
329
296
|
if (!hasWorkspaceFile && !plan.extraFiles.some((f) => f.relPath === workspaceName)) {
|
|
330
297
|
const abs = path.join(root, workspaceName)
|
|
331
|
-
files.push({
|
|
298
|
+
files.push({
|
|
299
|
+
relPath: relPosix(root, abs),
|
|
300
|
+
abs,
|
|
301
|
+
contents: workspaceFile(format, args.plugins ?? []),
|
|
302
|
+
})
|
|
303
|
+
}
|
|
304
|
+
// `.vx/.gitignore` keeps the machine-local `.vx/cache` out of git and the
|
|
305
|
+
// workspace config and lock in; one already there is the user's.
|
|
306
|
+
const dotVxIgnore = path.join(root, '.vx', '.gitignore')
|
|
307
|
+
if (!(await Bun.file(dotVxIgnore).exists())) {
|
|
308
|
+
files.push({ relPath: '.vx/.gitignore', abs: dotVxIgnore, contents: DOT_VX_GITIGNORE })
|
|
332
309
|
}
|
|
333
310
|
|
|
334
311
|
if (!dry && !force) {
|
|
@@ -336,7 +313,6 @@ export async function applyMigration(args: ApplyMigrationArgs): Promise<number>
|
|
|
336
313
|
// A discovered project with ANY existing vx config (.ts/.mjs/.js) — refuse
|
|
337
314
|
// so we never shadow a hand-written config with a fresh .ts.
|
|
338
315
|
for (const p of projects) {
|
|
339
|
-
if (p.tasks.length === 0) continue
|
|
340
316
|
const meta = metas.find((m) => m.dir === p.dir)
|
|
341
317
|
if (meta?.configPath) conflicts.add(relPosix(root, meta.configPath))
|
|
342
318
|
}
|
|
@@ -361,9 +337,8 @@ export async function applyMigration(args: ApplyMigrationArgs): Promise<number>
|
|
|
361
337
|
// (item 1033).
|
|
362
338
|
const replaced: string[] = []
|
|
363
339
|
for (const p of projects) {
|
|
364
|
-
if (p.tasks.length === 0) continue
|
|
365
340
|
const existing = metas.find((m) => m.dir === p.dir)?.configPath
|
|
366
|
-
if (existing && existing !== path.join(p.dir,
|
|
341
|
+
if (existing && existing !== path.join(p.dir, names.get(p.dir)!)) replaced.push(existing)
|
|
367
342
|
}
|
|
368
343
|
if (dry && args.json !== true) {
|
|
369
344
|
for (const f of files) {
|
|
@@ -372,7 +347,16 @@ export async function applyMigration(args: ApplyMigrationArgs): Promise<number>
|
|
|
372
347
|
} else if (!dry) {
|
|
373
348
|
for (const f of files) await Bun.write(f.abs, f.contents)
|
|
374
349
|
for (const f of replaced) await unlink(f)
|
|
350
|
+
// The lock that follows loads its plugins from the file just written.
|
|
351
|
+
workspaceFileWritten()
|
|
375
352
|
}
|
|
353
|
+
const formatted = dry
|
|
354
|
+
? []
|
|
355
|
+
: formatWritten(
|
|
356
|
+
root,
|
|
357
|
+
// A formatter has no parser for `.vx/.gitignore`.
|
|
358
|
+
files.map((f) => f.relPath).filter((f) => /\.[cm]?[jt]s$/.test(f)),
|
|
359
|
+
)
|
|
376
360
|
|
|
377
361
|
// One reason, its tasks: on a Turbo template the same two-line cache TODO
|
|
378
362
|
// printed five times and the persistent one eight (the first-five-minutes
|
|
@@ -383,120 +367,44 @@ export async function applyMigration(args: ApplyMigrationArgs): Promise<number>
|
|
|
383
367
|
root,
|
|
384
368
|
files.map((f) => f.relPath),
|
|
385
369
|
)
|
|
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
|
-
]
|
|
370
|
+
const ignoredNote = ignored.length === 0 ? [] : [ignoreAdvice(root, ignored, dry)]
|
|
392
371
|
const todos = new Map<string, string[]>()
|
|
393
|
-
let todoCount = 0
|
|
394
372
|
let clean = 0
|
|
395
|
-
let
|
|
373
|
+
let todo = 0
|
|
396
374
|
for (const p of projects) {
|
|
397
375
|
for (const t of p.tasks) {
|
|
398
376
|
if (t.todos.length === 0 && t.task !== null) clean++
|
|
399
|
-
if (t.
|
|
377
|
+
if (t.todos.length > 0) todo++
|
|
400
378
|
for (const reason of t.todos) {
|
|
401
|
-
todoCount++
|
|
402
379
|
const ids = todos.get(reason) ?? []
|
|
403
380
|
ids.push(`${p.name}#${t.name}`)
|
|
404
381
|
todos.set(reason, ids)
|
|
405
382
|
}
|
|
406
383
|
}
|
|
407
384
|
}
|
|
408
|
-
const report: string[] = []
|
|
409
|
-
// Single-project mode with packages the root's missing `workspaces` never
|
|
410
|
-
// reaches: the scripts exist, the globs do not (item 248).
|
|
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) {
|
|
425
|
-
report.push(
|
|
426
|
-
`${verb}: ${unreachedHint(unreached, root)}`,
|
|
427
|
-
hasWorkspaceFile
|
|
428
|
-
? `${workspaceName} already exists.`
|
|
429
|
-
: dry
|
|
430
|
-
? `would write ${workspaceName} (dry run, nothing written).`
|
|
431
|
-
: `wrote ${workspaceName}.`,
|
|
432
|
-
)
|
|
433
|
-
} else if (empty) {
|
|
434
|
-
report.push(
|
|
435
|
-
args.unmapped === true
|
|
436
|
-
? `${verb}: no package.json scripts became tasks.`
|
|
437
|
-
: `${verb}: no package.json scripts to turn into tasks.`,
|
|
438
|
-
...[...noMembers, ...(args.notes ?? []), ...ignoredNote].map((n) => `note: ${n}`),
|
|
439
|
-
hasWorkspaceFile
|
|
440
|
-
? `${workspaceName} already exists.`
|
|
441
|
-
: dry
|
|
442
|
-
? `would write ${workspaceName} (dry run, nothing written).`
|
|
443
|
-
: `wrote ${workspaceName}.`,
|
|
444
|
-
`Declare tasks in a ${configName} beside a package.json — your own command, for example:`,
|
|
445
|
-
'',
|
|
446
|
-
...exampleConfig(format)
|
|
447
|
-
.trimEnd()
|
|
448
|
-
.split('\n')
|
|
449
|
-
.map((l) => ` ${l}`),
|
|
450
|
-
)
|
|
451
|
-
} else {
|
|
452
|
-
report.push(`${verb}: ${source} → ${configName}`)
|
|
453
|
-
for (const n of [...(args.notes ?? []), ...ignoredNote]) report.push(`note: ${n}`)
|
|
454
|
-
for (const n of plan.headerNotes) report.push(`note: ${n}`)
|
|
455
|
-
report.push(
|
|
456
|
-
'',
|
|
457
|
-
`${clean} task${clean === 1 ? '' : 's'} migrated clean, ` +
|
|
458
|
-
`${todoCount} TODO${todoCount === 1 ? '' : 's'}${todoCount > 0 ? ':' : ''}`,
|
|
459
|
-
)
|
|
460
|
-
for (const [reason, ids] of todos) {
|
|
461
|
-
if (ids.length === 1) report.push(` ${ids[0]}: ${reason}`)
|
|
462
|
-
else {
|
|
463
|
-
const shown = ids.slice(0, TODO_IDS_SHOWN).join(', ')
|
|
464
|
-
const more = ids.length - TODO_IDS_SHOWN
|
|
465
|
-
report.push(` ${shown}${more > 0 ? ` and ${more} more` : ''}:`, ` ${reason}`)
|
|
466
|
-
}
|
|
467
|
-
}
|
|
468
|
-
report.push(...plan.notes)
|
|
469
|
-
if (files.length === 0) report.push(dry ? 'no files (dry run)' : 'no files written')
|
|
470
|
-
else report.push(dry ? 'files (dry run, nothing written):' : 'files written:')
|
|
471
|
-
for (const f of files) report.push(` ${f.relPath}`)
|
|
472
|
-
if (kept.length > 0) {
|
|
473
|
-
report.push('kept (each already has a vx config):')
|
|
474
|
-
for (const f of kept) report.push(` ${f}`)
|
|
475
|
-
}
|
|
476
|
-
if (replaced.length > 0) {
|
|
477
|
-
report.push(dry ? 'would replace (dry run):' : 'replaced:')
|
|
478
|
-
for (const f of replaced) report.push(` ${relPosix(root, f)}`)
|
|
479
|
-
}
|
|
480
|
-
}
|
|
481
385
|
const firstTask = nextTask(plan.projects.flatMap((p) => p.tasks))
|
|
482
|
-
// A run of tasks none of which caches is never a hit, and a first try
|
|
483
|
-
// that runs twice to see the cache work saw it run twice.
|
|
484
|
-
if (!cached && [...todos.keys()].some(isCacheTodo)) {
|
|
485
|
-
report.push(
|
|
486
|
-
'',
|
|
487
|
-
'no task caches yet: uncomment the cache line in each TODO task with its real globs, and a second run hits',
|
|
488
|
-
)
|
|
489
|
-
}
|
|
490
386
|
const installed = existsSync(path.join(root, 'node_modules', '@vzn', 'vx', 'package.json'))
|
|
491
387
|
// The line is the next thing to type, so it names what that run would
|
|
492
388
|
// refuse without: a git work tree (vx keys inputs on git's view) and,
|
|
493
389
|
// with no scripts mapped, a task to run.
|
|
494
|
-
const
|
|
390
|
+
const vx = vxInvocation(process.env['npm_config_user_agent'], installed)
|
|
391
|
+
const run = `${vx} run ${firstTask} --all`
|
|
392
|
+
// A run reads configs only from the lock; with no lock step here
|
|
393
|
+
// (`--no-install`), that run stopped with "no lock: run `vx lock`".
|
|
495
394
|
const steps = [
|
|
496
395
|
...(inGitWorkTree(root) ? [] : ['git init']),
|
|
497
396
|
...(empty ? [`declare a task as the example shows`] : []),
|
|
397
|
+
...(!dry && args.lock === undefined ? [`${vx} lock`] : []),
|
|
498
398
|
]
|
|
499
399
|
const next = steps.length === 0 ? run : `${steps.join(', ')}, then ${run}`
|
|
400
|
+
const emptyReason = empty ? args.emptyReason : undefined
|
|
401
|
+
const notes = [
|
|
402
|
+
...(args.notes ?? []),
|
|
403
|
+
...ignoredNote,
|
|
404
|
+
...formatted,
|
|
405
|
+
...plan.headerNotes,
|
|
406
|
+
...plan.notes,
|
|
407
|
+
]
|
|
500
408
|
if (args.json === true) {
|
|
501
409
|
process.stdout.write(
|
|
502
410
|
`${JSON.stringify({
|
|
@@ -507,29 +415,117 @@ export async function applyMigration(args: ApplyMigrationArgs): Promise<number>
|
|
|
507
415
|
replaced: replaced.map((f) => relPosix(root, f)),
|
|
508
416
|
todos: [...todos].map(([reason, tasks]) => ({ reason, tasks })),
|
|
509
417
|
notes: [
|
|
510
|
-
...(
|
|
511
|
-
...(
|
|
512
|
-
...
|
|
513
|
-
...ignoredNote,
|
|
514
|
-
...plan.headerNotes,
|
|
515
|
-
...plan.notes,
|
|
418
|
+
...(emptyReason === undefined ? [] : [emptyReason]),
|
|
419
|
+
...(args.steps ?? []).map(([label, value]) => `${label}: ${value}`),
|
|
420
|
+
...notes,
|
|
516
421
|
],
|
|
517
422
|
next,
|
|
518
423
|
})}\n`,
|
|
519
424
|
)
|
|
520
425
|
return 0
|
|
521
426
|
}
|
|
522
|
-
|
|
523
|
-
|
|
427
|
+
|
|
428
|
+
// The lock's own words become a row; a refusal prints above the report.
|
|
429
|
+
const lock = dry || args.lock === undefined ? undefined : await args.lock(root)
|
|
430
|
+
if (lock !== undefined && lock.code !== 0) process.stderr.write(lock.said)
|
|
431
|
+
|
|
432
|
+
const rows: ui.FieldRow[] = [['from', source]]
|
|
433
|
+
if (dry) rows.push(['dry run', 'nothing written'])
|
|
434
|
+
for (const [label, value] of args.steps ?? []) rows.push([label, value])
|
|
435
|
+
if (empty) {
|
|
436
|
+
const why =
|
|
437
|
+
emptyReason ??
|
|
438
|
+
(args.unmapped === true
|
|
439
|
+
? 'no package.json script became a task'
|
|
440
|
+
: 'no package.json scripts to turn into tasks')
|
|
441
|
+
rows.push(['tasks', [ui.dim('none'), `: ${why}`]])
|
|
442
|
+
} else {
|
|
443
|
+
const parts = [
|
|
444
|
+
...(clean > 0 ? [ui.tone('ok', `${clean} clean`)] : []),
|
|
445
|
+
...(todo > 0 ? [ui.tone('warn', `${todo} todo`)] : []),
|
|
446
|
+
]
|
|
447
|
+
if (parts.length > 0)
|
|
448
|
+
rows.push(['tasks', parts.flatMap((p, i) => (i === 0 ? [p] : [ui.dim(' · '), p]))])
|
|
449
|
+
const todoLines: string[] = []
|
|
450
|
+
for (const [reason, ids] of todos) {
|
|
451
|
+
if (ids.length === 1) todoLines.push(`${ids[0]}: ${reason}`)
|
|
452
|
+
else {
|
|
453
|
+
const shown = ids.slice(0, TODO_IDS_SHOWN).join(', ')
|
|
454
|
+
const more = ids.length - TODO_IDS_SHOWN
|
|
455
|
+
todoLines.push(`${shown}${more > 0 ? ` and ${more} more` : ''}:`, ` ${reason}`)
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
if (todoLines.length > 0) rows.push(['todo', ...todoLines])
|
|
459
|
+
}
|
|
460
|
+
if (files.length > 0) rows.push(['files', ...fileLines(files.map((f) => f.relPath))])
|
|
461
|
+
const keptLines = [
|
|
462
|
+
...kept.map((f) => `${f} (has a vx config)`),
|
|
463
|
+
...(empty && hasWorkspaceFile ? [workspaceName] : []),
|
|
464
|
+
]
|
|
465
|
+
if (keptLines.length > 0) rows.push(['kept', ...keptLines])
|
|
466
|
+
if (replaced.length > 0) rows.push(['replaced', ...replaced.map((f) => relPosix(root, f))])
|
|
467
|
+
if (lock !== undefined) {
|
|
468
|
+
rows.push([
|
|
469
|
+
'lock',
|
|
470
|
+
lock.code === 0
|
|
471
|
+
? lock.said.trim().replace(/^✓ vx lock\s+/, '')
|
|
472
|
+
: ui.tone('error', 'not written'),
|
|
473
|
+
])
|
|
474
|
+
}
|
|
475
|
+
if (notes.length > 0) rows.push(['note', ...notes])
|
|
476
|
+
if (empty && emptyReason === undefined) {
|
|
477
|
+
rows.push([
|
|
478
|
+
'example',
|
|
479
|
+
`a ${configName} beside a package.json, with your own command:`,
|
|
480
|
+
'',
|
|
481
|
+
exampleConfig(format).trimEnd(),
|
|
482
|
+
])
|
|
483
|
+
}
|
|
484
|
+
const said =
|
|
485
|
+
lock === undefined || lock.code === 0 ? next : `fix the above, then vx lock and ${next}`
|
|
486
|
+
rows.push(null, ['next', said])
|
|
487
|
+
ui.print('out', ui.report(`${verb} ${VERSION}`, rows))
|
|
524
488
|
return 0
|
|
525
489
|
}
|
|
526
490
|
|
|
491
|
+
/**
|
|
492
|
+
* What to do about written files .gitignore drops. A `!file` line cures a
|
|
493
|
+
* file, but git re-includes nothing inside an ignored directory: an
|
|
494
|
+
* old-layout `/.vx/` line (when `.vx` held only the cache) hides the
|
|
495
|
+
* workspace config and lock, so that advice names the directory instead.
|
|
496
|
+
*/
|
|
497
|
+
function ignoreAdvice(root: string, ignored: readonly string[], dry: boolean): string {
|
|
498
|
+
const ancestors = (f: string): string[] =>
|
|
499
|
+
f
|
|
500
|
+
.split('/')
|
|
501
|
+
.slice(0, -1)
|
|
502
|
+
.map((_, i, parts) => `${parts.slice(0, i + 1).join('/')}/`)
|
|
503
|
+
const dirs = new Set(gitIgnored(root, [...new Set(ignored.flatMap(ancestors))]))
|
|
504
|
+
const blocking = new Set<string>()
|
|
505
|
+
const names = new Set<string>()
|
|
506
|
+
for (const f of ignored) {
|
|
507
|
+
const dir = ancestors(f).find((d) => dirs.has(d))
|
|
508
|
+
if (dir === undefined) names.add(`!${path.posix.basename(f)}`)
|
|
509
|
+
else blocking.add(dir)
|
|
510
|
+
}
|
|
511
|
+
const cures = [
|
|
512
|
+
...(names.size === 0 ? [] : [`add ${[...names].join(' and ')} to .gitignore`]),
|
|
513
|
+
...(blocking.size === 0
|
|
514
|
+
? []
|
|
515
|
+
: [
|
|
516
|
+
`stop ignoring ${[...blocking].join(' and ')} (a ! line cannot re-include a file inside an ignored directory)`,
|
|
517
|
+
]),
|
|
518
|
+
]
|
|
519
|
+
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`
|
|
520
|
+
}
|
|
521
|
+
|
|
527
522
|
/** The paths (root-relative) git's ignore rules drop; none outside a work tree. */
|
|
528
523
|
function gitIgnored(root: string, relPaths: readonly string[]): string[] {
|
|
529
524
|
if (relPaths.length === 0) return []
|
|
530
525
|
try {
|
|
531
526
|
const r = Bun.spawnSync({
|
|
532
527
|
cmd: [executablePath('git'), 'check-ignore', '--stdin', '-z'],
|
|
528
|
+
env: gitEnv(),
|
|
533
529
|
cwd: root,
|
|
534
530
|
stdin: Buffer.from(relPaths.map((p) => `${p}\0`).join('')),
|
|
535
531
|
stdout: 'pipe',
|
|
@@ -551,6 +547,7 @@ function inGitWorkTree(root: string): boolean {
|
|
|
551
547
|
return (
|
|
552
548
|
Bun.spawnSync({
|
|
553
549
|
cmd: [executablePath('git'), 'rev-parse', '--is-inside-work-tree'],
|
|
550
|
+
env: gitEnv(),
|
|
554
551
|
cwd: root,
|
|
555
552
|
stdout: 'pipe',
|
|
556
553
|
stderr: 'ignore',
|
|
@@ -583,21 +580,22 @@ function nextTask(tasks: readonly GeneratedTask[]): string {
|
|
|
583
580
|
}
|
|
584
581
|
|
|
585
582
|
/**
|
|
586
|
-
* How the user runs vx, for the report's `next:` line. After `npx vx init`
|
|
583
|
+
* How the user runs vx, for the report's `next:` line. After `npx @vzn/vx init`
|
|
587
584
|
* or `bunx @vzn/vx init` a bare `vx` is on no PATH (the first-five-minutes
|
|
588
585
|
* walk, 2026-09-28), so the line names the runner that started this
|
|
589
586
|
* process — npx, pnpm, yarn and Bun each set `npm_config_user_agent` — and
|
|
590
|
-
* the spec it resolves
|
|
591
|
-
*
|
|
587
|
+
* the spec it resolves. npx and bunx always name the package, which runs
|
|
588
|
+
* the installed bin when there is one: their bare `vx` fetches an unrelated
|
|
589
|
+
* package named `vx` wherever vx is not installed. No
|
|
592
590
|
* runner (a global install, the compiled binary): `vx`.
|
|
593
591
|
*/
|
|
594
592
|
export function vxInvocation(userAgent: string | undefined, installed: boolean): string {
|
|
595
593
|
const runner = /^(npm|pnpm|yarn|bun)\//.exec(userAgent ?? '')?.[1]
|
|
596
594
|
switch (runner) {
|
|
597
595
|
case 'npm':
|
|
598
|
-
return
|
|
596
|
+
return 'npx @vzn/vx'
|
|
599
597
|
case 'bun':
|
|
600
|
-
return
|
|
598
|
+
return 'bunx @vzn/vx'
|
|
601
599
|
case 'pnpm':
|
|
602
600
|
return installed ? 'pnpm vx' : 'pnpm dlx @vzn/vx'
|
|
603
601
|
case 'yarn':
|
|
@@ -609,6 +607,15 @@ export function vxInvocation(userAgent: string | undefined, installed: boolean):
|
|
|
609
607
|
}
|
|
610
608
|
}
|
|
611
609
|
|
|
610
|
+
/** Files named one per line; past this, the root's and a count (TanStack form: 97). */
|
|
611
|
+
const FILES_SHOWN = 10
|
|
612
|
+
|
|
613
|
+
function fileLines(paths: readonly string[]): string[] {
|
|
614
|
+
if (paths.length <= FILES_SHOWN) return [...paths]
|
|
615
|
+
const root = paths.filter((p) => !p.includes('/') || p.startsWith('.vx/'))
|
|
616
|
+
return [...root, `and ${paths.length - root.length} more in package directories`]
|
|
617
|
+
}
|
|
618
|
+
|
|
612
619
|
/** Ids named per shared TODO; the files carry each one (sveltejs/kit: 92 persistent tasks). */
|
|
613
620
|
const TODO_IDS_SHOWN = 5
|
|
614
621
|
|
|
@@ -635,12 +642,12 @@ function exampleConfig(format: MigrationFormat): string {
|
|
|
635
642
|
// into every run — measured 2026-09-09 at ~17 ms on a two-package
|
|
636
643
|
// workspace, a fifth of the whole run — for an identity function.
|
|
637
644
|
// No explanatory comment: the file is the user's (owner, 2026-10-06).
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
645
|
+
function workspaceFile(format: MigrationFormat, plugins: readonly WorkspacePlugin[]): string {
|
|
646
|
+
const imports = plugins.map((p) => `import { ${p.factory} } from '${p.pkg}'\n`).join('')
|
|
647
|
+
const body = `export default { plugins: [${plugins.map((p) => `${p.factory}()`).join(', ')}] }`
|
|
641
648
|
return format === 'ts'
|
|
642
|
-
? `import type { WorkspaceConfig } from '@vzn/vx/config'\n\n${
|
|
643
|
-
: `${
|
|
649
|
+
? `import type { WorkspaceConfig } from '@vzn/vx/config'\n${imports}\n${body} satisfies WorkspaceConfig\n`
|
|
650
|
+
: `${imports}${imports === '' ? '' : '\n'}${body}\n`
|
|
644
651
|
}
|
|
645
652
|
|
|
646
653
|
const IDENT = /^[A-Za-z_$][\w$]*$/
|
|
@@ -679,8 +686,9 @@ function renderConfigFile(p: GeneratedProject, format: MigrationFormat): string
|
|
|
679
686
|
if (p.importLines.length > 0) lines.push(...p.importLines)
|
|
680
687
|
if (lines.length > 0) lines.push('')
|
|
681
688
|
lines.push('export default {')
|
|
689
|
+
if (p.writeName === true) lines.push(` name: ${quoteTsLiteral(p.name)},`)
|
|
682
690
|
if (p.tags !== undefined && p.tags.length > 0) lines.push(` tags: ${renderValue(p.tags, ' ')},`)
|
|
683
|
-
lines.push(' tasks: {')
|
|
691
|
+
lines.push(p.tasks.length === 0 ? ' tasks: {},' : ' tasks: {')
|
|
684
692
|
for (const t of p.tasks) {
|
|
685
693
|
// A reason can quote a manifest's own text (an Nx `env` key), and a
|
|
686
694
|
// line break there ended the comment: the rest was code in the file
|
|
@@ -709,6 +717,90 @@ function renderConfigFile(p: GeneratedProject, format: MigrationFormat): string
|
|
|
709
717
|
` ${key}: ${block === undefined ? rendered : rendered.replace('{\n', `{\n // ${block},\n`)},`,
|
|
710
718
|
)
|
|
711
719
|
}
|
|
712
|
-
lines.push(' },'
|
|
720
|
+
if (p.tasks.length > 0) lines.push(' },')
|
|
721
|
+
lines.push(format === 'ts' ? '} satisfies ProjectConfig' : '}', '')
|
|
713
722
|
return lines.join('\n')
|
|
714
723
|
}
|
|
724
|
+
|
|
725
|
+
/**
|
|
726
|
+
* `vx.config.mts` in a TypeScript package that is CommonJS: tsc (node16 /
|
|
727
|
+
* nodenext) reads a `.ts` there as CommonJS, and its type-only import of
|
|
728
|
+
* the ESM `@vzn/vx/config` fails (TS1479, TS1541: medusa's plugin builds).
|
|
729
|
+
*/
|
|
730
|
+
function projectConfigName(
|
|
731
|
+
dir: string,
|
|
732
|
+
metas: readonly MigrationMember[],
|
|
733
|
+
format: MigrationFormat,
|
|
734
|
+
): string {
|
|
735
|
+
if (format !== 'ts' || !existsSync(path.join(dir, 'tsconfig.json'))) return `vx.config.${format}`
|
|
736
|
+
const meta = metas.find((m) => m.dir === dir)
|
|
737
|
+
const manifest = (meta?.packageJson as { type?: unknown } | undefined) ?? readManifest(dir)
|
|
738
|
+
return manifest?.type === 'module' ? 'vx.config.ts' : 'vx.config.mts'
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
/**
|
|
742
|
+
* The plan takes the config back from an output as `!vx.config.<format>`;
|
|
743
|
+
* where the file written is the `.mts`, the take-back names it, or a `*`
|
|
744
|
+
* output covers it and `vx lock` refuses the config (medusa, 80 of 84).
|
|
745
|
+
*/
|
|
746
|
+
function ownConfigNamed(
|
|
747
|
+
p: GeneratedProject,
|
|
748
|
+
name: string,
|
|
749
|
+
format: MigrationFormat,
|
|
750
|
+
): GeneratedProject {
|
|
751
|
+
const planned = `!vx.config.${format}`
|
|
752
|
+
if (name === `vx.config.${format}`) return p
|
|
753
|
+
return {
|
|
754
|
+
...p,
|
|
755
|
+
tasks: p.tasks.map((t) => {
|
|
756
|
+
const outputs = (t.task?.['cache'] as { outputs?: { files?: unknown } } | undefined)?.outputs
|
|
757
|
+
if (!Array.isArray(outputs?.files) || !outputs.files.includes(planned)) return t
|
|
758
|
+
const cache = t.task!['cache'] as Record<string, unknown>
|
|
759
|
+
const files = outputs.files.map((f: unknown) => (f === planned ? `!${name}` : f))
|
|
760
|
+
return { ...t, task: { ...t.task, cache: { ...cache, outputs: { ...outputs, files } } } }
|
|
761
|
+
}),
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
function readManifest(dir: string): { type?: unknown } | undefined {
|
|
766
|
+
try {
|
|
767
|
+
return JSON.parse(readFileSync(path.join(dir, 'package.json'), 'utf8')) as { type?: unknown }
|
|
768
|
+
} catch {
|
|
769
|
+
return undefined
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
/** The formatters a repo installs at its root, each with its write command. */
|
|
774
|
+
const FORMATTERS: readonly (readonly [string, readonly string[]])[] = [
|
|
775
|
+
['prettier', ['--write']],
|
|
776
|
+
['biome', ['format', '--write']],
|
|
777
|
+
['oxfmt', []],
|
|
778
|
+
['dprint', ['fmt']],
|
|
779
|
+
]
|
|
780
|
+
|
|
781
|
+
/**
|
|
782
|
+
* Runs the repo's own formatter over the files just written, as Nx's
|
|
783
|
+
* generators do: create-t3-turbo's `prettier --check` failed on the quotes
|
|
784
|
+
* of a config vx wrote. Returns the report's note.
|
|
785
|
+
*/
|
|
786
|
+
function formatWritten(root: string, relPaths: readonly string[]): string[] {
|
|
787
|
+
if (relPaths.length === 0) return []
|
|
788
|
+
for (const [name, args] of FORMATTERS) {
|
|
789
|
+
const bin = path.join(root, 'node_modules', '.bin', name)
|
|
790
|
+
if (!existsSync(bin)) continue
|
|
791
|
+
const r = Bun.spawnSync({
|
|
792
|
+
cmd: [bin, ...args, ...relPaths],
|
|
793
|
+
cwd: root,
|
|
794
|
+
stdin: 'ignore',
|
|
795
|
+
stdout: 'pipe',
|
|
796
|
+
stderr: 'pipe',
|
|
797
|
+
timeout: 60_000,
|
|
798
|
+
})
|
|
799
|
+
if (r.exitCode === 0) {
|
|
800
|
+
return [`formatted ${relPaths.length} file${relPaths.length === 1 ? '' : 's'} with ${name}`]
|
|
801
|
+
}
|
|
802
|
+
const why = (r.stderr.toString() || r.stdout.toString()).trim().split('\n')[0] ?? ''
|
|
803
|
+
return [`${name} did not format the written files (exit ${r.exitCode ?? 'signal'}): ${why}`]
|
|
804
|
+
}
|
|
805
|
+
return []
|
|
806
|
+
}
|