@vzn/vx-migrate 0.0.645 → 0.0.646
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 +12 -15
- package/package.json +2 -2
- package/src/adoption-plugin.ts +9 -11
- package/src/index.ts +36 -10
- package/src/mapping-cache.ts +14 -6
- package/src/migrate-turbo.ts +2 -6
- package/src/migrate.ts +5 -14
- package/src/nx/nx-map.ts +30 -15
- package/src/nx/nx-upstream.ts +5 -5
- package/src/shared-outputs.ts +16 -10
- package/src/tracked-outputs.ts +10 -6
- package/src/turbo/index.ts +16 -10
- package/src/turbo/turbo-map.ts +8 -177
- package/src/turbo/turbo-reads.ts +183 -0
- package/src/turbo-cache/index.ts +56 -13
- package/src/turbo-cache/oidc.ts +31 -16
package/README.md
CHANGED
|
@@ -10,10 +10,10 @@ Everything for adopting [`@vzn/vx`](https://vznjs.github.io/vx/) from Turborepo,
|
|
|
10
10
|
- **`turboCache()`** and **`nxCache()`** — keep the remote cache you have: any server speaking Turbo's `/v8/artifacts` API (Vercel's hosted cache included) or Nx's self-hosted `/v1/cache` spec.
|
|
11
11
|
|
|
12
12
|
```sh
|
|
13
|
-
|
|
13
|
+
bun add -d @vzn/vx @vzn/vx-migrate
|
|
14
14
|
```
|
|
15
15
|
|
|
16
|
-
In a Turbo or Nx repo, `
|
|
16
|
+
In a Turbo or Nx repo, `bunx @vzn/vx init --keep` runs this package, which
|
|
17
17
|
writes the `.vx/workspace.ts` that declares `turbo()` or `nx()`, installs
|
|
18
18
|
what it imports and writes the lock. Where the repo shows a remote cache it
|
|
19
19
|
declares `turboCache()` or `nxCache()` too; by hand it reads:
|
|
@@ -66,8 +66,8 @@ Rules:
|
|
|
66
66
|
- `env` / `passThroughEnv`: explicit names go to `inputs.env` (env only) and `exec.env.passThrough` (both, plus both globals); a wildcard expands over the run's environment under `turbo()`, which maps where the tasks run (a new variable name such a wildcard matches maps afresh); the CLI, which writes for another environment, lists the names the task's package and its workspace dependencies spell (a root task: the whole repo; clerk's `E2E_*`), noted once per entry, and one nothing spells is a todo; `*` is the only wildcard, as in Turbo, so `\*`, a leading `\!`, `?` and `[` are literal: unkey's `NEXT_PUBLIC_\*` names one variable, which vx cannot key, so it is dropped and reported once for the workspace with the count of tasks naming it (openstatus's root `build` env `\*` had written one todo into each of 45 configs; `turbo()`: only when the run's environment sets it, as Turbo hashes nothing otherwise); and a `!` entry (openstatus' `!NEXT_PUBLIC_VERCEL_URL`) removes the names it matches from its list, with no todo, since vx matches no name it is not given. A name both a global list and the task's own list carry is listed once. A name that is no shell variable name (`p-q`) is keyed but left out of `exec.env.passThrough`, as `sh` drops it before the task runs.
|
|
67
67
|
- Framework inference (Turbo hashes and passes Next's `NEXT_PUBLIC_*`, Vite's `VITE_*`, … for a package that depends on the framework): `turbo()` expands the prefix over the run's environment; the CLI writes the names the tracked source and `.env.example` of the package and its workspace dependencies spell (the bare prefix, as a `startsWith` test spells it, is none) (Next bundles theirs: cal.com's web, 23 alone, 58 with them), and a note asks for any only an installed dependency reads.
|
|
68
68
|
|
|
69
|
-
- **Two tasks of one package on one output path** (strapi's `build`, `build:code` and `build:types`, all on `dist/**`): vx cleans a task's outputs before it runs and before a restore, so the loader refuses two cached tasks whose outputs provably overlap. Under vx's default `rules.exclusiveOutputs` an edge between the two changes nothing (twenty's `build:individual` writing into `build`'s `dist`). The mapping resolves it before the file is written — `build` keeps its cache, else the task with a `^` edge, else the first declared (typescript-eslint's `typecheck` has `^typecheck` and writes declarations into `build`'s `dist`: keeping it left `build` uncached); the rest run uncached with a todo naming the keeper and the fix, their own output path. A shared tsc incremental file (create-t3-turbo's `build` and `typecheck` both on `.cache/tsbuildinfo.json`) stays with the keeper alone, and the other task keeps its cache when it emits nothing else (a typecheck); one that emits more runs uncached, as `tsc --incremental` skips files the keeper's tsbuildinfo calls built.
|
|
70
|
-
- **A task whose inputs read another task's outputs** (a `test` keyed on `**/*` beside a `build` writing `dist/**`): `vx lock` refuses it, since such a key cannot be known before the producer ran. Each overlapping output is taken back from the reader's inputs with `!` (`['**/*', '!dist/**']`), in package and workspace inputs alike, and a workspace output another package writes into this one is taken back from its package inputs too; Turbo and Nx hash what git tracks, and a build's outputs are not, so the key reads what the adopted tool read. A literal input another task writes cannot be taken back: that task runs uncached with a todo. A committed file under such an output is still a source: a reader the `!` would hide it from runs uncached with a todo. A keyed group (an `
|
|
69
|
+
- **Two tasks of one package on one output path** (strapi's `build`, `build:code` and `build:types`, all on `dist/**`): vx cleans a task's outputs before it runs and before a restore, so the loader refuses two cached tasks whose outputs provably overlap. Under vx's default `rules.exclusiveOutputs` an edge between the two changes nothing (twenty's `build:individual` writing into `build`'s `dist`). The mapping resolves it before the file is written — `build` keeps its cache, else the task with a `^` edge, else the first declared (typescript-eslint's `typecheck` has `^typecheck` and writes declarations into `build`'s `dist`: keeping it left `build` uncached); the rest run uncached with a todo naming the keeper and the fix, their own output path. A shared tsc incremental file (create-t3-turbo's `build` and `typecheck` both on `.cache/tsbuildinfo.json`) stays with the keeper alone, and the other task keeps its cache when it emits nothing else (a typecheck); one that emits more runs uncached, as `tsc --incremental` skips files the keeper's tsbuildinfo calls built. When either side is a directory at any depth (solid's build `**/dist`, its types `**/types`), the two meet only where one tree nests in the other: the keeper takes the sibling's glob back (`!coverage/**`, `!**/types`) and both keep their cache, as under Turbo. Same rule for Nx targets.
|
|
70
|
+
- **A task whose inputs read another task's outputs** (a `test` keyed on `**/*` beside a `build` writing `dist/**`): `vx lock` refuses it, since such a key cannot be known before the producer ran. Each overlapping output is taken back from the reader's inputs with `!` (`['**/*', '!dist/**']`), in package and workspace inputs alike, and a workspace output another package writes into this one is taken back from its package inputs too; Turbo and Nx hash what git tracks, and a build's outputs are not, so the key reads what the adopted tool read. A literal input another task writes cannot be taken back: that task runs uncached with a todo. A committed file under such an output is still a source: a reader the `!` would hide it from runs uncached with a todo. A keyed group (an `inputs:<name>` twin, a transit node) runs nothing, so core lets it read what a task writes: it gets no `!` for a literal, the `!` that would hide a committed file goes, and it keeps its key (typescript-eslint's ast-spec commits the `*.shot` snapshots its `test` outputs, and its twin was written as `{}`, which core refuses). An output that names one committed file (eslint's `eslint-suppressions.json` in a `lint` task's outputs) is dropped, and so is the `!` each reader got for it: the file stays in every key, as Turbo and Nx hash it, and the readers stay cached. Same for Nx targets.
|
|
71
71
|
- **Two packages' tasks on one workspace output** (cal.com's shared `post-install` writes `../../node_modules/@prisma/client/**` from every package with the script): core refuses two cached tasks on one path with no edge between them, so `turbo()` (and `nx()`) keeps the first (in package and task order) cached and runs the rest uncached, each with a todo naming the keeper. A package's own outputs count at their workspace path: typescript-eslint's root project caches `dist` and each package's typecheck `dist/packages/<name>`, one path twice. Edges across packages are not read, so an ordered pair loses its cache too.
|
|
72
72
|
- `cache: false` or `persistent: true` → no `cache` block, the task still keyed on its `inputs` and `env` as Turbo hashes it; a persistent task gets `exec.persistent: {}` and, when some task depends on it, the consumer's `persistentTodo`.
|
|
73
73
|
- A task's `description` (Turbo 2.11.5's schema) is the vx task's `description`.
|
|
@@ -91,7 +91,7 @@ import { nx } from '@vzn/vx-migrate'
|
|
|
91
91
|
export default defineWorkspace({ plugins: [nx()] })
|
|
92
92
|
```
|
|
93
93
|
|
|
94
|
-
Until `bunx @vzn/vx-migrate` writes native config, `vx run build --all` maps every project's `build` target the way `nx run-many -t build` would. `nx()` is also the repo's edge source: each project's dependencies on Nx's graph (package deps, `implicitDependencies`, imports) are its `deps`, so `^build` follows Nx's order with no lockfile plugin. The plugin reads Nx's **resolved** project graph — where Nx has already applied `targetDefaults`, expanded `namedInputs`, inferred targets through its plugins and interpolated `{projectRoot}` and friends — so nothing is re-derived here. Per target: `nx:run-commands` and a plain `command` run as one shell line that does what Nx's run-commands does (below); `nx:run-script` is the package script, with the `$npm_package_name`, `$npm_package_version` and `$npm_lifecycle_event` it reads defined as `<pm> run` sets them (a migration reads the name and version from the manifest it imports, so a bump reaches them; any other `$npm_*` is a todo); `nx:noop` is a group task, and so is a target with neither an executor nor a command that has dependencies, as Nx normalizes it (one with none is dropped, as Nx drops it); **every other executor runs through `nx-exec`** with the executor and its options on the command line. Every target is keyed on its `inputs` (a target Nx does not cache on its own inputs alone, no `^` named inputs, since Nx folds no dependency's hash into it), and a target Nx caches (`cache: true`, or the legacy `cacheableOperations` list in `nx.json`) gets its `outputs` as the cache block, with no `inputs` meaning Nx's `default` and `^default`, named inputs resolved per project (nx.json's under the project's own; one neither defines, such as an `extends` preset not installed, keys the whole project with a todo, never an empty list that keys the task on its config alone), `{workspaceRoot}` / `{projectRoot}` / `{projectName}` interpolated anywhere in a path as Nx does (`{workspaceRoot}/coverage/{projectRoot}`), Nx's glob grammar translated (`*.[jt]s` is `*.{j,t}s`; the default `production` negation `?(*.)+(spec|test).[jt]s?(x)` becomes brace sets, a narrowing only a negation may take; what has no safe form is a todo), a project fileset of negations alone (nx-recipes' `noMarkdown`) starting from every project file, as Nx reads it, and an output outside the project dir (`dist/<project>` at the workspace root, Nx's default layout) a `workspaceFiles` output, an output outside the workspace (an old generator's `reportsDirectory: "../../coverage/<lib>"`) a todo and dropped, as vx caches only inside it, an output whose first segment is a wildcard (`{projectRoot}/**/*.d.ts`) cached, with the committed files it reaches kept (core never cleans one) and the rest cleaned before the run, and an output that covers the project's own `package.json` or vx.config cached with the file taken back by `!`; `dependsOn` becomes the edges (`^build` — dropped when no project has the target, as Nx gives it no edges and core refuses a `^name` nothing declares — `build`, `project:target` → `project#target`, everything past `project:` one target name, as Nx joins it (`ui:build:esm` is ui's `build:esm`; `ui:build:ci` names target `build:ci`, never `build`'s `ci` configuration, and is no edge where ui lacks it), this project's own target winning over a project name, `{ target, projects }` → each matched project's task, `projects` read as Nx's `findMatchingProjects` reads it: a name or a list of names, globs over names and over project directories (`libs/*`), `name:` / `tag:` / `directory:` labels, a bare word as a word in a name, and `!` exclusions; an edge to a target its project lacks is dropped without a word, as Nx drops it — `targetDefaults` that give every `typecheck` a `codegen` one project has; `^name` is every dependency's `name` whatever it holds, `^rsbuild:typecheck` included; a target glob — `test:e2e--*`, `^build-*`, `ui:build-{esm,cjs}`, a `{ target }` object's — expands over every target name in the workspace, as Nx 19.5+ does, before those rules; a same-project glob keeps its own project's matches), `{ env }` inputs pass through (one that is no shell variable name is keyed only), `{ runtime }` inputs run at the workspace root as Nx runs them (`inputs.workspaceRuntime`), with `{workspaceRoot}`, `{projectRoot}` and `{projectName}` written in as the paths and name they stand for (Nx runs the command as written, so `cat {workspaceRoot}/v.txt` keyed on cat's error there), and a command that exits non-zero keys its error and exit code and the task runs on, as Nx hashes its output whatever it exits (`git describe` in a repo with no tags): the written probe is `(<command>) || echo "exit $?"`, a `{ fileset, includeIgnored: true }` literal (Nx 23 hashes it from disk, gitignored or missing) is read by such a probe, since vx's globs see only what git lists, and a glob of one is a todo, a target that says `continuous` — or, in a graph from an Nx older than that field, runs a server executor (`@nx/vite:dev-server`, `@nx/next:server`, …) — is a persistent task and never cached, ready on spawn as Nx has it (Nx starts the dependents once it has started; a run-commands `readyWhen` gates them, below). The target's name never decides: a cached `dev` caches like any other target (nx#32610). `parallelism: false` (Nx runs the target alone) has no vx form and is a todo naming `--concurrency 1`; `syncGenerators` (Nx runs them first) is one workspace note per generator list, counting its tasks and naming `nx sync`; nx.json's `sync.globalGenerators` is one note per run naming `nx sync`. A target with `configurations` is one task per configuration (one whose name another target holds, `vite`'s `build` beside an inferred `vite:build`, is not written, with a todo, as Nx resolves `a:vite:build` to the target): `build` carries the default configuration's options, `build:ci` the `ci` one, and an edge of `build:ci` (own, named or `^`) reaches each target's `ci` task where it declares one, else its default, as Nx's `resolveConfiguration` does; a `^build` there becomes one edge per dependency Nx links. A `^name` input (`^production`, `{ input, dependencies: true }`, the pre-17 `{ input, projects: "dependencies" }`; `projects: "self"` is the own input, as Nx 23 still reads both) is what Nx hashes: each dependency's `name`, over the project graph, whether or not a task edge exists. A `{ input, projects }` list is read as `dependsOn`'s `projects` is. Each project some reader reaches gets an `
|
|
94
|
+
Until `bunx @vzn/vx-migrate` writes native config, `vx run build --all` maps every project's `build` target the way `nx run-many -t build` would. `nx()` is also the repo's edge source: each project's dependencies on Nx's graph (package deps, `implicitDependencies`, imports) are its `deps`, so `^build` follows Nx's order with no lockfile plugin. The plugin reads Nx's **resolved** project graph — where Nx has already applied `targetDefaults`, expanded `namedInputs`, inferred targets through its plugins and interpolated `{projectRoot}` and friends — so nothing is re-derived here. Per target: `nx:run-commands` and a plain `command` run as one shell line that does what Nx's run-commands does (below); `nx:run-script` is the package script, with the `$npm_package_name`, `$npm_package_version` and `$npm_lifecycle_event` it reads defined as `<pm> run` sets them (a migration reads the name and version from the manifest it imports, so a bump reaches them; any other `$npm_*` is a todo); `nx:noop` is a group task, and so is a target with neither an executor nor a command that has dependencies, as Nx normalizes it (one with none is dropped, as Nx drops it); **every other executor runs through `nx-exec`** with the executor and its options on the command line. Every target is keyed on its `inputs` (a target Nx does not cache on its own inputs alone, no `^` named inputs, since Nx folds no dependency's hash into it), and a target Nx caches (`cache: true`, or the legacy `cacheableOperations` list in `nx.json`) gets its `outputs` as the cache block, with no `inputs` meaning Nx's `default` and `^default`, named inputs resolved per project (nx.json's under the project's own; one neither defines, such as an `extends` preset not installed, keys the whole project with a todo, never an empty list that keys the task on its config alone), `{workspaceRoot}` / `{projectRoot}` / `{projectName}` interpolated anywhere in a path as Nx does (`{workspaceRoot}/coverage/{projectRoot}`), Nx's glob grammar translated (`*.[jt]s` is `*.{j,t}s`; the default `production` negation `?(*.)+(spec|test).[jt]s?(x)` becomes brace sets, a narrowing only a negation may take; what has no safe form is a todo), a project fileset of negations alone (nx-recipes' `noMarkdown`) starting from every project file, as Nx reads it, and an output outside the project dir (`dist/<project>` at the workspace root, Nx's default layout) a `workspaceFiles` output, an output outside the workspace (an old generator's `reportsDirectory: "../../coverage/<lib>"`) a todo and dropped, as vx caches only inside it, an output whose first segment is a wildcard (`{projectRoot}/**/*.d.ts`) cached, with the committed files it reaches kept (core never cleans one) and the rest cleaned before the run, and an output that covers the project's own `package.json` or vx.config cached with the file taken back by `!`; `dependsOn` becomes the edges (`^build` — dropped when no project has the target, as Nx gives it no edges and core refuses a `^name` nothing declares — `build`, `project:target` → `project#target`, everything past `project:` one target name, as Nx joins it (`ui:build:esm` is ui's `build:esm`; `ui:build:ci` names target `build:ci`, never `build`'s `ci` configuration, and is no edge where ui lacks it), this project's own target winning over a project name, `{ target, projects }` → each matched project's task, `projects` read as Nx's `findMatchingProjects` reads it: a name or a list of names, globs over names and over project directories (`libs/*`), `name:` / `tag:` / `directory:` labels, a bare word as a word in a name, and `!` exclusions; an edge to a target its project lacks is dropped without a word, as Nx drops it — `targetDefaults` that give every `typecheck` a `codegen` one project has; `^name` is every dependency's `name` whatever it holds, `^rsbuild:typecheck` included; a target glob — `test:e2e--*`, `^build-*`, `ui:build-{esm,cjs}`, a `{ target }` object's — expands over every target name in the workspace, as Nx 19.5+ does, before those rules; a same-project glob keeps its own project's matches), `{ env }` inputs pass through (one that is no shell variable name is keyed only), `{ runtime }` inputs run at the workspace root as Nx runs them (`inputs.workspaceRuntime`), with `{workspaceRoot}`, `{projectRoot}` and `{projectName}` written in as the paths and name they stand for (Nx runs the command as written, so `cat {workspaceRoot}/v.txt` keyed on cat's error there), and a command that exits non-zero keys its error and exit code and the task runs on, as Nx hashes its output whatever it exits (`git describe` in a repo with no tags): the written probe is `(<command>) || echo "exit $?"`, a `{ fileset, includeIgnored: true }` literal (Nx 23 hashes it from disk, gitignored or missing) is read by such a probe, since vx's globs see only what git lists, and a glob of one is a todo, a target that says `continuous` — or, in a graph from an Nx older than that field, runs a server executor (`@nx/vite:dev-server`, `@nx/next:server`, …) — is a persistent task and never cached, ready on spawn as Nx has it (Nx starts the dependents once it has started; a run-commands `readyWhen` gates them, below). The target's name never decides: a cached `dev` caches like any other target (nx#32610). `parallelism: false` (Nx runs the target alone) has no vx form and is a todo naming `--concurrency 1`; `syncGenerators` (Nx runs them first) is one workspace note per generator list, counting its tasks and naming `nx sync`; nx.json's `sync.globalGenerators` is one note per run naming `nx sync`. A target with `configurations` is one task per configuration (one whose name another target holds, `vite`'s `build` beside an inferred `vite:build`, is not written, with a todo, as Nx resolves `a:vite:build` to the target): `build` carries the default configuration's options, `build:ci` the `ci` one, and an edge of `build:ci` (own, named or `^`) reaches each target's `ci` task where it declares one, else its default, as Nx's `resolveConfiguration` does; a `^build` there becomes one edge per dependency Nx links. A `^name` input (`^production`, `{ input, dependencies: true }`, the pre-17 `{ input, projects: "dependencies" }`; `projects: "self"` is the own input, as Nx 23 still reads both) is what Nx hashes: each dependency's `name`, over the project graph, whether or not a task edge exists. A `{ input, projects }` list is read as `dependsOn`'s `projects` is. Each project some reader reaches gets an `inputs:<name>` task — a keyed group (no command, nothing spawns, not counted in the run summary) keyed on its own `name` input, depending on its Nx dependencies' twins (a project nothing reaches gets none) — and a task that reads `^name` depends on its direct dependencies' twins, so the whole closure folds into its key with each project hashed once. A dependency FILESET (`^{projectRoot}/tsconfig.lib.json`, `{ fileset, dependencies: true }`, which Nx's own inferred `typecheck` writes) folds the same way: its twin, `inputs:fileset-<hash>` (a glob's `*` is no task name; the task's description names the fileset), keys on that fileset in each project. A twin lists its project's `package.json` and `project.json` first (whichever exists; Nx keys every task on its project's manifest and configuration): a fileset that matches nothing (an absent `tsconfig.spec.json`, a dependency's unbuilt `*.d.ts`) is nothing to Nx and no "matched no files" warning, and the file appearing re-keys its readers. The twins spawn nothing and stay out of a run's output and task count, so `vx run build` counts the tasks `nx run-many -t build` does. A path input inside an output one of the project's own targets declares (TanStack/table's `public` input lists `{projectRoot}/dist`) is generated, and gitignored where Nx caches it: Nx's file map skips gitignored files, so Nx hashes nothing there (a changed `dist` file is a cache hit under Nx 23.3), and it is dropped without a todo, and the task that writes it keys its dependants through `dependsOn`.
|
|
95
95
|
|
|
96
96
|
| Option | Meaning |
|
|
97
97
|
| ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
@@ -147,7 +147,7 @@ Nx loads a task's `.env` files into its environment — the project's before the
|
|
|
147
147
|
|
|
148
148
|
### The target a task sees
|
|
149
149
|
|
|
150
|
-
|
|
150
|
+
A task whose command still runs Nx or Lerna (`nx-exec`, `nx-env`, `nx exec`, `lerna`) or names one of them gets `NX_TASK_TARGET_PROJECT`, `NX_TASK_TARGET_TARGET`, `LERNA_PACKAGE_NAME` (the project, which Lerna on Nx's runner documents to scripts) and, where it runs a configuration, `NX_TASK_TARGET_CONFIGURATION` in `exec.env.define`, as Nx hands them to every task; a run-commands `env` wins. Any other task gets none. Nx's own `npm:public` / `npm:private` tags are not written. A package script's `nx exec -- <cmd>` reads them: without them it starts Nx's own task runner, which runs the target and its dependencies again.
|
|
151
151
|
|
|
152
152
|
### Inputs that key nothing here
|
|
153
153
|
|
|
@@ -226,11 +226,11 @@ bunx @vzn/vx-migrate --keep # keep turbo.json / nx.json as the source: turbo(
|
|
|
226
226
|
bunx @vzn/vx-migrate --help # the usage, exit 0
|
|
227
227
|
```
|
|
228
228
|
|
|
229
|
-
It is the one command a repo needs (`
|
|
229
|
+
It is the one command a repo needs (`pbunx @vzn/vx-migrate` in a pnpm repo works the same):
|
|
230
230
|
|
|
231
231
|
- In a Turbo or Nx repo, in a terminal, it asks which adoption you want: **native** (the default, a `vx.config.ts` per package) or **keep** (a workspace file declaring `turbo()` or `nx()`). `--native` / `--keep` answer it; with no terminal (CI, a pipe) it is native. Elsewhere both are refused. A repo already set up (a workspace file declaring neither, and project configs) is said to be, exit 0, unless `--force`.
|
|
232
232
|
- Native or keep, a remote cache the repo shows is declared: `turboCache()` for turbo.json's enabled `remoteCache`, a `turbo link`ed `.turbo/config.json` or a CI file (`.github/workflows/*`, `.gitlab-ci.yml`, `.circleci/config.yml`) setting `TURBO_TOKEN`; `nxCache()` for one setting `NX_SELF_HOSTED_REMOTE_CACHE_SERVER`. The report names the file that showed it. Nx Cloud (`nxCloudId`, `nxCloudAccessToken`, the `nx-cloud` runner) is named instead: vx does not speak its wire.
|
|
233
|
-
- With no `.vx/workspace.*` yet, it writes one declaring the plugins the repo calls for (`src/workspace-plugins.ts`): the remote cache above, the `@vzn/vx-lockfile` factory for the lockfile (`
|
|
233
|
+
- With no `.vx/workspace.*` yet, it writes one declaring the plugins the repo calls for (`src/workspace-plugins.ts`): the remote cache above, the `@vzn/vx-lockfile` factory for the lockfile (`bun()`, `pnpm()`, `npm()`, `yarn()`), `scheduleHistoryPlugin()`, and `github()` when `.github/workflows` exists, from `@vzn/vx-ci`, each installed beside `@vzn/vx` at vx-migrate's own version. Keep adds them to the file it writes, or to one already in that shape; any other workspace file is the user's and left alone. Native over a file vx wrote (a keep adoption's) rewrites it: `turbo()` and `nx()` go, since the configs written replace them, and the other plugins stay.
|
|
234
234
|
- It installs what the written files import with the repo's own manager (`packageManager`, else the lockfile): `@vzn/vx`, every `@vzn/vx-*` package the workspace file imports (one an earlier init wrote included), and `@vzn/vx-migrate` for keep or when a written task runs its `nx-exec` or `nx-env` bin (`pnpm add -D -w`, `yarn add -D -W` on Yarn 1 and without `-W` on Yarn 2+, `bun add -d`, `npm install -D`). A package the root both lists and has installed at vx-migrate's own version is left alone; any other is installed at that version, and so is every other `@vzn/vx-*` plugin the root lists from the registry (one install moves them together, as `vx upgrade` does). Native removes a listed `@vzn/vx-migrate` when nothing written needs it (no `nx-exec` / `nx-env` task, no `turboCache()` or `nxCache()` left in the workspace file): `pnpm remove -w`, `yarn remove` (`-W` on Yarn 1), `bun remove`, `npm uninstall`. `--no-install` skips both. vx-migrate runs only with the `@vzn/vx` of its own release, which it installs: one of another release (a `bun x` install directory keeps the one it had) is refused, naming where it was loaded.
|
|
235
235
|
- Native, once the files are written, it ends with `vx lock`: `.vx/projects.lock.json` is written and the graph rules are checked, its line the report's `lock` row. A refusal prints above the report and the configs stay written (exit 0). `--no-install` skips it, since the plugins the workspace file declares are not installed yet, and `next` then starts with `vx lock`.
|
|
236
236
|
|
|
@@ -284,7 +284,7 @@ Detected when the root `package.json` lists `vite-plus`, after turbo.json and Nx
|
|
|
284
284
|
Where no runner's config is the source (or under `--from scripts`), each
|
|
285
285
|
package's `package.json` scripts become its `vx.config.ts`, plus a
|
|
286
286
|
`.vx/workspace.ts` that declares the `@vzn/vx-lockfile` plugin for the lockfile at
|
|
287
|
-
the root (`
|
|
287
|
+
the root (`bun()`, `pnpm()`, `npm()` or `yarn()`; `{ plugins: [] }`
|
|
288
288
|
without one): package edges (`^build`, `--affected`) come from it.
|
|
289
289
|
Running and caching here are the floor, so it declares no executor or
|
|
290
290
|
cache, and the file carries no comment (it is the user's). When
|
|
@@ -497,11 +497,8 @@ order may matter, so it is not made a group.
|
|
|
497
497
|
The report lists each TODO once per reason: tasks that share one are
|
|
498
498
|
named together (the first five, then a count; the files carry each),
|
|
499
499
|
and when no task caches it says uncommenting a TODO task's cache line
|
|
500
|
-
makes the second run a hit. Its `next:` line is a command the user can type
|
|
501
|
-
|
|
502
|
-
`npm_config_user_agent`) with the installed `vx` bin, else the
|
|
503
|
-
`@vzn/vx` package (`npx` under Yarn 1, which has no `dlx`); with no
|
|
504
|
-
runner, a bare `vx`. Outside a git work tree it starts with
|
|
500
|
+
makes the second run a hit. Its `next:` line is a command the user can type,
|
|
501
|
+
with a bare `vx` (`vx init` installs it). Outside a git work tree it starts with
|
|
505
502
|
`git init`, and with no script mapped with declaring a task, since the
|
|
506
503
|
run would refuse without either. It runs `build`, else the first task that
|
|
507
504
|
neither serves (`persistent`) nor changes the repo (`clean`, `release`,
|
|
@@ -550,7 +547,7 @@ export default defineWorkspace({
|
|
|
550
547
|
|
|
551
548
|
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()`.
|
|
552
549
|
|
|
553
|
-
Every option falls back to the tool's own environment variable, so a self-hosted setup carries over unchanged. A token with no `apiUrl` means Vercel's hosted Remote Cache (`https://vercel.com/api`), exactly as it does for `turbo` — so `
|
|
550
|
+
Every option falls back to the tool's own environment variable, so a self-hosted setup carries over unchanged. A token with no `apiUrl` 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 `apiUrl` 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.
|
|
554
551
|
|
|
555
552
|
| Option | Environment variable | Meaning |
|
|
556
553
|
| ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vzn/vx-migrate",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.646",
|
|
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.646"
|
|
44
44
|
},
|
|
45
45
|
"repository": {
|
|
46
46
|
"type": "git",
|
package/src/adoption-plugin.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// skeleton until item 592; what differs is only how the mapping is made.
|
|
7
7
|
|
|
8
8
|
import { definePlugin, type ProjectHookContext, type VxPlugin } from '@vzn/vx'
|
|
9
|
-
import { type AdoptionMapping, cachedMapping } from './mapping-cache.js'
|
|
9
|
+
import { type AdoptionMapping, cachedMapping, codeId } from './mapping-cache.js'
|
|
10
10
|
import { warnGaps } from './plugin-gaps.js'
|
|
11
11
|
import { headStamp, spareTrackedOutputs, trackedFiles } from './tracked-outputs.js'
|
|
12
12
|
|
|
@@ -76,18 +76,16 @@ export function adoptionPlugin(
|
|
|
76
76
|
async project(config, ctx) {
|
|
77
77
|
if (mappedFor !== ctx.projects) {
|
|
78
78
|
mappedFor = ctx.projects
|
|
79
|
+
// The key's other reads overlap the plugin's own.
|
|
80
|
+
void codeId()
|
|
81
|
+
const stamp = headStamp(ctx.workspaceRoot)
|
|
79
82
|
mapping = mapRun(ctx).then(async (r) =>
|
|
80
83
|
r.spareTracked === true
|
|
81
|
-
? cachedMapping(
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
let read: Promise<readonly string[] | null> | undefined
|
|
87
|
-
const tracked = () => (read ??= trackedFiles(ctx.workspaceRoot))
|
|
88
|
-
return spare(ctx, await r.map(tracked), await tracked())
|
|
89
|
-
},
|
|
90
|
-
)
|
|
84
|
+
? cachedMapping(ctx.cacheDir, r.name, [...r.reads, await stamp], async () => {
|
|
85
|
+
let read: Promise<readonly string[] | null> | undefined
|
|
86
|
+
const tracked = () => (read ??= trackedFiles(ctx.workspaceRoot))
|
|
87
|
+
return spare(ctx, await r.map(tracked), await tracked())
|
|
88
|
+
})
|
|
91
89
|
: cachedMapping(ctx.cacheDir, r.name, r.reads, () =>
|
|
92
90
|
r.map(() => trackedFiles(ctx.workspaceRoot)),
|
|
93
91
|
),
|
package/src/index.ts
CHANGED
|
@@ -2,14 +2,40 @@
|
|
|
2
2
|
// runs unchanged) and the two remote caches speaking Turbo's and Nx's
|
|
3
3
|
// wire. The CLI (`bunx @vzn/vx-migrate`) is `migrate.ts`; mappers and
|
|
4
4
|
// cache clients are internal (1.0 freezes what this file exports).
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
5
|
+
//
|
|
6
|
+
// Each factory loads its module on first call: a workspace evaluates this
|
|
7
|
+
// file on every run, and a Turbo repo paid ~4 ms per run transpiling nx()
|
|
8
|
+
// and both cache clients it never calls (solid, 2026-10-22).
|
|
9
|
+
import type { VxPlugin } from '@vzn/vx'
|
|
10
|
+
import type * as Turbo from './turbo/index.js'
|
|
11
|
+
import type { TurboPluginOptions } from './turbo/index.js'
|
|
12
|
+
import type { NxPluginOptions } from './nx/index.js'
|
|
13
|
+
import type { TurboCacheOptions } from './turbo-cache/index.js'
|
|
14
|
+
import type { NxCacheOptions } from './nx-cache/index.js'
|
|
15
|
+
import type * as Nx from './nx/index.js'
|
|
16
|
+
import type * as TurboCache from './turbo-cache/index.js'
|
|
17
|
+
import type * as NxCache from './nx-cache/index.js'
|
|
18
|
+
|
|
19
|
+
export type { TurboPluginOptions } from './turbo/index.js'
|
|
20
|
+
export type {
|
|
21
|
+
NxExecutors,
|
|
22
|
+
NxExecutorTarget,
|
|
23
|
+
NxExecutorTranslation,
|
|
24
|
+
NxExecutorTranslator,
|
|
25
|
+
NxPluginOptions,
|
|
13
26
|
} from './nx/index.js'
|
|
14
|
-
export {
|
|
15
|
-
export {
|
|
27
|
+
export type { TurboCacheOptions } from './turbo-cache/index.js'
|
|
28
|
+
export type { NxCacheOptions } from './nx-cache/index.js'
|
|
29
|
+
|
|
30
|
+
export function turbo(options: TurboPluginOptions = {}): VxPlugin {
|
|
31
|
+
return (require('./turbo/index.js') as typeof Turbo).turbo(options)
|
|
32
|
+
}
|
|
33
|
+
export function nx(options: NxPluginOptions = {}): VxPlugin {
|
|
34
|
+
return (require('./nx/index.js') as typeof Nx).nx(options)
|
|
35
|
+
}
|
|
36
|
+
export function turboCache(options: TurboCacheOptions = {}): VxPlugin {
|
|
37
|
+
return (require('./turbo-cache/index.js') as typeof TurboCache).turboCache(options)
|
|
38
|
+
}
|
|
39
|
+
export function nxCache(options: NxCacheOptions = {}): VxPlugin {
|
|
40
|
+
return (require('./nx-cache/index.js') as typeof NxCache).nxCache(options)
|
|
41
|
+
}
|
package/src/mapping-cache.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
// package's sources and the core helpers the mappers call: a new
|
|
9
9
|
// `@vzn/vx-migrate` or `@vzn/vx` maps afresh.
|
|
10
10
|
|
|
11
|
+
import { statSync } from 'node:fs'
|
|
11
12
|
import { mkdir, rename } from 'node:fs/promises'
|
|
12
13
|
import path from 'node:path'
|
|
13
14
|
import { foldScriptHooks, isLiteralPattern, outputsOverlap, VERSION } from '@vzn/vx'
|
|
@@ -30,8 +31,8 @@ export interface AdoptionMapping {
|
|
|
30
31
|
|
|
31
32
|
let code: Promise<string> | undefined
|
|
32
33
|
|
|
33
|
-
/** This package's sources and the core helpers the mappers call,
|
|
34
|
-
function codeId(): Promise<string> {
|
|
34
|
+
/** This package's sources (by size and mtime) and the core helpers the mappers call, once per process. */
|
|
35
|
+
export function codeId(): Promise<string> {
|
|
35
36
|
code ??= (async () => {
|
|
36
37
|
const parts: string[] = [
|
|
37
38
|
VERSION,
|
|
@@ -40,7 +41,13 @@ function codeId(): Promise<string> {
|
|
|
40
41
|
const files: string[] = []
|
|
41
42
|
for await (const f of new Bun.Glob('**/*.{ts,cjs}').scan(import.meta.dir)) files.push(f)
|
|
42
43
|
files.sort()
|
|
43
|
-
|
|
44
|
+
// Size and mtime, not bytes: reading the 650 kB of sources cost ~10 ms
|
|
45
|
+
// on every run, more than the mapping hit it guards saves on a small
|
|
46
|
+
// repo (solid, 2026-10-22). An edit moves both.
|
|
47
|
+
for (const f of files) {
|
|
48
|
+
const s = statSync(path.join(import.meta.dir, f))
|
|
49
|
+
parts.push(f, `${s.size}:${s.mtimeMs}`)
|
|
50
|
+
}
|
|
44
51
|
return hex(parts.join('\0'))
|
|
45
52
|
})()
|
|
46
53
|
return code
|
|
@@ -71,11 +78,12 @@ export async function cachedMapping(
|
|
|
71
78
|
parts: readonly string[],
|
|
72
79
|
map: () => Promise<AdoptionMapping>,
|
|
73
80
|
): Promise<AdoptionMapping> {
|
|
74
|
-
const key = hex([await codeId(), ...parts].join('\0'))
|
|
75
81
|
const file = path.join(cacheDir, `vx-migrate-${name}-mapping.json`)
|
|
76
|
-
const
|
|
82
|
+
const reading = Bun.file(file)
|
|
77
83
|
.json()
|
|
78
|
-
.catch(() => null)
|
|
84
|
+
.catch(() => null) as Promise<Held | null>
|
|
85
|
+
const key = hex([await codeId(), ...parts].join('\0'))
|
|
86
|
+
const held = await reading
|
|
79
87
|
if (held?.key === key) {
|
|
80
88
|
return {
|
|
81
89
|
byName: new Map(held.byName),
|
package/src/migrate-turbo.ts
CHANGED
|
@@ -12,12 +12,8 @@ import {
|
|
|
12
12
|
quoteTsLiteral as quote,
|
|
13
13
|
} from '@vzn/vx'
|
|
14
14
|
import type { PackageMeta } from './packages.js'
|
|
15
|
-
import {
|
|
16
|
-
|
|
17
|
-
rootTaskProject,
|
|
18
|
-
type TurboGlobal,
|
|
19
|
-
type TurboMappedProject,
|
|
20
|
-
} from './turbo/turbo-map.js'
|
|
15
|
+
import { mapTurboWorkspace, type TurboGlobal, type TurboMappedProject } from './turbo/turbo-map.js'
|
|
16
|
+
import { rootTaskProject } from './turbo/turbo-reads.js'
|
|
21
17
|
import { relPosix } from './paths.js'
|
|
22
18
|
import { gitIgnored, spareTrackedOutputs, trackedFiles, trackedKinds } from './tracked-outputs.js'
|
|
23
19
|
import { DOTENV_GLOBS_HEAD, DOTENV_PROBE, DOTENV_PROBE_TOP } from './dotenv-probe.js'
|
package/src/migrate.ts
CHANGED
|
@@ -53,14 +53,13 @@ import {
|
|
|
53
53
|
missingPackages,
|
|
54
54
|
MODE_QUESTION,
|
|
55
55
|
ownVersion,
|
|
56
|
-
type PackageManager,
|
|
57
56
|
packageManagerOf,
|
|
58
57
|
parseModeAnswer,
|
|
59
58
|
uninstall,
|
|
60
59
|
} from './adopt.js'
|
|
61
60
|
import { migrateTurbo } from './migrate-turbo.js'
|
|
62
61
|
import { migrateViteTask } from './migrate-vite-task.js'
|
|
63
|
-
import { turboConfigFile } from './turbo/turbo-
|
|
62
|
+
import { turboConfigFile } from './turbo/turbo-reads.js'
|
|
64
63
|
import { lockfilePlugin } from './workspace-notes.js'
|
|
65
64
|
import {
|
|
66
65
|
extendWorkspaceFile,
|
|
@@ -642,14 +641,6 @@ function lockRow(lock: { code: number; said: string }): ui.FieldRow {
|
|
|
642
641
|
/** `.vx/.gitignore`'s text, as core writes it: the machine-local cache and restore staging stay out of git. */
|
|
643
642
|
const DOT_VX_GITIGNORE = '/cache\n/tmp\n'
|
|
644
643
|
|
|
645
|
-
// npx and bunx name the package: their bare `vx` is a stranger's npm package.
|
|
646
|
-
const EXEC: Record<PackageManager, string> = {
|
|
647
|
-
pnpm: 'pnpm vx',
|
|
648
|
-
yarn: 'yarn vx',
|
|
649
|
-
bun: 'bunx @vzn/vx',
|
|
650
|
-
npm: 'npx @vzn/vx',
|
|
651
|
-
}
|
|
652
|
-
|
|
653
644
|
/**
|
|
654
645
|
* Install what the workspace file imports, if missing, then run the repo's
|
|
655
646
|
* build. Pinned to this release: every @vzn package releases in lockstep,
|
|
@@ -666,12 +657,12 @@ export function adoptionNext(
|
|
|
666
657
|
const missing = needs.filter(
|
|
667
658
|
(p) => !existsSync(path.join(root, 'node_modules', p, 'package.json')),
|
|
668
659
|
)
|
|
669
|
-
//
|
|
670
|
-
|
|
671
|
-
const run =
|
|
660
|
+
// `vx init` installs vx globally first: vx alone, never a package
|
|
661
|
+
// manager's runner (owner, 2026-10-22: vx owns the command).
|
|
662
|
+
const run = `vx run ${firstTask(path.join(root, source), runner)} --all`
|
|
672
663
|
if (missing.length === 0) return run
|
|
673
664
|
const pinned = version === null ? missing : missing.map((p) => `${p}@${version}`)
|
|
674
|
-
return `${installArgv(root, pm, pinned).join(' ')} &&
|
|
665
|
+
return `${installArgv(root, pm, pinned).join(' ')} && vx lock && ${run}`
|
|
675
666
|
}
|
|
676
667
|
|
|
677
668
|
/**
|
package/src/nx/nx-map.ts
CHANGED
|
@@ -284,11 +284,20 @@ export interface NxMapping {
|
|
|
284
284
|
readonly notes: string[]
|
|
285
285
|
}
|
|
286
286
|
|
|
287
|
-
/** A
|
|
287
|
+
/** A command that still runs Nx or Lerna (`nx-exec`, `nx-env`, `nx exec`, `lerna run`) or reads what they set. */
|
|
288
|
+
const RUNS_NX = /(?:^|[\s;&|('"/])(?:nx(?:-exec|-env)?|lerna)\s|NX_TASK_TARGET_|LERNA_PACKAGE_NAME/
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* A node's tags as vx takes them: a blank one would be refused at load, and
|
|
292
|
+
* `npm:public` / `npm:private` are Nx's own, read off package.json.
|
|
293
|
+
*/
|
|
288
294
|
function nxTags(node: NxNode | undefined): string[] {
|
|
289
295
|
const tags = node?.data?.tags
|
|
290
296
|
return Array.isArray(tags)
|
|
291
|
-
? tags.filter(
|
|
297
|
+
? tags.filter(
|
|
298
|
+
(t): t is string =>
|
|
299
|
+
typeof t === 'string' && t.trim() !== '' && t !== 'npm:public' && t !== 'npm:private',
|
|
300
|
+
)
|
|
292
301
|
: []
|
|
293
302
|
}
|
|
294
303
|
|
|
@@ -481,7 +490,7 @@ export async function mapNxWorkspace(
|
|
|
481
490
|
}
|
|
482
491
|
mapped.push({ meta, tasks })
|
|
483
492
|
}
|
|
484
|
-
// The `
|
|
493
|
+
// The `inputs:<name>` twins the `^` inputs above asked for, in every
|
|
485
494
|
// project a graph node owns — one with no targets too.
|
|
486
495
|
const byMeta = new Map(mapped.map((m) => [m.meta, m]))
|
|
487
496
|
for (const [nodeName, list] of upstream.inputTasks()) {
|
|
@@ -898,7 +907,7 @@ function buildTask(
|
|
|
898
907
|
// saw nothing the task reads, and a changed document replayed the old
|
|
899
908
|
// generated client (Growth, 2026-10-16).
|
|
900
909
|
const orphanReader =
|
|
901
|
-
inputs.dependentOutputs.length > 0 && !deps.some((d) => !d.includes('#
|
|
910
|
+
inputs.dependentOutputs.length > 0 && !deps.some((d) => !d.includes('#inputs:'))
|
|
902
911
|
if (orphanReader && cacheWanted && !persistent) {
|
|
903
912
|
todos.push(orphanReaderTodo(inputs.dependentOutputs, target.dependsOn))
|
|
904
913
|
}
|
|
@@ -967,18 +976,24 @@ function buildTask(
|
|
|
967
976
|
// `nx exec -- <cmd>`, a package script's way to run under Nx, reads it:
|
|
968
977
|
// unset, it booted Nx's own task runner, which ran the target and its
|
|
969
978
|
// dependencies again. `LERNA_PACKAGE_NAME` is the project too: Lerna
|
|
970
|
-
// runs on Nx's runner and documents it to scripts.
|
|
971
|
-
// still
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
+
// runs on Nx's runner and documents it to scripts. Only a command that
|
|
980
|
+
// still runs Nx or Lerna gets them: a native task names no other runner
|
|
981
|
+
// (owner, 2026-10-22). A run-commands `env` still wins, as in Nx.
|
|
982
|
+
const define = {
|
|
983
|
+
...(RUNS_NX.test(mapped.command)
|
|
984
|
+
? {
|
|
985
|
+
NX_TASK_TARGET_PROJECT: projectName,
|
|
986
|
+
NX_TASK_TARGET_TARGET: targetName,
|
|
987
|
+
LERNA_PACKAGE_NAME: projectName,
|
|
988
|
+
...(variant.configuration === undefined
|
|
989
|
+
? {}
|
|
990
|
+
: { NX_TASK_TARGET_CONFIGURATION: variant.configuration }),
|
|
991
|
+
}
|
|
992
|
+
: {}),
|
|
979
993
|
...mapped.env,
|
|
980
994
|
}
|
|
981
|
-
|
|
995
|
+
if (Object.keys(define).length > 0) env.define = define
|
|
996
|
+
if (Object.keys(env).length > 0) exec.env = env
|
|
982
997
|
if (mapped.timeout !== undefined) exec.timeout = mapped.timeout
|
|
983
998
|
if (readyWhen !== undefined) {
|
|
984
999
|
exec.persistent = { readyWhen }
|
|
@@ -1040,7 +1055,7 @@ function reachesProducer(
|
|
|
1040
1055
|
if (!Array.isArray(deps)) return false
|
|
1041
1056
|
return deps.some((d) => {
|
|
1042
1057
|
if (typeof d !== 'string' || d.includes('*')) return true
|
|
1043
|
-
if (d.includes('#
|
|
1058
|
+
if (d.includes('#inputs:')) return false
|
|
1044
1059
|
if (d.startsWith('^'))
|
|
1045
1060
|
return [...reached(project)].some((p) => emittedIds.has(`${p}#${d.slice(1)}`))
|
|
1046
1061
|
return d.includes('#') || emittedIds.has(`${project}#${d}`)
|
package/src/nx/nx-upstream.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// dependency's `name` input for every project the graph reaches, whether or
|
|
3
3
|
// not a task edge exists, so a `test` with `^production` and no `dependsOn`
|
|
4
4
|
// re-keys on a dependency's source. vx folds upstream KEYS along
|
|
5
|
-
// `dependsOn`, so each project gets one `
|
|
5
|
+
// `dependsOn`, so each project gets one `inputs:<name>` task — a keyed
|
|
6
6
|
// group, keyed on the project's own `name` input — with an edge to its
|
|
7
7
|
// dependencies' twin. A task that reads `^name` depends on its direct
|
|
8
8
|
// dependencies' twins and folds the whole closure through them: each
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
//
|
|
12
12
|
// The edges are the NX graph's, not vx's package graph (`^` in vx follows
|
|
13
13
|
// package.json, and an Nx edge from a tsconfig path has no manifest entry),
|
|
14
|
-
// so they are explicit `pkg#
|
|
14
|
+
// so they are explicit `pkg#inputs:<name>`. A graph node with no vx
|
|
15
15
|
// project is transparent: its files join as workspace globs and the walk
|
|
16
16
|
// goes on through it. Nx allows a project cycle and vx's task graph does
|
|
17
17
|
// not: inside a cycle a twin carries its peers' files as workspace globs
|
|
@@ -47,8 +47,8 @@ const isFileset = (name: string): boolean =>
|
|
|
47
47
|
/** A fileset twin is named by its hash: a glob's `*` is no task name. */
|
|
48
48
|
const inputTask = (name: string): string =>
|
|
49
49
|
isFileset(name)
|
|
50
|
-
? `
|
|
51
|
-
: `
|
|
50
|
+
? `inputs:fileset-${Bun.hash.xxHash3(name).toString(16).padStart(16, '0')}`
|
|
51
|
+
: `inputs:${name}`
|
|
52
52
|
|
|
53
53
|
function normRel(p: string): string {
|
|
54
54
|
const s = p.replace(/\/+$/, '')
|
|
@@ -63,7 +63,7 @@ export interface NxUpstream {
|
|
|
63
63
|
* needs, with a transparent node's files added to `into`.
|
|
64
64
|
*/
|
|
65
65
|
resolve(node: string, into: NxInputs, todos: string[]): string[]
|
|
66
|
-
/** Each project's `
|
|
66
|
+
/** Each project's `inputs:<name>` tasks, for every twin an edge reaches. */
|
|
67
67
|
inputTasks(): Map<string, GeneratedTask[]>
|
|
68
68
|
}
|
|
69
69
|
|
package/src/shared-outputs.ts
CHANGED
|
@@ -192,18 +192,24 @@ function cachedOutputs(t: GeneratedTask, index: number): Cached | null {
|
|
|
192
192
|
}
|
|
193
193
|
|
|
194
194
|
/**
|
|
195
|
-
* Can the keeper's `ga` give `gb`'s tree back? Only when
|
|
196
|
-
* directory at any depth (`**\/dist`, `**\/dist/**`) and
|
|
197
|
-
* head names no such directory:
|
|
198
|
-
*
|
|
199
|
-
* beside types' `types
|
|
200
|
-
* vx the slower of the two.
|
|
195
|
+
* Can the keeper's `ga` give `gb`'s tree back? Only when one of the two is
|
|
196
|
+
* a directory at any depth (`**\/dist`, `**\/dist/**`) and the other's
|
|
197
|
+
* literal head names no such directory: they meet only where one tree
|
|
198
|
+
* nests in the other, which neither task writes. solid's build outputs
|
|
199
|
+
* `dist/**` and `**\/dist` beside types' `types/**` and `**\/types`; Turbo
|
|
200
|
+
* caches both, and uncaching types made vx the slower of the two.
|
|
201
201
|
*/
|
|
202
|
-
|
|
203
|
-
|
|
202
|
+
const DIR_ANYWHERE = /^\*\*\/([^*?[\]{}()!/\\]+)(?:\/\*\*)?$/
|
|
203
|
+
|
|
204
|
+
const separable = (ga: string, gb: string): boolean => apart(ga, gb) || apart(gb, ga)
|
|
205
|
+
|
|
206
|
+
function apart(dir: string, other: string): boolean {
|
|
207
|
+
const name = DIR_ANYWHERE.exec(dir)?.[1]
|
|
204
208
|
if (name === undefined) return false
|
|
209
|
+
const otherName = DIR_ANYWHERE.exec(other)?.[1]
|
|
210
|
+
if (otherName !== undefined) return otherName !== name
|
|
205
211
|
const head: string[] = []
|
|
206
|
-
for (const seg of
|
|
212
|
+
for (const seg of other.replace(/^(\.\/)+/, '').split('/')) {
|
|
207
213
|
if (!isLiteralPattern(seg) || seg === '') break
|
|
208
214
|
head.push(seg)
|
|
209
215
|
}
|
|
@@ -348,7 +354,7 @@ function prefixIndex(): {
|
|
|
348
354
|
}
|
|
349
355
|
|
|
350
356
|
/**
|
|
351
|
-
* A keyed group (`cache`, no `exec`: the `
|
|
357
|
+
* A keyed group (`cache`, no `exec`: the `inputs:<name>` twins, Turbo's
|
|
352
358
|
* transit nodes) runs nothing, so core lets its key read what a task
|
|
353
359
|
* writes (`detectInputOverlaps` exempts it). Uncaching one left `{}`, a
|
|
354
360
|
* task core refuses, and every verb failed.
|
package/src/tracked-outputs.ts
CHANGED
|
@@ -11,7 +11,7 @@ import { lstat, readFile } from 'node:fs/promises'
|
|
|
11
11
|
import path from 'node:path'
|
|
12
12
|
import { isLiteralPattern, outputsOverlap } from '@vzn/vx'
|
|
13
13
|
import { relPosix } from './paths.js'
|
|
14
|
-
import
|
|
14
|
+
import type * as SharedOutputs from './shared-outputs.js'
|
|
15
15
|
|
|
16
16
|
/** The files git tracks under `root`, root-relative; null outside a repo or without git. */
|
|
17
17
|
export async function trackedFiles(root: string): Promise<string[] | null> {
|
|
@@ -135,11 +135,13 @@ export async function headStamp(root: string): Promise<string> {
|
|
|
135
135
|
const m = /^gitdir: (.*)$/m.exec(await readFile(dotGit, 'utf8').catch(() => ''))
|
|
136
136
|
if (m !== null) gitDir = path.resolve(dir, m[1]!.trim())
|
|
137
137
|
}
|
|
138
|
-
const head = await
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
138
|
+
const [head, log, index, common] = await Promise.all([
|
|
139
|
+
readFile(path.join(gitDir, 'HEAD'), 'utf8').catch(() => ''),
|
|
140
|
+
lstat(path.join(gitDir, 'logs', 'HEAD')).catch(() => null),
|
|
141
|
+
lstat(path.join(gitDir, 'index')).catch(() => null),
|
|
142
|
+
// A worktree's own stack holds its HEAD; the common one, its branches.
|
|
143
|
+
readFile(path.join(gitDir, 'commondir'), 'utf8').catch(() => null),
|
|
144
|
+
])
|
|
143
145
|
const tables = await Promise.all(
|
|
144
146
|
[gitDir, ...(common === null ? [] : [path.resolve(gitDir, common.trim())])].map((d) =>
|
|
145
147
|
readFile(path.join(d, 'reftable', 'tables.list'), 'utf8').catch(() => ''),
|
|
@@ -236,6 +238,8 @@ export function spareTrackedOutputs(
|
|
|
236
238
|
}[],
|
|
237
239
|
tracked: readonly string[],
|
|
238
240
|
): [string, string][] {
|
|
241
|
+
// Loaded here, on a remap only: a kept mapping's run never reads it.
|
|
242
|
+
const { isKeyedGroup, unkey } = require('./shared-outputs.js') as typeof SharedOutputs
|
|
239
243
|
const todos: [string, string][] = []
|
|
240
244
|
const trackedSet = new Set(tracked)
|
|
241
245
|
/** Drop the `!` entries readers carry for a committed file no output names any more. */
|
package/src/turbo/index.ts
CHANGED
|
@@ -21,14 +21,14 @@ import { collectGaps } from '../plugin-gaps.js'
|
|
|
21
21
|
import { gitIgnored, trackedKinds } from '../tracked-outputs.js'
|
|
22
22
|
import { packageScripts, relPosix } from '../paths.js'
|
|
23
23
|
import { onceReader, type ReadText, tscBuildInfo } from '../tsc-buildinfo.js'
|
|
24
|
+
import type * as TurboMap from './turbo-map.js'
|
|
25
|
+
import type { TurboMappedProject } from './turbo-map.js'
|
|
24
26
|
import {
|
|
25
27
|
envNamesThatMap,
|
|
26
|
-
mapTurboWorkspace,
|
|
27
28
|
microfrontendsConfigs,
|
|
28
29
|
rootTaskProject,
|
|
29
30
|
turboConfigFile,
|
|
30
|
-
|
|
31
|
-
} from './turbo-map.js'
|
|
31
|
+
} from './turbo-reads.js'
|
|
32
32
|
|
|
33
33
|
/** The note every persistent task carries; like every gap, reported once per run for all its tasks. */
|
|
34
34
|
const PERSISTENT_NOTE =
|
|
@@ -192,11 +192,15 @@ const textOf = (file: string): Promise<string> =>
|
|
|
192
192
|
* `.yarnrc.yml` and the microfrontends configs.
|
|
193
193
|
*/
|
|
194
194
|
async function run(root: string, projects: readonly ProjectMeta[]): Promise<AdoptionRun> {
|
|
195
|
-
const
|
|
196
|
-
const
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
195
|
+
const dirs = [root, ...projects.map((m) => m.dir)]
|
|
196
|
+
const [metas, configs, yarnrc, rootManifest] = await Promise.all([
|
|
197
|
+
withManifests(projects),
|
|
198
|
+
Promise.all(
|
|
199
|
+
dirs.flatMap((d) => ['turbo.json', 'turbo.jsonc'].map((f) => textOf(path.join(d, f)))),
|
|
200
|
+
),
|
|
201
|
+
textOf(path.join(root, '.yarnrc.yml')),
|
|
202
|
+
textOf(path.join(root, 'package.json')),
|
|
203
|
+
])
|
|
200
204
|
const envNames = envNamesThatMap(configs, envNamesNow())
|
|
201
205
|
// The tsconfigs a `tsc` script reads are the mapping's inputs too.
|
|
202
206
|
const tsc = onceReader()
|
|
@@ -213,9 +217,9 @@ async function run(root: string, projects: readonly ProjectMeta[]): Promise<Adop
|
|
|
213
217
|
process.env['TURBO_CI_VENDOR_ENV_KEY'] ?? '',
|
|
214
218
|
process.env['TURBO_ENV_MODE'] ?? '',
|
|
215
219
|
...configs,
|
|
216
|
-
|
|
220
|
+
yarnrc,
|
|
217
221
|
// Its dependencies are global inputs, a member or not.
|
|
218
|
-
|
|
222
|
+
rootManifest,
|
|
219
223
|
JSON.stringify(microfrontendsConfigs(root, dirs)),
|
|
220
224
|
// A config file added beside mapped tasks changes what an output may cover.
|
|
221
225
|
JSON.stringify(metas.map((m) => [m.name, m.dir, m.packageJson, m.configPath])),
|
|
@@ -244,6 +248,8 @@ async function mapAll(
|
|
|
244
248
|
m.configPath === null ? [] : [[relPosix(root, m.dir), path.basename(m.configPath)] as const],
|
|
245
249
|
),
|
|
246
250
|
)
|
|
251
|
+
// Only a mapping the kept one no longer holds loads the mapper.
|
|
252
|
+
const { mapTurboWorkspace } = require('./turbo-map.js') as typeof TurboMap
|
|
247
253
|
const mapped = await mapTurboWorkspace(root, metas, {
|
|
248
254
|
// Inline: the values themselves, where `bunx @vzn/vx-migrate` splices a preset import.
|
|
249
255
|
splice: (_kind, values) => values,
|
package/src/turbo/turbo-map.ts
CHANGED
|
@@ -10,7 +10,6 @@
|
|
|
10
10
|
// written.
|
|
11
11
|
// The consumer decides what a global becomes through `splice`.
|
|
12
12
|
|
|
13
|
-
import { existsSync, readFileSync } from 'node:fs'
|
|
14
13
|
import path from 'node:path'
|
|
15
14
|
import { isLiteralPattern, pruneOrphanPersistentNotes, UserError } from '@vzn/vx'
|
|
16
15
|
import type { PackageMeta } from '../packages.js'
|
|
@@ -31,6 +30,13 @@ import {
|
|
|
31
30
|
} from '../shared-outputs.js'
|
|
32
31
|
import { manifestGraph, type ManifestGraph } from '../manifest-graph.js'
|
|
33
32
|
import { isRecord, packageScripts, relPosix } from '../paths.js'
|
|
33
|
+
import {
|
|
34
|
+
FRAMEWORK_ENV,
|
|
35
|
+
keyable,
|
|
36
|
+
microfrontendsConfigs,
|
|
37
|
+
ROOT,
|
|
38
|
+
turboConfigFile,
|
|
39
|
+
} from './turbo-reads.js'
|
|
34
40
|
import type { TrackedKinds } from '../tracked-outputs.js'
|
|
35
41
|
import {
|
|
36
42
|
DOTENV_PROBE,
|
|
@@ -69,34 +75,6 @@ interface TurboJson {
|
|
|
69
75
|
|
|
70
76
|
const SHELL_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/
|
|
71
77
|
|
|
72
|
-
/** A name core takes in `inputs.env` and `exec.env.passThrough`. */
|
|
73
|
-
const keyable = (name: string): boolean =>
|
|
74
|
-
name.length > 0 && !name.startsWith('!') && !/[*?[\]{}=\0]/.test(name)
|
|
75
|
-
|
|
76
|
-
/**
|
|
77
|
-
* The names of `names` a live mapping can read differently: each a `*` in
|
|
78
|
-
* one of `configs` (turbo.json texts, any string, a superset) or in Turbo's
|
|
79
|
-
* framework table matches, and each core cannot key (a literal one is
|
|
80
|
-
* dropped only when no variable has it). The kept mapping keyed on every
|
|
81
|
-
* name, so any variable that came or went (a CI step's, `VX_DEBUG_TIMING`)
|
|
82
|
-
* remapped the workspace: ~330 ms a run on vercel/ai.
|
|
83
|
-
*/
|
|
84
|
-
export function envNamesThatMap(configs: readonly string[], names: readonly string[]): string[] {
|
|
85
|
-
const patterns = new Set(FRAMEWORK_ENV.flatMap((f) => f.env).filter((e) => e.includes('*')))
|
|
86
|
-
for (const text of configs)
|
|
87
|
-
for (const m of text.matchAll(/"((?:[^"\\]|\\.)*)"/g))
|
|
88
|
-
if (m[1]!.includes('*')) patterns.add(m[1]!)
|
|
89
|
-
// Backslashes and a leading `!` dropped, every `*` a wildcard: wider
|
|
90
|
-
// than Turbo's reading, never narrower.
|
|
91
|
-
const res = [...patterns].map(
|
|
92
|
-
(p) =>
|
|
93
|
-
new RegExp(
|
|
94
|
-
`^${p.replaceAll('\\', '').replace(/^!/, '').split('*').map(RegExp.escape).join('.*')}$`,
|
|
95
|
-
),
|
|
96
|
-
)
|
|
97
|
-
return names.filter((n) => !keyable(n) || res.some((re) => re.test(n)))
|
|
98
|
-
}
|
|
99
|
-
|
|
100
78
|
/**
|
|
101
79
|
* A Turbo env list's explicit names, read as Turbo reads them
|
|
102
80
|
* (`wildcard_to_regex_pattern`): `*` is the one wildcard, `\*` a
|
|
@@ -195,83 +173,6 @@ const KNOWN_TASK_KEYS = new Set([
|
|
|
195
173
|
const OUTPUT_LOGS_DEFAULT = 'new-only'
|
|
196
174
|
const OUTPUT_LOGS_RUN_FLAG = new Set(['full', 'hash-only', 'errors-only', 'none'])
|
|
197
175
|
|
|
198
|
-
// Turbo 2's framework inference: a package that depends on a framework
|
|
199
|
-
// has its variables hashed into its tasks and passed to them, with
|
|
200
|
-
// nothing in turbo.json saying so. vx env names are explicit, so the
|
|
201
|
-
// variables were stripped in silence and a build that inlines them
|
|
202
|
-
// (Next's `NEXT_PUBLIC_*`) read empty values (item 940). A live mapping
|
|
203
|
-
// (`envNames`) infers them as Turbo does; the migrate CLI lists the names
|
|
204
|
-
// the package's own files spell (`sourceNames`) and says so in a note.
|
|
205
|
-
// Turbo's own table (`packages/turbo-types/src/json/frameworks.json`),
|
|
206
|
-
// in its order: a package takes the FIRST framework it matches, `all`
|
|
207
|
-
// needing every dependency and `some` any one.
|
|
208
|
-
const NITRO_ENV = [
|
|
209
|
-
'NITRO_*',
|
|
210
|
-
'SERVER_*',
|
|
211
|
-
'AWS_APP_ID',
|
|
212
|
-
'INPUT_AZURE_STATIC_WEB_APPS_API_TOKEN',
|
|
213
|
-
'CLEAVR',
|
|
214
|
-
'CF_PAGES',
|
|
215
|
-
'FIREBASE_APP_HOSTING',
|
|
216
|
-
'NETLIFY',
|
|
217
|
-
'STORMKIT',
|
|
218
|
-
'NOW_BUILDER',
|
|
219
|
-
'ZEABUR',
|
|
220
|
-
'RENDER',
|
|
221
|
-
]
|
|
222
|
-
const FRAMEWORK_ENV: ReadonlyArray<{
|
|
223
|
-
slug: string
|
|
224
|
-
env: readonly string[]
|
|
225
|
-
all: boolean
|
|
226
|
-
dependencies: readonly string[]
|
|
227
|
-
}> = [
|
|
228
|
-
{ slug: 'astro', env: ['PUBLIC_*'], all: true, dependencies: ['astro'] },
|
|
229
|
-
{ slug: 'blitzjs', env: ['NEXT_PUBLIC_*'], all: true, dependencies: ['blitz'] },
|
|
230
|
-
{
|
|
231
|
-
slug: 'create-react-app',
|
|
232
|
-
env: ['REACT_APP_*'],
|
|
233
|
-
all: false,
|
|
234
|
-
dependencies: ['react-scripts', 'react-dev-utils'],
|
|
235
|
-
},
|
|
236
|
-
{ slug: 'expo', env: ['EXPO_PUBLIC_*'], all: true, dependencies: ['expo'] },
|
|
237
|
-
{ slug: 'gatsby', env: ['GATSBY_*'], all: true, dependencies: ['gatsby'] },
|
|
238
|
-
{
|
|
239
|
-
slug: 'nextjs',
|
|
240
|
-
env: ['NEXT_PUBLIC_*', 'NEXT_DEPLOYMENT_ID'],
|
|
241
|
-
all: true,
|
|
242
|
-
dependencies: ['next'],
|
|
243
|
-
},
|
|
244
|
-
{
|
|
245
|
-
slug: 'nitro',
|
|
246
|
-
env: NITRO_ENV,
|
|
247
|
-
all: false,
|
|
248
|
-
dependencies: ['nitropack', 'nitropack-nightly', 'nitro', 'nitro-nightly'],
|
|
249
|
-
},
|
|
250
|
-
{
|
|
251
|
-
slug: 'nuxtjs',
|
|
252
|
-
env: ['NUXT_*', 'NUXT_ENV_*', ...NITRO_ENV, 'LAUNCH_EDITOR'],
|
|
253
|
-
all: false,
|
|
254
|
-
dependencies: ['nuxt', 'nuxt-edge', 'nuxt3', 'nuxt3-edge'],
|
|
255
|
-
},
|
|
256
|
-
{ slug: 'redwoodjs', env: ['REDWOOD_ENV_*'], all: true, dependencies: ['@redwoodjs/core'] },
|
|
257
|
-
{
|
|
258
|
-
slug: 'remix',
|
|
259
|
-
env: ['REMIX_*'],
|
|
260
|
-
all: false,
|
|
261
|
-
dependencies: ['@remix-run/dev', '@remix-run/react', '@remix-run/serve', '@react-router/dev'],
|
|
262
|
-
},
|
|
263
|
-
{ slug: 'sanity', env: ['SANITY_STUDIO_*'], all: true, dependencies: ['@sanity/cli'] },
|
|
264
|
-
{ slug: 'solidstart', env: ['VITE_*'], all: true, dependencies: ['solid-js', 'solid-start'] },
|
|
265
|
-
{ slug: 'sveltekit', env: ['VITE_*', 'PUBLIC_*'], all: true, dependencies: ['@sveltejs/kit'] },
|
|
266
|
-
{ slug: 'vite', env: ['VITE_*'], all: true, dependencies: ['vite'] },
|
|
267
|
-
{
|
|
268
|
-
slug: 'vue',
|
|
269
|
-
env: ['VUE_APP_*', 'LAUNCH_EDITOR'],
|
|
270
|
-
all: true,
|
|
271
|
-
dependencies: ['@vue/cli-service'],
|
|
272
|
-
},
|
|
273
|
-
]
|
|
274
|
-
|
|
275
176
|
/** The three global fields of turbo.json a task may draw on. */
|
|
276
177
|
export type TurboGlobal = 'inputs' | 'env' | 'pass'
|
|
277
178
|
|
|
@@ -424,45 +325,6 @@ function keyIgnored(
|
|
|
424
325
|
inputs.workspaceRuntime = [...probes, ignoredFilesProbe(hits.map(([rel]) => rel))]
|
|
425
326
|
}
|
|
426
327
|
|
|
427
|
-
/**
|
|
428
|
-
* A directory's Turbo config: `turbo.json`, else `turbo.jsonc` (Turbo 2.5+
|
|
429
|
-
* reads either). Only `turbo.json` was looked for, so a `turbo.jsonc` root
|
|
430
|
-
* failed the run with ENOENT and a package's `turbo.jsonc` overlay was
|
|
431
|
-
* skipped in silence — its `inputs` then keyed nothing (item 938).
|
|
432
|
-
*/
|
|
433
|
-
export async function turboConfigFile(dir: string): Promise<string | null> {
|
|
434
|
-
for (const name of ['turbo.json', 'turbo.jsonc']) {
|
|
435
|
-
const file = path.join(dir, name)
|
|
436
|
-
if (await Bun.file(file).exists()) return file
|
|
437
|
-
}
|
|
438
|
-
return null
|
|
439
|
-
}
|
|
440
|
-
|
|
441
|
-
/**
|
|
442
|
-
* The workspace root as a project to name when turbo.json declares `//#`
|
|
443
|
-
* tasks: its package name and directory. Null with no such task, a
|
|
444
|
-
* nameless root, or a file the mapping refuses (it names the file).
|
|
445
|
-
*/
|
|
446
|
-
export async function rootTaskProject(root: string): Promise<{ name: string; dir: string } | null> {
|
|
447
|
-
const file = await turboConfigFile(root)
|
|
448
|
-
if (file === null) return null
|
|
449
|
-
let turbo: { tasks?: unknown; pipeline?: unknown } | null
|
|
450
|
-
let pkg: { name?: unknown } | null
|
|
451
|
-
try {
|
|
452
|
-
turbo = Bun.JSONC.parse(await Bun.file(file).text()) as typeof turbo
|
|
453
|
-
pkg = (await Bun.file(path.join(root, 'package.json')).json()) as typeof pkg
|
|
454
|
-
} catch {
|
|
455
|
-
return null
|
|
456
|
-
}
|
|
457
|
-
const tasks = turbo?.tasks ?? turbo?.pipeline
|
|
458
|
-
const hasRootTask =
|
|
459
|
-
typeof tasks === 'object' &&
|
|
460
|
-
tasks !== null &&
|
|
461
|
-
Object.keys(tasks).some((k) => k.startsWith(`${ROOT}#`))
|
|
462
|
-
if (!hasRootTask || typeof pkg?.name !== 'string' || pkg.name === '') return null
|
|
463
|
-
return { name: pkg.name, dir: root }
|
|
464
|
-
}
|
|
465
|
-
|
|
466
328
|
async function readTurboJson(file: string, root: string): Promise<TurboJson> {
|
|
467
329
|
const text = await Bun.file(file).text()
|
|
468
330
|
let parsed: unknown
|
|
@@ -606,7 +468,6 @@ function badCommand(def: TurboTask): boolean {
|
|
|
606
468
|
|
|
607
469
|
/** Declared task names for a package: plain root keys, `pkg#name` keys
|
|
608
470
|
* for this package, and per-package turbo.json keys — in that order. */
|
|
609
|
-
const ROOT = '//'
|
|
610
471
|
|
|
611
472
|
/**
|
|
612
473
|
* A package's turbo.json files, root first and its own last, as Turbo's
|
|
@@ -892,36 +753,6 @@ async function rootPackageJson(root: string): Promise<PackageMeta['packageJson']
|
|
|
892
753
|
return (pkg !== null && typeof pkg === 'object' ? pkg : {}) as PackageMeta['packageJson']
|
|
893
754
|
}
|
|
894
755
|
|
|
895
|
-
/**
|
|
896
|
-
* The microfrontends configs Turbo hashes into every task: each package's
|
|
897
|
-
* (the root's too) `microfrontends.json`, else `microfrontends.jsonc`, or
|
|
898
|
-
* the one name `VC_MICROFRONTENDS_CONFIG_FILE_NAME` gives, unless it is a
|
|
899
|
-
* child's `{ "partOf": … }` (Turbo's `update_turbo_json` appends them to
|
|
900
|
-
* the root's global deps). Unread, an edit to `web`'s routes replayed every
|
|
901
|
-
* sibling app's build (with-microfrontends, Turbo 2.11's dry run moves all
|
|
902
|
-
* 18 tasks). Paths are root-relative; `text` is what the mapping read.
|
|
903
|
-
*/
|
|
904
|
-
export function microfrontendsConfigs(
|
|
905
|
-
root: string,
|
|
906
|
-
dirs: readonly string[],
|
|
907
|
-
custom = process.env['VC_MICROFRONTENDS_CONFIG_FILE_NAME'] ?? '',
|
|
908
|
-
): { rel: string; text: string }[] {
|
|
909
|
-
const names = custom === '' ? ['microfrontends.json', 'microfrontends.jsonc'] : [custom]
|
|
910
|
-
const out: { rel: string; text: string }[] = []
|
|
911
|
-
for (const dir of new Set(dirs.map((d) => path.resolve(d)))) {
|
|
912
|
-
const file = names.map((n) => path.join(dir, n)).find((f) => existsSync(f))
|
|
913
|
-
if (file === undefined) continue
|
|
914
|
-
const text = readFileSync(file, 'utf8')
|
|
915
|
-
let child = false
|
|
916
|
-
try {
|
|
917
|
-
const parsed = Bun.JSONC.parse(text) as { partOf?: unknown } | null
|
|
918
|
-
child = typeof parsed?.partOf === 'string'
|
|
919
|
-
} catch {}
|
|
920
|
-
if (!child) out.push({ rel: relPosix(root, file), text })
|
|
921
|
-
}
|
|
922
|
-
return out.sort((a, b) => (a.rel < b.rel ? -1 : 1))
|
|
923
|
-
}
|
|
924
|
-
|
|
925
756
|
export async function mapTurboWorkspace(
|
|
926
757
|
root: string,
|
|
927
758
|
metas: readonly PackageMeta[],
|
|
@@ -1177,7 +1008,7 @@ export async function mapTurboWorkspace(
|
|
|
1177
1008
|
// over its files, so `test` keys on its dependencies' sources. Dropped,
|
|
1178
1009
|
// vx keyed `test` on its own files alone: a dependency's edit was a hit.
|
|
1179
1010
|
// Each package has it as a key-only task, a keyed group, as nx()'s
|
|
1180
|
-
// `
|
|
1011
|
+
// `inputs:*` twins are.
|
|
1181
1012
|
const withScript = new Set<string>()
|
|
1182
1013
|
for (const set of runnable.values()) for (const name of set) withScript.add(name)
|
|
1183
1014
|
const sameRefs = new Set<string>()
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
// What a turbo() run reads before it knows whether its kept mapping still
|
|
2
|
+
// holds: the mapping itself (`turbo-map.ts`, ~240 KB with what it imports)
|
|
3
|
+
// loads only when it does not.
|
|
4
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
5
|
+
import path from 'node:path'
|
|
6
|
+
import { relPosix } from '../paths.js'
|
|
7
|
+
|
|
8
|
+
/** Turbo's name for the workspace root in `//#task` and `extends`. */
|
|
9
|
+
export const ROOT = '//'
|
|
10
|
+
|
|
11
|
+
// Turbo 2's framework inference: a package that depends on a framework
|
|
12
|
+
// has its variables hashed into its tasks and passed to them, with
|
|
13
|
+
// nothing in turbo.json saying so. vx env names are explicit, so the
|
|
14
|
+
// variables were stripped in silence and a build that inlines them
|
|
15
|
+
// (Next's `NEXT_PUBLIC_*`) read empty values (item 940). A live mapping
|
|
16
|
+
// (`envNames`) infers them as Turbo does; the migrate CLI lists the names
|
|
17
|
+
// the package's own files spell (`sourceNames`) and says so in a note.
|
|
18
|
+
// Turbo's own table (`packages/turbo-types/src/json/frameworks.json`),
|
|
19
|
+
// in its order: a package takes the FIRST framework it matches, `all`
|
|
20
|
+
// needing every dependency and `some` any one.
|
|
21
|
+
const NITRO_ENV = [
|
|
22
|
+
'NITRO_*',
|
|
23
|
+
'SERVER_*',
|
|
24
|
+
'AWS_APP_ID',
|
|
25
|
+
'INPUT_AZURE_STATIC_WEB_APPS_API_TOKEN',
|
|
26
|
+
'CLEAVR',
|
|
27
|
+
'CF_PAGES',
|
|
28
|
+
'FIREBASE_APP_HOSTING',
|
|
29
|
+
'NETLIFY',
|
|
30
|
+
'STORMKIT',
|
|
31
|
+
'NOW_BUILDER',
|
|
32
|
+
'ZEABUR',
|
|
33
|
+
'RENDER',
|
|
34
|
+
]
|
|
35
|
+
export const FRAMEWORK_ENV: ReadonlyArray<{
|
|
36
|
+
slug: string
|
|
37
|
+
env: readonly string[]
|
|
38
|
+
all: boolean
|
|
39
|
+
dependencies: readonly string[]
|
|
40
|
+
}> = [
|
|
41
|
+
{ slug: 'astro', env: ['PUBLIC_*'], all: true, dependencies: ['astro'] },
|
|
42
|
+
{ slug: 'blitzjs', env: ['NEXT_PUBLIC_*'], all: true, dependencies: ['blitz'] },
|
|
43
|
+
{
|
|
44
|
+
slug: 'create-react-app',
|
|
45
|
+
env: ['REACT_APP_*'],
|
|
46
|
+
all: false,
|
|
47
|
+
dependencies: ['react-scripts', 'react-dev-utils'],
|
|
48
|
+
},
|
|
49
|
+
{ slug: 'expo', env: ['EXPO_PUBLIC_*'], all: true, dependencies: ['expo'] },
|
|
50
|
+
{ slug: 'gatsby', env: ['GATSBY_*'], all: true, dependencies: ['gatsby'] },
|
|
51
|
+
{
|
|
52
|
+
slug: 'nextjs',
|
|
53
|
+
env: ['NEXT_PUBLIC_*', 'NEXT_DEPLOYMENT_ID'],
|
|
54
|
+
all: true,
|
|
55
|
+
dependencies: ['next'],
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
slug: 'nitro',
|
|
59
|
+
env: NITRO_ENV,
|
|
60
|
+
all: false,
|
|
61
|
+
dependencies: ['nitropack', 'nitropack-nightly', 'nitro', 'nitro-nightly'],
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
slug: 'nuxtjs',
|
|
65
|
+
env: ['NUXT_*', 'NUXT_ENV_*', ...NITRO_ENV, 'LAUNCH_EDITOR'],
|
|
66
|
+
all: false,
|
|
67
|
+
dependencies: ['nuxt', 'nuxt-edge', 'nuxt3', 'nuxt3-edge'],
|
|
68
|
+
},
|
|
69
|
+
{ slug: 'redwoodjs', env: ['REDWOOD_ENV_*'], all: true, dependencies: ['@redwoodjs/core'] },
|
|
70
|
+
{
|
|
71
|
+
slug: 'remix',
|
|
72
|
+
env: ['REMIX_*'],
|
|
73
|
+
all: false,
|
|
74
|
+
dependencies: ['@remix-run/dev', '@remix-run/react', '@remix-run/serve', '@react-router/dev'],
|
|
75
|
+
},
|
|
76
|
+
{ slug: 'sanity', env: ['SANITY_STUDIO_*'], all: true, dependencies: ['@sanity/cli'] },
|
|
77
|
+
{ slug: 'solidstart', env: ['VITE_*'], all: true, dependencies: ['solid-js', 'solid-start'] },
|
|
78
|
+
{ slug: 'sveltekit', env: ['VITE_*', 'PUBLIC_*'], all: true, dependencies: ['@sveltejs/kit'] },
|
|
79
|
+
{ slug: 'vite', env: ['VITE_*'], all: true, dependencies: ['vite'] },
|
|
80
|
+
{
|
|
81
|
+
slug: 'vue',
|
|
82
|
+
env: ['VUE_APP_*', 'LAUNCH_EDITOR'],
|
|
83
|
+
all: true,
|
|
84
|
+
dependencies: ['@vue/cli-service'],
|
|
85
|
+
},
|
|
86
|
+
]
|
|
87
|
+
|
|
88
|
+
/** A name core takes in `inputs.env` and `exec.env.passThrough`. */
|
|
89
|
+
export const keyable = (name: string): boolean =>
|
|
90
|
+
name.length > 0 && !name.startsWith('!') && !/[*?[\]{}=\0]/.test(name)
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* 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
|
|
96
|
+
* dropped only when no variable has it). The kept mapping keyed on every
|
|
97
|
+
* name, so any variable that came or went (a CI step's, `VX_DEBUG_TIMING`)
|
|
98
|
+
* remapped the workspace: ~330 ms a run on vercel/ai.
|
|
99
|
+
*/
|
|
100
|
+
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('*')))
|
|
102
|
+
for (const text of configs)
|
|
103
|
+
for (const m of text.matchAll(/"((?:[^"\\]|\\.)*)"/g))
|
|
104
|
+
if (m[1]!.includes('*')) patterns.add(m[1]!)
|
|
105
|
+
// Backslashes and a leading `!` dropped, every `*` a wildcard: wider
|
|
106
|
+
// than Turbo's reading, never narrower.
|
|
107
|
+
const res = [...patterns].map(
|
|
108
|
+
(p) =>
|
|
109
|
+
new RegExp(
|
|
110
|
+
`^${p.replaceAll('\\', '').replace(/^!/, '').split('*').map(RegExp.escape).join('.*')}$`,
|
|
111
|
+
),
|
|
112
|
+
)
|
|
113
|
+
return names.filter((n) => !keyable(n) || res.some((re) => re.test(n)))
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* A directory's Turbo config: `turbo.json`, else `turbo.jsonc` (Turbo 2.5+
|
|
118
|
+
* reads either). Only `turbo.json` was looked for, so a `turbo.jsonc` root
|
|
119
|
+
* failed the run with ENOENT and a package's `turbo.jsonc` overlay was
|
|
120
|
+
* skipped in silence — its `inputs` then keyed nothing (item 938).
|
|
121
|
+
*/
|
|
122
|
+
export async function turboConfigFile(dir: string): Promise<string | null> {
|
|
123
|
+
for (const name of ['turbo.json', 'turbo.jsonc']) {
|
|
124
|
+
const file = path.join(dir, name)
|
|
125
|
+
if (await Bun.file(file).exists()) return file
|
|
126
|
+
}
|
|
127
|
+
return null
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* The workspace root as a project to name when turbo.json declares `//#`
|
|
132
|
+
* tasks: its package name and directory. Null with no such task, a
|
|
133
|
+
* nameless root, or a file the mapping refuses (it names the file).
|
|
134
|
+
*/
|
|
135
|
+
export async function rootTaskProject(root: string): Promise<{ name: string; dir: string } | null> {
|
|
136
|
+
const file = await turboConfigFile(root)
|
|
137
|
+
if (file === null) return null
|
|
138
|
+
let turbo: { tasks?: unknown; pipeline?: unknown } | null
|
|
139
|
+
let pkg: { name?: unknown } | null
|
|
140
|
+
try {
|
|
141
|
+
turbo = Bun.JSONC.parse(await Bun.file(file).text()) as typeof turbo
|
|
142
|
+
pkg = (await Bun.file(path.join(root, 'package.json')).json()) as typeof pkg
|
|
143
|
+
} catch {
|
|
144
|
+
return null
|
|
145
|
+
}
|
|
146
|
+
const tasks = turbo?.tasks ?? turbo?.pipeline
|
|
147
|
+
const hasRootTask =
|
|
148
|
+
typeof tasks === 'object' &&
|
|
149
|
+
tasks !== null &&
|
|
150
|
+
Object.keys(tasks).some((k) => k.startsWith(`${ROOT}#`))
|
|
151
|
+
if (!hasRootTask || typeof pkg?.name !== 'string' || pkg.name === '') return null
|
|
152
|
+
return { name: pkg.name, dir: root }
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* The microfrontends configs Turbo hashes into every task: each package's
|
|
157
|
+
* (the root's too) `microfrontends.json`, else `microfrontends.jsonc`, or
|
|
158
|
+
* the one name `VC_MICROFRONTENDS_CONFIG_FILE_NAME` gives, unless it is a
|
|
159
|
+
* child's `{ "partOf": … }` (Turbo's `update_turbo_json` appends them to
|
|
160
|
+
* the root's global deps). Unread, an edit to `web`'s routes replayed every
|
|
161
|
+
* sibling app's build (with-microfrontends, Turbo 2.11's dry run moves all
|
|
162
|
+
* 18 tasks). Paths are root-relative; `text` is what the mapping read.
|
|
163
|
+
*/
|
|
164
|
+
export function microfrontendsConfigs(
|
|
165
|
+
root: string,
|
|
166
|
+
dirs: readonly string[],
|
|
167
|
+
custom = process.env['VC_MICROFRONTENDS_CONFIG_FILE_NAME'] ?? '',
|
|
168
|
+
): { rel: string; text: string }[] {
|
|
169
|
+
const names = custom === '' ? ['microfrontends.json', 'microfrontends.jsonc'] : [custom]
|
|
170
|
+
const out: { rel: string; text: string }[] = []
|
|
171
|
+
for (const dir of new Set(dirs.map((d) => path.resolve(d)))) {
|
|
172
|
+
const file = names.map((n) => path.join(dir, n)).find((f) => existsSync(f))
|
|
173
|
+
if (file === undefined) continue
|
|
174
|
+
const text = readFileSync(file, 'utf8')
|
|
175
|
+
let child = false
|
|
176
|
+
try {
|
|
177
|
+
const parsed = Bun.JSONC.parse(text) as { partOf?: unknown } | null
|
|
178
|
+
child = typeof parsed?.partOf === 'string'
|
|
179
|
+
} catch {}
|
|
180
|
+
if (!child) out.push({ rel: relPosix(root, file), text })
|
|
181
|
+
}
|
|
182
|
+
return out.sort((a, b) => (a.rel < b.rel ? -1 : 1))
|
|
183
|
+
}
|
package/src/turbo-cache/index.ts
CHANGED
|
@@ -18,6 +18,7 @@ import path from 'node:path'
|
|
|
18
18
|
import {
|
|
19
19
|
type ArtifactInfo,
|
|
20
20
|
definePlugin,
|
|
21
|
+
type LentAccess,
|
|
21
22
|
type RemoteCacheLayer,
|
|
22
23
|
type RemoteOpenContext,
|
|
23
24
|
type VxPlugin,
|
|
@@ -428,6 +429,9 @@ async function boundedText(res: Response, max: number): Promise<string | null> {
|
|
|
428
429
|
*/
|
|
429
430
|
const MAX_SIGNED_BODY = 2 * 1024 ** 3 + ((2 * 1024 ** 3) >> 8) + 64 * 1024
|
|
430
431
|
|
|
432
|
+
/** A lent token ending sooner than this is exchanged again first, so an agent's request never carries a spent one. */
|
|
433
|
+
const LEND_MARGIN_MS = 5 * 60_000
|
|
434
|
+
|
|
431
435
|
/**
|
|
432
436
|
* The seam implementation: `has` is HEAD, `hasMany` is the batch query,
|
|
433
437
|
* `get`/`put` carry `x-artifact-duration` (and the tag when signing). An
|
|
@@ -464,8 +468,14 @@ export class TurboRemoteCache implements RemoteCacheLayer {
|
|
|
464
468
|
private token: string | undefined
|
|
465
469
|
/** Set by `open` when the token came from the keychain: a refusal deletes it. */
|
|
466
470
|
private forget: (() => Promise<void>) | undefined
|
|
467
|
-
/** The
|
|
468
|
-
private exchanged: string
|
|
471
|
+
/** The tokens OIDC exchanges gave, the last one current, each revoked when the run ends. */
|
|
472
|
+
private exchanged: string[] = []
|
|
473
|
+
/** When the current exchanged token ends (epoch ms), when the server said. */
|
|
474
|
+
private expiresAt: number | undefined
|
|
475
|
+
/** Set by `open` when this machine has a lent token and no token of its own: read per request. */
|
|
476
|
+
private borrowed: (() => string | undefined) | undefined
|
|
477
|
+
/** The token a run running its tasks here lent (`CacheContext.lent`). */
|
|
478
|
+
lent: (() => string | undefined) | undefined
|
|
469
479
|
private readonly keychainRemote: KeychainRemote
|
|
470
480
|
/** The remote's rate limit, which every request of this client waits out. */
|
|
471
481
|
private readonly cooldown = new Cooldown()
|
|
@@ -491,26 +501,59 @@ export class TurboRemoteCache implements RemoteCacheLayer {
|
|
|
491
501
|
}
|
|
492
502
|
|
|
493
503
|
async open(ctx: RemoteOpenContext): Promise<void> {
|
|
494
|
-
|
|
504
|
+
const lent = this.lent?.() === undefined ? undefined : this.lent
|
|
505
|
+
if (this.config.oidcMissing && lent === undefined)
|
|
495
506
|
ctx.warn(
|
|
496
507
|
'oidc: GitHub gave this job no OIDC token: it needs `permissions: id-token: write` (a fork or Dependabot pull request never gets one)',
|
|
497
508
|
)
|
|
498
509
|
if (this.token !== undefined) return
|
|
510
|
+
// A lent token beats this machine's own OIDC: the server files a write
|
|
511
|
+
// by the token, so an agent's own (another workflow, another ref)
|
|
512
|
+
// filed the task where its run could not read it.
|
|
513
|
+
if (lent !== undefined) {
|
|
514
|
+
this.borrowed = lent
|
|
515
|
+
return
|
|
516
|
+
}
|
|
499
517
|
if (this.config.oidc !== undefined) {
|
|
500
518
|
try {
|
|
501
|
-
|
|
519
|
+
await this.exchange()
|
|
502
520
|
} catch (err) {
|
|
503
521
|
throw new Error(
|
|
504
522
|
`oidc: ${err instanceof Error ? err.message : String(err)}; local cache only this run`,
|
|
505
523
|
)
|
|
506
524
|
}
|
|
507
|
-
this.exchanged = this.token
|
|
508
525
|
return
|
|
509
526
|
}
|
|
510
527
|
this.token = await keychainToken(ctx, this.keychainRemote)
|
|
511
528
|
this.forget = () => ctx.forgetSecret({ key: this.keychainRemote.key })
|
|
512
529
|
}
|
|
513
530
|
|
|
531
|
+
private async exchange(): Promise<void> {
|
|
532
|
+
const got = await exchangeOidc(this.config.oidc!, this.fetchImpl, this.wait)
|
|
533
|
+
this.token = got.token
|
|
534
|
+
this.expiresAt = got.expiresAt
|
|
535
|
+
this.exchanged.push(got.token)
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/**
|
|
539
|
+
* The token an OIDC exchange gave this run, for a machine that runs its
|
|
540
|
+
* tasks (`RemoteCacheLayer.lend`); exchanged again when it ends within
|
|
541
|
+
* five minutes. A token of any other kind is never lent.
|
|
542
|
+
*/
|
|
543
|
+
async lend(): Promise<LentAccess | undefined> {
|
|
544
|
+
if (this.exchanged.length === 0) return undefined
|
|
545
|
+
if (this.expiresAt !== undefined && this.expiresAt - Date.now() < LEND_MARGIN_MS)
|
|
546
|
+
await this.exchange()
|
|
547
|
+
return {
|
|
548
|
+
token: this.token!,
|
|
549
|
+
...(this.expiresAt === undefined ? {} : { expiresAt: this.expiresAt }),
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
private bearer(): string | undefined {
|
|
554
|
+
return this.borrowed?.() ?? this.token
|
|
555
|
+
}
|
|
556
|
+
|
|
514
557
|
private url(pathname: string): string {
|
|
515
558
|
const u = new URL(`${this.config.apiUrl}/v8/artifacts${pathname}`)
|
|
516
559
|
// Turbo sends a team id only in Vercel's `team_` form (its API client's
|
|
@@ -523,7 +566,7 @@ export class TurboRemoteCache implements RemoteCacheLayer {
|
|
|
523
566
|
|
|
524
567
|
private headers(extra: Record<string, string> = {}, auth = true): Record<string, string> {
|
|
525
568
|
return {
|
|
526
|
-
...(auth ? { Authorization: `Bearer ${this.
|
|
569
|
+
...(auth ? { Authorization: `Bearer ${this.bearer()}` } : {}),
|
|
527
570
|
'x-artifact-client-interactive': process.stdout.isTTY ? '1' : '0',
|
|
528
571
|
...extra,
|
|
529
572
|
}
|
|
@@ -550,7 +593,7 @@ export class TurboRemoteCache implements RemoteCacheLayer {
|
|
|
550
593
|
const res = await this.fetchImpl(url, {
|
|
551
594
|
method: 'OPTIONS',
|
|
552
595
|
headers: {
|
|
553
|
-
Authorization: `Bearer ${this.
|
|
596
|
+
Authorization: `Bearer ${this.bearer()}`,
|
|
554
597
|
'Access-Control-Request-Method': method === 'HEAD' ? 'GET' : method,
|
|
555
598
|
'Access-Control-Request-Headers': ['Authorization', ...Object.keys(headers)].join(', '),
|
|
556
599
|
},
|
|
@@ -579,7 +622,7 @@ export class TurboRemoteCache implements RemoteCacheLayer {
|
|
|
579
622
|
pathname: string,
|
|
580
623
|
init: { body?: Blob | string; headers?: Record<string, string>; timeoutMs?: number } = {},
|
|
581
624
|
): Promise<Response | undefined> {
|
|
582
|
-
if (this.
|
|
625
|
+
if (this.bearer() === undefined) return undefined
|
|
583
626
|
const timeoutMs = init.timeoutMs ?? this.config.timeoutMs
|
|
584
627
|
// 0 is Turbo's "no deadline".
|
|
585
628
|
const signal = () => (timeoutMs === 0 ? undefined : deadline(timeoutMs))
|
|
@@ -629,12 +672,11 @@ export class TurboRemoteCache implements RemoteCacheLayer {
|
|
|
629
672
|
return res
|
|
630
673
|
}
|
|
631
674
|
|
|
632
|
-
/** Revoke
|
|
675
|
+
/** Revoke each token this run exchanged, once; a throw is the caller's warning. */
|
|
633
676
|
async revoke(): Promise<void> {
|
|
634
|
-
const
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
await revokeOidc(this.config.oidc!, token, this.fetchImpl, this.wait)
|
|
677
|
+
const tokens = this.exchanged.splice(0)
|
|
678
|
+
for (const token of tokens)
|
|
679
|
+
await revokeOidc(this.config.oidc!, token, this.fetchImpl, this.wait)
|
|
638
680
|
}
|
|
639
681
|
|
|
640
682
|
async has(hash: string): Promise<ArtifactInfo | null> {
|
|
@@ -859,6 +901,7 @@ export function turboCache(options: TurboCacheOptions = {}): VxPlugin {
|
|
|
859
901
|
// sandboxed task may read (L-45).
|
|
860
902
|
const layer = new TurboRemoteCache(config, fetch, ctx.cacheDir)
|
|
861
903
|
layer.reads = readScopes(ctx.refs)
|
|
904
|
+
layer.lent = ctx.lent
|
|
862
905
|
if (config.oidc !== undefined) opened = { layer, warn: ctx.warn }
|
|
863
906
|
return scope === 'key' ? keyedByRef(layer, ctx.refs) : layer
|
|
864
907
|
},
|
package/src/turbo-cache/oidc.ts
CHANGED
|
@@ -111,12 +111,18 @@ async function field(res: Response, name: string, what: string): Promise<string>
|
|
|
111
111
|
return value
|
|
112
112
|
}
|
|
113
113
|
|
|
114
|
+
/** An exchanged token, and when it ends (epoch ms) when the server says. */
|
|
115
|
+
export interface Exchanged {
|
|
116
|
+
readonly token: string
|
|
117
|
+
readonly expiresAt?: number
|
|
118
|
+
}
|
|
119
|
+
|
|
114
120
|
/** The job's OIDC token, exchanged for the cache's access token. Errors never quote a token. */
|
|
115
121
|
export async function exchangeOidc(
|
|
116
122
|
cfg: OidcConfig,
|
|
117
123
|
fetchImpl: typeof fetch = fetch,
|
|
118
124
|
wait: (ms: number) => Promise<void> = Bun.sleep,
|
|
119
|
-
): Promise<
|
|
125
|
+
): Promise<Exchanged> {
|
|
120
126
|
const idUrl = new URL(cfg.idTokenUrl)
|
|
121
127
|
if (cfg.audience !== undefined) idUrl.searchParams.set('audience', cfg.audience)
|
|
122
128
|
const idToken = await field(
|
|
@@ -129,22 +135,31 @@ export async function exchangeOidc(
|
|
|
129
135
|
'value',
|
|
130
136
|
`GitHub's OIDC endpoint (${where(cfg.idTokenUrl)})`,
|
|
131
137
|
)
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
wait,
|
|
144
|
-
),
|
|
145
|
-
'access_token',
|
|
146
|
-
`the token exchange (${where(cfg.tokenUrl)})`,
|
|
138
|
+
const res = await send(
|
|
139
|
+
fetchImpl,
|
|
140
|
+
cfg.tokenUrl,
|
|
141
|
+
form({
|
|
142
|
+
grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange',
|
|
143
|
+
subject_token: idToken,
|
|
144
|
+
subject_token_type: 'urn:ietf:params:oauth:token-type:id_token',
|
|
145
|
+
requested_token_type: 'urn:ietf:params:oauth:token-type:access_token',
|
|
146
|
+
...cfg.params,
|
|
147
|
+
}),
|
|
148
|
+
wait,
|
|
147
149
|
)
|
|
150
|
+
const what = `the token exchange (${where(cfg.tokenUrl)})`
|
|
151
|
+
if (!res.ok) {
|
|
152
|
+
await res.body?.cancel()
|
|
153
|
+
throw new Error(`${what} answered HTTP ${res.status}`)
|
|
154
|
+
}
|
|
155
|
+
const body = (await res.json().catch(() => null)) as Record<string, unknown> | null
|
|
156
|
+
const token = body?.['access_token']
|
|
157
|
+
if (typeof token !== 'string' || token === '') throw new Error(`${what} sent no access_token`)
|
|
158
|
+
const ttl = body?.['expires_in']
|
|
159
|
+
return {
|
|
160
|
+
token,
|
|
161
|
+
...(typeof ttl === 'number' && ttl > 0 ? { expiresAt: Date.now() + ttl * 1000 } : {}),
|
|
162
|
+
}
|
|
148
163
|
}
|
|
149
164
|
|
|
150
165
|
/** Revoke the exchanged token; a throw is the caller's warning. */
|