diff --git a/README.md b/README.md index 1f46bad301..981421ea76 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,8 @@ for large scale TypeScript monorepos. - [API Documenter](https://api-extractor.com/pages/setup/generating_docs/) - use TSDoc comments to publish an API documentation website - [Lockfile Explorer](https://lfx.rushstack.io/) - investigate and solve version conflicts for PNPM lockfiles - [TSDoc](https://tsdoc.org/) - the standard for doc comments in TypeScript code +- [Dogfooding the Rush daemon](./docs/rush/dogfooding-rush-daemon.md) - contributor guide for building this repo + with the opt-in `rush-client` daemon, built from source ## Related Repos diff --git a/apps/rush-cli-client/README.md b/apps/rush-cli-client/README.md index 483d671312..48ea3a20ab 100644 --- a/apps/rush-cli-client/README.md +++ b/apps/rush-cli-client/README.md @@ -3,6 +3,9 @@ Separate `rush-client` and `rushx-client` binaries, opt-in until cutover. Existing `rush`, `rushx`, and their reporter entrypoints are unchanged. +To try the daemon on the rushstack repository itself before a release contains it, follow the +[contributor dogfooding guide](../../docs/rush/dogfooding-rush-daemon.md). + ## Native frontend dependency The dependency on `@microsoft/rush` is intentional: it is the version-selecting @@ -126,9 +129,11 @@ changed configuration or command shape replaces the session and graph in the sam process. Environment, installed dependencies, implementation content, or selected Rush version changes require a process restart rather than patching the existing engine. Direct, inherited, and rig-based project configuration uses private native -loaders and is rechecked before execution. External plugins, `.env`, phased +loaders and is rechecked before execution. External plugins that participate in the +requested command (unassociated plugins, plugins associated with it, or plugin command-line +files that define it, its phases or parameters for either), `.env`, phased watch/install options, and unsupported event-hook scripts still use typed -pre-execution fallback; this does not exclude the built-in `install` and `update` +pre-execution fallback; plugins scoped only to other commands are permitted. This does not exclude the built-in `install` and `update` commands described above. The native Rush lock is held for preparation and each coalesced iteration, not while idle; native commands and `--no-daemon` can run after a completed request without stopping the daemon. diff --git a/common/changes/@microsoft/rush/thelarkinn-client-startup-perf_2026-09-23-22-20-00.json b/common/changes/@microsoft/rush/thelarkinn-client-startup-perf_2026-09-23-22-20-00.json new file mode 100644 index 0000000000..ed7c2b1852 --- /dev/null +++ b/common/changes/@microsoft/rush/thelarkinn-client-startup-perf_2026-09-23-22-20-00.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@microsoft/rush", + "comment": "Speed up daemon requests: include the bundled implementation in the runtime fingerprint without resolving every file's real path on each request, and resolve rig profiles asynchronously when the daemon reloads project configuration.", + "type": "patch" + } + ], + "packageName": "@microsoft/rush" +} \ No newline at end of file diff --git a/common/changes/@microsoft/rush/thelarkinn-dogfood-rush-daemon_2026-09-23-17-36-00.json b/common/changes/@microsoft/rush/thelarkinn-dogfood-rush-daemon_2026-09-23-17-36-00.json new file mode 100644 index 0000000000..2cc94db24b --- /dev/null +++ b/common/changes/@microsoft/rush/thelarkinn-dogfood-rush-daemon_2026-09-23-17-36-00.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@microsoft/rush", + "comment": "Allow daemon build and rebuild requests in repositories whose external Rush plugins are associated only with other commands, and reload the daemon generation when a configured plugin's autoinstaller package.json, cached manifest, or command-line.json changes.", + "type": "patch" + } + ], + "packageName": "@microsoft/rush" +} diff --git a/common/changes/@rushstack/rush-cli-client/thelarkinn-dogfood-rush-daemon_2026-09-23-17-36-00.json b/common/changes/@rushstack/rush-cli-client/thelarkinn-dogfood-rush-daemon_2026-09-23-17-36-00.json new file mode 100644 index 0000000000..452883b2c5 --- /dev/null +++ b/common/changes/@rushstack/rush-cli-client/thelarkinn-dogfood-rush-daemon_2026-09-23-17-36-00.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@rushstack/rush-cli-client", + "comment": "Document the plugin narrowing and link the rushstack contributor guide for dogfooding the daemon from source.", + "type": "none" + } + ], + "packageName": "@rushstack/rush-cli-client" +} diff --git a/common/changes/@rushstack/rush-client-core/thelarkinn-client-startup-perf_2026-09-23-22-20-00.json b/common/changes/@rushstack/rush-client-core/thelarkinn-client-startup-perf_2026-09-23-22-20-00.json new file mode 100644 index 0000000000..da964e5717 --- /dev/null +++ b/common/changes/@rushstack/rush-client-core/thelarkinn-client-startup-perf_2026-09-23-22-20-00.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@rushstack/rush-client-core", + "comment": "Keep waiting for a slow detached daemon start after the requesting client gives up, so later clients can use the daemon instead of finding an abandoned startup reservation.", + "type": "patch" + } + ], + "packageName": "@rushstack/rush-client-core" +} diff --git a/common/changes/@rushstack/rush-daemon/thelarkinn-dogfood-rush-daemon_2026-09-23-17-36-00.json b/common/changes/@rushstack/rush-daemon/thelarkinn-dogfood-rush-daemon_2026-09-23-17-36-00.json new file mode 100644 index 0000000000..d8691f7d62 --- /dev/null +++ b/common/changes/@rushstack/rush-daemon/thelarkinn-dogfood-rush-daemon_2026-09-23-17-36-00.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@rushstack/rush-daemon", + "comment": "Document that external Rush plugins scoped only to other commands no longer prevent daemon builds.", + "type": "none" + } + ], + "packageName": "@rushstack/rush-daemon" +} diff --git a/common/changes/@rushstack/rush-daemon/thelarkinn-windows-hide-subprocess-consoles_2026-09-23-21-30-00.json b/common/changes/@rushstack/rush-daemon/thelarkinn-windows-hide-subprocess-consoles_2026-09-23-21-30-00.json new file mode 100644 index 0000000000..1ab4d195b3 --- /dev/null +++ b/common/changes/@rushstack/rush-daemon/thelarkinn-windows-hide-subprocess-consoles_2026-09-23-21-30-00.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@rushstack/rush-daemon", + "comment": "On Windows, keep tools started by the detached daemon (Git, tar and operation shells) from opening visible console windows.", + "type": "patch" + } + ], + "packageName": "@rushstack/rush-daemon" +} \ No newline at end of file diff --git a/common/config/rush/deploy-rush-daemon-dogfood.json b/common/config/rush/deploy-rush-daemon-dogfood.json new file mode 100644 index 0000000000..c83062bf53 --- /dev/null +++ b/common/config/rush/deploy-rush-daemon-dogfood.json @@ -0,0 +1,15 @@ +/** + * This configuration file defines a deployment scenario for use with the "rush deploy" command. + * For full documentation, please see https://rushjs.io + * + * The "rush-daemon-dogfood" scenario extracts a stable, self-contained copy of the source-built + * "rush-client" and "rushx-client" binaries, including the daemon and the Rush engine they bundle. + * Contributors use it to try the opt-in Rush daemon on this repository before a release contains it: + * a daemon started from the copy keeps working while the workspace rebuilds its own Rush projects. + * See docs/rush/dogfooding-rush-daemon.md. + */ +{ + "$schema": "https://developer.microsoft.com/json-schemas/rush/v5/deploy-scenario.schema.json", + + "deploymentProjectNames": ["@rushstack/rush-cli-client"] +} diff --git a/docs/rush/dogfooding-rush-daemon.md b/docs/rush/dogfooding-rush-daemon.md new file mode 100644 index 0000000000..5ab364cda1 --- /dev/null +++ b/docs/rush/dogfooding-rush-daemon.md @@ -0,0 +1,242 @@ +# Dogfooding the Rush daemon in rushstack + +This guide is for contributors who want to build the rushstack repository with the opt-in Rush daemon +(`rush-client`, from [`apps/rush-cli-client`](../../apps/rush-cli-client/README.md)) **before** a published +Rush release contains it. Ordinary `rush`, `rushx`, and CI are unaffected: they keep running in-process +unless you opt in explicitly. + +Nothing described here is published yet, so the client is built from source. The daemon must not run +directly from the workspace's build outputs: rebuilding `@microsoft/rush-lib`, `@rushstack/rush-daemon`, or +`@rushstack/rush-cli-client` would replace modules that the long-lived daemon is executing. Instead, +`rush deploy` extracts a stable, self-contained snapshot of the built client closure into the gitignored +`common/temp/rush-daemon-dogfood` folder, and the daemon runs from that snapshot. + +## Prerequisites + +- A supported Node.js version (see `nodeSupportedVersionRange` in `rush.json`) and Git. +- A completed install: `node common/scripts/install-run-rush.js install`. +- No `.env` file in the repository root or in `~/.rush-user/`. The daemon does not support `.env` + initialization and would fall back to in-process Rush. +- The source-built Rush version must equal the repository's `rushVersion`. Compare the output of + `node -p "require('./apps/rush/package.json').version"` with the `rushVersion` field in `rush.json`. + If they differ, see [Version skew](#version-skew-and-rush_preview_version). + +> **Do not export `RUSH_DAEMON` in a shell that also runs ordinary `rush`.** The published Rush release that +> `rush.json` currently selects rejects unrecognized `RUSH_` environment variables, so +> `node common/scripts/install-run-rush.js ...` fails with +> `The following environment variables were found with the "RUSH_" prefix, but they are not recognized`. +> The shell functions below set `RUSH_DAEMON=1` only for `rush-client` invocations. +> +> **Do not add a `daemon` block to `rush.json` yet.** The published schema rejects it, which would break +> ordinary `rush` and CI. Opting in with the environment variable is sufficient. + +## 1. Build the client closure + +```bash +node common/scripts/install-run-rush.js build --to @rushstack/rush-cli-client +``` + +## 2. Create the snapshot + +If a daemon from an earlier snapshot is running, stop it first (see [Refresh](#refresh-the-snapshot)). +Then extract the snapshot with the `rush-daemon-dogfood` deployment scenario +([`common/config/rush/deploy-rush-daemon-dogfood.json`](../../common/config/rush/deploy-rush-daemon-dogfood.json)): + +```bash +node common/scripts/install-run-rush.js deploy --scenario rush-daemon-dogfood --target-folder common/temp/rush-daemon-dogfood --overwrite +``` + +The snapshot contains copies of the built Rush projects and their npm dependencies, with links that stay +inside the snapshot. To confirm that the client resolves its engine from the snapshot rather than from the +workspace, run the following from the repository root. It must print a path under +`common/temp/rush-daemon-dogfood`: + +```bash +node -p "require('fs').realpathSync(require.resolve('@microsoft/rush-lib', { paths: [require('path').resolve('common/temp/rush-daemon-dogfood/apps/rush-cli-client')] }))" +``` + +The built-in cloud build-cache plugins are not part of the snapshot. That is fine for this repository, whose +build cache is `local-only`. + +## 3. Opt in and build + +Define a `rush-client` function in the terminal you will use, from the repository root. + +Bash: + +```bash +DOGFOOD_SNAPSHOT="$PWD/common/temp/rush-daemon-dogfood" +rush-client() { RUSH_DAEMON=1 node "$DOGFOOD_SNAPSHOT/apps/rush-cli-client/bin/rush-client" "$@"; } +``` + +PowerShell: + +```powershell +$DogfoodSnapshot = "$PWD\common\temp\rush-daemon-dogfood" +function rush-client { + $env:RUSH_DAEMON = '1' + try { node "$DogfoodSnapshot\apps\rush-cli-client\bin\rush-client" @args } finally { Remove-Item Env:RUSH_DAEMON } +} +``` + +Then build as usual. The first request starts the daemon and constructs the all-project graph; later +compatible requests from the same terminal reuse it: + +```bash +rush-client build --to @rushstack/tree-pattern +rush-client build --to @rushstack/tree-pattern +``` + +Any project selection that `rush build` accepts works, including projects in the `build-tests-subspace` +subspace. `rush-client rebuild` is also supported. + +## 4. Confirm that the daemon served the build + +Enabling the daemon is not proof that a build used it: unsupported requests silently or explicitly use +in-process Rush. Check all of the following: + +1. **No fallback.** When the client does not use the daemon, it runs native Rush, which prints the + `Rush Multi-Project Build Tool` banner. If the daemon was selected but could not serve the request, stderr + also contains a line starting with `rush-client:` and ending with `using in-process Rush.` A + daemon-served build prints neither. +2. **A warm graph.** `rush-client daemon status` prints one JSON object. `workspace.graphInitialized` must be + `true`. +3. **The same process.** `pid` and `workspace.generationToken` stay the same across requests from the same + terminal. `workspace.lastReloadTier` is `0` when the request reused the existing graph, and `1` after an + in-process reload (for example, the first request, or a configuration change). The command line of that + `pid` runs `SelectedDaemonBootstrap.js` from `common/temp/rush-daemon-dogfood`, not from the workspace. +4. **Parity.** For the same selection, ordinary Rush produces the same outputs. For example, after a + daemon-served build, `node common/scripts/install-run-rush.js rebuild --only @rushstack/tree-pattern` + leaves the files in `lib-commonjs`, `lib-dts`, `lib-esm`, and `dist` unchanged. + +A warm request whose selected operations are all up to date finishes **without printing anything**, and exits +with code `0`. Unlike ordinary Rush, the daemon client does not yet print a summary, so "no output" after a +build that already ran means everything was up to date; `rush-client daemon status` confirms that it was served. + +For example, in one Bash terminal after steps 1–3: + +```bash +rush-client build --to @rushstack/tree-pattern 2>&1 | tee /tmp/dogfood-build.log +grep -E 'in-process Rush|Rush Multi-Project Build Tool' /tmp/dogfood-build.log && echo 'NOT served by the daemon' +rush-client daemon status # graphInitialized: true; note pid and generationToken +echo "// throwaway" >> libraries/tree-pattern/src/index.ts +rush-client build --to @rushstack/tree-pattern # rebuilds tree-pattern in the same daemon +rush-client daemon status # same pid and generationToken +git checkout -- libraries/tree-pattern/src/index.ts +``` + +In PowerShell, use `rush-client build --to @rushstack/tree-pattern *>&1 | Tee-Object dogfood-build.log` and +`Select-String -Path dogfood-build.log -Pattern 'in-process Rush', 'Rush Multi-Project Build Tool'`. + +## Use native Rush for one command + +Pass `--no-daemon` to run a single `rush-client` command in-process: + +```bash +rush-client build --no-daemon --to @rushstack/tree-pattern +``` + +Ordinary `node common/scripts/install-run-rush.js ...` commands also keep working while the daemon is +running. The daemon holds the Rush lock only while it prepares or executes a request, not while idle. + +## Refresh the snapshot + +The snapshot does not pick up later source changes to Rush itself; after changing or pulling changes to +`libraries/rush-lib`, `libraries/rush-daemon`, `libraries/rush-client-core`, or `apps/rush-cli-client`, refresh it. +Stop the daemon first, because `--overwrite` deletes the files that a running daemon executes: + +```bash +rush-client daemon stop +node common/scripts/install-run-rush.js build --to @rushstack/rush-cli-client +node common/scripts/install-run-rush.js deploy --scenario rush-daemon-dogfood --target-folder common/temp/rush-daemon-dogfood --overwrite +``` + +On Windows, the first daemon start from a new or refreshed snapshot can take longer than the client's 15-second +startup deadline while Windows scans the newly written files. That request then uses native Rush (with a +`rush-client: ... Using in-process Rush.` message), but the daemon finishes starting in the background and the next +request uses it. + +The daemon's identity is the canonical repository root plus the selected Rush version, so each checkout or +worktree has its own daemon, and `rush-client daemon ...` commands address the daemon for the checkout that +contains the current directory. + +## Stop and clean up + +```bash +rush-client daemon stop # prints state "shutdownAccepted" +rush-client daemon status # exits with code 1: no daemon is listening +rush-client daemon logs # launcher log, available even after the daemon stopped +``` + +Remove the snapshot with `rm -rf common/temp/rush-daemon-dogfood` (or `rush purge`, which clears all of +`common/temp`). Stop the daemon before purging or reinstalling. + +## Known limits + +- **Environment identity.** The request environment is a daemon input, apart from per-shell bookkeeping + (such as `PWD`, `SHLVL`, `TERM`, `TERM_SESSION_ID`, `WSL_INTEROP`, and SSH or tmux session handles) and the + client's own `RUSH_DAEMON` routing variables. Any other difference, such as a different `WT_SESSION` or + `VSCODE_*` value from another terminal window, or a changed `PATH`, restarts the daemon before anything runs, + and the new process then serves the request. This is not a fallback, but it costs a cold start and changes + `pid`. Run related requests from the same terminal. +- **Only phased `build` and `rebuild` use the warm engine.** `rush start` (which always watches), + `--watch`, `--install`, `--variant`, and `--node-diagnostic-dir` stay native, as do build event-hook + scripts and reporter controls such as `--output`. Keep using ordinary Rush for `install`, `update`, and + other commands. `rushx-client` keeps scripts attached to a TTY in-process. +- **No persistent Heft or TypeScript workers.** Each operation still starts its Heft process; the daemon + saves Rush startup and graph construction, not compilation. The `usePersistentIpcRunners`/`daemonIpc` + mode requires a bundled, self-contained worker entry point, and Heft is not packaged that way. +- **Plugins.** This repository's only configured plugin, `@rushstack/rush-published-versions-json-plugin`, + is associated only with `record-published-versions` and is inert for builds. A plugin without + `associatedCommands`, a plugin associated with `build` or `rebuild`, or a plugin command-line that defines + the command, one of its phases, or a parameter for either would make those builds fall back to native Rush. +- **Windows.** Native Windows validation of the daemon code saw unresolved, intermittent failures in which + Git `hash-object --stdin-paths` exited with `0xC0000142` (DLL initialization failed) while the daemon + captured workspace snapshots under Jest. Direct fixtures did not reproduce it, and it did not occur while + this workflow was validated on native Windows. The cause is unresolved, so treat it as an open risk: if a + daemon-served build fails this way, retry it with `--no-daemon` and report the failure. + The daemon runs without a console, so it starts its tools (Git, tar and operation shells) with hidden + windows. Daemons started from a snapshot older than that change open a visible terminal window for each + tool; if you see that, [refresh the snapshot](#refresh-the-snapshot). +- **CI** stays in-process unless `RUSH_DAEMON=1` is set; do not set it in CI workflows. + +## Version skew and `RUSH_PREVIEW_VERSION` + +`rush-client` runs the daemon with its bundled engine only if that engine's version equals the selected Rush +version (`RUSH_PREVIEW_VERSION`, otherwise `rushVersion` in `rush.json`). Today both are the same, so the +snapshot's engine is used. If a release bumps `apps/rush` and `libraries/rush-lib` without updating +`rushVersion`, the client instead looks for a daemon-capable published release of the selected version. +None exists, so every build falls back to native Rush, with a `rush-client: ... Using in-process Rush.` +message (see [Confirm](#4-confirm-that-the-daemon-served-the-build)). + +To keep dogfooding in that state, select the snapshot's exact version for `rush-client` invocations only: + +```bash +DOGFOOD_VERSION=$(node -p "require('./common/temp/rush-daemon-dogfood/libraries/rush-lib/package.json').version") +rush-client() { RUSH_DAEMON=1 RUSH_PREVIEW_VERSION="$DOGFOOD_VERSION" node "$DOGFOOD_SNAPSHOT/apps/rush-cli-client/bin/rush-client" "$@"; } +``` + +Side effects: + +- The build runs with the snapshot's engine rather than the repository's selected release. +- `RUSH_PREVIEW_VERSION` is part of the daemon's identity and environment, so `rush-client daemon status` + and `rush-client daemon stop` address the matching daemon only when the same value is set. +- A native fallback, including `--no-daemon`, also selects that version. Because it equals the snapshot's + own frontend version, the snapshot's source-built Rush runs in-process, printing the + `RUSH_PREVIEW_VERSION` warning banner, rather than the repository's selected release. Use ordinary + `node common/scripts/install-run-rush.js ...` for builds with the selected release. +- Never export `RUSH_PREVIEW_VERSION` to ordinary `rush`; it would try to install that version from npm. + +## After a release contains the daemon + +Once a published Rush release includes the daemon, the standalone client, and the plugin narrowing that lets +this repository's command-scoped plugin coexist with daemon builds: + +1. Update `rushVersion` in `rush.json` to that release (or a later one). +2. Optionally add `"daemon": { "enabled": true }` to `rush.json`, so that `rush-client` uses the daemon + without `RUSH_DAEMON=1`. CI still stays in-process unless it sets `RUSH_DAEMON=1`, and `RUSH_DAEMON=0` + opts a shell out. +3. Install the published client whose engine matches `rushVersion`, for example + `npm install --global @rushstack/rush-cli-client`, and run `rush-client` directly instead of the snapshot. +4. Stop any snapshot daemon and delete `common/temp/rush-daemon-dogfood`. The `rush-daemon-dogfood` + deployment scenario remains useful for trying unreleased daemon changes. diff --git a/docs/rush/environment-variables.md b/docs/rush/environment-variables.md index b1664e9c73..35f266c2f2 100644 --- a/docs/rush/environment-variables.md +++ b/docs/rush/environment-variables.md @@ -50,6 +50,11 @@ Environment-only opt-in avoids adding a `daemon` block that an older Rush schema `RUSH_PREVIEW_VERSION` continues to select the requested Rush engine; a matching daemon must actually be available. A client never relabels its bundled engine to satisfy a different version. +Releases that predate these variables reject unknown `RUSH_` names. While a repository's `rushVersion` +selects such a release, set `RUSH_DAEMON` only for `rush-client` invocations rather than exporting it +to a shell that also runs ordinary `rush`. The [contributor dogfooding guide](./dogfooding-rush-daemon.md) +shows this for the rushstack repository. + See the [client configuration and management reference](../../apps/rush-cli-client/README.md) for launcher selection, lifecycle commands, persistent-worker restrictions and status fields. diff --git a/libraries/rush-client-core/README.md b/libraries/rush-client-core/README.md index 6c1afe75e6..9341bb96d3 100644 --- a/libraries/rush-client-core/README.md +++ b/libraries/rush-client-core/README.md @@ -46,7 +46,10 @@ file sharing on Windows. The winning client rechecks readiness, reclaims only an absent/dead owner, and reserves `.starting` before handing the explicit command to a detached startup helper. The helper spawns without a shell and retains that reservation until the daemon completes hello/ping readiness, -independently of whether the requesting client survives. Clients still await +independently of whether the requesting client survives. It waits for a live launcher for +at least 120 seconds, even when the requesting client's own deadline is shorter, so a slow +first start (for example while Windows scans newly installed files) is still handed off to +later clients instead of leaving an abandoned reservation. Clients still await hello/pong under bounded backoff. Stdout/stderr go to `.log`. No PID is killed; a live (possibly reused) PID with an unreachable socket fails closed. The helper uses a stable tool cwd, and the starting client awaits its exit after diff --git a/libraries/rush-client-core/src/connectOrStartDaemon.ts b/libraries/rush-client-core/src/connectOrStartDaemon.ts index 866bde97c4..9f2382deaa 100644 --- a/libraries/rush-client-core/src/connectOrStartDaemon.ts +++ b/libraries/rush-client-core/src/connectOrStartDaemon.ts @@ -34,6 +34,13 @@ interface IStartupHelper { readonly closed: Promise; } +/** + * The minimum time the detached helper waits for a live launcher to become ready. It is independent of the + * requesting client's deadline: a slow first start (for example while Windows scans newly installed files) + * would otherwise leave a retained reservation that keeps every later client from using the ready daemon. + */ +const STARTUP_HELPER_READINESS_TIMEOUT_MS: number = 120_000; + /** A version-selected launch command supplied by the embedding application, never guessed by the core. @beta */ export interface IDaemonStartCommand { readonly command: string; @@ -491,7 +498,7 @@ async function spawnDetachedAsync( paths: options.paths, startCommand: start, token, - timeoutMs: Math.max(1, deadline - Date.now()) + timeoutMs: Math.max(STARTUP_HELPER_READINESS_TIMEOUT_MS, deadline - Date.now()) }; let delivered: boolean = false; try { diff --git a/libraries/rush-client-core/src/test/connectOrStartDaemon.test.ts b/libraries/rush-client-core/src/test/connectOrStartDaemon.test.ts index dc4275cd74..471690bede 100644 --- a/libraries/rush-client-core/src/test/connectOrStartDaemon.test.ts +++ b/libraries/rush-client-core/src/test/connectOrStartDaemon.test.ts @@ -147,6 +147,25 @@ describe('detached daemon startup', () => { expect(fs.existsSync(getDaemonStartupFilePath(paths))).toBe(false); }, 15000); + it('lets the detached helper finish a startup that outlasts the requesting client', async () => { + fs.writeFileSync(path.join(folder, 'hold-prebind'), ''); + const first = startClient({ ...options, startupTimeoutMs: 1000 }); + const barrier: string = path.join(folder, 'prebind'); + const deadline: number = Date.now() + 5000; + while (!fs.existsSync(barrier) && Date.now() < deadline) await delayAsync(20); + const daemonPid: number = Number(fs.readFileSync(barrier, 'utf8')); + expect((await first.result).code).not.toBe(0); + expect(fs.existsSync(getDaemonStartupFilePath(paths))).toBe(true); + + // The launcher becomes ready only after the first client has given up. + fs.unlinkSync(path.join(folder, 'hold-prebind')); + const client = await connectOrStartDaemonAsync(options); + expect((await client.status).pid).toBe(daemonPid); + await client.closeAsync(); + expect(fs.readFileSync(path.join(folder, 'starts'), 'utf8')).toBe(`${daemonPid}\n`); + expect(fs.existsSync(getDaemonStartupFilePath(paths))).toBe(false); + }, 15000); + it('preserves an unresolved startup reservation rather than trusting or reclaiming its contents', async () => { const startupPath: string = getDaemonStartupFilePath(paths); const contents: string = JSON.stringify({ pid: process.pid, startedAt: 'not an ownership contract' }); diff --git a/libraries/rush-daemon/README.md b/libraries/rush-daemon/README.md index 7979146a41..36f41ddad2 100644 --- a/libraries/rush-daemon/README.md +++ b/libraries/rush-daemon/README.md @@ -116,9 +116,14 @@ the graph/cache settings with the construction snapshot. Changed inherited or ri generation reload before execution, even outside watcher roots or in ignored `node_modules` files. The retained graph and its cache policy are never patched in place. -External Rush plugins, `.env` initialization, watch/install/variant +External Rush plugins that Rush would initialize for the requested command (plugins without `associatedCommands`, +or associated with that command) or whose cached command-line.json defines that command, one of its phases, or a +parameter associated with either are rejected, as are `.env` initialization, watch/install/variant and diagnostic-directory options, build event-hook scripts (unless explicitly ignored), and arbitrary global -commands are rejected by the phased path, not silently bypassed. Native Rushx is handled separately below. +commands; they are rejected by the phased path, not silently bypassed. Configured plugins that are scoped only to +other commands are inert for the build and are permitted; their autoinstaller `package.json` and cached manifest and +command-line files are workspace definitions, so changing them reloads the generation. An unreadable manifest or +command-line file is rejected. Native Rushx is handled separately below. For phased commands, a changed request environment requires a new process, including Rush/cache policy variables (the volatile variables listed above excepted). These restrictions remain until the corresponding initialization, environment, and resource-lifetime contracts are request-scoped. @@ -147,6 +152,11 @@ an installation using native Rush package-install APIs; the host does not instal packages during restart. Selection probes foreign runtimes in isolation, and launch rechecks the actual engine version and protocol before binding. An unavailable or incompatible installation is never impersonated by the bundled engine. + +On Windows the standalone launcher starts the daemon detached, without a console. Before serving, the launched +daemon therefore makes `windowsHide: true` the default for every `node:child_process` call that does not choose a +value, so Git, tar, operation shells and plugin tools do not each open a visible console window. Their descendants +inherit the resulting windowless console. Embedded hosts, which own their process, are not changed. Embedded `RushDaemonHost` users can provide `getSuccessorLaunchAsync`, returning the existing core `IDaemonStartCommand` plus the expected daemon implementation version. Selection is validated before shutdown; an unavailable selected Rush version fails explicitly and is never run by the current engine under a false version. diff --git a/libraries/rush-daemon/src/ProductionDaemonRequestResolver.ts b/libraries/rush-daemon/src/ProductionDaemonRequestResolver.ts index 4bc77ac193..a429ad1ff1 100644 --- a/libraries/rush-daemon/src/ProductionDaemonRequestResolver.ts +++ b/libraries/rush-daemon/src/ProductionDaemonRequestResolver.ts @@ -40,8 +40,9 @@ import type { IWorkspaceResolverLifecycle } from './WorkspaceResolverLifecycle'; * @remarks * A host is pinned to its first command and non-selection parameters. Incompatible parameters, * environments, or graph inputs are rejected before scheduling; no request is retried automatically. - * The initial supported surface excludes external plugins, .env initialization, install/watch, - * event-hook scripts, and rushx/global commands. Use the unchanged native CLI for those surfaces. + * The initial supported surface excludes external plugins that participate in the requested command, + * .env initialization, install/watch, event-hook scripts, and rushx/global commands. Use the unchanged + * native CLI for those surfaces. * @beta */ export class ProductionDaemonRequestResolver implements IDaemonRequestResolver { diff --git a/libraries/rush-daemon/src/SelectedDaemonBootstrap.ts b/libraries/rush-daemon/src/SelectedDaemonBootstrap.ts index 7b8a6b1d2c..ee8d7b1e97 100644 --- a/libraries/rush-daemon/src/SelectedDaemonBootstrap.ts +++ b/libraries/rush-daemon/src/SelectedDaemonBootstrap.ts @@ -13,6 +13,7 @@ import { type IDaemonInstallationMetadata, type IInstalledDaemonLauncher } from './DaemonInstallation'; +import { installWindowsHideDefault } from './WindowsSubprocessConsoles'; async function mainAsync(): Promise { const [mode, packageJsonPath, expectedVersion, repoRoot] = process.argv.slice(2); @@ -95,6 +96,10 @@ async function mainAsync(): Promise { configured.daemon, process.env ); + if (process.platform === 'win32') { + // This process was started detached, without a console; keep its tools from opening console windows. + installWindowsHideDefault(); + } await serveRushDaemonAsync({ repoRoot, rushVersion: installation.rushVersion, diff --git a/libraries/rush-daemon/src/WindowsSubprocessConsoles.ts b/libraries/rush-daemon/src/WindowsSubprocessConsoles.ts new file mode 100644 index 0000000000..9551a0f04b --- /dev/null +++ b/libraries/rush-daemon/src/WindowsSubprocessConsoles.ts @@ -0,0 +1,89 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import childProcessModule from 'node:child_process'; +import { syncBuiltinESMExports } from 'node:module'; + +type ChildProcessFunction = (...args: unknown[]) => unknown; + +/** + * The index of the options argument of a child_process function, given its arguments. + */ +type OptionsIndexSelector = (args: ReadonlyArray) => number; + +const installedTargets: WeakSet = new WeakSet(); + +/** `(command, args?, options?, callback?)`: spawn, spawnSync, execFile, execFileSync and fork. */ +function afterOptionalArgs(args: ReadonlyArray): number { + return Array.isArray(args[1]) || (args[1] === undefined && args.length > 2) || args[1] === null ? 2 : 1; +} + +/** `(command, options?, callback?)`: exec and execSync. */ +function afterCommand(): number { + return 1; +} + +const OPTIONS_INDEX_BY_FUNCTION: ReadonlyMap = new Map([ + ['spawn', afterOptionalArgs], + ['spawnSync', afterOptionalArgs], + ['execFile', afterOptionalArgs], + ['execFileSync', afterOptionalArgs], + ['fork', afterOptionalArgs], + ['exec', afterCommand], + ['execSync', afterCommand] +]); + +/** + * Returns the arguments with `windowsHide: true` added to the options, unless the caller specified it. + */ +export function withWindowsHideDefault(args: ReadonlyArray, optionsIndex: number): unknown[] { + const result: unknown[] = [...args]; + const options: unknown = result[optionsIndex]; + if (options === undefined || options === null) { + result[optionsIndex] = { windowsHide: true }; + } else if (typeof options === 'function') { + // A callback occupies the options position, as in `execFile(file, callback)`. + result.splice(optionsIndex, 0, { windowsHide: true }); + } else if ( + typeof options === 'object' && + (options as { windowsHide?: unknown }).windowsHide === undefined + ) { + result[optionsIndex] = { ...options, windowsHide: true }; + } + return result; +} + +/** + * Makes every child process started by this process default to `windowsHide: true`. + * + * @remarks + * The standalone daemon is started as a detached Windows process, which has no console. Windows then + * gives each console program it starts (Git, tar, the shell running an operation, and so on) a new, + * visible console window, unless that program is started with `windowsHide`. A hidden child instead + * owns a windowless console, which its own descendants inherit, so defaulting this process's direct + * children is sufficient. Library and plugin code starts those children, so the default is applied + * where they all resolve it: the `node:child_process` module. Explicit `windowsHide` values are kept. + * + * Only the dedicated daemon process calls this, and only on Windows. It is idempotent. + */ +export function installWindowsHideDefault( + target: Record = childProcessModule as unknown as Record +): void { + if (installedTargets.has(target)) { + return; + } + installedTargets.add(target); + for (const [name, getOptionsIndex] of OPTIONS_INDEX_BY_FUNCTION) { + const original: ChildProcessFunction = target[name] as ChildProcessFunction; + if (typeof original !== 'function') { + continue; + } + target[name] = function (this: unknown, ...args: unknown[]): unknown { + return original.apply(this, withWindowsHideDefault(args, getOptionsIndex(args))); + }; + } + if (target === (childProcessModule as unknown as Record)) { + // Also update the named exports seen by ECMAScript modules. + syncBuiltinESMExports(); + } +} diff --git a/libraries/rush-daemon/src/test/WindowsSubprocessConsoles.test.ts b/libraries/rush-daemon/src/test/WindowsSubprocessConsoles.test.ts new file mode 100644 index 0000000000..3241c18ca5 --- /dev/null +++ b/libraries/rush-daemon/src/test/WindowsSubprocessConsoles.test.ts @@ -0,0 +1,112 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as childProcess from 'node:child_process'; + +import { installWindowsHideDefault, withWindowsHideDefault } from '../WindowsSubprocessConsoles'; + +describe('Windows subprocess console defaults', () => { + const callback = (): void => {}; + + it('adds windowsHide to every child_process call shape unless the caller chose a value', () => { + expect(withWindowsHideDefault(['git'], 1)).toEqual(['git', { windowsHide: true }]); + expect(withWindowsHideDefault(['git', ['status']], 2)).toEqual([ + 'git', + ['status'], + { windowsHide: true } + ]); + expect(withWindowsHideDefault(['git', ['status'], { cwd: '/repo' }], 2)).toEqual([ + 'git', + ['status'], + { cwd: '/repo', windowsHide: true } + ]); + expect(withWindowsHideDefault(['git', { stdio: 'pipe' }], 1)).toEqual([ + 'git', + { stdio: 'pipe', windowsHide: true } + ]); + expect(withWindowsHideDefault(['git', callback], 1)).toEqual(['git', { windowsHide: true }, callback]); + expect(withWindowsHideDefault(['git', ['status'], callback], 2)).toEqual([ + 'git', + ['status'], + { windowsHide: true }, + callback + ]); + expect(withWindowsHideDefault(['git', ['status'], { windowsHide: false }], 2)).toEqual([ + 'git', + ['status'], + { windowsHide: false } + ]); + expect(withWindowsHideDefault(['git', ['status'], { windowsHide: undefined }], 2)).toEqual([ + 'git', + ['status'], + { windowsHide: true } + ]); + }); + + it('does not modify the caller options object', () => { + const options: childProcess.SpawnOptions = { cwd: '/repo' }; + withWindowsHideDefault(['git', [], options], 2); + expect(options).toEqual({ cwd: '/repo' }); + }); + + it('wraps each child_process function once, preserving results and argument positions', () => { + const calls: Array<[string, unknown[]]> = []; + const record = + (name: string): ((...args: unknown[]) => string) => + (...args: unknown[]): string => { + calls.push([name, args]); + return name; + }; + const target: Record = { + spawn: record('spawn'), + spawnSync: record('spawnSync'), + execFile: record('execFile'), + execFileSync: record('execFileSync'), + fork: record('fork'), + exec: record('exec'), + execSync: record('execSync'), + unrelated: record('unrelated') + }; + const unrelated: unknown = target.unrelated; + installWindowsHideDefault(target); + const spawn: unknown = target.spawn; + installWindowsHideDefault(target); + expect(target.spawn).toBe(spawn); + expect(target.unrelated).toBe(unrelated); + + const call = (name: string, ...args: unknown[]): unknown => + (target[name] as (...callArgs: unknown[]) => unknown)(...args); + expect(call('spawn', 'git', ['status'])).toBe('spawn'); + call('spawnSync', 'git', { encoding: 'utf8' }); + call('execFile', 'git', ['status'], callback); + call('execFileSync', 'git', ['status'], { windowsHide: false }); + call('fork', './worker.js', ['--flag']); + call('exec', 'git status', callback); + call('execSync', 'git status'); + call('unrelated', 'git'); + expect(calls).toEqual([ + ['spawn', ['git', ['status'], { windowsHide: true }]], + ['spawnSync', ['git', { encoding: 'utf8', windowsHide: true }]], + ['execFile', ['git', ['status'], { windowsHide: true }, callback]], + ['execFileSync', ['git', ['status'], { windowsHide: false }]], + ['fork', ['./worker.js', ['--flag'], { windowsHide: true }]], + ['exec', ['git status', { windowsHide: true }, callback]], + ['execSync', ['git status', { windowsHide: true }]], + ['unrelated', ['git']] + ]); + }); + + it('still runs real child processes through the wrapped functions', () => { + // A copy, so that this test does not change the child_process module shared with other tests. + const target: Record = { ...childProcess }; + installWindowsHideDefault(target); + const spawnSync: typeof childProcess.spawnSync = target.spawnSync as typeof childProcess.spawnSync; + const result: childProcess.SpawnSyncReturns = spawnSync( + process.execPath, + ['-e', 'process.stdout.write("ok")'], + { encoding: 'utf8' } + ); + expect(result.status).toBe(0); + expect(result.stdout).toBe('ok'); + }); +}); diff --git a/libraries/rush-lib/src/api/PhasedCommandEngine.ts b/libraries/rush-lib/src/api/PhasedCommandEngine.ts index 0fefc3fddf..98b48a9b4c 100644 --- a/libraries/rush-lib/src/api/PhasedCommandEngine.ts +++ b/libraries/rush-lib/src/api/PhasedCommandEngine.ts @@ -49,8 +49,10 @@ export interface IParsePhasedCommandOptions { * * @remarks * The initial engine surface deliberately rejects watch/install, event-hook scripts, .env files, and - * externally supplied plugins. Those require request-scoped initialization and asynchronous disposal - * contracts before they can safely run in a shared process. Native graph/cache plugins are not replaced. + * external plugins that Rush would initialize for the command or whose command-line.json shapes it. + * Those require request-scoped initialization and asynchronous disposal contracts before they can + * safely run in a shared process. Plugins associated only with other commands are inert and permitted. + * Native graph/cache plugins are not replaced. * @alpha */ export class PhasedCommandEngine { @@ -74,9 +76,6 @@ export class PhasedCommandEngine { if (argv.length === 0 || argv.includes('--help') || argv.includes('-h')) { throw new Error('Command help must be handled by the native CLI, not by an engine request.'); } - if (rushConfiguration._rushPluginsConfiguration.configuration.plugins.length > 0) { - throw new Error('Daemon engine execution does not yet support external Rush plugins. Use --no-daemon.'); - } for (const folder of [rushConfiguration.rushJsonFolder, RushUserConfiguration.getRushUserFolderPath()]) { if (FileSystem.exists(path.join(folder, '.env'))) { throw new Error('Daemon engine execution does not yet support .env initialization. Use --no-daemon.'); @@ -91,6 +90,18 @@ export class PhasedCommandEngine { if (!(action instanceof PhasedScriptAction) || !['build', 'rebuild'].includes(action.actionName)) { throw new Error('The production daemon engine currently supports native build and rebuild only.'); } + // Plugins which the command would never initialize, and whose command-line.json does not shape + // this command, cannot affect a shared engine. Every other external plugin still requires native Rush. + const participatingPlugins: ReadonlyArray = parser.pluginManager.getPluginsParticipatingInCommand( + action.actionName, + action.schedulablePhaseNames + ); + if (participatingPlugins.length > 0) { + throw new Error( + `Daemon engine execution does not yet support Rush plugins that participate in "${action.actionName}": ` + + `${participatingPlugins.join('; ')}. Use --no-daemon.` + ); + } action.validateEngineCommand(); return new PhasedCommandEngine(parser, action); } diff --git a/libraries/rush-lib/src/api/RushProjectConfiguration.ts b/libraries/rush-lib/src/api/RushProjectConfiguration.ts index 9a621e544a..d3e356756b 100644 --- a/libraries/rush-lib/src/api/RushProjectConfiguration.ts +++ b/libraries/rush-lib/src/api/RushProjectConfiguration.ts @@ -6,7 +6,7 @@ import * as path from 'node:path'; import { AlreadyReportedError, Async, FileSystem, JsonFile, Path } from '@rushstack/node-core-library'; import type { ITerminal } from '@rushstack/terminal'; import { ProjectConfigurationFile, InheritanceType } from '@rushstack/heft-config-file'; -import { RigConfig, type IRigConfigJson } from '@rushstack/rig-package'; +import { RigConfig, type IRigConfigJson, type ILoadForProjectFolderOptions } from '@rushstack/rig-package'; import type { RushConfigurationProject } from './RushConfigurationProject'; import { RushConstants } from '../logic/RushConstants'; @@ -712,10 +712,22 @@ async function loadIsolatedRigConfigAsync(projectFolder: string): Promise[] = []; for (const filename of Array.from(filenames).sort()) { try { + // statSync follows links, so dev and ino identify the file that is loaded. Its resolved path is + // recomputed whenever that identity changes, which avoids a costly realpath for every unchanged file. const stat: fsSync.BigIntStats = fsSync.statSync(filename, { bigint: true }); - const realPath: string = fsSync.realpathSync(filename); - const stamp: string = `${realPath}:${stat.dev}:${stat.ino}:${stat.size}:${stat.mtimeNs}:${stat.ctimeNs}`; + const stamp: string = `${stat.dev}:${stat.ino}:${stat.size}:${stat.mtimeNs}:${stat.ctimeNs}`; let cached: { stamp: string; entry: ReadonlyArray } | undefined = this._files.get(filename); if (cached?.stamp !== stamp) { cached = { stamp, entry: [ filename, - realPath, + fsSync.realpathSync(filename), createHash('sha256').update(fsSync.readFileSync(filename)).digest('hex') ] }; @@ -238,6 +240,15 @@ export async function captureWorkspaceInputFingerprintAsync( for (const filename of configurationFiles) { (isProcessBoundConfiguration(filename) ? installation : definitions).add(filename); } + // Configured plugins shape the command-line parser even when they are never loaded for a command. + for (const pluginConfiguration of rushConfiguration._rushPluginsConfiguration.configuration.plugins) { + for (const filename of AutoinstallerPluginLoader.getPluginShapeFilePaths( + rushConfiguration, + pluginConfiguration + )) { + definitions.add(filename); + } + } for (const project of rushJson.projects) { const projectFolder: string = path.resolve(root, project.projectFolder); if (!Path.isUnderOrEqual(projectFolder, root)) { @@ -259,6 +270,8 @@ export async function captureWorkspaceInputFingerprintAsync( const runtimePaths: string[] = [ path.join(packageFolder, 'package.json'), path.join(packageFolder, 'lib-commonjs'), + // In a bundled Rush, lib-commonjs only forwards to the bundle chunks, which contain the implementation. + path.join(packageFolder, 'dist'), ...(options.runtimePaths ?? []) ]; const runtimeHash: string = (options.runtimeCache ?? new WorkspaceRuntimeFingerprintCache())._hashPaths( diff --git a/libraries/rush-lib/src/api/test/PhasedCommandEngine.test.ts b/libraries/rush-lib/src/api/test/PhasedCommandEngine.test.ts new file mode 100644 index 0000000000..1faae1ece9 --- /dev/null +++ b/libraries/rush-lib/src/api/test/PhasedCommandEngine.test.ts @@ -0,0 +1,205 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import { NoOpTerminalProvider } from '@rushstack/terminal'; + +import { PhasedCommandEngine } from '../PhasedCommandEngine'; +import { RushConfiguration } from '../RushConfiguration'; + +const PACKAGE_NAME: string = '@example/rush-example-plugin'; +const PLUGIN_NAME: string = 'rush-example-plugin'; +const PLUGIN_COMMAND: string = 'record-example'; + +interface IPluginFixture { + readonly associatedCommands?: string[]; + readonly commandLineJson?: object; + readonly writeManifest?: boolean; +} + +// Mirrors the command-scoped plugin that rushstack configures in common/config/rush/rush-plugins.json. +const COMMAND_SCOPED_COMMAND_LINE_JSON: object = { + commands: [{ commandKind: 'globalPlugin', name: PLUGIN_COMMAND, summary: 'Records an example.' }], + parameters: [ + { + parameterKind: 'string', + longName: '--output-path', + argumentName: 'FILE_PATH', + description: 'The output path.', + associatedCommands: [PLUGIN_COMMAND] + } + ] +}; + +const REPO_COMMAND_LINE_JSON: object = { + commands: [ + { + commandKind: 'phased', + name: 'build', + summary: 'Build', + phases: ['_phase:build'], + enableParallelism: true, + incremental: true + } + ], + phases: [{ name: '_phase:build', dependencies: { upstream: ['_phase:build'] } }] +}; + +function createRepo(plugin: IPluginFixture): string { + const folder: string = fs.mkdtempSync(path.join(os.tmpdir(), 'rush-engine-plugins-')); + const write = (relativePath: string, json: object): void => { + const filename: string = path.join(folder, relativePath); + fs.mkdirSync(path.dirname(filename), { recursive: true }); + fs.writeFileSync(filename, JSON.stringify(json)); + }; + write('rush.json', { rushVersion: '5.179.0', pnpmVersion: '10.27.0', projects: [] }); + write('common/config/rush/command-line.json', REPO_COMMAND_LINE_JSON); + write('common/config/rush/rush-plugins.json', { + plugins: [{ packageName: PACKAGE_NAME, pluginName: PLUGIN_NAME, autoinstallerName: 'plugins' }] + }); + write('common/autoinstallers/plugins/package.json', { + name: 'plugins', + version: '1.0.0', + private: true, + dependencies: { [PACKAGE_NAME]: '1.0.0' } + }); + const storeFolder: string = `common/autoinstallers/plugins/rush-plugins/${PACKAGE_NAME}`; + if (plugin.writeManifest !== false) { + write(`${storeFolder}/rush-plugin-manifest.json`, { + plugins: [ + { + pluginName: PLUGIN_NAME, + description: 'An example plugin.', + entryPoint: './lib/index.js', + associatedCommands: plugin.associatedCommands, + commandLineJsonFilePath: './command-line.json' + } + ] + }); + } + if (plugin.commandLineJson) { + write(`${storeFolder}/${PLUGIN_NAME}/command-line.json`, plugin.commandLineJson); + } + return folder; +} + +async function parseBuildAsync(folder: string, argv: string[] = ['build']): Promise { + const rushConfiguration: RushConfiguration = RushConfiguration.loadFromConfigurationFile( + path.join(folder, 'rush.json') + ); + return await PhasedCommandEngine.parseAsync({ + argv, + cwd: folder, + rushConfiguration, + terminalProvider: new NoOpTerminalProvider() + }); +} + +describe(PhasedCommandEngine.name, () => { + const folders: string[] = []; + function createTestRepo(plugin: IPluginFixture): string { + const folder: string = createRepo(plugin); + folders.push(folder); + return folder; + } + + afterEach(() => { + for (const folder of folders.splice(0)) { + fs.rmSync(folder, { recursive: true, force: true }); + } + }); + + it('accepts a plugin that is associated with, and only shapes, a different command', async () => { + const folder: string = createTestRepo({ + associatedCommands: [PLUGIN_COMMAND], + commandLineJson: COMMAND_SCOPED_COMMAND_LINE_JSON + }); + for (const commandName of ['build', 'rebuild']) { + const command: PhasedCommandEngine = await parseBuildAsync(folder, [commandName]); + expect(command.commandName).toBe(commandName); + } + }); + + it('accepts a plugin associated with no commands and without a command-line.json', async () => { + const folder: string = createTestRepo({ associatedCommands: [] }); + const command: PhasedCommandEngine = await parseBuildAsync(folder); + expect(command.commandName).toBe('build'); + }); + + it('rejects an unassociated plugin, which Rush initializes for every command', async () => { + const folder: string = createTestRepo({ commandLineJson: COMMAND_SCOPED_COMMAND_LINE_JSON }); + await expect(parseBuildAsync(folder)).rejects.toThrow( + `Daemon engine execution does not yet support Rush plugins that participate in "build": ` + + `"${PLUGIN_NAME}" (${PACKAGE_NAME}) is initialized for every command. Use --no-daemon.` + ); + }); + + it('rejects a plugin associated with the requested command', async () => { + const folder: string = createTestRepo({ + associatedCommands: [PLUGIN_COMMAND, 'build'], + commandLineJson: COMMAND_SCOPED_COMMAND_LINE_JSON + }); + await expect(parseBuildAsync(folder)).rejects.toThrow( + `"${PLUGIN_NAME}" (${PACKAGE_NAME}) is associated with "build"` + ); + // The association is specific to the requested command. + await expect(parseBuildAsync(folder, ['rebuild'])).resolves.toBeInstanceOf(PhasedCommandEngine); + }); + + it('rejects a plugin parameter associated with the requested command', async () => { + const folder: string = createTestRepo({ + associatedCommands: [PLUGIN_COMMAND], + commandLineJson: { + commands: [ + { commandKind: 'globalPlugin', name: PLUGIN_COMMAND, summary: 'Records an example.' }, + { commandKind: 'bulk', name: 'build', summary: 'Build', enableParallelism: true } + ], + parameters: [ + { + parameterKind: 'flag', + longName: '--example-flag', + description: 'An example flag.', + associatedCommands: [PLUGIN_COMMAND, 'build'] + } + ] + } + }); + // A plugin-defined build replaces the repository build, so the repository must not also define it. + fs.rmSync(path.join(folder, 'common/config/rush/command-line.json')); + await expect(parseBuildAsync(folder)).rejects.toThrow( + `"${PLUGIN_NAME}" (${PACKAGE_NAME}) associates "--example-flag" with "build"` + ); + }); + + it('rejects a plugin parameter associated with a phase of the requested command', async () => { + const folder: string = createTestRepo({ + associatedCommands: [PLUGIN_COMMAND], + commandLineJson: { + ...COMMAND_SCOPED_COMMAND_LINE_JSON, + phases: [{ name: '_phase:build' }], + parameters: [ + { + parameterKind: 'flag', + longName: '--example-flag', + description: 'An example flag.', + associatedCommands: [PLUGIN_COMMAND], + associatedPhases: ['_phase:build'] + } + ] + } + }); + await expect(parseBuildAsync(folder)).rejects.toThrow( + `"${PLUGIN_NAME}" (${PACKAGE_NAME}) associates "--example-flag" with the "_phase:build" phase` + ); + }); + + it('fails closed when a configured plugin manifest cannot be read', async () => { + const folder: string = createTestRepo({ writeManifest: false }); + await expect(parseBuildAsync(folder)).rejects.toThrow( + `"${PLUGIN_NAME}" (${PACKAGE_NAME}): its manifest could not be read` + ); + }); +}); diff --git a/libraries/rush-lib/src/api/test/RushProjectConfiguration.test.ts b/libraries/rush-lib/src/api/test/RushProjectConfiguration.test.ts index 76ecd423cc..74dba0d20b 100644 --- a/libraries/rush-lib/src/api/test/RushProjectConfiguration.test.ts +++ b/libraries/rush-lib/src/api/test/RushProjectConfiguration.test.ts @@ -1,6 +1,10 @@ // Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. // See LICENSE in the project root for license information. +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + import { StringBufferTerminalProvider, Terminal } from '@rushstack/terminal'; import type { CommandLineParameter } from '@rushstack/ts-command-line'; @@ -96,6 +100,73 @@ function validateConfigurationWithParameters( } describe(RushProjectConfiguration.name, () => { + describe(RushProjectConfiguration._tryLoadForProjectsUncachedAsync.name, () => { + let folder: string; + const write = (relativePath: string, json: object): void => { + const filename: string = path.join(folder, relativePath); + fs.mkdirSync(path.dirname(filename), { recursive: true }); + fs.writeFileSync(filename, JSON.stringify(json)); + }; + const project = (name: string): RushConfigurationProject => + ({ + packageName: name, + projectFolder: path.join(folder, name), + projectRelativeFolder: name + }) as RushConfigurationProject; + const loadAsync = ( + ...projects: RushConfigurationProject[] + ): Promise> => + RushProjectConfiguration._tryLoadForProjectsUncachedAsync( + projects, + new Terminal(new StringBufferTerminalProvider()) + ); + const getOutputFolderNames = ( + configuration: RushProjectConfiguration | undefined + ): string[] | undefined => { + const outputFolderNames: ReadonlyArray | undefined = + configuration?.operationSettingsByOperationName.get('_phase:build')?.outputFolderNames; + return outputFolderNames && [...outputFolderNames]; + }; + + beforeEach(() => { + folder = fs.mkdtempSync(path.join(os.tmpdir(), 'rush-project-rigs-')); + for (const name of ['rigged', 'missing-profile', 'own-file']) { + write(`${name}/package.json`, { name, version: '1.0.0' }); + } + write('rigged/node_modules/example-rig/package.json', { name: 'example-rig', version: '1.0.0' }); + write('rigged/node_modules/example-rig/profiles/default/config/rush-project.json', { + operationSettings: [{ operationName: '_phase:build', outputFolderNames: ['from-rig'] }] + }); + write('rigged/config/rig.json', { rigPackageName: 'example-rig' }); + for (const name of ['missing-profile', 'own-file']) { + write(`${name}/node_modules/example-rig/package.json`, { name: 'example-rig', version: '1.0.0' }); + write(`${name}/config/rig.json`, { rigPackageName: 'example-rig', rigProfile: 'missing' }); + } + write('own-file/config/rush-project.json', { + operationSettings: [{ operationName: '_phase:build', outputFolderNames: ['from-project'] }] + }); + }); + + afterEach(() => { + fs.rmSync(folder, { recursive: true, force: true }); + }); + + it('loads configuration from a rig profile', async () => { + const rigged: RushConfigurationProject = project('rigged'); + const configurations = await loadAsync(rigged); + expect(getOutputFolderNames(configurations.get(rigged))).toEqual(['from-rig']); + }); + + it('reports a missing rig profile only when the rig provides the configuration', async () => { + await expect(loadAsync(project('missing-profile'))).rejects.toThrow( + 'The rig profile "missing" is not defined by the rig package "example-rig"' + ); + const ownFile: RushConfigurationProject = project('own-file'); + const configurations = await loadAsync(ownFile); + expect(getOutputFolderNames(configurations.get(ownFile))).toEqual(['from-project']); + }); + }); + describe('operationSettingsByOperationName', () => { it('loads a rush-project.json config that extends another config file', async () => { const rushProjectConfiguration: RushProjectConfiguration | undefined = diff --git a/libraries/rush-lib/src/api/test/WorkspaceInputFingerprint.test.ts b/libraries/rush-lib/src/api/test/WorkspaceInputFingerprint.test.ts index 96ec77fc72..a81b6b66a9 100644 --- a/libraries/rush-lib/src/api/test/WorkspaceInputFingerprint.test.ts +++ b/libraries/rush-lib/src/api/test/WorkspaceInputFingerprint.test.ts @@ -42,6 +42,52 @@ describe('workspace input fingerprints', () => { } }); + it('reloads when a configured plugin shape outside common/config changes', async () => { + const folder: string = fs.mkdtempSync(path.join(os.tmpdir(), 'rush-fingerprint-')); + try { + const write = (relativePath: string, content: string): void => { + const filename: string = path.join(folder, relativePath); + fs.mkdirSync(path.dirname(filename), { recursive: true }); + fs.writeFileSync(filename, content); + }; + write('rush.json', JSON.stringify({ rushVersion: '5.179.0', pnpmVersion: '10.27.0', projects: [] })); + write( + 'common/config/rush/rush-plugins.json', + JSON.stringify({ + plugins: [{ packageName: '@example/plugin', pluginName: 'example', autoinstallerName: 'plugins' }] + }) + ); + const store: string = 'common/autoinstallers/plugins/rush-plugins/@example/plugin'; + write('common/autoinstallers/plugins/package.json', '{"name":"plugins","version":"1.0.0"}'); + write(`${store}/rush-plugin-manifest.json`, '{"plugins":[]}'); + write(`${store}/example/command-line.json`, '{"commands":[]}'); + const rushConfiguration: RushConfiguration = RushConfiguration.loadFromConfigurationFile( + path.join(folder, 'rush.json') + ); + const runtimeCache: WorkspaceRuntimeFingerprintCache = new WorkspaceRuntimeFingerprintCache(); + const captureAsync = (): Promise => + captureWorkspaceInputFingerprintAsync({ rushConfiguration, runtimeCache, environment: {} }); + + let previous: IWorkspaceInputFingerprint = await captureAsync(); + for (const [relativePath, content] of [ + [`${store}/example/command-line.json`, '{"commands":[],"parameters":[]}'], + [`${store}/rush-plugin-manifest.json`, '{"plugins":[{}]}'], + ['common/autoinstallers/plugins/package.json', '{"name":"plugins","version":"1.0.1"}'] + ]) { + write(relativePath, content); + const next: IWorkspaceInputFingerprint = await captureAsync(); + expect(classifyWorkspaceInputChange(previous, next)).toBe(WorkspaceInputChangeTier.Reload); + previous = next; + } + fs.rmSync(path.join(folder, `${store}/example/command-line.json`)); + expect(classifyWorkspaceInputChange(previous, await captureAsync())).toBe( + WorkspaceInputChangeTier.Reload + ); + } finally { + fs.rmSync(folder, { recursive: true, force: true }); + } + }); + it('ignores volatile per-shell variables but not engine, Node.js or tool resolution inputs', async () => { const folder: string = fs.mkdtempSync(path.join(os.tmpdir(), 'rush-fingerprint-')); try { diff --git a/libraries/rush-lib/src/cli/scriptActions/PhasedScriptAction.ts b/libraries/rush-lib/src/cli/scriptActions/PhasedScriptAction.ts index 2818bca1f7..2f1eb707b9 100644 --- a/libraries/rush-lib/src/cli/scriptActions/PhasedScriptAction.ts +++ b/libraries/rush-lib/src/cli/scriptActions/PhasedScriptAction.ts @@ -345,6 +345,17 @@ export class PhasedScriptAction extends BaseScriptAction i await this.#runAsync(); } + /** The names of every phase this command can schedule, including dependency and watch phases. */ + public get schedulablePhaseNames(): ReadonlySet { + const phaseNames: Set = new Set(); + for (const phases of [this.#originalPhases, this.#initialPhases, this.#watchPhases]) { + for (const phase of phases) { + phaseNames.add(phase.name); + } + } + return phaseNames; + } + public validateEngineCommand(): void { if ( this.#alwaysWatch || diff --git a/libraries/rush-lib/src/pluginFramework/PluginLoader/AutoinstallerPluginLoader.ts b/libraries/rush-lib/src/pluginFramework/PluginLoader/AutoinstallerPluginLoader.ts index 7528fe72ea..8c464bcc6d 100644 --- a/libraries/rush-lib/src/pluginFramework/PluginLoader/AutoinstallerPluginLoader.ts +++ b/libraries/rush-lib/src/pluginFramework/PluginLoader/AutoinstallerPluginLoader.ts @@ -4,6 +4,7 @@ import * as path from 'node:path'; import { + FileConstants, FileSystem, JsonFile, NewlineKind, @@ -13,6 +14,7 @@ import { } from '@rushstack/node-core-library'; import type { IRushPluginConfiguration } from '../../api/RushPluginsConfiguration'; +import type { RushConfiguration } from '../../api/RushConfiguration'; import { Autoinstaller } from '../../logic/Autoinstaller'; import { RushConstants } from '../../logic/RushConstants'; import { @@ -53,7 +55,29 @@ export class AutoinstallerPluginLoader extends PluginLoaderBase\rush-plugins` */ public static getPluginAutoinstallerStorePath(autoinstaller: Autoinstaller): string { - return path.join(autoinstaller.folderFullPath, 'rush-plugins'); + return getStorePath(autoinstaller.folderFullPath); + } + + /** + * Files outside `common/config` from which Rush reads a configured plugin's manifest and command-line + * shape without loading the plugin: the autoinstaller package.json, and the cached manifest and + * command-line.json that `rush update` copies into the autoinstaller's store. + */ + public static getPluginShapeFilePaths( + rushConfiguration: RushConfiguration, + pluginConfiguration: IRushPluginConfiguration + ): string[] { + const { autoinstallerName, packageName, pluginName } = pluginConfiguration; + const autoinstallerFolder: string = path.join( + rushConfiguration.commonAutoinstallersFolder, + autoinstallerName + ); + const storePath: string = getStorePath(autoinstallerFolder); + return [ + path.join(autoinstallerFolder, FileConstants.PackageJson), + getCachedManifestPath(storePath, packageName), + getCachedCommandLineJsonFilePath(storePath, packageName, pluginName) + ]; } public update(): void { @@ -155,19 +179,33 @@ export class AutoinstallerPluginLoader extends PluginLoaderBase + ): ReadonlyArray { + const reasons: string[] = []; + for (const pluginLoader of this.#autoinstallerPluginLoaders) { + const pluginLabel: string = `"${pluginLoader.pluginName}" (${pluginLoader.packageName})`; + let associatedCommands: ReadonlyArray | undefined; + try { + associatedCommands = pluginLoader.pluginManifest.associatedCommands; + } catch (error) { + reasons.push(`${pluginLabel}: its manifest could not be read: ${(error as Error).message}`); + continue; + } + if (!associatedCommands) { + reasons.push(`${pluginLabel} is initialized for every command`); + } else if (associatedCommands.includes(commandName)) { + reasons.push(`${pluginLabel} is associated with "${commandName}"`); + } + + let commandLineConfiguration: CommandLineConfiguration | undefined; + try { + commandLineConfiguration = pluginLoader.getCommandLineConfiguration(); + } catch (error) { + reasons.push(`${pluginLabel}: its command-line.json could not be read: ${(error as Error).message}`); + continue; + } + if (!commandLineConfiguration) { + continue; + } + if (commandLineConfiguration.commands.has(commandName)) { + reasons.push(`${pluginLabel} defines the "${commandName}" command`); + } + for (const phaseName of commandLineConfiguration.phases.keys()) { + if (phaseNames.has(phaseName)) { + reasons.push(`${pluginLabel} defines the "${phaseName}" phase`); + } + } + for (const parameter of commandLineConfiguration.parameters) { + const { longName, associatedPhases } = parameter; + if (parameter.associatedCommands?.includes(commandName)) { + reasons.push(`${pluginLabel} associates "${longName}" with "${commandName}"`); + } + for (const phaseName of associatedPhases ?? []) { + if (phaseNames.has(phaseName)) { + reasons.push(`${pluginLabel} associates "${longName}" with the "${phaseName}" phase`); + } + } + } + } + return reasons; + } + #initializePlugins(pluginLoaders: PluginLoaderBase[]): void { for (const pluginLoader of pluginLoaders) { const pluginName: string = pluginLoader.pluginName;