@vzn/vx-migrate 0.0.0 → 0.0.485
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/LICENSE +21 -0
- package/README.md +306 -0
- package/index.ts +4 -0
- package/package.json +44 -6
- package/src/adoption-plugin.ts +125 -0
- package/src/bin.ts +18 -0
- package/src/dotenv-probe.ts +86 -0
- package/src/glob-grammar.ts +63 -0
- package/src/index.ts +15 -0
- package/src/mapping-cache.ts +101 -0
- package/src/migrate-nx.ts +154 -0
- package/src/migrate-turbo.ts +400 -0
- package/src/migrate.ts +165 -0
- package/src/nx/export-graph.ts +29 -0
- package/src/nx/index.ts +638 -0
- package/src/nx/nx-deps.ts +272 -0
- package/src/nx/nx-dotenv.ts +106 -0
- package/src/nx/nx-inputs.ts +210 -0
- package/src/nx/nx-map.ts +1430 -0
- package/src/nx/nx-outputs.ts +231 -0
- package/src/nx/nx-upstream.ts +356 -0
- package/src/nx-cache/index.ts +236 -0
- package/src/nx-command.ts +458 -0
- package/src/nx-dotenv.cjs +41 -0
- package/src/nx-env.cjs +152 -0
- package/src/nx-exec.cjs +294 -0
- package/src/paths.ts +36 -0
- package/src/plugin-gaps.ts +48 -0
- package/src/remote-deadline.ts +11 -0
- package/src/remote-retry.ts +52 -0
- package/src/remote-token.ts +18 -0
- package/src/script-command.ts +69 -0
- package/src/shared-outputs.ts +373 -0
- package/src/tracked-outputs.ts +248 -0
- package/src/turbo/index.ts +291 -0
- package/src/turbo/turbo-map.ts +1976 -0
- package/src/turbo-cache/index.ts +661 -0
- package/src/workspace-notes.ts +54 -0
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { shellQuote } from './nx-command.js'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Every `.env`-shaped file under the probe's directory, name and bytes, in
|
|
5
|
+
* a stable order. Git ignores them, so a glob over git's files keys none of
|
|
6
|
+
* them; this is a superset of what a `.env` glob names, so an edit to one
|
|
7
|
+
* misses and nothing else is lost (item 1032). node_modules and .git are
|
|
8
|
+
* pruned. Each file ends on a `.`: core trims a probe's output, so a
|
|
9
|
+
* newline added to the last file keyed nothing.
|
|
10
|
+
*/
|
|
11
|
+
export const DOTENV_PROBE =
|
|
12
|
+
'find . \\( -name node_modules -o -name .git \\) -prune -o -type f \\( -name \'.env*\' -o -name \'*.env\' \\) -print | LC_ALL=C sort | while IFS= read -r f; do echo "$f"; cat -- "$f"; echo .; done'
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The same for a package whose `.env` globs all sit at its root (`.env*`,
|
|
16
|
+
* create-turbo's default): the files named `.env…` or `…env` there, by the
|
|
17
|
+
* shell's own globbing. `DOTENV_PROBE` is three processes and walks the
|
|
18
|
+
* package; this is one shell, 1.2 ms against 3.3 (kitchen-sink's warm run
|
|
19
|
+
* spent ~45 ms of ~145 in probes). `LC_ALL=C` keeps the glob order, and so
|
|
20
|
+
* the key, the same on every machine.
|
|
21
|
+
*/
|
|
22
|
+
export const DOTENV_PROBE_TOP =
|
|
23
|
+
'LC_ALL=C; export LC_ALL; for f in .env* .*.env *.env; do [ -f "$f" ] && { printf \'%s\\n\' "$f"; cat -- "$f"; echo .; }; done; :'
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The named root-relative files, name and bytes, run at the workspace root:
|
|
27
|
+
* the inputs a turbo.json names by path that git ignores
|
|
28
|
+
* (`config.local.json`), which Turbo hashes and a glob over git's files
|
|
29
|
+
* keys nothing of. Core trims a probe's output, so each file ends on a `.`:
|
|
30
|
+
* a trailing newline added to the last one is still an edit.
|
|
31
|
+
*/
|
|
32
|
+
export function ignoredFilesProbe(rels: readonly string[]): string {
|
|
33
|
+
return `for f in ${rels.map(shellQuote).join(' ')}; do [ -f "$f" ] && { printf '%s\\n' "$f"; cat -- "$f"; echo .; }; done; :`
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** How every `dotenvGlobsProbe` line opens, and no other probe's. */
|
|
37
|
+
export const DOTENV_GLOBS_HEAD = 'LC_ALL=C; export LC_ALL; { '
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The files the root-relative `.env` globs name, name and bytes, run at the
|
|
41
|
+
* workspace root: Turbo hashes exactly these, and `DOTENV_PROBE` over the
|
|
42
|
+
* whole tree also keyed `hosting/docker/.env.example`, which trigger.dev's
|
|
43
|
+
* `apps/*\/.env`-shaped globs never reach. Turbo's `*` matches a leading
|
|
44
|
+
* dot and the shell's does not, so a segment that opens on `*` is spelled
|
|
45
|
+
* again for hidden names. `<dir>/**\/<name>` (create-turbo's
|
|
46
|
+
* `**\/.env.*local`) is a `find -name`, which matches a leading dot and
|
|
47
|
+
* skips `node_modules` as Turbo does. Null for a glob the shell would read
|
|
48
|
+
* otherwise (`**` elsewhere, a class, a brace, a quote): the caller keeps
|
|
49
|
+
* the walk.
|
|
50
|
+
*/
|
|
51
|
+
export function dotenvGlobsProbe(globs: readonly string[]): string | null {
|
|
52
|
+
const safe = (seg: string): boolean =>
|
|
53
|
+
/^[A-Za-z0-9._@+*-]+$/.test(seg) && !seg.includes('**') && seg !== '.' && seg !== '..'
|
|
54
|
+
const words = new Set<string>()
|
|
55
|
+
const finds: string[] = []
|
|
56
|
+
for (const glob of globs) {
|
|
57
|
+
const segs = glob.split('/')
|
|
58
|
+
const deep = segs.indexOf('**')
|
|
59
|
+
if (deep !== -1) {
|
|
60
|
+
const dir = segs.slice(0, deep)
|
|
61
|
+
const name = segs.slice(deep + 1)
|
|
62
|
+
if (name.length !== 1 || !safe(name[0]!) || dir.some((d) => !safe(d) || d.includes('*')))
|
|
63
|
+
return null
|
|
64
|
+
finds.push(
|
|
65
|
+
`find ${dir.length === 0 ? '.' : dir.join('/')} \\( -name node_modules -o -name .git \\) -prune -o -type f -name '${name[0]}' -print`,
|
|
66
|
+
)
|
|
67
|
+
continue
|
|
68
|
+
}
|
|
69
|
+
let alts = ['']
|
|
70
|
+
for (const seg of segs) {
|
|
71
|
+
if (!safe(seg)) return null
|
|
72
|
+
const forms =
|
|
73
|
+
seg === '*' ? ['*', '.[!.]*', '..?*'] : seg.startsWith('*') ? [seg, `.${seg}`] : [seg]
|
|
74
|
+
alts = alts.flatMap((a) => forms.map((f) => (a === '' ? f : `${a}/${f}`)))
|
|
75
|
+
if (alts.length > 64) return null
|
|
76
|
+
}
|
|
77
|
+
for (const a of alts) words.add(a)
|
|
78
|
+
}
|
|
79
|
+
const list = [
|
|
80
|
+
...(words.size === 0
|
|
81
|
+
? []
|
|
82
|
+
: [`for f in ${[...words].join(' ')}; do [ -f "$f" ] && printf '%s\\n' "$f"; done`]),
|
|
83
|
+
...finds.map((f) => `${f} 2>/dev/null | sed 's|^\\./||'`),
|
|
84
|
+
]
|
|
85
|
+
return `${DOTENV_GLOBS_HEAD}${list.join('; ')}; } | sort -u | while IFS= read -r f; do printf '%s\\n' "$f"; cat -- "$f"; echo .; done`
|
|
86
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// The glob grammar Nx and Turbo share and vx's own differ in brackets and
|
|
2
|
+
// extglobs; both adapters translate through here (items 914, 1031).
|
|
3
|
+
|
|
4
|
+
const EXTGLOB = /([?@+*!])\(([^()]*)\)/g
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A minimatch-family glob (Nx's minimatch, Turbo's wax) in vx's grammar,
|
|
8
|
+
* or null when it has no safe form. vx has no character classes (a bracket is a literal, for route
|
|
9
|
+
* dirs, item 667) and no extglobs, so `src/**\/*.[jt]s` matched nothing
|
|
10
|
+
* and Nx's own default `production` negation,
|
|
11
|
+
* `?(*.)+(spec|test).[jt]s?(x)`, excluded nothing (item 914):
|
|
12
|
+
* - `[jt]` is the brace set `{j,t}`, plus the literal `[jt]` itself in a
|
|
13
|
+
* positive glob (a superset of inputs is safe; the route dir may be
|
|
14
|
+
* meant); a range within digits or one case of letters (`[a-c]`,
|
|
15
|
+
* `[0-9]`) is its members;
|
|
16
|
+
* - a negated class (`[!a]`) is `?` in a positive glob, a superset;
|
|
17
|
+
* - `?(a|b)` is `{a,b,}` and `@(a|b)` is `{a,b}`;
|
|
18
|
+
* - `+(a|b)` and `*(a|b)` narrow to one repetition, which only a
|
|
19
|
+
* NEGATION may do — it then excludes fewer files, never more;
|
|
20
|
+
* - any other range, a negated class in a negation, `!(…)`, or nesting is
|
|
21
|
+
* null.
|
|
22
|
+
*/
|
|
23
|
+
export function minimatchToVx(glob: string, negated: boolean): string | null {
|
|
24
|
+
if (!/[[(]/.test(glob)) return glob
|
|
25
|
+
let out = glob.replace(EXTGLOB, (whole, kind: string, body: string) => {
|
|
26
|
+
const alts = body.split('|')
|
|
27
|
+
if (alts.some((a) => /[{},]/.test(a))) return '\0'
|
|
28
|
+
if (kind === '?') return `{${alts.join(',')},}`
|
|
29
|
+
if (kind === '@') return `{${alts.join(',')}}`
|
|
30
|
+
if (!negated || kind === '!') return '\0'
|
|
31
|
+
return kind === '+' ? `{${alts.join(',')}}` : `{${alts.join(',')},}`
|
|
32
|
+
})
|
|
33
|
+
if (out.includes('\0') || /[?@+*!]\(/.test(out)) return null
|
|
34
|
+
out = out.replace(/\[([^\]]*)\]/g, (whole, body: string) => {
|
|
35
|
+
if (body === '' || /[{},/]/.test(body)) return '\0'
|
|
36
|
+
if (/^[!^]/.test(body)) return negated || body.length === 1 ? '\0' : '?'
|
|
37
|
+
const chars = classMembers(body)
|
|
38
|
+
if (chars === null) return '\0'
|
|
39
|
+
return negated ? `{${chars.join(',')}}` : `{${whole},${chars.join(',')}}`
|
|
40
|
+
})
|
|
41
|
+
return out.includes('\0') ? null : out
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const RANGES = ['0123456789', 'abcdefghijklmnopqrstuvwxyz', 'ABCDEFGHIJKLMNOPQRSTUVWXYZ']
|
|
45
|
+
|
|
46
|
+
/** A class body's characters, ranges spelled out; null for a range across kinds or backwards. */
|
|
47
|
+
function classMembers(body: string): string[] | null {
|
|
48
|
+
const out = new Set<string>()
|
|
49
|
+
for (let i = 0; i < body.length; i++) {
|
|
50
|
+
const c = body[i]!
|
|
51
|
+
// A `-` first or last is itself, as in minimatch.
|
|
52
|
+
if (body[i + 1] === '-' && i + 2 < body.length) {
|
|
53
|
+
const end = body[i + 2]!
|
|
54
|
+
const kind = RANGES.find((r) => r.includes(c))
|
|
55
|
+
const from = kind?.indexOf(c) ?? -1
|
|
56
|
+
const to = kind?.indexOf(end) ?? -1
|
|
57
|
+
if (kind === undefined || to < from) return null
|
|
58
|
+
for (const m of kind.slice(from, to + 1)) out.add(m)
|
|
59
|
+
i += 2
|
|
60
|
+
} else out.add(c)
|
|
61
|
+
}
|
|
62
|
+
return [...out]
|
|
63
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// @vzn/vx-migrate — adoption: the Turbo and Nx project stages (a repo
|
|
2
|
+
// runs unchanged) and the two remote caches speaking Turbo's and Nx's
|
|
3
|
+
// wire. The CLI (`bunx @vzn/vx-migrate`) is `migrate.ts`; mappers and
|
|
4
|
+
// cache clients are internal (1.0 freezes what this file exports).
|
|
5
|
+
export { turbo, type TurboPluginOptions } from './turbo/index.js'
|
|
6
|
+
export {
|
|
7
|
+
nx,
|
|
8
|
+
type NxExecutors,
|
|
9
|
+
type NxExecutorTarget,
|
|
10
|
+
type NxExecutorTranslation,
|
|
11
|
+
type NxExecutorTranslator,
|
|
12
|
+
type NxPluginOptions,
|
|
13
|
+
} from './nx/index.js'
|
|
14
|
+
export { turboCache, type TurboCacheOptions } from './turbo-cache/index.js'
|
|
15
|
+
export { nxCache, type NxCacheOptions } from './nx-cache/index.js'
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// A plugin's mapping, kept under the cache dir and keyed on everything it
|
|
2
|
+
// reads. Both adoption plugins mapped every package of the workspace on
|
|
3
|
+
// every run, when a run asks for a few: nx() ~55 ms cold on refine (206
|
|
4
|
+
// projects), turbo() ~43 ms on astro (553). A hit costs the key's reads
|
|
5
|
+
// and one JSON parse.
|
|
6
|
+
//
|
|
7
|
+
// The key is the plugin's own list of what its mapping read, plus this
|
|
8
|
+
// package's sources and the core helpers the mappers call: a new
|
|
9
|
+
// `@vzn/vx-migrate` or `@vzn/vx` maps afresh.
|
|
10
|
+
|
|
11
|
+
import { mkdir, rename } from 'node:fs/promises'
|
|
12
|
+
import path from 'node:path'
|
|
13
|
+
import {
|
|
14
|
+
buildPackageGraph,
|
|
15
|
+
foldScriptHooks,
|
|
16
|
+
isLiteralPattern,
|
|
17
|
+
outputsOverlap,
|
|
18
|
+
VERSION,
|
|
19
|
+
} from '@vzn/vx'
|
|
20
|
+
import type { Gaps } from './plugin-gaps.js'
|
|
21
|
+
|
|
22
|
+
/** What the stage uses of a mapping: tasks per package name, and the gaps to report once. */
|
|
23
|
+
export interface AdoptionMapping {
|
|
24
|
+
readonly byName: ReadonlyMap<
|
|
25
|
+
string,
|
|
26
|
+
{
|
|
27
|
+
readonly tasks: readonly {
|
|
28
|
+
readonly name: string
|
|
29
|
+
readonly task: Record<string, unknown> | null
|
|
30
|
+
}[]
|
|
31
|
+
}
|
|
32
|
+
>
|
|
33
|
+
readonly gaps: Gaps
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
let code: Promise<string> | undefined
|
|
37
|
+
|
|
38
|
+
/** This package's sources and the core helpers the mappers call, hashed once per process. */
|
|
39
|
+
function codeId(): Promise<string> {
|
|
40
|
+
code ??= (async () => {
|
|
41
|
+
const parts: string[] = [
|
|
42
|
+
VERSION,
|
|
43
|
+
...[buildPackageGraph, foldScriptHooks, isLiteralPattern, outputsOverlap].map(String),
|
|
44
|
+
]
|
|
45
|
+
const files: string[] = []
|
|
46
|
+
for await (const f of new Bun.Glob('**/*.{ts,cjs}').scan(import.meta.dir)) files.push(f)
|
|
47
|
+
files.sort()
|
|
48
|
+
for (const f of files) parts.push(f, await Bun.file(path.join(import.meta.dir, f)).text())
|
|
49
|
+
return hex(parts.join('\0'))
|
|
50
|
+
})()
|
|
51
|
+
return code
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function hex(s: string): string {
|
|
55
|
+
return Bun.hash.xxHash3(s).toString(16).padStart(16, '0')
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
interface Held {
|
|
59
|
+
readonly key: string
|
|
60
|
+
readonly byName: [string, { tasks: { name: string; task: Record<string, unknown> | null }[] }][]
|
|
61
|
+
readonly notes: string[]
|
|
62
|
+
readonly todos: [string, string[]][]
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** `map()`, or the mapping it returned last under the same `parts`. */
|
|
66
|
+
export async function cachedMapping(
|
|
67
|
+
cacheDir: string,
|
|
68
|
+
name: string,
|
|
69
|
+
parts: readonly string[],
|
|
70
|
+
map: () => Promise<AdoptionMapping>,
|
|
71
|
+
): Promise<AdoptionMapping> {
|
|
72
|
+
const key = hex([await codeId(), ...parts].join('\0'))
|
|
73
|
+
const file = path.join(cacheDir, `vx-migrate-${name}-mapping.json`)
|
|
74
|
+
const held = (await Bun.file(file)
|
|
75
|
+
.json()
|
|
76
|
+
.catch(() => null)) as Held | null
|
|
77
|
+
if (held?.key === key) {
|
|
78
|
+
return {
|
|
79
|
+
byName: new Map(held.byName),
|
|
80
|
+
gaps: { notes: held.notes, todos: new Map(held.todos) },
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
const mapping = await map()
|
|
84
|
+
const out: Held = {
|
|
85
|
+
key,
|
|
86
|
+
byName: [...mapping.byName].map(([name, p]) => [
|
|
87
|
+
name,
|
|
88
|
+
{ tasks: p.tasks.map((t) => ({ name: t.name, task: t.task })) },
|
|
89
|
+
]),
|
|
90
|
+
notes: [...mapping.gaps.notes],
|
|
91
|
+
todos: [...mapping.gaps.todos].map(([todo, ids]) => [todo, [...ids]]),
|
|
92
|
+
}
|
|
93
|
+
// Best-effort, and whole or absent: a reader never sees half a file, and
|
|
94
|
+
// a cache dir it cannot write costs the next run a mapping, not the run.
|
|
95
|
+
const tmp = `${file}.${process.pid}.tmp`
|
|
96
|
+
await mkdir(cacheDir, { recursive: true })
|
|
97
|
+
.then(() => Bun.write(tmp, JSON.stringify(out)))
|
|
98
|
+
.then(() => rename(tmp, file))
|
|
99
|
+
.catch(() => {})
|
|
100
|
+
return mapping
|
|
101
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
// `bunx @vzn/vx-migrate --from nx`: the resolved graph snapshot on disk,
|
|
2
|
+
// through the mapper the `nx()` plugin runs live, as a migration plan.
|
|
3
|
+
|
|
4
|
+
import { stat } from 'node:fs/promises'
|
|
5
|
+
import path from 'node:path'
|
|
6
|
+
import { type MigrationFormat, type MigrationPlan, type ProjectMeta } from '@vzn/vx'
|
|
7
|
+
import { mapNxWorkspace, nxSizeText, parseNxGraph, readNxJson } from './nx/nx-map.js'
|
|
8
|
+
import { spareTrackedOutputs, trackedFiles, trackedKinds } from './tracked-outputs.js'
|
|
9
|
+
import { adoptedToolNotes } from './workspace-notes.js'
|
|
10
|
+
|
|
11
|
+
/** What a task's `npm_package_*` read: the manifest, so a bump reaches them. */
|
|
12
|
+
const MANIFEST_IMPORT = "import pkg from './package.json' with { type: 'json' }"
|
|
13
|
+
|
|
14
|
+
export const NX_GRAPH_REL = '.nx/workspace-data/project-graph.json'
|
|
15
|
+
|
|
16
|
+
/** `snapshot`: the graph file to read, absolute; the checked-in one by default. */
|
|
17
|
+
export async function migrateNx(
|
|
18
|
+
root: string,
|
|
19
|
+
metas: readonly ProjectMeta[],
|
|
20
|
+
format: MigrationFormat = 'ts',
|
|
21
|
+
snapshot = path.join(root, NX_GRAPH_REL),
|
|
22
|
+
): Promise<MigrationPlan> {
|
|
23
|
+
const graph = parseNxGraph(await Bun.file(snapshot).text(), path.relative(root, snapshot))
|
|
24
|
+
const tracked = await trackedFiles(root)
|
|
25
|
+
const mapped = await mapNxWorkspace(root, metas, graph, {
|
|
26
|
+
cacheable: new Set(),
|
|
27
|
+
...(tracked === null ? {} : { tracked: trackedKinds(tracked) }),
|
|
28
|
+
// The file this writes is each task's config.
|
|
29
|
+
ownConfig: () => `vx.config.${format}`,
|
|
30
|
+
manifestField: (key) => ({ raw: `pkg.${key}` }),
|
|
31
|
+
migration: true,
|
|
32
|
+
})
|
|
33
|
+
// nx() takes each committed file under an output back every run; a
|
|
34
|
+
// written config must carry the same `!` entries, or its first run's
|
|
35
|
+
// clean deletes them.
|
|
36
|
+
if (tracked !== null)
|
|
37
|
+
for (const [id, todo] of spareTrackedOutputs(root, mapped.projects, tracked)) {
|
|
38
|
+
const at = id.lastIndexOf('#')
|
|
39
|
+
const p = mapped.projects.find((x) => x.name === id.slice(0, at))
|
|
40
|
+
p?.tasks.find((t) => t.name === id.slice(at + 1))?.todos.push(todo)
|
|
41
|
+
}
|
|
42
|
+
const adopted = await adoptedToolNotes(root, {
|
|
43
|
+
plugin: 'nx',
|
|
44
|
+
config: 'nx.json',
|
|
45
|
+
runner: 'nx',
|
|
46
|
+
keys: (lock) => `Nx keys each project on the npm packages it depends on in ${lock}`,
|
|
47
|
+
})
|
|
48
|
+
const unlisted = await unlistedProjects(root, metas, mapped.projects)
|
|
49
|
+
return {
|
|
50
|
+
headerNotes: [
|
|
51
|
+
'migrating from the resolved project-graph snapshot — plugin-inferred targets ' +
|
|
52
|
+
'are frozen as static config; `nx:run-commands` targets are their shell lines, and ' +
|
|
53
|
+
'every other executor runs as itself through `nx-exec` (keep Nx and @vzn/vx-migrate ' +
|
|
54
|
+
'installed until those targets are rewritten as commands); targets with `.env` files ' +
|
|
55
|
+
'run through `nx-env`',
|
|
56
|
+
...adopted,
|
|
57
|
+
],
|
|
58
|
+
projects: mapped.projects.map((p) =>
|
|
59
|
+
p.tasks.some((t) => JSON.stringify(t.task ?? {}).includes('"pkg.'))
|
|
60
|
+
? { ...p, importLines: [MANIFEST_IMPORT, ...(p.importLines ?? [])] }
|
|
61
|
+
: p,
|
|
62
|
+
),
|
|
63
|
+
extraFiles: unlisted.manifests,
|
|
64
|
+
notes: [
|
|
65
|
+
...mapped.notes,
|
|
66
|
+
...unlisted.notes,
|
|
67
|
+
...workspaceNotes((await readNxJson(root).catch(() => null))?.json),
|
|
68
|
+
],
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Nx projects no workspace glob lists (an integrated repo's `project.json`
|
|
74
|
+
* libraries): core finds a project only by a listed package.json, so a
|
|
75
|
+
* config written there would never run. Each gets a package.json when it
|
|
76
|
+
* has none, and one note names the globs to add — the root manifest is
|
|
77
|
+
* the user's to edit, and `applyMigration` never overwrites a file.
|
|
78
|
+
*/
|
|
79
|
+
async function unlistedProjects(
|
|
80
|
+
root: string,
|
|
81
|
+
metas: readonly ProjectMeta[],
|
|
82
|
+
projects: readonly { name: string; dir: string }[],
|
|
83
|
+
): Promise<{ manifests: { relPath: string; contents: string }[]; notes: string[] }> {
|
|
84
|
+
const listed = new Set(metas.map((m) => path.resolve(m.dir)))
|
|
85
|
+
const unlisted = projects.filter(
|
|
86
|
+
(p) => path.resolve(p.dir) !== path.resolve(root) && !listed.has(path.resolve(p.dir)),
|
|
87
|
+
)
|
|
88
|
+
if (unlisted.length === 0) return { manifests: [], notes: [] }
|
|
89
|
+
const rels = unlisted.map((p) => path.relative(root, p.dir).split(path.sep).join('/')).sort()
|
|
90
|
+
const manifests: { relPath: string; contents: string }[] = []
|
|
91
|
+
for (const p of unlisted) {
|
|
92
|
+
const file = path.join(p.dir, 'package.json')
|
|
93
|
+
if (
|
|
94
|
+
await stat(file).then(
|
|
95
|
+
() => true,
|
|
96
|
+
() => false,
|
|
97
|
+
)
|
|
98
|
+
)
|
|
99
|
+
continue
|
|
100
|
+
manifests.push({
|
|
101
|
+
relPath: path.relative(root, file).split(path.sep).join('/'),
|
|
102
|
+
contents: `${JSON.stringify({ name: p.name, private: true }, null, 2)}\n`,
|
|
103
|
+
})
|
|
104
|
+
}
|
|
105
|
+
const pnpm = await stat(path.join(root, 'pnpm-workspace.yaml')).then(
|
|
106
|
+
() => true,
|
|
107
|
+
() => false,
|
|
108
|
+
)
|
|
109
|
+
const where = pnpm ? "pnpm-workspace.yaml's `packages`" : 'package.json `workspaces`'
|
|
110
|
+
const wrote = manifests.length > 0 ? ' (a package.json is written where there was none)' : ''
|
|
111
|
+
return {
|
|
112
|
+
manifests,
|
|
113
|
+
notes: [
|
|
114
|
+
`${unlisted.length} Nx project${unlisted.length === 1 ? ' is' : 's are'} in no workspace glob${wrote} — ` +
|
|
115
|
+
`vx finds a project by a package.json the workspace lists: add ${rels.map((r) => JSON.stringify(r)).join(', ')} to ${where}`,
|
|
116
|
+
],
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* nx.json's run-wide settings `nx()` applies live and the written
|
|
122
|
+
* `vx.workspace.ts` cannot hold (core writes it): each is a line naming
|
|
123
|
+
* the field to add, or a migrated repo that ran one task at a time for a
|
|
124
|
+
* shared database ran on every core. nx.json only: the files are written
|
|
125
|
+
* for every machine, so `NX_PARALLEL` and friends are not read.
|
|
126
|
+
*/
|
|
127
|
+
function workspaceNotes(json: Record<string, unknown> | undefined): string[] {
|
|
128
|
+
const nx = json as
|
|
129
|
+
| {
|
|
130
|
+
parallel?: unknown
|
|
131
|
+
tasksRunnerOptions?: { default?: { options?: { parallel?: unknown } } }
|
|
132
|
+
defaultBase?: unknown
|
|
133
|
+
affected?: { defaultBase?: unknown }
|
|
134
|
+
maxCacheSize?: unknown
|
|
135
|
+
}
|
|
136
|
+
| undefined
|
|
137
|
+
const out: string[] = []
|
|
138
|
+
const parallel = nx?.parallel ?? nx?.tasksRunnerOptions?.default?.options?.parallel
|
|
139
|
+
if (typeof parallel === 'number' && Number.isInteger(parallel) && parallel > 0)
|
|
140
|
+
out.push(
|
|
141
|
+
`nx.json \`parallel: ${parallel}\`: add \`concurrency: ${parallel}\` to vx.workspace.ts`,
|
|
142
|
+
)
|
|
143
|
+
const base = nx?.defaultBase ?? nx?.affected?.defaultBase
|
|
144
|
+
if (typeof base === 'string' && base.trim() !== '')
|
|
145
|
+
out.push(
|
|
146
|
+
`nx.json \`defaultBase\` ${JSON.stringify(base.trim())}: add \`affectedBase: ${JSON.stringify(base.trim())}\` to vx.workspace.ts`,
|
|
147
|
+
)
|
|
148
|
+
const size = nxSizeText(nx?.maxCacheSize)
|
|
149
|
+
if (size !== undefined)
|
|
150
|
+
out.push(
|
|
151
|
+
`nx.json \`maxCacheSize\`: add \`cacheRetention: { maxSize: ${JSON.stringify(size)} }\` to vx.workspace.ts`,
|
|
152
|
+
)
|
|
153
|
+
return out
|
|
154
|
+
}
|