@vzn/vx-migrate 0.0.635 → 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/src/paths.ts CHANGED
@@ -13,7 +13,7 @@
13
13
  // cheapest moment to collapse a duplicate is before it does.
14
14
 
15
15
  import path from 'node:path'
16
- import type { ProjectMeta } from '@vzn/vx'
16
+ import type { PackageMeta } from './packages.js'
17
17
 
18
18
  /** `to` relative to `from`, POSIX-separated — core's `relPosix`, which is not on the façade. */
19
19
  export function relPosix(from: string, to: string): string {
@@ -30,7 +30,7 @@ export function relPosix(from: string, to: string): string {
30
30
  * mappers did not. Null-prototype, so a turbo.json task or Nx target named
31
31
  * `constructor` or `toString` finds no script rather than Object's method.
32
32
  */
33
- export function packageScripts(meta: ProjectMeta): Record<string, unknown> {
33
+ export function packageScripts(meta: PackageMeta): Record<string, unknown> {
34
34
  const raw = (meta.packageJson as unknown as { scripts?: unknown }).scripts
35
35
  const scripts: Record<string, unknown> = Object.create(null)
36
36
  if (!isRecord(raw)) return scripts
@@ -0,0 +1,118 @@
1
+ // The remote cache a Turbo or Nx repo already uses, as the repo shows it,
2
+ // so the workspace file a migration writes keeps sharing it: native or
3
+ // keep, from `vx init` or `bunx @vzn/vx-migrate` (M, 2026-10-14). Nx
4
+ // Cloud's wire is Nx's own, so it is named, never declared.
5
+
6
+ import { readFileSync } from 'node:fs'
7
+ import path from 'node:path'
8
+
9
+ /** The cache plugin each runner's remote speaks through, from this package. */
10
+ export const REMOTE_CACHE = { turbo: 'turboCache', nx: 'nxCache' } as const
11
+
12
+ /** The variable each runner's self-hosted remote cache is set by, as its plugin reads it. */
13
+ const REMOTE_CACHE_ENV = { turbo: 'TURBO_TOKEN', nx: 'NX_SELF_HOSTED_REMOTE_CACHE_SERVER' } as const
14
+
15
+ const CI_FILES = ['.gitlab-ci.yml', '.circleci/config.yml']
16
+
17
+ /**
18
+ * Where the repo shows a remote cache its runner uses, or undefined:
19
+ * turbo.json's enabled `remoteCache`, a `turbo link`ed `.turbo/config.json`,
20
+ * or a CI file that sets the runner's cache variable (a local repo holds
21
+ * no token; CI does).
22
+ */
23
+ export function remoteCacheSignal(root: string, runner: 'turbo' | 'nx'): string | undefined {
24
+ if (runner === 'turbo') {
25
+ for (const f of ['turbo.json', 'turbo.jsonc']) {
26
+ let rc: unknown
27
+ try {
28
+ rc = (
29
+ Bun.JSONC.parse(readFileSync(path.join(root, f), 'utf8')) as { remoteCache?: unknown }
30
+ )?.remoteCache
31
+ } catch {
32
+ continue
33
+ }
34
+ if (typeof rc === 'object' && rc !== null) {
35
+ // Turned off in turbo.json is off, whatever CI sets.
36
+ if ((rc as { enabled?: unknown }).enabled === false) return undefined
37
+ return `${f} names a remoteCache`
38
+ }
39
+ }
40
+ // `turbo link` writes the team here (gitignored, on the machine that
41
+ // linked); turboCache() reads it, so a linked repo has a remote cache.
42
+ let linked: unknown
43
+ try {
44
+ linked = JSON.parse(readFileSync(path.join(root, '.turbo', 'config.json'), 'utf8'))
45
+ } catch {}
46
+ if (
47
+ typeof linked === 'object' &&
48
+ linked !== null &&
49
+ Object.entries(linked).some(
50
+ ([k, v]) => /^(teamid|teamslug|token)$/i.test(k) && typeof v === 'string' && v !== '',
51
+ )
52
+ ) {
53
+ return '.turbo/config.json links a team (turbo link)'
54
+ }
55
+ }
56
+ const variable = REMOTE_CACHE_ENV[runner]
57
+ // A line that sets it (`TURBO_TOKEN: …`, `TURBO_TOKEN=…`), not one that
58
+ // names it: a comment or a doc line counted as "sets" (J's lead).
59
+ const sets = new RegExp(`^(?!\\s*#).*\\b${variable}\\s*[:=]`, 'm')
60
+ const workflows = new Bun.Glob('.github/workflows/*.{yml,yaml}')
61
+ const files = [...workflows.scanSync({ cwd: root, dot: true })].sort().concat(CI_FILES)
62
+ for (const f of files) {
63
+ let text: string
64
+ try {
65
+ text = readFileSync(path.join(root, f), 'utf8')
66
+ } catch {
67
+ continue
68
+ }
69
+ if (sets.test(text)) return `${f} sets ${variable}`
70
+ }
71
+ return undefined
72
+ }
73
+
74
+ /**
75
+ * nx.json names an Nx Cloud workspace (`nxCloudId`, `nxCloudAccessToken`,
76
+ * or the legacy `nx-cloud` runner). Its wire is Nx's own, so a user who
77
+ * expected their remote cache under vx heard nothing and ran cold.
78
+ */
79
+ function usesNxCloud(root: string): boolean {
80
+ let json: {
81
+ nxCloudId?: unknown
82
+ nxCloudAccessToken?: unknown
83
+ tasksRunnerOptions?: { default?: { runner?: unknown } }
84
+ } | null
85
+ try {
86
+ json = Bun.JSONC.parse(readFileSync(path.join(root, 'nx.json'), 'utf8')) as typeof json
87
+ } catch {
88
+ return false
89
+ }
90
+ const set = (v: unknown) => typeof v === 'string' && v !== ''
91
+ const runner = json?.tasksRunnerOptions?.default?.runner
92
+ return (
93
+ set(json?.nxCloudId) ||
94
+ set(json?.nxCloudAccessToken) ||
95
+ (typeof runner === 'string' && /(^|\/)nx-cloud$/.test(runner))
96
+ )
97
+ }
98
+
99
+ /**
100
+ * The report's line about the remote cache, or undefined: the cache plugin
101
+ * declared (`declared`), the one a kept file still lacks, or Nx Cloud.
102
+ */
103
+ export function remoteCacheNote(
104
+ root: string,
105
+ runner: 'turbo' | 'nx',
106
+ declared: boolean,
107
+ ): string | undefined {
108
+ const signal = remoteCacheSignal(root, runner)
109
+ const cache = REMOTE_CACHE[runner]
110
+ if (signal !== undefined) {
111
+ return declared
112
+ ? `${cache}(): ${signal}, so vx shares that remote cache (inert where the variable is unset).`
113
+ : `${signal}: add ${cache}() from @vzn/vx-migrate to its plugins and vx shares that remote cache.`
114
+ }
115
+ return runner === 'nx' && usesNxCloud(root)
116
+ ? 'nx.json connects Nx Cloud, whose cache vx cannot share: runs cache on this machine (nxCache() serves a self-hosted Nx cache).'
117
+ : undefined
118
+ }
@@ -26,13 +26,15 @@ import { isLiteralPattern, outputsOverlap, type GeneratedTask } from '@vzn/vx'
26
26
  import { relPosix } from './paths.js'
27
27
  import type { TrackedKinds } from './tracked-outputs.js'
28
28
 
29
- // Core refuses an output glob that covers the project's own package.json
30
- // or vx.config (config-schema's `ownFilesCovered`): vx cleans outputs before
31
- // a run and restores them on a hit, so the manifest would go. Turbo caches
32
- // it: trpc's client build lists `package.json` (it rewrites `exports`), and
33
- // the one task made core refuse the whole run (2026-09-28). The façade does
34
- // not export the check; tests/own-file-outputs.test.ts holds this copy to
35
- // the loader in both directions.
29
+ // vx cleans outputs before a run and restores them on a hit, so an output
30
+ // glob that covers the project's own package.json or vx.config would delete
31
+ // it. Core refuses the config file (config-schema's `ownFilesCovered`); the
32
+ // manifest it no longer guards (core keys no package file, 2026-10-14), but
33
+ // a mapped task still must not delete it. Turbo caches it: trpc's client
34
+ // build lists `package.json` (it rewrites `exports`), and the one task made
35
+ // core refuse the whole run (2026-09-28). The façade does not export the
36
+ // check; tests/own-file-outputs.test.ts holds the config half of this copy
37
+ // to the loader in both directions.
36
38
  const OWN_FILES = ['package.json', 'vx.config.ts', 'vx.config.mts', 'vx.config.js', 'vx.config.mjs']
37
39
 
38
40
  /**
@@ -102,7 +104,7 @@ export function ownFileTodo(own: { glob: string; file: string }): string {
102
104
  export function wildcardOutput(
103
105
  files: readonly string[],
104
106
  tracked?: TrackedKinds,
105
- /** Let a literal-tail glob through once its tracked matches are taken back (`literalTailMatches`). */
107
+ /** Let a literal-tail glob through: it reaches only the committed files it names, which core keeps (`literalTailMatches`). */
106
108
  literalTail = false,
107
109
  ): string | undefined {
108
110
  return files.find(
@@ -110,20 +112,16 @@ export function wildcardOutput(
110
112
  !o.startsWith('!') &&
111
113
  !isLiteralPattern(o.split('/')[0] ?? '') &&
112
114
  !untrackedKind(o, tracked) &&
113
- !(
114
- literalTail &&
115
- (literalTailMatches(o, tracked)?.every((m) => files.includes(`!${m}`)) ?? false)
116
- ),
115
+ !(literalTail && literalTailMatches(o, tracked) !== null),
117
116
  )
118
117
  }
119
118
 
120
119
  /**
121
120
  * The tracked files a wildcard-first glob with a literal rest can reach
122
121
  * (clerk's builds output `*\/package.json`, the subpath stubs it commits):
123
- * it reaches no other file, so with each of these taken back by a `!` it
124
- * reaches no source. Null for any other shape, or with `tracked` unknown.
122
+ * it reaches no other file, and core never cleans a committed one. Null for any other shape, or with `tracked` unknown.
125
123
  */
126
- export function literalTailMatches(glob: string, tracked?: TrackedKinds): string[] | null {
124
+ function literalTailMatches(glob: string, tracked?: TrackedKinds): string[] | null {
127
125
  if (tracked === undefined || glob.startsWith('!')) return null
128
126
  const segs = glob.replace(/^(\.\/)+/, '').split('/')
129
127
  const [first, ...rest] = segs
@@ -159,6 +157,15 @@ export function wildcardTodo(glob: string): string {
159
157
  )
160
158
  }
161
159
 
160
+ export function wildcardCleanTodo(glob: string): string {
161
+ return (
162
+ `output ${JSON.stringify(glob)}: vx cleans outputs before a run and a restore, so every ` +
163
+ 'untracked file this glob reaches is deleted; committed files are kept'
164
+ )
165
+ }
166
+
167
+ const TSBUILDINFO = /(?:^|\/)[^/]*tsbuildinfo[^/]*$/i
168
+
162
169
  interface Cached {
163
170
  index: number
164
171
  name: string
@@ -204,7 +211,12 @@ export function resolveSharedOutputs(tasks: GeneratedTask[]): GeneratedTask[] {
204
211
  )
205
212
  if (group.length === 0) continue
206
213
  const all = [a, ...group]
207
- const keeper = all.find((c) => c.hasUpstreamEdge) ?? all[0]!
214
+ // `build` is the product Turbo and Nx repos run: typescript-eslint's
215
+ // typecheck (with `^typecheck`) writes declarations into build's dist,
216
+ // and keeping typecheck left every build uncached (Growth parity,
217
+ // 2026-10-14).
218
+ const keeper =
219
+ all.find((c) => c.name === 'build') ?? all.find((c) => c.hasUpstreamEdge) ?? all[0]!
208
220
  const kept: Cached[] = [keeper]
209
221
  for (const c of all) {
210
222
  if (c === keeper) continue
@@ -215,8 +227,28 @@ export function resolveSharedOutputs(tasks: GeneratedTask[]): GeneratedTask[] {
215
227
  kept.push(c)
216
228
  continue
217
229
  }
218
- dropped.add(c.index)
219
230
  const t = tasks[c.index]!
231
+ // tsc's incremental state is no product: create-t3-turbo's build and
232
+ // typecheck both output `.cache/tsbuildinfo.json`, and typecheck ran
233
+ // uncached on every first run (Growth funnel F8). Left to the keeper,
234
+ // it costs a task that emits nothing else nothing to cache. A task
235
+ // that emits more stays uncached: `tsc --incremental` skips a file
236
+ // the keeper's tsbuildinfo calls built, missing or not, so its save
237
+ // could be a partial dist.
238
+ const sharedFiles = c.files.filter((gb) =>
239
+ kept.some((k) => k.files.some((ga) => outputsOverlap(ga, gb))),
240
+ )
241
+ if (
242
+ sharedFiles.length === c.files.length &&
243
+ sharedFiles.every((g) => isLiteralPattern(g) && TSBUILDINFO.test(g))
244
+ ) {
245
+ c.files = c.files.filter((g) => !sharedFiles.includes(g))
246
+ const outputs = (t.task!['cache'] as { outputs: { files: string[] } }).outputs
247
+ outputs.files = outputs.files.filter((g) => !sharedFiles.includes(g))
248
+ kept.push(c)
249
+ continue
250
+ }
251
+ dropped.add(c.index)
220
252
  const shared = c.files.find((gb) => clash.files.some((ga) => outputsOverlap(ga, gb)))!
221
253
  delete t.task!['cache']
222
254
  t.todos.push(
@@ -276,6 +308,15 @@ function prefixIndex(): {
276
308
  }
277
309
  }
278
310
 
311
+ /**
312
+ * A keyed group (`cache`, no `exec`: the `nx-input:<name>` twins, Turbo's
313
+ * transit nodes) runs nothing, so core lets its key read what a task
314
+ * writes (`detectInputOverlaps` exempts it). Uncaching one left `{}`, a
315
+ * task core refuses, and every verb failed.
316
+ */
317
+ export const isKeyedGroup = (t: { readonly task: Record<string, unknown> | null }): boolean =>
318
+ t.task !== null && t.task['exec'] === undefined && t.task['cache'] !== undefined
319
+
279
320
  /** A task's cached `inputs` block, when it has one with a `files` list. */
280
321
  function inputsOf(t: GeneratedTask): { files: string[]; workspaceFiles?: unknown } | undefined {
281
322
  const inputs = (t.task?.['cache'] as { inputs?: { files?: unknown } } | undefined)?.inputs
@@ -294,8 +335,8 @@ function inputsOf(t: GeneratedTask): { files: string[]; workspaceFiles?: unknown
294
335
  * files git tracks, and a build's outputs are not, so the adopted tool
295
336
  * never keyed them either. A literal input another task writes cannot be
296
337
  * taken back (core refuses a `!` over a literal input), so that task runs
297
- * uncached with a todo. Committed files under an output are the tracked
298
- * pass's (`spareTrackedOutputs`).
338
+ * uncached with a todo. A reader whose `!` hides a committed file is the
339
+ * tracked pass's (`spareTrackedOutputs`).
299
340
  */
300
341
  export function excludeSiblingOutputs(tasks: GeneratedTask[]): GeneratedTask[] {
301
342
  const writers = tasks.flatMap((t, index) => {
@@ -314,6 +355,7 @@ export function excludeSiblingOutputs(tasks: GeneratedTask[]): GeneratedTask[] {
314
355
  const read = positive.filter((g) => outputsOverlap(g, out))
315
356
  if (read.length === 0) continue
316
357
  const literal = read.find((g) => isLiteralPattern(g))
358
+ if (literal !== undefined && isKeyedGroup(t)) continue
317
359
  if (literal !== undefined) {
318
360
  delete t.task!['cache']
319
361
  t.todos.push(
@@ -463,6 +505,7 @@ export function excludeWorkspaceOutputs(
463
505
  const read = reads.filter((g) => outputsOverlap(g, out))
464
506
  if (read.length === 0) continue
465
507
  const literal = read.find((g) => isLiteralPattern(g))
508
+ if (literal !== undefined && isKeyedGroup(t)) continue
466
509
  if (literal !== undefined) {
467
510
  delete t.task!['cache']
468
511
  t.todos.push(
@@ -497,6 +540,7 @@ export function excludeWorkspaceOutputs(
497
540
  // An output that reaches in from above (`**/gen/**`) has no
498
541
  // project-relative `!` that takes it back.
499
542
  const literal = out.startsWith(at) ? read.find((g) => isLiteralPattern(g)) : read[0]
543
+ if (literal !== undefined && isKeyedGroup(t)) continue
500
544
  if (literal !== undefined) {
501
545
  delete t.task!['cache']
502
546
  t.todos.push(
@@ -11,6 +11,7 @@ import { lstat, readFile } from 'node:fs/promises'
11
11
  import path from 'node:path'
12
12
  import { isLiteralPattern, outputsOverlap } from '@vzn/vx'
13
13
  import { relPosix } from './paths.js'
14
+ import { isKeyedGroup } from './shared-outputs.js'
14
15
 
15
16
  /** The files git tracks under `root`, root-relative; null outside a repo or without git. */
16
17
  export async function trackedFiles(root: string): Promise<string[] | null> {
@@ -118,8 +119,8 @@ export function trackedKinds(tracked: readonly string[]): (rel: string) => Track
118
119
  * checkout, a pull appends to it), HEAD itself, and the index's size and
119
120
  * mtime (`git add`, `git rm`). Stats and a small read, so a kept mapping
120
121
  * stays a hit between them; a run does not write the index. Without the
121
- * index, a file `git add`ed under an output and not yet committed was not
122
- * taken back by the kept mapping, and the run's clean deleted it. Under
122
+ * index, a file `git add`ed under an output and not yet committed left a
123
+ * reader that its `!` hides cached by the kept mapping. Under
123
124
  * reftable storage HEAD reads `ref: refs/heads/.invalid` and no reflog file
124
125
  * exists, so the ref stack's table list stands for both: every ref update
125
126
  * names a new table in it.
@@ -191,8 +192,25 @@ function hidden(globs: unknown, files: readonly string[]): string | undefined {
191
192
  )
192
193
  }
193
194
 
194
- /** Past this many, a task's take-backs cost its runs more than its cache saves. */
195
- export const MAX_SPARED = 16
195
+ /** `globs` without the `!` entries in `backs` that take back one of `files`. */
196
+ function unhidden(
197
+ globs: readonly unknown[],
198
+ backs: ReadonlySet<string>,
199
+ files: readonly string[],
200
+ ): unknown[] {
201
+ return globs.filter(
202
+ (g) =>
203
+ !(
204
+ typeof g === 'string' &&
205
+ g.startsWith('!') &&
206
+ backs.has(g.slice(1)) &&
207
+ files.some((f) => outputsOverlap(g.slice(1), f))
208
+ ),
209
+ )
210
+ }
211
+
212
+ const positives = (v: unknown): string[] =>
213
+ Array.isArray(v) ? v.filter((g): g is string => typeof g === 'string' && !g.startsWith('!')) : []
196
214
 
197
215
  interface Outputs {
198
216
  files?: unknown
@@ -200,9 +218,11 @@ interface Outputs {
200
218
  }
201
219
 
202
220
  /**
203
- * Take back every committed file a mapped task's outputs cover, in place,
204
- * and return one `[pkg#task, todo]` per task that had any. `tracked` is
205
- * root-relative.
221
+ * Fit a mapped task's outputs to the committed files they cover, in place,
222
+ * and return one `[pkg#task, todo]` per task this leaves uncached. Core
223
+ * never cleans a committed file (owner, 2026-10-14), so
224
+ * the outputs keep them; what is left is the readers' `!` that hides one.
225
+ * `tracked` is root-relative.
206
226
  */
207
227
  export function spareTrackedOutputs(
208
228
  root: string,
@@ -244,7 +264,7 @@ export function spareTrackedOutputs(
244
264
  for (const t of p.tasks) {
245
265
  const outputs = (t.task?.['cache'] as { outputs?: Outputs } | undefined)?.outputs
246
266
  if (outputs === undefined) continue
247
- const hits: [key: 'files' | 'workspaceFiles', globs: string[], hit: string[]][] = []
267
+ const hits: [key: 'files' | 'workspaceFiles', hit: string[]][] = []
248
268
  for (const [key, base] of [
249
269
  ['files', own],
250
270
  ['workspaceFiles', tracked],
@@ -253,9 +273,9 @@ export function spareTrackedOutputs(
253
273
  if (!Array.isArray(globs) || globs.length === 0) continue
254
274
  let strings = globs.filter((g): g is string => typeof g === 'string')
255
275
  // An output naming one committed file (eslint's
256
- // `eslint-suppressions.json`) is a source: its `!` would take the
257
- // whole entry back, so the entry goes, and so does the `!` a
258
- // reader's inputs got for it, or every reader ran uncached (hono).
276
+ // `eslint-suppressions.json`) is a source: the entry goes, and so
277
+ // does the `!` a reader's inputs got for it, or every reader ran
278
+ // uncached (hono).
259
279
  const rootedOf = (g: string) => (key === 'files' && rel !== '' ? `${rel}/${g}` : g)
260
280
  const sources = new Set(
261
281
  strings.filter((g) => isLiteralPattern(g) && trackedSet.has(rootedOf(g))),
@@ -266,17 +286,26 @@ export function spareTrackedOutputs(
266
286
  for (const g of sources) unhide(key === 'files' ? p : null, rootedOf(g))
267
287
  }
268
288
  const hit = coveredTracked(strings, base)
269
- if (hit.length > 0) hits.push([key, strings, hit])
289
+ if (hit.length > 0) hits.push([key, hit])
270
290
  }
271
- const spared = hits.flatMap(([, , hit]) => hit)
272
- if (spared.length === 0) continue
291
+ if (hits.length === 0) continue
273
292
  // A committed file under these outputs is still a source, and the `!`
274
293
  // entry `excludeSiblingOutputs` gave a reader over the whole output
275
294
  // hides it from that reader's key: such a reader runs uncached (X-54).
276
- const local = hits.flatMap(([key, , hit]) => (key === 'files' ? hit : []))
277
- const rooted = hits.flatMap(([key, , hit]) =>
295
+ const local = hits.flatMap(([key, hit]) => (key === 'files' ? hit : []))
296
+ const rooted = hits.flatMap(([key, hit]) =>
278
297
  key === 'files' ? hit.map((f) => (rel === '' ? f : `${rel}/${f}`)) : hit,
279
298
  )
299
+ // The `!` entries the sibling passes gave readers for these outputs.
300
+ const ownOut = positives(outputs.files)
301
+ const fileBacks = new Set(ownOut)
302
+ const wsBacks = new Set([
303
+ ...positives(outputs.workspaceFiles),
304
+ ...ownOut.map((g) => {
305
+ const bare = g.replace(/^(\.\/)+/, '')
306
+ return rel === '' ? bare : `${rel}/${bare}`
307
+ }),
308
+ ])
280
309
  for (const q of projects) {
281
310
  for (const r of q.tasks) {
282
311
  if (r === t) continue
@@ -286,6 +315,15 @@ export function spareTrackedOutputs(
286
315
  (q === p ? hidden(inputs.files, local) : undefined) ??
287
316
  hidden(inputs.workspaceFiles, rooted)
288
317
  if (f === undefined) continue
318
+ if (isKeyedGroup(r)) {
319
+ // Its key reads the committed files, as Nx hashes them. Only the
320
+ // take-backs of this output go, never a `!` the input declares.
321
+ if (q === p && Array.isArray(inputs.files))
322
+ inputs.files = unhidden(inputs.files, fileBacks, local)
323
+ if (Array.isArray(inputs.workspaceFiles))
324
+ inputs.workspaceFiles = unhidden(inputs.workspaceFiles, wsBacks, rooted)
325
+ continue
326
+ }
289
327
  delete r.task!['cache']
290
328
  todos.push([
291
329
  `${q.name}#${r.name}`,
@@ -294,25 +332,6 @@ export function spareTrackedOutputs(
294
332
  ])
295
333
  }
296
334
  }
297
- const shown = spared.slice(0, 3).join(', ') + (spared.length > 3 ? ', …' : '')
298
- // Each `!` is matched against every output path on each save, clean
299
- // and restore: 4,200 of them cost typescript-eslint's warm run 1.2 s.
300
- if (spared.length > MAX_SPARED) {
301
- delete t.task!['cache']
302
- todos.push([
303
- `${p.name}#${t.name}`,
304
- `outputs cover ${spared.length} committed files (${shown}), which vx cleans before ` +
305
- 'every run — task runs uncached; declare the exact outputs in a vx.config to cache it',
306
- ])
307
- continue
308
- }
309
- for (const [key, strings, hit] of hits)
310
- outputs[key] = [...strings, ...hit.map((f) => `!${f}`)]
311
- todos.push([
312
- `${p.name}#${t.name}`,
313
- `outputs cover ${spared.length} committed file(s) (${shown}) — vx cleans outputs ` +
314
- 'before a run, so they are taken back with `!` and kept',
315
- ])
316
335
  }
317
336
  }
318
337
  return todos
@@ -2,7 +2,7 @@
2
2
  //
3
3
  // Fills the `project` stage: every package the workspace discovers is given
4
4
  // the tasks turbo.json + its package.json scripts define, mapped by the same
5
- // mapper `vx migrate --from turbo` renders files from — so what runs here is
5
+ // mapper `bunx @vzn/vx-migrate` renders files from — so what runs here is
6
6
  // what a migration would have written, minus the file. Turbo's global fields
7
7
  // are inlined into each task instead of imported from a generated preset.
8
8
  // A task the package's own vx.config already declares wins; the plugin never
@@ -11,7 +11,9 @@
11
11
  import { readFileSync } from 'node:fs'
12
12
  import { availableParallelism } from 'node:os'
13
13
  import path from 'node:path'
14
- import type { ProjectMeta, VxPlugin } from '@vzn/vx'
14
+ import type { ProjectMeta, VxPlugin, WorkspaceConfig } from '@vzn/vx'
15
+ import type { PackageMeta } from '../packages.js'
16
+ import { memberProjects, withManifests } from '../manifests.js'
15
17
  import { refuseUnknownOptions, type PluginOptionKinds } from '@vzn/vx'
16
18
  import { type AdoptionRun, adoptionPlugin } from '../adoption-plugin.js'
17
19
  import type { AdoptionMapping } from '../mapping-cache.js'
@@ -60,29 +62,38 @@ export function turbo(options: TurboPluginOptions = {}): VxPlugin {
60
62
  {
61
63
  async config(workspace, ctx) {
62
64
  const keys = await workspaceKeys(options.root ?? ctx.workspaceRoot)
63
- if (workspace.concurrency === undefined && keys.concurrency !== undefined)
64
- workspace.concurrency = keys.concurrency
65
- if (workspace.cacheRetention === undefined && keys.cacheRetention !== undefined)
66
- workspace.cacheRetention = keys.cacheRetention
65
+ if (workspace.run?.concurrency === undefined && keys.concurrency !== undefined)
66
+ workspace.run = { ...workspace.run, concurrency: keys.concurrency }
67
+ if (workspace.cache?.retention === undefined && keys.cacheRetention !== undefined)
68
+ workspace.cache = {
69
+ ...workspace.cache,
70
+ // `olderThan` is built with its unit below.
71
+ retention: keys.cacheRetention as NonNullable<
72
+ NonNullable<WorkspaceConfig['cache']>['retention']
73
+ >,
74
+ }
67
75
  // `turbo run --affected` compares with TURBO_SCM_BASE when set, and
68
76
  // on GitHub Actions with what the event names.
69
77
  const base =
70
78
  Bun.env['TURBO_SCM_BASE']?.trim() ||
71
79
  githubActionsBase(options.root ?? ctx.workspaceRoot, keys.remoteBaseFallback === true)
72
- if (workspace.affectedBase === undefined && base) workspace.affectedBase = base
80
+ if (workspace.run?.affectedBase === undefined && base)
81
+ workspace.run = { ...workspace.run, affectedBase: base }
73
82
  },
74
- // `//#task` keys run on the root, as Turbo runs them: named here, the
75
- // root is a project with no vx.config written (a create-turbo repo's
76
- // `//#format`). A package that holds the root's name keeps it, and
77
- // the mapping's note says so.
83
+ // Turbo's packages are the package manager's members, each a project
84
+ // with no vx.config written. `//#task` keys run on the root, as Turbo
85
+ // runs them: named here, the root is a project too (a create-turbo
86
+ // repo's `//#format`). A package that holds the root's name keeps it,
87
+ // and the mapping's note says so.
78
88
  async discover(ctx) {
79
- if (options.root !== undefined) return []
89
+ const members = await memberProjects(ctx.workspaceRoot)
90
+ if (options.root !== undefined) return members
80
91
  const named = await rootTaskProject(ctx.workspaceRoot)
81
- if (named === null) return []
82
- const taken = ctx.projects.some(
83
- (m) => m.name === named.name && path.resolve(m.dir) !== path.resolve(named.dir),
84
- )
85
- return taken ? [] : [named]
92
+ if (named === null) return members
93
+ const rootDir = path.resolve(named.dir)
94
+ const known = [...ctx.projects, ...members.map((m) => ({ name: m.name, dir: m.dir }))]
95
+ const taken = known.some((m) => m.name === named.name && path.resolve(m.dir) !== rootDir)
96
+ return taken ? members : [...members.filter((m) => path.resolve(m.dir) !== rootDir), named]
86
97
  },
87
98
  },
88
99
  )
@@ -229,7 +240,8 @@ const textOf = (file: string): Promise<string> =>
229
240
  * `turbo.json` / `turbo.jsonc`, every package manifest, the root's,
230
241
  * `.yarnrc.yml` and the microfrontends configs.
231
242
  */
232
- async function run(root: string, metas: readonly ProjectMeta[]): Promise<AdoptionRun> {
243
+ async function run(root: string, projects: readonly ProjectMeta[]): Promise<AdoptionRun> {
244
+ const metas = await withManifests(projects)
233
245
  const dirs = [root, ...metas.map((m) => m.dir)]
234
246
  const configs = await Promise.all(
235
247
  dirs.flatMap((d) => ['turbo.json', 'turbo.jsonc'].map((f) => textOf(path.join(d, f)))),
@@ -264,7 +276,7 @@ async function run(root: string, metas: readonly ProjectMeta[]): Promise<Adoptio
264
276
  */
265
277
  async function mapAll(
266
278
  root: string,
267
- metas: readonly ProjectMeta[],
279
+ metas: readonly PackageMeta[],
268
280
  envNames: readonly string[],
269
281
  trackedFiles: () => Promise<readonly string[] | null>,
270
282
  ): Promise<AdoptionMapping> {
@@ -275,7 +287,7 @@ async function mapAll(
275
287
  ),
276
288
  )
277
289
  const mapped = await mapTurboWorkspace(root, metas, {
278
- // Inline: the values themselves, where `vx migrate` splices a preset import.
290
+ // Inline: the values themselves, where `bunx @vzn/vx-migrate` splices a preset import.
279
291
  splice: (_kind, values) => values,
280
292
  persistentTodo: PERSISTENT_NOTE,
281
293
  envNames,