@vzn/vx-migrate 0.0.649 → 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 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 task's `tags` (Turbo main, after 2.11.5) map to nothing: Turbo keeps them out of the hash and the run.
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
- - `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`.
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. A token with no URL means Vercel's hosted Remote Cache (`https://vercel.com/api`), exactly as it does 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`), then the root `turbo.json`'s `remoteCache` (`apiUrl`, `teamId`, `teamSlug`), whose `enabled: false` declines unless the options name a cache. A team with no URL also means Vercel's. With a URL or a team but no token, the token comes from the OS keychain ([Tokens](#tokens-and-the-os-keychain)); with none of the three the plugin **declines** and the run stays local.
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`). A CI that keeps untrusted pull requests off the shared cache sets `cache.policy` or `--cache-policy=local:rw,remote:r` for them.
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.649",
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.649"
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 live = process.stdout.isTTY === true
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
- if (live) process.stdout.write('\r\x1b[2K\x1b[1A')
305
+ clear()
304
306
  if (code !== 0) {
305
- process.stderr.write(said)
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
- process.stdout.write(
42
- `${JSON.stringify({ ok: false, error: { code, message: `vx-migrate: ${message}`, ...docs } })}\n`,
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
@@ -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 `.env` probes, by name: Turbo hashes a task's `.env` files although
250
- * git ignores them, and a glob over git's files sees none, so the mapper
251
- * keys them through a shell line that prints each file. Inline in every
252
- * package's config, that line read as noise nobody could review.
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) process.stderr.write(err)
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
- process.stdout.write(
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) process.stderr.write(lock.said)
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 = pattern.indexOf('{'); open !== -1; open = pattern.indexOf('{', open + 1)) {
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 === '{') depth++
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
  }
@@ -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
- return new Error(`${what}HTTP ${res.status}${said === '' ? '' : `: ${said.slice(0, 200)}`}`)
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
  }
@@ -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
+ }
@@ -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
- // Labels: Turbo keeps them out of the hash and the behaviour.
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) list((c['global'] as Record<string, unknown>)[k], `global.${k}`)
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 turboConfigFile(root)
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, rootMeta?.packageJson ?? (await rootPackageJson(root)), metas)),
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
- ...envNames('globalEnv', rootCfg.globalEnv ?? []),
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: envNames('globalPassThroughEnv', rootCfg.globalPassThroughEnv ?? []),
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 turboConfigFile(meta.dir)
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
- fw.env.flatMap((e) => {
1129
- if (!e.endsWith('*')) return [e]
1130
- const head = e.slice(0, -1)
1131
- return spelled.filter((n) => n.startsWith(head) && n.length > head.length)
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
- projects.push({ name: meta.name, dir: meta.dir, tasks })
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
- const outputs: Record<string, unknown> = { files: takingBack(outFiles) }
2076
- const ws = takingBack(wsOutFiles)
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,
@@ -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) or in Turbo's
95
- * framework table matches, and each core cannot key (a literal one is
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(FRAMEWORK_ENV.flatMap((f) => f.env).filter((e) => e.includes('*')))
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
  /**
@@ -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) => console.error(`[vx] plugin '@vzn/vx-migrate': ${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's own default when a token or a team is set and no URL is:
305
- // Vercel's hosted Remote Cache. A token alone is a configured cache, as it
306
- // is for `turbo`; a URL or a team without one asks the OS keychain at
307
- // `open`; none of them is the declined, local run.
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 raw: unknown
916
+ function localConfigOf(root: string, warn: (message: string) => void): TurboLocalConfig {
917
+ let text: string
905
918
  try {
906
- raw = JSON.parse(readFileSync(path.join(root, '.turbo', 'config.json'), 'utf8'))
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
- if (typeof raw !== 'object' || raw === null) return {}
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
- (m) => ctx.warn(`[vx] plugin '@vzn/vx-migrate': ${m}`),
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