@vzn/vx-migrate 0.0.0 → 0.0.484

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.
@@ -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
+ }