@vzn/vx-migrate 0.0.650 → 0.0.651
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 +10 -5
- package/package.json +2 -2
- package/src/adopt.ts +6 -4
- package/src/bin.ts +6 -6
- package/src/migrate-turbo.ts +11 -4
- package/src/migrate.ts +4 -3
- package/src/nx/nx-map.ts +17 -1
- package/src/package-fs.ts +18 -2
- package/src/remote-refusal.ts +6 -1
- package/src/turbo/index.ts +47 -0
- package/src/turbo/root-engines.ts +19 -0
- package/src/turbo/turbo-map.ts +197 -17
- package/src/turbo/turbo-reads.ts +95 -4
- package/src/turbo-cache/index.ts +38 -11
package/README.md
CHANGED
|
@@ -30,7 +30,7 @@ The package exports `turbo`, `nx`, `turboCache`, `nxCache` and their options typ
|
|
|
30
30
|
|
|
31
31
|
## `turbo()` — a Turbo repo, mid-migration
|
|
32
32
|
|
|
33
|
-
Until `bunx @vzn/vx-migrate` writes native config, `vx run build --all` maps every package's `build` script the way `turbo run build` would: `dependsOn` edges (`^build`, same-package deps, `pkg#task`), `inputs` as every task's key and `outputs` as the cache block, `env` / `passThroughEnv`, `cache: false`, `persistent`. Turbo's global fields (`globalDependencies`, `globalEnv`, `globalPassThroughEnv`, Turbo 1's `globalDotEnv`) are inlined into every task; a `*` env name (`NEXT_PUBLIC_*`) expands over the run's environment, as Turbo matches it; `*` is Turbo's only wildcard (`\*`, a leading `\!`, `?` and `[` are literal), and a literal vx cannot key (unkey's `NEXT_PUBLIC_\*`) is left out, reported once only when the run's environment sets it; per-package `turbo.json` overlays apply. A `turbo.jsonc` (Turbo 2.5+) is read wherever a `turbo.json` would be, at the root and in a package; a workspace root with neither is refused, naming the fix. Turbo 2's framework inference (a package on `next` has `NEXT_PUBLIC_*` and `NEXT_DEPLOYMENT_ID` hashed and passed; Turbo's whole table, `expo`, `nuxt`, `remix`, `@sveltejs/kit` and the rest, each package taking the first framework it matches, as Turbo does, and leaving out names under the platform's `TURBO_CI_VENDOR_ENV_KEY`) applies too: the prefix expands over the run's environment into that package's tasks, and a task's `!` entry takes names back (`bunx @vzn/vx-migrate`, which writes files for another environment, names each framework and its packages in a note instead). What runs is what a migration would have written, minus the file: the key a task derives here equals the key the written config would derive.
|
|
33
|
+
Until `bunx @vzn/vx-migrate` writes native config, `vx run build --all` maps every package's `build` script the way `turbo run build` would: `dependsOn` edges (`^build`, same-package deps, `pkg#task`), `inputs` as every task's key and `outputs` as the cache block, `env` / `passThroughEnv`, `cache: false`, `persistent`. Turbo's global fields (`globalDependencies`, `globalEnv`, `globalPassThroughEnv`, Turbo 1's `globalDotEnv`) are inlined into every task; a `*` env name (`NEXT_PUBLIC_*`) expands over the run's environment, as Turbo matches it; `*` is Turbo's only wildcard (`\*`, a leading `\!`, `?` and `[` are literal), and a literal vx cannot key (unkey's `NEXT_PUBLIC_\*`) is left out, reported once only when the run's environment sets it; per-package `turbo.json` overlays apply. A `turbo.jsonc` (Turbo 2.5+) is read wherever a `turbo.json` would be, at the root and in a package; a directory holding both is refused, as Turbo refuses it, and a workspace root with neither is refused, naming the fix. An absolute path in `inputs`, `outputs`, `globalDependencies` or `global.inputs` is refused naming the file, as Turbo refuses it. Turbo 2's framework inference (a package on `next` has `NEXT_PUBLIC_*` and `NEXT_DEPLOYMENT_ID` hashed and passed; Turbo's whole table, `expo`, `nuxt`, `remix`, `@sveltejs/kit` and the rest, each package taking the first framework it matches, as Turbo does, and leaving out names under the platform's `TURBO_CI_VENDOR_ENV_KEY`) applies too: the prefix expands over the run's environment into that package's tasks, and a task's `!` entry takes names back (`bunx @vzn/vx-migrate`, which writes files for another environment, names each framework and its packages in a note instead). Turbo's builtin env applies as Turbo's source has it: `VERCEL_ANALYTICS_ID` and `VERCEL_TARGET_ENV` are keyed in every task (a `globalEnv` `!` entry such as `!VERCEL*` takes them back), Next's skew rule keys `VERCEL_DEPLOYMENT_ID` when `VERCEL_SKEW_PROTECTION_ENABLED=1`, and the literal names of Turbo's builtin pass-through list (`TZ`, `HOME`, `CI`, the Vercel, Docker and IDE names) reach every task unkeyed. Its wildcard families (`VERCEL_*`, `GITHUB_*`, `DOCKER_*` and the rest) are not matched against the run's environment: `exec.env.passThrough` is keyed by name, so a live match keyed each machine apart. The written config keys the defaults and `VERCEL_DEPLOYMENT_ID` always (a miss where Turbo would hit, never a stale hit) and passes the same literal names from the preset; a note names the wildcard families to add where a task reads them. What runs is what a migration would have written, minus the file: the key a task derives here equals the key the written config would derive.
|
|
34
34
|
|
|
35
35
|
| Option | Meaning |
|
|
36
36
|
| ------ | ----------------------------------------------------------------------------------------------------------- |
|
|
@@ -74,12 +74,17 @@ Rules:
|
|
|
74
74
|
- A task's `description` (Turbo 2.11.5's schema) is the vx task's `description`.
|
|
75
75
|
- `outputLogs: "new-only"` maps to nothing: frames for the tasks that ran and a one-liner per cache hit is vx's default flow already. The other values are per-run in vx, so they are a todo naming the flag (`vx run … --output-logs hash-only`).
|
|
76
76
|
- `interruptible` maps to nothing: `vx watch` keeps a persistent task up across cycles and restarts it only when its config changes or it dies.
|
|
77
|
-
- A
|
|
77
|
+
- A package's own turbo.json `tags` (Turbo 2.11.6) are its vx project `tags`, so `vx run build --filter tag:ci` selects what `turbo run build --filter=tag:ci` does through package tags (the root's label the root project, none inherited, as in Turbo). A task's `tags` map to nothing: vx tags are a project's, so a `tag:` filter never reaches a task by its own tags.
|
|
78
|
+
- A task's `experimentalCI` maps to nothing: Turbo reads it for external CI tooling and acts on nothing.
|
|
78
79
|
- `envMode: "loose"` (top-level or in `global`, or `TURBO_ENV_MODE=loose`, which wins) is a note: Turbo hands every task the whole environment, vx only the declared names.
|
|
79
80
|
- The workspace keys vx has a home for, top level or in `global`, fill what `.vx/workspace.ts` leaves unset: `run.concurrency` (`"10"`, `"50%"` of the cores) → `run.concurrency`. `TURBO_CONCURRENCY` wins over turbo.json, as under Turbo; a set one decides, its `0` included. `cacheMaxSize` / `cacheMaxAge` map to nothing: no vx run evicts, `vx cache clean --max-size / --older-than` does.
|
|
80
|
-
- `
|
|
81
|
+
- `TURBO_FORCE=true` (or `1`), `turbo run --force`, turns every cache read off through the workspace's `cache.policy`: each task runs and refreshes the local cache. The remote is written only where `.vx/workspace.ts`'s own `cache.policy` names it, so a laptop's forced run never writes the cache CI reads; `vx run --force` is the flag. A `--cache-policy` or `VX_CACHE_POLICY` replaces the workspace's policy, this included.
|
|
82
|
+
- `TURBO_SCM_BASE`, the base `turbo run --affected` compares with, is the run's base commit (`setBranch({ base })`): a bare `vx run build --affected` compares with the same ref. With it unset on GitHub Actions (`GITHUB_ACTIONS`), Turbo's own base applies: a pull request's `GITHUB_BASE_REF`, else the push event's `before` from `GITHUB_EVENT_PATH`. `TURBO_SCM_HEAD`, the commit Turbo compares up to, is vx's checkout: `--affected` compares the base with HEAD and its working tree, so a head at HEAD's commit (`$GITHUB_SHA` on a checkout of it) is honoured as is, and any other one is a warning to check that commit out.
|
|
81
83
|
- Turbo hashes each package's `microfrontends.json` (or `.jsonc`) into every task, or the one file `VC_MICROFRONTENDS_CONFIG_FILE_NAME` names; so does `turbo()`.
|
|
84
|
+
- `TURBO_ROOT_TURBO_JSON`, Turbo's root config path (against the cwd), is refused when it names another file than the workspace root's `turbo.json` or `turbo.jsonc`: `turbo()` reads only that one.
|
|
85
|
+
- Turbo's run switches the plugins do not read, on purpose: `TURBO_CACHE`, `TURBO_REMOTE_CACHE_READ_ONLY` and `TURBO_REMOTE_ONLY` (core's `cache.policy` decides); `TURBO_CACHE_DIR`, `TURBO_CACHE_MAX_AGE` and `TURBO_CACHE_MAX_SIZE` (`cache.dir`; no run evicts); `TURBO_LOG_ORDER`, `TURBO_UI`, `TURBO_EXPERIMENTAL_UI`, `TURBO_TUI_SCROLLBACK_LENGTH` and `TURBO_LOG_VERBOSITY` (vx frames each task's output its own way); `TURBO_RUN_SUMMARY` and `TURBO_LOG_FILE` (`vx last`, telemetry plugins); `TURBO_EXPERIMENTAL_OTEL_*` (`@vzn/vx-otel`); `TURBO_DAEMON` (no daemon); `TURBO_DANGEROUSLY_DISABLE_PACKAGE_MANAGER_CHECK` (no such check); `TURBO_ALLOW_NO_TURBO_JSON` (`turbo()` maps a turbo.json and refuses a root with none); `TURBO_PLATFORM_ENV` and `TURBO_PLATFORM_ENV_DISABLED` (Turbo warns of CI variables strict mode drops; a vx task never sees an undeclared variable, and no run warns); `TURBO_LOGIN`, `TURBO_SSO_LOGIN_CALLBACK_PORT` and `TURBO_CONFIG_DIR_PATH` (no login verb and no user config file: the token comes as [Tokens](#tokens-and-the-os-keychain) says); `TURBO_NO_UPDATE_NOTIFIER`, `TURBO_PRINT_VERSION_DISABLED`, `TURBO_GLOBAL_WARNING_DISABLED` and `TURBO_DOWNLOAD_LOCAL_ENABLED` (the `turbo` binary's own; vx runs none).
|
|
82
86
|
- Turbo 2.11's `global` block (`futureFlags.globalConfiguration`) is read as the `globalDependencies`, `globalEnv` and `globalPassThroughEnv` it replaces, except that a task's `!` input takes a `global.inputs` file back, as Turbo prepends them to each task's inputs.
|
|
87
|
+
- The root `package.json`'s `engines` (a non-empty object), which Turbo hashes into every task's key: each task's `inputs.workspaceRuntime` holds one `awk` line that prints that value alone, compacted, at the workspace root, named `rootEngines` in the written `vx-preset.ts`. `turbo()` and the written config carry the same line, so they key alike; raising `engines.node` re-runs every task, and no other root edit re-keys one. A reformat keys alike; reordering its keys misses (Turbo's map would hit). A config written while the root declared no `engines` keys none added later.
|
|
83
88
|
- An unknown Turbo key is a todo naming it; `extends` is accepted and ignored (the overlay order above is what it means).
|
|
84
89
|
|
|
85
90
|
## `nx()` — an Nx repo, mid-migration
|
|
@@ -552,7 +557,7 @@ export default defineWorkspace({
|
|
|
552
557
|
|
|
553
558
|
Off CI a run reads the remote and does not write it unless its policy names the remote. Give the write token only to the default branch's CI and a read-only one to laptops and pull request jobs: the server's token is the boundary (`docs/security.md` § Cache poisoning). The same holds for `nxCache()`.
|
|
554
559
|
|
|
555
|
-
Every option falls back to the tool's own environment variable, so a self-hosted setup carries over unchanged. An empty variable is unset, as Turbo reads it.
|
|
560
|
+
Every option falls back to the tool's own environment variable, so a self-hosted setup carries over unchanged. An empty variable is unset, as Turbo reads it. With no URL the cache is Vercel's hosted Remote Cache (`https://vercel.com/api`), exactly as for `turbo` — so `bunx turbo login && bunx turbo link`, then `turboCache()` with `TURBO_TOKEN` / `TURBO_TEAM` set, is the whole hosted setup. Below the environment, in Turbo's order: a Vercel build's `VERCEL_ARTIFACTS_TOKEN` and `VERCEL_ARTIFACTS_OWNER` (the team id, where `TURBO_TOKEN` with a team is not set), then the repo's `.turbo/config.json` (what `turbo link` writes: `apiUrl`, `teamId`, `teamSlug`, `token`; one that is not a JSON object is named in a warning and read as absent, where Turbo refuses it), then the root `turbo.json`'s `remoteCache` (`apiUrl`, `teamId`, `teamSlug`), whose `enabled: false` declines unless the options name a cache. As for `turbo`, these sources link a cache only with a team: a token without one is named in a warning, and with no team the plugin **declines** and the run stays local. A team with no token takes the token from the OS keychain ([Tokens](#tokens-and-the-os-keychain)). A `url`, `token` or `oidc` option names a cache with or without a team.
|
|
556
561
|
|
|
557
562
|
| Option | Environment variable | Meaning |
|
|
558
563
|
| ----------------- | ----------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
@@ -632,7 +637,7 @@ With no token from an option, a variable or a config file, both plugins read the
|
|
|
632
637
|
- Three outages in a row (no connection, no answer within the deadline, or a `5xx` once the resends are spent) open Turbo's outage breaker: no request is sent for 30 s, each lookup is a miss at once, then one request probes and its answer closes or reopens it. A hit is judged when its body ends, so a server that sends headers and then stalls the body counts the same way. A hung server costs three deadlines, not one per task.
|
|
633
638
|
- A refused token (`401`/`403`) warns **once** and turns the layer off for the rest of the process (a `403` on an upload is a read-only token — Nx's spec, or turborepo-remote-cache's `READ_ONLY` and write-less JWTs: it turns off uploads alone, and reads go on) — including the requests already in flight when the refusal lands, which degrade in silence rather than repeating it (a six-project run printed five identical lines before 2026-09-20).
|
|
634
639
|
- Policy (`--cache-policy=remote:r`, `VX_CACHE_POLICY`, `cache.policy`) is enforced by core, which layers each plugin's remote over the local cache — a read-only token pairs naturally with `remote:r`.
|
|
635
|
-
- Core's policy alone decides reads and writes: the plugins read none of Turbo's or Nx's own switches (`TURBO_CACHE`, `TURBO_REMOTE_CACHE_READ_ONLY`, `NX_SKIP_REMOTE_CACHE`, `NX_DISABLE_REMOTE_CACHE`)
|
|
640
|
+
- Core's policy alone decides reads and writes: the plugins read none of Turbo's or Nx's own switches (`TURBO_CACHE`, `TURBO_REMOTE_ONLY`, `TURBO_REMOTE_CACHE_READ_ONLY`, `NX_SKIP_REMOTE_CACHE`, `NX_DISABLE_REMOTE_CACHE`); `turbo()` reads `TURBO_FORCE`, as `--force`. A CI that keeps untrusted pull requests off the shared cache sets `cache.policy` or `--cache-policy=local:rw,remote:r` for them.
|
|
636
641
|
|
|
637
642
|
## Testing
|
|
638
643
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vzn/vx-migrate",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.651",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"description": "Adopt @vzn/vx from Turborepo, Nx or package.json scripts: run the repo unchanged, migrate it to vx.config.ts, and keep your Turbo or Nx remote cache",
|
|
6
6
|
"keywords": [
|
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
"access": "public"
|
|
41
41
|
},
|
|
42
42
|
"peerDependencies": {
|
|
43
|
-
"@vzn/vx": "^0.0.
|
|
43
|
+
"@vzn/vx": "^0.0.651"
|
|
44
44
|
},
|
|
45
45
|
"repository": {
|
|
46
46
|
"type": "git",
|
package/src/adopt.ts
CHANGED
|
@@ -6,6 +6,9 @@
|
|
|
6
6
|
|
|
7
7
|
import { existsSync, readFileSync } from 'node:fs'
|
|
8
8
|
import path from 'node:path'
|
|
9
|
+
// By namespace: bin.ts loads this before its release check, and a core
|
|
10
|
+
// with no `ui` must still reach that refusal.
|
|
11
|
+
import * as vx from '@vzn/vx'
|
|
9
12
|
import { UserError } from '@vzn/vx'
|
|
10
13
|
|
|
11
14
|
export type AdoptionMode = 'native' | 'keep'
|
|
@@ -270,8 +273,7 @@ async function runManager(
|
|
|
270
273
|
const line = argv.join(' ')
|
|
271
274
|
// The report's `install` row says what ran; a terminal sees it while it
|
|
272
275
|
// runs, on a line the report then takes the place of.
|
|
273
|
-
const
|
|
274
|
-
if (live) process.stdout.write(`\n ${line.slice(0, (process.stdout.columns ?? 80) - 4)} …`)
|
|
276
|
+
const clear = vx.ui.transient(line)
|
|
275
277
|
// The manager's own output (progress, deprecations, audit, postinstalls)
|
|
276
278
|
// is shown only when it fails: mid-init it buried the report (Growth's
|
|
277
279
|
// first-run funnel F3, 2026-10-13).
|
|
@@ -300,9 +302,9 @@ async function runManager(
|
|
|
300
302
|
failed = `: ${(e2 as Error).message}`
|
|
301
303
|
}
|
|
302
304
|
}
|
|
303
|
-
|
|
305
|
+
clear()
|
|
304
306
|
if (code !== 0) {
|
|
305
|
-
|
|
307
|
+
vx.ui.data('err', said)
|
|
306
308
|
const missing = notServed(said)
|
|
307
309
|
throw new UserError(
|
|
308
310
|
missing === undefined
|
package/src/bin.ts
CHANGED
|
@@ -27,6 +27,9 @@ try {
|
|
|
27
27
|
const { migrateCmd } = await import('./migrate.js')
|
|
28
28
|
process.exitCode = await migrateCmd(process.argv.slice(2))
|
|
29
29
|
} catch (err) {
|
|
30
|
+
// `ui` by namespace: a core older than it has none, and its refusal
|
|
31
|
+
// still reaches the reader.
|
|
32
|
+
const ui = (core as Partial<typeof core>).ui
|
|
30
33
|
// Asked for JSON (`vx init --dry --format json` hands a Turbo or Nx repo
|
|
31
34
|
// here), a refusal is core's error document on stdout too
|
|
32
35
|
// (schemas/error.json): stdout was empty.
|
|
@@ -38,14 +41,11 @@ try {
|
|
|
38
41
|
const docs = code.startsWith('VX_E_')
|
|
39
42
|
? { docs: `https://vznjs.github.io/vx/cli/#${code.toLowerCase()}` }
|
|
40
43
|
: {}
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
)
|
|
44
|
+
const doc = `${JSON.stringify({ ok: false, error: { code, message: `vx-migrate: ${message}`, ...docs } })}\n`
|
|
45
|
+
if (ui === undefined) process.stdout.write(doc)
|
|
46
|
+
else ui.data('out', doc)
|
|
44
47
|
}
|
|
45
48
|
if (isUserError(err)) {
|
|
46
|
-
// `ui` by namespace: a core older than it has none, and its refusal
|
|
47
|
-
// still reaches the reader.
|
|
48
|
-
const ui = (core as Partial<typeof core>).ui
|
|
49
49
|
if (ui === undefined) process.stderr.write(`vx-migrate: ${err.message}\n`)
|
|
50
50
|
else ui.say('error', `vx-migrate: ${err.message}`)
|
|
51
51
|
process.exitCode = 1
|
package/src/migrate-turbo.ts
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
import type { PackageMeta } from './packages.js'
|
|
15
15
|
import { mapTurboWorkspace, type TurboGlobal, type TurboMappedProject } from './turbo/turbo-map.js'
|
|
16
16
|
import { rootTaskProject } from './turbo/turbo-reads.js'
|
|
17
|
+
import { ROOT_ENGINES_PROBE } from './turbo/root-engines.js'
|
|
17
18
|
import { relPosix } from './paths.js'
|
|
18
19
|
import { gitIgnored, spareTrackedOutputs, trackedFiles, trackedKinds } from './tracked-outputs.js'
|
|
19
20
|
import { DOTENV_GLOBS_HEAD, DOTENV_PROBE, DOTENV_PROBE_TOP } from './dotenv-probe.js'
|
|
@@ -86,6 +87,7 @@ export async function migrateTurbo(
|
|
|
86
87
|
return {
|
|
87
88
|
name: p.name,
|
|
88
89
|
dir: p.dir,
|
|
90
|
+
...(p.tags !== undefined ? { tags: p.tags } : {}),
|
|
89
91
|
importLines: [
|
|
90
92
|
...(readsManifest ? [MANIFEST_IMPORT] : []),
|
|
91
93
|
...(sealed
|
|
@@ -246,10 +248,11 @@ function identifier(task: string): string {
|
|
|
246
248
|
}
|
|
247
249
|
|
|
248
250
|
/**
|
|
249
|
-
* The
|
|
250
|
-
*
|
|
251
|
-
*
|
|
252
|
-
*
|
|
251
|
+
* The probes, by name: Turbo hashes a task's `.env` files although git
|
|
252
|
+
* ignores them, and a glob over git's files sees none, so the mapper keys
|
|
253
|
+
* them through a shell line that prints each file; and the root's
|
|
254
|
+
* `engines`, which Turbo folds into every task. Inline in every package's
|
|
255
|
+
* config, each line read as noise nobody could review.
|
|
253
256
|
*/
|
|
254
257
|
type Probe = { name: string; command: string }
|
|
255
258
|
|
|
@@ -262,6 +265,10 @@ const PROBES: readonly Probe[] = [
|
|
|
262
265
|
name: 'dotenvFilesDeep',
|
|
263
266
|
command: DOTENV_PROBE,
|
|
264
267
|
},
|
|
268
|
+
{
|
|
269
|
+
name: 'rootEngines',
|
|
270
|
+
command: ROOT_ENGINES_PROBE,
|
|
271
|
+
},
|
|
265
272
|
]
|
|
266
273
|
|
|
267
274
|
function nameProbes(projects: readonly TurboMappedProject[]): {
|
package/src/migrate.ts
CHANGED
|
@@ -429,7 +429,7 @@ async function lockWorkspace(root: string): Promise<{ code: number; said: string
|
|
|
429
429
|
// A success's warnings print above the report; a refusal is said whole.
|
|
430
430
|
// A success is a report row, matched as text: the colour FORCE_COLOR
|
|
431
431
|
// gave the child would hide its `✓ vx lock` mark from that match.
|
|
432
|
-
if (code === 0)
|
|
432
|
+
if (code === 0) ui.data('err', err)
|
|
433
433
|
return { code, said: code === 0 ? Bun.stripANSI(out) : out + err }
|
|
434
434
|
}
|
|
435
435
|
|
|
@@ -613,7 +613,8 @@ async function keep(
|
|
|
613
613
|
...new Set(['@vzn/vx', '@vzn/vx-migrate', ...(declares ? all.map((p) => p.pkg) : [])]),
|
|
614
614
|
])
|
|
615
615
|
if (args.json) {
|
|
616
|
-
|
|
616
|
+
ui.data(
|
|
617
|
+
'out',
|
|
617
618
|
`${JSON.stringify({
|
|
618
619
|
dry: true,
|
|
619
620
|
source,
|
|
@@ -637,7 +638,7 @@ async function keep(
|
|
|
637
638
|
// A run reads only the lock; with the plugins installed it is written
|
|
638
639
|
// now, else `next` names `vx lock` after the install.
|
|
639
640
|
const lock = !args.dry && !next.includes(' lock && ') ? await lockWorkspace(root) : undefined
|
|
640
|
-
if (lock !== undefined && lock.code !== 0)
|
|
641
|
+
if (lock !== undefined && lock.code !== 0) ui.data('err', lock.said)
|
|
641
642
|
const rows: ui.FieldRow[] = [
|
|
642
643
|
['from', `${source}, run by ${runner}() from @vzn/vx-migrate`],
|
|
643
644
|
...(args.dry ? [['dry run', 'nothing written'] as const] : []),
|
package/src/nx/nx-map.ts
CHANGED
|
@@ -181,6 +181,7 @@ function checkNxNodes(nodes: Record<string, unknown>, label: string): void {
|
|
|
181
181
|
const data = (n['data'] ?? {}) as Record<string, unknown>
|
|
182
182
|
optional(data['root'], `${at}.data.root`, (v) => typeof v === 'string', 'a string')
|
|
183
183
|
optional(data['targets'], `${at}.data.targets`, isRecord, 'an object of targets')
|
|
184
|
+
checkNamedInputs(data['namedInputs'], `${at}.data.namedInputs`, label)
|
|
184
185
|
for (const [name, target] of Object.entries((data['targets'] ?? {}) as object)) {
|
|
185
186
|
const t = `${at}.data.targets.${JSON.stringify(name)}`
|
|
186
187
|
if (!isRecord(target)) refuse(t, 'an object')
|
|
@@ -195,6 +196,19 @@ function checkNxNodes(nodes: Record<string, unknown>, label: string): void {
|
|
|
195
196
|
}
|
|
196
197
|
}
|
|
197
198
|
|
|
199
|
+
/**
|
|
200
|
+
* `namedInputs` as the mapper expands them: each name a list of inputs.
|
|
201
|
+
* A name holding an object or a number reached the expansion's loop and
|
|
202
|
+
* printed `TypeError: {} is not iterable` with its stack (fuzzed).
|
|
203
|
+
*/
|
|
204
|
+
function checkNamedInputs(v: unknown, at: string, label: string): void {
|
|
205
|
+
if (v === undefined) return
|
|
206
|
+
if (!isRecord(v)) throw new UserError(`${label}: ${at} must be an object of named inputs`)
|
|
207
|
+
for (const [name, members] of Object.entries(v))
|
|
208
|
+
if (!Array.isArray(members) || members.some((x) => typeof x !== 'string' && !isRecord(x)))
|
|
209
|
+
throw new UserError(`${label}: ${at}.${JSON.stringify(name)} must be an array of inputs`)
|
|
210
|
+
}
|
|
211
|
+
|
|
198
212
|
export interface MapNxOptions {
|
|
199
213
|
/** nx.json's legacy `cacheableOperations`, for a graph whose targets carry no `cache` field. */
|
|
200
214
|
readonly cacheable: ReadonlySet<string>
|
|
@@ -743,6 +757,7 @@ export async function readNxJsonFacts(root: string): Promise<NxJsonFacts> {
|
|
|
743
757
|
sync?: { globalGenerators?: unknown }
|
|
744
758
|
}
|
|
745
759
|
const named = parsed?.namedInputs
|
|
760
|
+
checkNamedInputs(named ?? undefined, 'namedInputs', 'nx.json')
|
|
746
761
|
const ops = parsed?.tasksRunnerOptions?.default?.options?.cacheableOperations
|
|
747
762
|
return {
|
|
748
763
|
namedInputs:
|
|
@@ -754,7 +769,8 @@ export async function readNxJsonFacts(root: string): Promise<NxJsonFacts> {
|
|
|
754
769
|
? parsed.sync.globalGenerators.filter((g): g is string => typeof g === 'string')
|
|
755
770
|
: [],
|
|
756
771
|
}
|
|
757
|
-
} catch {
|
|
772
|
+
} catch (err) {
|
|
773
|
+
if (err instanceof UserError) throw err
|
|
758
774
|
// An unreadable nx.json (or base) just degrades named-input refs to TODOs.
|
|
759
775
|
return none
|
|
760
776
|
}
|
package/src/package-fs.ts
CHANGED
|
@@ -40,19 +40,35 @@ export function normalizeBunGlob(glob: string): string {
|
|
|
40
40
|
return neg ? `!${g}` : g
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
/**
|
|
44
|
+
* Where the `[`…`]` class opening at `i` ends (a `]` right after the `[` is
|
|
45
|
+
* a member); `i` for a `[` with no `]`. A `,` `{` or `}` in a class is a
|
|
46
|
+
* member, never a brace cut, as `Bun.Glob` reads it.
|
|
47
|
+
*/
|
|
48
|
+
function classEnd(pattern: string, i: number): number {
|
|
49
|
+
const end = pattern.indexOf(']', i + 2)
|
|
50
|
+
return end === -1 ? i : end
|
|
51
|
+
}
|
|
52
|
+
|
|
43
53
|
/**
|
|
44
54
|
* `pattern` with its first brace group expanded, recursively, while that
|
|
45
55
|
* group holds a `/`: `Bun.Glob`'s scan finds nothing for one.
|
|
46
56
|
*/
|
|
47
57
|
export function slashBraceExpansions(pattern: string): string[] {
|
|
48
58
|
if (pattern.includes('\\')) return [pattern]
|
|
49
|
-
for (let open =
|
|
59
|
+
for (let open = 0; open < pattern.length; open++) {
|
|
60
|
+
if (pattern[open] === '[') {
|
|
61
|
+
open = classEnd(pattern, open)
|
|
62
|
+
continue
|
|
63
|
+
}
|
|
64
|
+
if (pattern[open] !== '{') continue
|
|
50
65
|
let depth = 0
|
|
51
66
|
const cuts: number[] = []
|
|
52
67
|
let close = -1
|
|
53
68
|
for (let i = open; i < pattern.length && close === -1; i++) {
|
|
54
69
|
const c = pattern[i]
|
|
55
|
-
if (c === '
|
|
70
|
+
if (c === '[') i = classEnd(pattern, i)
|
|
71
|
+
else if (c === '{') depth++
|
|
56
72
|
else if (c === '}' && --depth === 0) close = i
|
|
57
73
|
else if (c === ',' && depth === 1) cuts.push(i)
|
|
58
74
|
}
|
package/src/remote-refusal.ts
CHANGED
|
@@ -27,5 +27,10 @@ export async function refusal(res: Response, what = ''): Promise<Error> {
|
|
|
27
27
|
} catch {
|
|
28
28
|
// quiet: the status alone still names the refusal
|
|
29
29
|
}
|
|
30
|
-
|
|
30
|
+
// Each control as `\xNN`: an `ESC ]` in the server's words retitled the terminal (L-31).
|
|
31
|
+
const line = said
|
|
32
|
+
.slice(0, 200)
|
|
33
|
+
// oxlint-disable-next-line no-control-regex
|
|
34
|
+
.replace(/[\x00-\x1f\x7f-\x9f]/g, (c) => `\\x${c.charCodeAt(0).toString(16).padStart(2, '0')}`)
|
|
35
|
+
return new Error(`${what}HTTP ${res.status}${line === '' ? '' : `: ${line}`}`)
|
|
31
36
|
}
|
package/src/turbo/index.ts
CHANGED
|
@@ -24,6 +24,7 @@ import { onceReader, type ReadText, tscBuildInfo } from '../tsc-buildinfo.js'
|
|
|
24
24
|
import type * as TurboMap from './turbo-map.js'
|
|
25
25
|
import type { TurboMappedProject } from './turbo-map.js'
|
|
26
26
|
import {
|
|
27
|
+
ENV_CONDITION_KEYS,
|
|
27
28
|
envNamesThatMap,
|
|
28
29
|
microfrontendsConfigs,
|
|
29
30
|
rootTaskProject,
|
|
@@ -71,12 +72,24 @@ export function turbo(options: TurboPluginOptions = {}): VxPlugin {
|
|
|
71
72
|
const keys = await workspaceKeys(rootOf(ctx.workspaceRoot))
|
|
72
73
|
if (workspace.run?.concurrency === undefined && keys.concurrency !== undefined)
|
|
73
74
|
workspace.run = { ...workspace.run, concurrency: keys.concurrency }
|
|
75
|
+
// `TURBO_FORCE` (`true` or `1`, as Turbo reads it) is `turbo run --force`.
|
|
76
|
+
const force = Bun.env['TURBO_FORCE']?.trim()
|
|
77
|
+
if (force === 'true' || force === '1')
|
|
78
|
+
workspace.cache = { ...workspace.cache, policy: forcedPolicy(workspace.cache?.policy) }
|
|
74
79
|
// `turbo run --affected` compares with TURBO_SCM_BASE when set, and
|
|
75
80
|
// on GitHub Actions with what the event names.
|
|
76
81
|
const base =
|
|
77
82
|
Bun.env['TURBO_SCM_BASE']?.trim() ||
|
|
78
83
|
githubActionsBase(rootOf(ctx.workspaceRoot), keys.remoteBaseFallback === true)
|
|
79
84
|
if (base) ctx.setBranch({ base })
|
|
85
|
+
// ...up to TURBO_SCM_HEAD. vx's head is the checkout (HEAD and its
|
|
86
|
+
// working tree): a head at HEAD's commit is that, any other is said.
|
|
87
|
+
const head = Bun.env['TURBO_SCM_HEAD']?.trim()
|
|
88
|
+
if (head && !atHead(rootOf(ctx.workspaceRoot), head))
|
|
89
|
+
ctx.warn(
|
|
90
|
+
`TURBO_SCM_HEAD (${head}) is not the checked-out commit: --affected compares ` +
|
|
91
|
+
`the base with the checkout, so check out ${head} to compare with it.`,
|
|
92
|
+
)
|
|
80
93
|
},
|
|
81
94
|
// Turbo's packages are the package manager's members, each a project
|
|
82
95
|
// with no vx.config written. `//#task` keys run on the root, as Turbo
|
|
@@ -99,6 +112,18 @@ export function turbo(options: TurboPluginOptions = {}): VxPlugin {
|
|
|
99
112
|
|
|
100
113
|
const UNKNOWN_SHA = '0'.repeat(40)
|
|
101
114
|
|
|
115
|
+
/** Whether `ref` names HEAD's commit. */
|
|
116
|
+
function atHead(root: string, ref: string): boolean {
|
|
117
|
+
// An option is no ref; rev-parse prints `--end-of-options` back (git 2.43).
|
|
118
|
+
if (ref.startsWith('-')) return false
|
|
119
|
+
const proc = Bun.spawnSync(['git', 'rev-parse', 'HEAD', `${ref}^{commit}`], {
|
|
120
|
+
cwd: root,
|
|
121
|
+
stderr: 'ignore',
|
|
122
|
+
})
|
|
123
|
+
const [head, at] = proc.stdout.toString().split('\n')
|
|
124
|
+
return proc.exitCode === 0 && head === at
|
|
125
|
+
}
|
|
126
|
+
|
|
102
127
|
/**
|
|
103
128
|
* Turbo's `--affected` base on GitHub Actions when `TURBO_SCM_BASE` is
|
|
104
129
|
* unset (`get_github_base_ref`): a pull request's `GITHUB_BASE_REF`, else
|
|
@@ -185,6 +210,25 @@ async function workspaceKeys(root: string): Promise<{
|
|
|
185
210
|
return out
|
|
186
211
|
}
|
|
187
212
|
|
|
213
|
+
/**
|
|
214
|
+
* A policy with every read off, as `--force` is: no hit is replayed. The
|
|
215
|
+
* remote is written only where the workspace's own policy names it, so a
|
|
216
|
+
* laptop's forced run never writes the cache CI reads.
|
|
217
|
+
*/
|
|
218
|
+
function forcedPolicy(spec = ''): string {
|
|
219
|
+
const flags = new Map([
|
|
220
|
+
['local', 'w'],
|
|
221
|
+
['remote', ''],
|
|
222
|
+
])
|
|
223
|
+
for (const seg of spec.split(',')) {
|
|
224
|
+
if (seg.trim() === '') continue
|
|
225
|
+
const m = /^(local|remote):([rw]*)$/.exec(seg.trim())
|
|
226
|
+
if (m === null) return spec // core refuses it, naming `cache.policy`
|
|
227
|
+
flags.set(m[1]!, m[2]!.replace('r', ''))
|
|
228
|
+
}
|
|
229
|
+
return [...flags].map(([layer, f]) => `${layer}:${f}`).join(',')
|
|
230
|
+
}
|
|
231
|
+
|
|
188
232
|
const envNamesNow = (): string[] => Object.keys(process.env).sort()
|
|
189
233
|
|
|
190
234
|
const textOf = (file: string): Promise<string> =>
|
|
@@ -223,6 +267,8 @@ async function run(root: string, projects: readonly ProjectMeta[]): Promise<Adop
|
|
|
223
267
|
// An env wildcard expands over these; a new name it matches maps afresh.
|
|
224
268
|
JSON.stringify(envNames),
|
|
225
269
|
process.env['TURBO_CI_VENDOR_ENV_KEY'] ?? '',
|
|
270
|
+
// A framework conditional keys its names on these values (skew protection).
|
|
271
|
+
JSON.stringify(ENV_CONDITION_KEYS.map((k) => process.env[k] ?? null)),
|
|
226
272
|
process.env['TURBO_ENV_MODE'] ?? '',
|
|
227
273
|
...configs,
|
|
228
274
|
yarnrc,
|
|
@@ -265,6 +311,7 @@ async function mapAll(
|
|
|
265
311
|
splice: (_kind, values) => values,
|
|
266
312
|
persistentTodo: PERSISTENT_NOTE,
|
|
267
313
|
envNames,
|
|
314
|
+
envValue: (name) => process.env[name],
|
|
268
315
|
vendorEnvPrefix: process.env['TURBO_CI_VENDOR_ENV_KEY'] ?? '',
|
|
269
316
|
envMode: process.env['TURBO_ENV_MODE'] ?? '',
|
|
270
317
|
...(tracked === null ? {} : { tracked: trackedKinds(tracked) }),
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The root manifest's `engines`, compacted, printed by one `awk` at the
|
|
3
|
+
* workspace root: Turbo folds that value into every task's global hash
|
|
4
|
+
* (crates/turborepo-task-hash/src/global_hash.rs), so raising
|
|
5
|
+
* `engines.node` re-ran every task under `turbo run` and replayed a hit
|
|
6
|
+
* under vx. A probe reads it live, so `turbo()` and the config the
|
|
7
|
+
* migration writes key it alike; it prints only the `engines` value, so
|
|
8
|
+
* no other root edit re-keys a task. A top-level JSON scanner, strings
|
|
9
|
+
* and escapes skipped, whitespace outside strings dropped: a reformat
|
|
10
|
+
* keys alike, a reorder misses (never a stale hit). One process, no Node.
|
|
11
|
+
*/
|
|
12
|
+
export const ROOT_ENGINES_PROBE =
|
|
13
|
+
'LC_ALL=C awk \'BEGIN{RS="\\001"}{n=length($0);d=0;q=0;c=0;o="";for(i=1;i<=n;i++){x=substr($0,i,1);if(c&&(q||x!~/[ \\t\\r\\n]/))o=o x;if(q){if(x=="\\\\"){i++;if(c)o=o substr($0,i,1)}else if(x=="\\""){q=0;if(d==1&&!c)k=substr($0,s,i-s)}continue}if(x=="\\""){q=1;s=i+1}else if(x=="{"||x=="[")d++;else if(x=="}"||x=="]"){d--;if(c&&d==1){print o;exit}}else if(x==":"&&d==1&&!c&&k=="engines")c=1}}\' package.json'
|
|
14
|
+
|
|
15
|
+
/** Whether Turbo hashes the root's `engines`: a non-empty object, as it reads one. */
|
|
16
|
+
export function keysRootEngines(rootPackageJson: unknown): boolean {
|
|
17
|
+
const engines = (rootPackageJson as { engines?: unknown } | null)?.engines
|
|
18
|
+
return typeof engines === 'object' && engines !== null && Object.keys(engines).length > 0
|
|
19
|
+
}
|
package/src/turbo/turbo-map.ts
CHANGED
|
@@ -36,9 +36,12 @@ import {
|
|
|
36
36
|
keyable,
|
|
37
37
|
microfrontendsConfigs,
|
|
38
38
|
ROOT,
|
|
39
|
+
TURBO_BUILTIN_PASS_THROUGH,
|
|
40
|
+
TURBO_DEFAULT_ENV,
|
|
39
41
|
turboConfigFile,
|
|
40
42
|
} from './turbo-reads.js'
|
|
41
43
|
import type { TrackedKinds } from '../tracked-outputs.js'
|
|
44
|
+
import { keysRootEngines, ROOT_ENGINES_PROBE } from './root-engines.js'
|
|
42
45
|
import {
|
|
43
46
|
DOTENV_PROBE,
|
|
44
47
|
DOTENV_PROBE_TOP,
|
|
@@ -72,6 +75,8 @@ interface TurboJson {
|
|
|
72
75
|
/** Turbo 2.11 `futureFlags.globalConfiguration`: the global lists live here. */
|
|
73
76
|
global?: { inputs?: string[]; env?: string[]; passThroughEnv?: string[]; envMode?: unknown }
|
|
74
77
|
envMode?: unknown
|
|
78
|
+
/** Turbo 2.11.6: the package's labels, which `--filter tag:` selects by. */
|
|
79
|
+
tags?: string[]
|
|
75
80
|
}
|
|
76
81
|
|
|
77
82
|
const SHELL_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/
|
|
@@ -133,6 +138,18 @@ function explicitEnv(
|
|
|
133
138
|
return out.filter((n) => !excluded.some((re) => re.test(n)))
|
|
134
139
|
}
|
|
135
140
|
|
|
141
|
+
/**
|
|
142
|
+
* The literal names of Turbo's builtin pass-through list, the same under
|
|
143
|
+
* `turbo()` as in written configs: `exec.env.passThrough` is keyed by name,
|
|
144
|
+
* so a list matched against the live environment keyed each machine apart
|
|
145
|
+
* and missed the cache `turbo()` filled once the configs were written. A
|
|
146
|
+
* wildcard family is a note. A name that is no shell variable name
|
|
147
|
+
* (`ProgramFiles(x86)`) is left out: core refuses it there.
|
|
148
|
+
*/
|
|
149
|
+
const BUILTIN_PASS_THROUGH = TURBO_BUILTIN_PASS_THROUGH.filter((e) =>
|
|
150
|
+
/^[A-Za-z_][A-Za-z0-9_]*$/.test(e),
|
|
151
|
+
)
|
|
152
|
+
|
|
136
153
|
/** A gap's message: a wildcard, or a literal name core cannot key. */
|
|
137
154
|
const envGap = (field: string, entry: string, literal: string | null, list: string): string =>
|
|
138
155
|
literal === null
|
|
@@ -161,8 +178,11 @@ const KNOWN_TASK_KEYS = new Set([
|
|
|
161
178
|
// watch` keeps it up and restarts it only when its config changes or it
|
|
162
179
|
// dies, so either value maps to nothing.
|
|
163
180
|
'interruptible',
|
|
164
|
-
//
|
|
181
|
+
// Turbo 2.11.6 selects a task by them (`--filter tag:`); a vx tag belongs
|
|
182
|
+
// to a project, so only the package-level `tags` map (`packageTags`).
|
|
165
183
|
'tags',
|
|
184
|
+
// Turbo parses it for external CI tooling and acts on nothing.
|
|
185
|
+
'experimentalCI',
|
|
166
186
|
])
|
|
167
187
|
|
|
168
188
|
// Turbo's per-task `outputLogs` against vx's per-run `--output-logs`.
|
|
@@ -191,6 +211,8 @@ export interface TurboMappedTask {
|
|
|
191
211
|
export interface TurboMappedProject {
|
|
192
212
|
name: string
|
|
193
213
|
dir: string
|
|
214
|
+
/** The package's turbo.json `tags`, its vx project tags. */
|
|
215
|
+
tags?: string[]
|
|
194
216
|
tasks: TurboMappedTask[]
|
|
195
217
|
}
|
|
196
218
|
|
|
@@ -222,6 +244,12 @@ export interface MapTurboOptions {
|
|
|
222
244
|
* Absent (`bunx @vzn/vx-migrate` writes files), a wildcard is a todo.
|
|
223
245
|
*/
|
|
224
246
|
envNames?: readonly string[]
|
|
247
|
+
/**
|
|
248
|
+
* A variable's value where the tasks will run, read only for a framework
|
|
249
|
+
* conditional's key (Turbo's `envConditionals`). Absent (written configs),
|
|
250
|
+
* every conditional name is keyed: more misses, never a stale hit.
|
|
251
|
+
*/
|
|
252
|
+
envValue?: (name: string) => string | undefined
|
|
225
253
|
/**
|
|
226
254
|
* Without `envNames` (written configs): the upper-case names the files
|
|
227
255
|
* under `dirs` spell, which a framework's `*` prefix is matched against.
|
|
@@ -354,10 +382,12 @@ const TOP_LISTS = [
|
|
|
354
382
|
'globalPassThroughEnv',
|
|
355
383
|
'globalDotEnv',
|
|
356
384
|
'extends',
|
|
385
|
+
'tags',
|
|
357
386
|
]
|
|
358
387
|
const GLOBAL_LISTS = ['inputs', 'env', 'passThroughEnv']
|
|
359
388
|
const TASK_LISTS = ['dependsOn', 'outputs', 'env', 'passThroughEnv', 'with', 'dotEnv']
|
|
360
389
|
const TASK_FLAGS = ['cache', 'persistent', 'interactive', 'interruptible']
|
|
390
|
+
const ENV_LISTS = new Set(['globalEnv', 'globalPassThroughEnv', 'env', 'passThroughEnv'])
|
|
361
391
|
|
|
362
392
|
/**
|
|
363
393
|
* The shape of every field the mapper reads, refused by name. A number
|
|
@@ -373,12 +403,47 @@ function checkTurboShape(cfg: unknown, label: string): void {
|
|
|
373
403
|
if (v !== undefined && (!Array.isArray(v) || v.some((x) => typeof x !== 'string')))
|
|
374
404
|
refuse(at, 'an array of strings')
|
|
375
405
|
}
|
|
406
|
+
// Turbo refuses `$NAME` in an env list (`InvalidEnvPrefix`), its own
|
|
407
|
+
// `$TURBO_EXTENDS$` aside; mapped, core keyed a variable literally
|
|
408
|
+
// named `$NAME`, which is never set.
|
|
409
|
+
const envList = (v: unknown, at: string): void => {
|
|
410
|
+
list(v, at)
|
|
411
|
+
const bad = (v as string[] | undefined)?.find(
|
|
412
|
+
(x) => x.startsWith('$') && x !== '$TURBO_EXTENDS$',
|
|
413
|
+
)
|
|
414
|
+
if (bad !== undefined)
|
|
415
|
+
throw new UserError(
|
|
416
|
+
`${label}: ${at} holds ${JSON.stringify(bad)}: Turbo refuses a "$" before an env name — write ${JSON.stringify(bad.slice(1))}`,
|
|
417
|
+
)
|
|
418
|
+
}
|
|
419
|
+
// Turbo refuses an absolute glob (`AbsolutePathInConfig`), read past its
|
|
420
|
+
// `!` and `$TURBO_ROOT$/`; mapped, core refused it naming the project,
|
|
421
|
+
// as if its own config held it.
|
|
422
|
+
const paths = (v: unknown, at: string): void => {
|
|
423
|
+
for (const x of Array.isArray(v) ? v : []) {
|
|
424
|
+
const globs: unknown[] = isRecord(x) && Array.isArray(x['globs']) ? x['globs'] : [x]
|
|
425
|
+
for (const g of globs) {
|
|
426
|
+
if (typeof g !== 'string') continue
|
|
427
|
+
const bare = g.replace(/^!/, '').replace(/^\$TURBO_ROOT\$\//, '')
|
|
428
|
+
if (path.isAbsolute(bare))
|
|
429
|
+
throw new UserError(
|
|
430
|
+
`${label}: ${at} holds ${JSON.stringify(g)}: Turbo refuses an absolute path — name it relative to the package, or to the root with $TURBO_ROOT$/`,
|
|
431
|
+
)
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
}
|
|
376
435
|
if (!isRecord(cfg)) refuse('the file', 'a JSON object')
|
|
377
436
|
const c = cfg as Record<string, unknown>
|
|
378
|
-
for (const k of TOP_LISTS) list(c[k], k)
|
|
437
|
+
for (const k of TOP_LISTS) (ENV_LISTS.has(k) ? envList : list)(c[k], k)
|
|
438
|
+
paths(c['globalDependencies'], 'globalDependencies')
|
|
379
439
|
if (c['global'] !== undefined) {
|
|
380
440
|
if (!isRecord(c['global'])) refuse('global', 'an object')
|
|
381
|
-
for (const k of GLOBAL_LISTS)
|
|
441
|
+
for (const k of GLOBAL_LISTS)
|
|
442
|
+
(ENV_LISTS.has(k) ? envList : list)(
|
|
443
|
+
(c['global'] as Record<string, unknown>)[k],
|
|
444
|
+
`global.${k}`,
|
|
445
|
+
)
|
|
446
|
+
paths((c['global'] as Record<string, unknown>)['inputs'], 'global.inputs')
|
|
382
447
|
}
|
|
383
448
|
for (const field of ['tasks', 'pipeline']) {
|
|
384
449
|
const tasks = c[field]
|
|
@@ -388,7 +453,7 @@ function checkTurboShape(cfg: unknown, label: string): void {
|
|
|
388
453
|
const at = `${field}.${JSON.stringify(name)}`
|
|
389
454
|
if (!isRecord(def)) refuse(at, 'an object')
|
|
390
455
|
const d = def as Record<string, unknown>
|
|
391
|
-
for (const k of TASK_LISTS) list(d[k], `${at}.${k}`)
|
|
456
|
+
for (const k of TASK_LISTS) (ENV_LISTS.has(k) ? envList : list)(d[k], `${at}.${k}`)
|
|
392
457
|
// Turbo 2.11 adds `{ mode, globs, withDefaults }` entries (`flatInputs`).
|
|
393
458
|
const inputs = d['inputs']
|
|
394
459
|
if (
|
|
@@ -396,6 +461,8 @@ function checkTurboShape(cfg: unknown, label: string): void {
|
|
|
396
461
|
(!Array.isArray(inputs) || inputs.some((x) => typeof x !== 'string' && !isRecord(x)))
|
|
397
462
|
)
|
|
398
463
|
refuse(`${at}.inputs`, 'an array of globs')
|
|
464
|
+
paths(inputs, `${at}.inputs`)
|
|
465
|
+
paths(d['outputs'], `${at}.outputs`)
|
|
399
466
|
for (const k of TASK_FLAGS)
|
|
400
467
|
if (d[k] !== undefined && typeof d[k] !== 'boolean') refuse(`${at}.${k}`, 'true or false')
|
|
401
468
|
for (const k of ['outputLogs', 'outputMode'])
|
|
@@ -404,6 +471,16 @@ function checkTurboShape(cfg: unknown, label: string): void {
|
|
|
404
471
|
}
|
|
405
472
|
}
|
|
406
473
|
|
|
474
|
+
/**
|
|
475
|
+
* A package's top-level turbo.json `tags`, which Turbo 2.11.6's `--filter
|
|
476
|
+
* tag:<label>` selects every task of the package by, as vx's `tag:` does a
|
|
477
|
+
* project's. Its own file only: Turbo inherits no package tag, and the
|
|
478
|
+
* root's label the root package. Blank ones dropped, as vx refuses them.
|
|
479
|
+
*/
|
|
480
|
+
function packageTags(cfg: TurboJson | undefined): string[] {
|
|
481
|
+
return [...new Set((cfg?.tags ?? []).filter((t) => t.trim() !== ''))]
|
|
482
|
+
}
|
|
483
|
+
|
|
407
484
|
function tasksOf(cfg: TurboJson): Record<string, TurboTask> {
|
|
408
485
|
return cfg.tasks ?? cfg.pipeline ?? {}
|
|
409
486
|
}
|
|
@@ -762,6 +839,22 @@ async function rootPackageJson(root: string): Promise<PackageMeta['packageJson']
|
|
|
762
839
|
return (pkg !== null && typeof pkg === 'object' ? pkg : {}) as PackageMeta['packageJson']
|
|
763
840
|
}
|
|
764
841
|
|
|
842
|
+
/**
|
|
843
|
+
* `turboConfigFile`, refusing a directory that holds both files as Turbo
|
|
844
|
+
* does (vercel/turborepo#10083): `turbo.json` shadowed the `.jsonc` beside
|
|
845
|
+
* it in silence, so an edit there keyed and ran nothing.
|
|
846
|
+
*/
|
|
847
|
+
async function onlyConfigFile(dir: string, root: string): Promise<string | null> {
|
|
848
|
+
const file = await turboConfigFile(dir)
|
|
849
|
+
if (file?.endsWith('.json') === true && (await Bun.file(`${file}c`).exists())) {
|
|
850
|
+
const where = dir === root ? 'the workspace root' : relPosix(root, dir)
|
|
851
|
+
throw new UserError(
|
|
852
|
+
`${where} holds both turbo.json and turbo.jsonc: Turbo refuses this — remove one`,
|
|
853
|
+
)
|
|
854
|
+
}
|
|
855
|
+
return file
|
|
856
|
+
}
|
|
857
|
+
|
|
765
858
|
export async function mapTurboWorkspace(
|
|
766
859
|
root: string,
|
|
767
860
|
metas: readonly PackageMeta[],
|
|
@@ -769,7 +862,16 @@ export async function mapTurboWorkspace(
|
|
|
769
862
|
): Promise<TurboMapping> {
|
|
770
863
|
// A workspace with no Turbo config answered with a bare ENOENT and a stack
|
|
771
864
|
// (item 1043): turbo() declared in the wrong repo, or its file removed.
|
|
772
|
-
const rootFile = await
|
|
865
|
+
const rootFile = await onlyConfigFile(root, root)
|
|
866
|
+
// Turbo reads its root config from the file TURBO_ROOT_TURBO_JSON names,
|
|
867
|
+
// against the cwd (vercel/turborepo#9087); reading the root's in its
|
|
868
|
+
// place ran another task graph than `turbo run`.
|
|
869
|
+
const named = Bun.env['TURBO_ROOT_TURBO_JSON']
|
|
870
|
+
if (named && (rootFile === null || path.resolve(named) !== rootFile)) {
|
|
871
|
+
throw new UserError(
|
|
872
|
+
`TURBO_ROOT_TURBO_JSON names ${path.resolve(named)}: turbo() reads only the workspace root's turbo.json or turbo.jsonc — unset it, or move that config to the root`,
|
|
873
|
+
)
|
|
874
|
+
}
|
|
773
875
|
if (rootFile === null) {
|
|
774
876
|
throw new UserError(
|
|
775
877
|
`no turbo.json or turbo.jsonc at the workspace root (${root}): turbo() maps a Turbo repo's config — add one, or remove turbo() from .vx/workspace.ts`,
|
|
@@ -829,6 +931,7 @@ export async function mapTurboWorkspace(
|
|
|
829
931
|
// over git's files keyed nothing: the workspace probe keys them (item
|
|
830
932
|
// 1032).
|
|
831
933
|
const rootMeta = metas.find((m) => path.resolve(m.dir) === path.resolve(root))
|
|
934
|
+
const rootPkg = rootMeta?.packageJson ?? (await rootPackageJson(root))
|
|
832
935
|
const globalFiles = [
|
|
833
936
|
...globalDeps.filter((d) => envDependency(d) === null).map(turboGlobstar),
|
|
834
937
|
...(rootCfg.globalDotEnv ?? []),
|
|
@@ -837,7 +940,7 @@ export async function mapTurboWorkspace(
|
|
|
837
940
|
// packages that depend on the database package).
|
|
838
941
|
...(rootCfg.tasks === undefined && rootCfg.pipeline !== undefined
|
|
839
942
|
? []
|
|
840
|
-
: rootDependencyGlobs(root,
|
|
943
|
+
: rootDependencyGlobs(root, rootPkg, metas)),
|
|
841
944
|
...microfrontendsConfigs(root, [root, ...metas.map((m) => m.dir)]).map((c) => c.rel),
|
|
842
945
|
]
|
|
843
946
|
const rootDotenv = globalFiles.filter((f) => isDotenvGlob(f))
|
|
@@ -851,18 +954,31 @@ export async function mapTurboWorkspace(
|
|
|
851
954
|
const globals = {
|
|
852
955
|
inputs: globalFiles.filter((f) => !isDotenvGlob(f) && !globalIgnored.includes(f)),
|
|
853
956
|
env: [
|
|
854
|
-
|
|
957
|
+
// Turbo's defaults sit before the user's list, so its `!` takes them back.
|
|
958
|
+
...envNames('globalEnv', [...TURBO_DEFAULT_ENV, ...(rootCfg.globalEnv ?? [])]),
|
|
855
959
|
...globalDeps.flatMap((d) => envDependency(d) ?? []),
|
|
856
960
|
],
|
|
857
|
-
pass:
|
|
961
|
+
pass: [
|
|
962
|
+
...envNames('globalPassThroughEnv', rootCfg.globalPassThroughEnv ?? []),
|
|
963
|
+
...BUILTIN_PASS_THROUGH,
|
|
964
|
+
],
|
|
858
965
|
}
|
|
859
966
|
|
|
967
|
+
// A written config names no variable by pattern: Turbo's builtin
|
|
968
|
+
// families are the user's to list, once, for the tasks that read them.
|
|
969
|
+
if (opts.envNames === undefined && opts.sourceNames !== undefined)
|
|
970
|
+
notes.push(
|
|
971
|
+
`Turbo passes every ${TURBO_BUILTIN_PASS_THROUGH.filter((e) => e.endsWith('*')).join(', ')} ` +
|
|
972
|
+
'variable to each task; the configs pass its literal names only — add the ones a task ' +
|
|
973
|
+
'reads to exec.env.passThrough',
|
|
974
|
+
)
|
|
975
|
+
|
|
860
976
|
// The workspace root as a project (a `vx.config` there, D-39) holds
|
|
861
977
|
// Turbo's `//#task`s (`rootMeta`, above); Turbo runs no plain task in the root package.
|
|
862
978
|
const files = new Map<string, TurboJson>([[ROOT, rootCfg]])
|
|
863
979
|
for (const meta of metas) {
|
|
864
980
|
if (meta === rootMeta) continue
|
|
865
|
-
const file = await
|
|
981
|
+
const file = await onlyConfigFile(meta.dir, root)
|
|
866
982
|
if (file !== null) files.set(meta.name, await readTurboJson(file, root))
|
|
867
983
|
}
|
|
868
984
|
|
|
@@ -1113,6 +1229,8 @@ export async function mapTurboWorkspace(
|
|
|
1113
1229
|
const head = e.slice(0, -1)
|
|
1114
1230
|
return live.filter((n) => n.startsWith(head)).sort()
|
|
1115
1231
|
})
|
|
1232
|
+
for (const c of fw.conditionals ?? [])
|
|
1233
|
+
if (opts.envValue?.(c.key) === c.value) names.push(...c.include)
|
|
1116
1234
|
inferredOf.set(m.name, vendor ? names.filter((n) => !n.startsWith(vendor)) : names)
|
|
1117
1235
|
} else if (opts.sourceNames !== undefined) {
|
|
1118
1236
|
// A written config cannot ask the run's environment, and with the
|
|
@@ -1125,11 +1243,15 @@ export async function mapTurboWorkspace(
|
|
|
1125
1243
|
m.name,
|
|
1126
1244
|
// The bare prefix is no name: formbricks' web spells `NEXT_PUBLIC_`
|
|
1127
1245
|
// in a `startsWith` test, and the configs keyed a variable of it.
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1246
|
+
[
|
|
1247
|
+
...fw.env.flatMap((e) => {
|
|
1248
|
+
if (!e.endsWith('*')) return [e]
|
|
1249
|
+
const head = e.slice(0, -1)
|
|
1250
|
+
return spelled.filter((n) => n.startsWith(head) && n.length > head.length)
|
|
1251
|
+
}),
|
|
1252
|
+
// Written for another environment: keyed whatever the condition.
|
|
1253
|
+
...(fw.conditionals ?? []).flatMap((c) => c.include),
|
|
1254
|
+
],
|
|
1133
1255
|
)
|
|
1134
1256
|
sourcedOf.set(fw, [...(sourcedOf.get(fw) ?? []), m.name])
|
|
1135
1257
|
} else usersOf.set(fw, [...(usersOf.get(fw) ?? []), m.name])
|
|
@@ -1323,7 +1445,8 @@ export async function mapTurboWorkspace(
|
|
|
1323
1445
|
tasks.push(mapped)
|
|
1324
1446
|
}
|
|
1325
1447
|
excludeSiblingOutputs(resolveSharedOutputs(tasks))
|
|
1326
|
-
|
|
1448
|
+
const tags = packageTags(meta === rootMeta ? rootCfg : files.get(meta.name))
|
|
1449
|
+
projects.push({ name: meta.name, dir: meta.dir, ...(tags.length > 0 ? { tags } : {}), tasks })
|
|
1327
1450
|
}
|
|
1328
1451
|
|
|
1329
1452
|
// Turbo 2.11's `dependencyOutputs` input hashes the named files after
|
|
@@ -1426,6 +1549,14 @@ export async function mapTurboWorkspace(
|
|
|
1426
1549
|
}
|
|
1427
1550
|
}
|
|
1428
1551
|
|
|
1552
|
+
if (keysRootEngines(rootPkg))
|
|
1553
|
+
for (const p of projects)
|
|
1554
|
+
for (const t of p.tasks) {
|
|
1555
|
+
const inputs = t.task?.['inputs'] as Record<string, unknown> | undefined
|
|
1556
|
+
if (inputs === undefined) continue
|
|
1557
|
+
const probes = (inputs['workspaceRuntime'] as unknown[] | undefined) ?? []
|
|
1558
|
+
inputs['workspaceRuntime'] = [...probes, ROOT_ENGINES_PROBE]
|
|
1559
|
+
}
|
|
1429
1560
|
nestedInputs(root, projects)
|
|
1430
1561
|
for (const [entry, n] of wildcardUses)
|
|
1431
1562
|
notes.push(
|
|
@@ -1446,9 +1577,42 @@ export async function mapTurboWorkspace(
|
|
|
1446
1577
|
pruneUnreachedPersistentNotes(projects, metas, opts.persistentTodo)
|
|
1447
1578
|
pruneOrphanPersistentNotes(projects, opts.persistentTodo)
|
|
1448
1579
|
refuseTaskNames(projects, 'task')
|
|
1580
|
+
sortOrderFree(projects)
|
|
1449
1581
|
return { projects, notes, globals }
|
|
1450
1582
|
}
|
|
1451
1583
|
|
|
1584
|
+
function byText(a: unknown, b: unknown): number {
|
|
1585
|
+
const x = typeof a === 'string' ? a : JSON.stringify(a)
|
|
1586
|
+
const y = typeof b === 'string' ? b : JSON.stringify(b)
|
|
1587
|
+
return x < y ? -1 : x > y ? 1 : 0
|
|
1588
|
+
}
|
|
1589
|
+
|
|
1590
|
+
function negationsLast(a: unknown, b: unknown): number {
|
|
1591
|
+
const neg = (g: unknown) => Number(typeof g === 'string' && g.startsWith('!'))
|
|
1592
|
+
return neg(a) - neg(b) || byText(a, b)
|
|
1593
|
+
}
|
|
1594
|
+
|
|
1595
|
+
/**
|
|
1596
|
+
* Turbo sorts a task's `outputs` and `dependsOn` before hashing, so
|
|
1597
|
+
* reordering them in turbo.json keeps the hit (vercel/turborepo#2064).
|
|
1598
|
+
* Core keys a config's lists as written, and gives neither list's order a
|
|
1599
|
+
* meaning: an edge set, and a `!` output takes back from the positives
|
|
1600
|
+
* wherever it stands (`outputMatcher`). Sorted here, negations last, so
|
|
1601
|
+
* the reorder keys the same and a written config reads the same.
|
|
1602
|
+
*/
|
|
1603
|
+
function sortOrderFree(projects: readonly TurboMappedProject[]): void {
|
|
1604
|
+
for (const p of projects)
|
|
1605
|
+
for (const t of p.tasks) {
|
|
1606
|
+
const task = t.task
|
|
1607
|
+
if (task === null) continue
|
|
1608
|
+
if (Array.isArray(task['dependsOn'])) task['dependsOn'] = [...task['dependsOn']].sort(byText)
|
|
1609
|
+
const outputs = (task['cache'] as { outputs?: Record<string, unknown> } | undefined)?.outputs
|
|
1610
|
+
if (outputs === undefined) continue
|
|
1611
|
+
for (const k of ['files', 'workspaceFiles'])
|
|
1612
|
+
if (Array.isArray(outputs[k])) outputs[k] = [...outputs[k]].sort(negationsLast)
|
|
1613
|
+
}
|
|
1614
|
+
}
|
|
1615
|
+
|
|
1452
1616
|
/**
|
|
1453
1617
|
* Turbo hashes a package's files as git lists them, nested workspace
|
|
1454
1618
|
* packages included (probed on 2.11.6: `a#build` keys `n/x.ts` of a
|
|
@@ -1800,6 +1964,12 @@ function buildTask(
|
|
|
1800
1964
|
todos.push(
|
|
1801
1965
|
`dependsOn ${JSON.stringify(d)}: no workspace package is named ${pkg} — edge dropped`,
|
|
1802
1966
|
)
|
|
1967
|
+
// Turbo runs a root task only when turbo.json names it `//#x`; the
|
|
1968
|
+
// script alone is not enough (vercel/turborepo#3592).
|
|
1969
|
+
else if (d.startsWith(`${ROOT}#`))
|
|
1970
|
+
todos.push(
|
|
1971
|
+
`dependsOn ${JSON.stringify(d)}: turbo.json defines no "${d}" task — edge dropped`,
|
|
1972
|
+
)
|
|
1803
1973
|
else
|
|
1804
1974
|
todos.push(
|
|
1805
1975
|
`dependsOn ${JSON.stringify(d)}: ${pkg} declares no ${task} script — edge dropped`,
|
|
@@ -1961,6 +2131,14 @@ function buildTask(
|
|
|
1961
2131
|
if (files.length > 0 && files.every((f) => typeof f === 'string' && f.startsWith('!'))) {
|
|
1962
2132
|
files.unshift('**/*')
|
|
1963
2133
|
}
|
|
2134
|
+
// Turbo keys the package's own package.json beside any explicit
|
|
2135
|
+
// `inputs` (PACKAGE_CONFIG_INPUTS, turborepo#1832): without it, an edit
|
|
2136
|
+
// to its `exports` or `type` replayed the old build. A glob naming it
|
|
2137
|
+
// already, or an exclusion of it, is left as written.
|
|
2138
|
+
const named = files.some(
|
|
2139
|
+
(g) => typeof g === 'string' && new Bun.Glob(g.replace(/^!/, '')).match('package.json'),
|
|
2140
|
+
)
|
|
2141
|
+
if (!named) files.push('package.json')
|
|
1964
2142
|
}
|
|
1965
2143
|
// Turbo 1's task `dotEnv`: package-relative `.env` files it hashes,
|
|
1966
2144
|
// gitignored as a rule, so read as files they keyed nothing (item 1032).
|
|
@@ -2072,8 +2250,10 @@ function buildTask(
|
|
|
2072
2250
|
const rewritesManifest = takeBacks.includes('!package.json')
|
|
2073
2251
|
|
|
2074
2252
|
if (atRoot) wsOutFiles.unshift(...outFiles.splice(0))
|
|
2075
|
-
|
|
2076
|
-
|
|
2253
|
+
// Sorted here, before the passes that take them back from other tasks'
|
|
2254
|
+
// inputs, so those `!` entries come in this order too (`sortOrderFree`).
|
|
2255
|
+
const outputs: Record<string, unknown> = { files: takingBack(outFiles).sort(negationsLast) }
|
|
2256
|
+
const ws = takingBack(wsOutFiles).sort(negationsLast)
|
|
2077
2257
|
if (ws.length > 0) outputs.workspaceFiles = ws
|
|
2078
2258
|
task.cache = {
|
|
2079
2259
|
outputs,
|
package/src/turbo/turbo-reads.ts
CHANGED
|
@@ -37,6 +37,8 @@ export const FRAMEWORK_ENV: ReadonlyArray<{
|
|
|
37
37
|
env: readonly string[]
|
|
38
38
|
all: boolean
|
|
39
39
|
dependencies: readonly string[]
|
|
40
|
+
/** Turbo's `envConditionals`: `include` is keyed while `key` holds `value`. */
|
|
41
|
+
conditionals?: ReadonlyArray<{ key: string; value: string; include: readonly string[] }>
|
|
40
42
|
}> = [
|
|
41
43
|
{ slug: 'astro', env: ['PUBLIC_*'], all: true, dependencies: ['astro'] },
|
|
42
44
|
{ slug: 'blitzjs', env: ['NEXT_PUBLIC_*'], all: true, dependencies: ['blitz'] },
|
|
@@ -53,6 +55,10 @@ export const FRAMEWORK_ENV: ReadonlyArray<{
|
|
|
53
55
|
env: ['NEXT_PUBLIC_*', 'NEXT_DEPLOYMENT_ID'],
|
|
54
56
|
all: true,
|
|
55
57
|
dependencies: ['next'],
|
|
58
|
+
// Skew protection builds the deployment id into the app (vercel/turborepo#9853).
|
|
59
|
+
conditionals: [
|
|
60
|
+
{ key: 'VERCEL_SKEW_PROTECTION_ENABLED', value: '1', include: ['VERCEL_DEPLOYMENT_ID'] },
|
|
61
|
+
],
|
|
56
62
|
},
|
|
57
63
|
{
|
|
58
64
|
slug: 'nitro',
|
|
@@ -85,20 +91,105 @@ export const FRAMEWORK_ENV: ReadonlyArray<{
|
|
|
85
91
|
},
|
|
86
92
|
]
|
|
87
93
|
|
|
94
|
+
/** The variables each framework conditional reads; a live mapping is keyed on their values. */
|
|
95
|
+
export const ENV_CONDITION_KEYS: readonly string[] = FRAMEWORK_ENV.flatMap((f) =>
|
|
96
|
+
(f.conditionals ?? []).map((c) => c.key),
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
// Turbo's own env, from crates/turborepo-env (2026-10-27, edace4621): the
|
|
100
|
+
// promise is a Turbo repo run unchanged, so these mirror it exactly.
|
|
101
|
+
/** `DEFAULT_ENV_VARS`: hashed into every task as if listed in `globalEnv` (a `!` there takes them back). */
|
|
102
|
+
export const TURBO_DEFAULT_ENV: readonly string[] = ['VERCEL_ANALYTICS_ID', 'VERCEL_TARGET_ENV']
|
|
103
|
+
/** `BUILTIN_PASS_THROUGH_ENV`: passed to every task, never hashed. */
|
|
104
|
+
export const TURBO_BUILTIN_PASS_THROUGH: readonly string[] = [
|
|
105
|
+
'HOME',
|
|
106
|
+
'USER',
|
|
107
|
+
'TZ',
|
|
108
|
+
'LANG',
|
|
109
|
+
'SHELL',
|
|
110
|
+
'PWD',
|
|
111
|
+
'XDG_DATA_*',
|
|
112
|
+
'XDG_RUNTIME_DIR',
|
|
113
|
+
'XAUTHORITY',
|
|
114
|
+
'DBUS_SESSION_BUS_ADDRESS',
|
|
115
|
+
'CI',
|
|
116
|
+
'NODE_OPTIONS',
|
|
117
|
+
'COREPACK_*',
|
|
118
|
+
'LD_LIBRARY_PATH',
|
|
119
|
+
'DYLD_FALLBACK_LIBRARY_PATH',
|
|
120
|
+
'LIBPATH',
|
|
121
|
+
'LD_PRELOAD',
|
|
122
|
+
'DYLD_INSERT_LIBRARIES',
|
|
123
|
+
'COLORTERM',
|
|
124
|
+
'FORCE_COLOR',
|
|
125
|
+
'NO_COLOR',
|
|
126
|
+
'TERM',
|
|
127
|
+
'TERM_PROGRAM',
|
|
128
|
+
'DISPLAY',
|
|
129
|
+
'TMP',
|
|
130
|
+
'TEMP',
|
|
131
|
+
'WINDIR',
|
|
132
|
+
'ProgramFiles',
|
|
133
|
+
'ProgramFiles(x86)',
|
|
134
|
+
'VSCODE_*',
|
|
135
|
+
'ELECTRON_RUN_AS_NODE',
|
|
136
|
+
'DOCKER_*',
|
|
137
|
+
'BUILDKIT_*',
|
|
138
|
+
'COMPOSE_*',
|
|
139
|
+
'JB_IDE_*',
|
|
140
|
+
'JB_INTERPRETER',
|
|
141
|
+
'_JETBRAINS_TEST_RUNNER_RUN_SCOPE_TYPE',
|
|
142
|
+
'TURBO_*',
|
|
143
|
+
'VERCEL',
|
|
144
|
+
'VERCEL_*',
|
|
145
|
+
'NEXT_*',
|
|
146
|
+
'USE_OUTPUT_FOR_EDGE_FUNCTIONS',
|
|
147
|
+
'NOW_BUILDER',
|
|
148
|
+
'VC_MICROFRONTENDS_CONFIG_FILE_NAME',
|
|
149
|
+
'GITHUB_*',
|
|
150
|
+
'RUNNER_*',
|
|
151
|
+
'__NIXOS_*',
|
|
152
|
+
'NIX_*',
|
|
153
|
+
'APPDATA',
|
|
154
|
+
'LOCALAPPDATA',
|
|
155
|
+
'PATH',
|
|
156
|
+
'PROGRAMDATA',
|
|
157
|
+
'SYSTEMROOT',
|
|
158
|
+
'SYSTEMDRIVE',
|
|
159
|
+
'USERPROFILE',
|
|
160
|
+
'HOMEDRIVE',
|
|
161
|
+
'HOMEPATH',
|
|
162
|
+
'PNPM_HOME',
|
|
163
|
+
'pnpm_config_verify_deps_before_run',
|
|
164
|
+
'NPM_CONFIG_STORE_DIR',
|
|
165
|
+
'COMSPEC',
|
|
166
|
+
'PATHEXT',
|
|
167
|
+
]
|
|
168
|
+
|
|
88
169
|
/** A name core takes in `inputs.env` and `exec.env.passThrough`. */
|
|
89
170
|
export const keyable = (name: string): boolean =>
|
|
90
171
|
name.length > 0 && !name.startsWith('!') && !/[*?[\]{}=\0]/.test(name)
|
|
91
172
|
|
|
92
173
|
/**
|
|
93
174
|
* The names of `names` a live mapping can read differently: each a `*` in
|
|
94
|
-
* one of `configs` (turbo.json texts, any string, a superset)
|
|
95
|
-
* framework table
|
|
175
|
+
* one of `configs` (turbo.json texts, any string, a superset), in Turbo's
|
|
176
|
+
* framework table or in its builtin env matches, each of those tables'
|
|
177
|
+
* literals, and each core cannot key (a literal one is
|
|
96
178
|
* dropped only when no variable has it). The kept mapping keyed on every
|
|
97
179
|
* name, so any variable that came or went (a CI step's, `VX_DEBUG_TIMING`)
|
|
98
180
|
* remapped the workspace: ~330 ms a run on vercel/ai.
|
|
99
181
|
*/
|
|
100
182
|
export function envNamesThatMap(configs: readonly string[], names: readonly string[]): string[] {
|
|
101
|
-
const patterns = new Set(
|
|
183
|
+
const patterns = new Set(
|
|
184
|
+
[...FRAMEWORK_ENV.flatMap((f) => f.env), ...TURBO_BUILTIN_PASS_THROUGH].filter((e) =>
|
|
185
|
+
e.includes('*'),
|
|
186
|
+
),
|
|
187
|
+
)
|
|
188
|
+
// A builtin or conditional literal is passed or keyed only when set.
|
|
189
|
+
const literals = new Set([
|
|
190
|
+
...TURBO_BUILTIN_PASS_THROUGH.filter((e) => !e.includes('*')),
|
|
191
|
+
...FRAMEWORK_ENV.flatMap((f) => (f.conditionals ?? []).flatMap((c) => c.include)),
|
|
192
|
+
])
|
|
102
193
|
for (const text of configs)
|
|
103
194
|
for (const m of text.matchAll(/"((?:[^"\\]|\\.)*)"/g))
|
|
104
195
|
if (m[1]!.includes('*')) patterns.add(m[1]!)
|
|
@@ -110,7 +201,7 @@ export function envNamesThatMap(configs: readonly string[], names: readonly stri
|
|
|
110
201
|
`^${p.replaceAll('\\', '').replace(/^!/, '').split('*').map(RegExp.escape).join('.*')}$`,
|
|
111
202
|
),
|
|
112
203
|
)
|
|
113
|
-
return names.filter((n) => !keyable(n) || res.some((re) => re.test(n)))
|
|
204
|
+
return names.filter((n) => !keyable(n) || literals.has(n) || res.some((re) => re.test(n)))
|
|
114
205
|
}
|
|
115
206
|
|
|
116
207
|
/**
|
package/src/turbo-cache/index.ts
CHANGED
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
type RemoteOpenContext,
|
|
24
24
|
type VxPlugin,
|
|
25
25
|
refuseUnknownOptions,
|
|
26
|
+
ui,
|
|
26
27
|
UserError,
|
|
27
28
|
type PluginOptionKinds,
|
|
28
29
|
} from '@vzn/vx'
|
|
@@ -265,7 +266,7 @@ export function resolveTurboCacheConfig(
|
|
|
265
266
|
file: TurboJsonRemoteCache = {},
|
|
266
267
|
/** The repo's `.turbo/config.json` (`turbo link` writes it): below the environment, above turbo.json. */
|
|
267
268
|
local: TurboLocalConfig = {},
|
|
268
|
-
warn: (message: string) => void = (m) =>
|
|
269
|
+
warn: (message: string) => void = (m) => ui.say('warn', `[vx] plugin '@vzn/vx-migrate': ${m}`),
|
|
269
270
|
): TurboCacheConfig | undefined {
|
|
270
271
|
// `enabled: false` turns Turbo's remote cache off whatever the env says;
|
|
271
272
|
// options name a cache for vx alone, and win.
|
|
@@ -301,10 +302,20 @@ export function resolveTurboCacheConfig(
|
|
|
301
302
|
fromFile(file.teamId)
|
|
302
303
|
const teamSlug =
|
|
303
304
|
options.teamSlug ?? set('TURBO_TEAM') ?? fromFile(local.teamSlug) ?? fromFile(file.teamSlug)
|
|
304
|
-
// Turbo
|
|
305
|
-
//
|
|
306
|
-
//
|
|
307
|
-
|
|
305
|
+
// Turbo uses its remote only when linked, a team set (`is_linked`): a
|
|
306
|
+
// token without one ran remote here, team-less, where `turbo` runs local.
|
|
307
|
+
// An option naming the cache is vx's own, and stands.
|
|
308
|
+
const named =
|
|
309
|
+
options.url !== undefined || options.token !== undefined || options.oidc !== undefined
|
|
310
|
+
if (!named && teamId === undefined && teamSlug === undefined) {
|
|
311
|
+
if (token !== undefined)
|
|
312
|
+
warn(
|
|
313
|
+
'a Turbo token is set but no team (TURBO_TEAM, TURBO_TEAMID or .turbo/config.json): remote cache off for this run, as for turbo',
|
|
314
|
+
)
|
|
315
|
+
return undefined
|
|
316
|
+
}
|
|
317
|
+
// Turbo's own default when no URL is set: Vercel's hosted Remote Cache.
|
|
318
|
+
// A team without a token asks the OS keychain at `open`.
|
|
308
319
|
// Each source with its name: TURBO_API was refused as a `url`
|
|
309
320
|
// option the workspace never set.
|
|
310
321
|
const [rawUrl, urlFrom] =
|
|
@@ -899,16 +910,31 @@ export class TurboRemoteCache implements RemoteCacheLayer {
|
|
|
899
910
|
/**
|
|
900
911
|
* The repo's `.turbo/config.json`, by Turbo's field names and their
|
|
901
912
|
* aliases (`apiurl`, `ApiUrl`, `APIURL`, …); `{}` when absent or unreadable.
|
|
913
|
+
* One that does not parse is named: Turbo refuses it, and read as absent
|
|
914
|
+
* in silence a linked repo lost its remote with no word.
|
|
902
915
|
*/
|
|
903
|
-
function localConfigOf(root: string): TurboLocalConfig {
|
|
904
|
-
let
|
|
916
|
+
function localConfigOf(root: string, warn: (message: string) => void): TurboLocalConfig {
|
|
917
|
+
let text: string
|
|
905
918
|
try {
|
|
906
|
-
|
|
919
|
+
text = readFileSync(path.join(root, '.turbo', 'config.json'), 'utf8')
|
|
907
920
|
// quiet: a local config file: none read is none linked
|
|
908
921
|
} catch {
|
|
909
922
|
return {}
|
|
910
923
|
}
|
|
911
|
-
|
|
924
|
+
let raw: unknown
|
|
925
|
+
try {
|
|
926
|
+
raw = JSON.parse(text)
|
|
927
|
+
} catch (err) {
|
|
928
|
+
warn(
|
|
929
|
+
`.turbo/config.json does not parse as a JSON object (${(err as Error).message}): read as absent`,
|
|
930
|
+
)
|
|
931
|
+
return {}
|
|
932
|
+
}
|
|
933
|
+
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
|
|
934
|
+
const kind = Array.isArray(raw) ? 'an array' : raw === null ? 'null' : `a ${typeof raw}`
|
|
935
|
+
warn(`.turbo/config.json does not parse as a JSON object (${kind}): read as absent`)
|
|
936
|
+
return {}
|
|
937
|
+
}
|
|
912
938
|
const byLower = new Map(Object.entries(raw).map(([k, v]) => [k.toLowerCase(), v]))
|
|
913
939
|
return {
|
|
914
940
|
apiUrl: byLower.get('apiurl'),
|
|
@@ -1004,12 +1030,13 @@ export function turboCache(options: TurboCacheOptions = {}): VxPlugin {
|
|
|
1004
1030
|
let opened: { layer: TurboRemoteCache; warn: (m: string) => void } | undefined
|
|
1005
1031
|
return definePlugin(import.meta, {
|
|
1006
1032
|
cache(ctx): RemoteCacheLayer | undefined {
|
|
1033
|
+
const warn = (m: string) => ctx.warn(`[vx] plugin '@vzn/vx-migrate': ${m}`)
|
|
1007
1034
|
const config = resolveTurboCacheConfig(
|
|
1008
1035
|
options,
|
|
1009
1036
|
Bun.env,
|
|
1010
1037
|
remoteCacheOf(ctx.workspaceRoot),
|
|
1011
|
-
localConfigOf(ctx.workspaceRoot),
|
|
1012
|
-
|
|
1038
|
+
localConfigOf(ctx.workspaceRoot, warn),
|
|
1039
|
+
warn,
|
|
1013
1040
|
)
|
|
1014
1041
|
if (config === undefined) return undefined
|
|
1015
1042
|
// A signed download waits in a temp until its tag checks: in vx's
|