diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 2c22dc367c..9a5d44aacf 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -6,12 +6,12 @@ Eggjs is a progressive Node.js framework for building enterprise-class server-side applications. Built on top of Koa.js, it provides a plugin system, conventions over configuration, and enterprise-grade features like clustering, logging, and security. -This is a **pnpm monorepo** with multiple packages using pnpm workspaces and catalog mode for centralized dependency management. +This is a **utoo monorepo** with multiple packages using utoo workspaces and catalog mode for centralized dependency management. ## Prerequisites and Environment Setup -- **Node.js >= 20.19.0 required** - This is a hard requirement -- Enable pnpm first: `corepack enable pnpm` (installs pnpm v10.16.0) +- **Node.js >= 22.18.0 required** - This is a hard requirement +- Enable utoo first: `corepack enable utoo` (installs utoo v1.0.28) - **NEVER CANCEL** any build or test commands - they can take several minutes to complete ## Bootstrap and Build Process @@ -19,17 +19,17 @@ This is a **pnpm monorepo** with multiple packages using pnpm workspaces and cat **Always run these commands in sequence after fresh clone:** ```bash -# 1. Enable pnpm (required first) -corepack enable pnpm +# 1. Enable utoo (required first) +corepack enable utoo # 2. Install all dependencies - takes ~63 seconds. NEVER CANCEL. Set timeout to 120+ seconds. -pnpm install +ut install # 3. Build all packages - takes ~14 seconds. NEVER CANCEL. Set timeout to 60+ seconds. -pnpm run build +ut run build # 4. Run linting (optional but recommended) - takes ~2 seconds -pnpm run lint +ut run lint ``` ## Monorepo Structure @@ -48,51 +48,51 @@ pnpm run lint ### Supporting Directories - **`examples/`** - Two example apps: `helloworld-commonjs` and `helloworld-typescript` (currently have runtime issues) -- **`site/`** - Documentation website built with Dumi +- **`site/`** - Documentation website built with VitePress ## Essential Commands and Timing ### Build Commands -- `pnpm run build` - **Build all packages (~14 seconds). NEVER CANCEL. Set timeout to 60+ seconds.** -- `pnpm run clean` - Clean all dist directories +- `ut run build` - **Build all packages (~14 seconds). NEVER CANCEL. Set timeout to 60+ seconds.** +- `ut run clean-dist` - Clean all dist directories ### Testing Commands -- `pnpm run test` - **Run all tests (~2 minutes). NEVER CANCEL. Set timeout to 180+ seconds.** -- `pnpm run test:cov` - **Run tests with coverage (~2 minutes). NEVER CANCEL. Set timeout to 180+ seconds.** -- `pnpm run ci` - **Run test coverage + build (~2.1 minutes). NEVER CANCEL. Set timeout to 180+ seconds.** +- `ut run test` - **Run all tests (~2 minutes). NEVER CANCEL. Set timeout to 180+ seconds.** +- `ut run test:cov` - **Run tests with coverage (~2 minutes). NEVER CANCEL. Set timeout to 180+ seconds.** +- `ut run ci` - **Run tests with coverage (~2 minutes). NEVER CANCEL. Set timeout to 180+ seconds.** ### Linting Commands -- `pnpm run lint` - Run oxlint across all packages (~2 seconds) +- `ut run lint` - Run oxlint across all packages (~2 seconds) ### Documentation Commands -- `pnpm run site:dev` - Start documentation dev server at http://localhost:8000 -- `cd site && pnpm run build:skip` - **Build documentation site (~24 seconds). NEVER CANCEL. Set timeout to 60+ seconds.** +- `ut run site:dev` - Start documentation dev server (defaults to VitePress port 5173) +- `ut run site:build` - **Build documentation site (~24 seconds). NEVER CANCEL. Set timeout to 60+ seconds.** ### Example Applications (Currently Not Working) -- `pnpm run example:commonjs` - Start CommonJS example (has runtime issues) -- `pnpm run example:typescript` - Start TypeScript example (has runtime issues) +- `ut run example:dev:commonjs` - Start CommonJS example (has runtime issues) +- `ut run example:dev:typescript` - Start TypeScript example (has runtime issues) ## Package-Specific Commands -Run commands for specific packages using `pnpm --filter=`: +Run commands for specific packages using `ut --filter=`: ```bash # Examples -pnpm --filter=egg run test -pnpm --filter=@eggjs/core run build -pnpm --filter=site run dev +ut --filter=egg run test +ut --filter=@eggjs/core run build +ut --filter=site run dev ``` ## Development Workflow ### 1. Making Changes -- Always build packages first: `pnpm run build` +- Always build packages first: `ut run build` - Work primarily in `packages/egg/src/` for core framework features - Use TypeScript throughout - all packages are TypeScript-based - Follow the existing directory conventions in `packages/egg/src/`: @@ -108,16 +108,16 @@ pnpm --filter=site run dev ```bash # 1. Build all packages (required) -pnpm run build +ut run build # 2. Run linting -pnpm run lint +ut run lint # 3. Run tests (some failures are expected in fresh environment) -pnpm run test +ut run test # 4. Test documentation site -pnpm run site:dev +ut run site:dev ``` ### 3. Testing Strategy @@ -162,7 +162,7 @@ pnpm run site:dev - **All sub-project tsconfig.json files MUST extend from root:** `"extends": "../../tsconfig.json"` - Root tsconfig.json includes all packages in `references` array -## pnpm Workspace & Catalog Dependencies +## utoo Workspace & Catalog Dependencies - Dependencies defined in `pnpm-workspace.yaml` catalog section - Reference catalog entries: `"package-name": "catalog:"` @@ -179,7 +179,7 @@ pnpm run site:dev ### Build Issues -- Always run `pnpm run build` after making changes +- Always run `ut run build` after making changes - TypeScript compilation errors will show clearly - Build warnings are generally acceptable @@ -217,10 +217,10 @@ pnpm run site:dev After making changes, always verify: -1. **Build Success**: `pnpm run build` completes without errors -2. **Linting Passes**: `pnpm run lint` shows no new errors -3. **Documentation Loads**: `pnpm run site:dev` starts successfully and site loads at http://localhost:8000 -4. **Tests Run**: `pnpm run test` executes (some failures expected, focus on your changes) +1. **Build Success**: `ut run build` completes without errors +2. **Linting Passes**: `ut run lint` shows no new errors +3. **Documentation Loads**: `ut run site:dev` starts successfully and the printed VitePress URL responds +4. **Tests Run**: `ut run test` executes (some failures expected, focus on your changes) **Remember**: This is a complex enterprise framework. Always build first, validate incrementally, and focus on the core packages (`egg`, `core`, `utils`) for most development work. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 863000fbc6..bdfea3f22c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,11 +18,20 @@ permissions: actions: write jobs: - typecheck: + # Static-analysis / build checks. Each check runs as its own parallel job + # (instead of serial steps) so the slowest one — not their sum — bounds the + # wall time. Goal: keep each ≤ 60s. + quality: + strategy: + fail-fast: false + matrix: + check: ['lint', 'typecheck', 'fmtcheck', 'build', 'site'] + + name: Quality (${{ matrix.check }}) runs-on: ubuntu-latest concurrency: - group: typecheck-${{ github.workflow }}-#${{ github.event.pull_request.number || github.head_ref || github.ref }} + group: quality-${{ matrix.check }}-${{ github.workflow }}-#${{ github.event.pull_request.number || github.head_ref || github.ref }} cancel-in-progress: true steps: - name: Checkout repository @@ -37,43 +46,155 @@ jobs: node-version: '24' - name: Install dependencies - run: ut install --from pnpm + # Retry to absorb transient `exit 141` (SIGPIPE) from `ut install`. + run: ut install --from pnpm || (sleep 5 && ut install --from pnpm) || (sleep 10 && ut install --from pnpm) - name: Run lint + if: ${{ matrix.check == 'lint' }} run: ut run lint - name: Run typecheck + if: ${{ matrix.check == 'typecheck' }} run: ut run typecheck - name: Run format check + if: ${{ matrix.check == 'fmtcheck' }} run: ut run fmtcheck - name: Run build + if: ${{ matrix.check == 'build' }} run: ut run build - name: Run site build + if: ${{ matrix.check == 'site' }} run: ut run site:build test: strategy: fail-fast: false matrix: - os: ['ubuntu-latest', 'macos-latest', 'windows-latest'] + # ubuntu-latest runs the fine-grained shards: fork-heavy packages are + # split into vitest --shard slices and the light remainder into + # weight-balanced rest-* shards (see scripts/run-shard.js). Sized so each + # shard's `vitest run` step stays ≤ ~60s on ubuntu-latest, where runner + # concurrency is high enough to run them all in parallel. + os: ['ubuntu-latest'] + node: ['22', '24'] + shard: + - cluster-1 + - cluster-2 + - cluster-3 + - egg-1 + - egg-2 + - mock-1 + - mock-2 + - schedule-1 + - schedule-2 + - schedule-3 + - schedule-4 + - development + - security-1 + - security-2 + - redis + - multipart + - rest-1 + - rest-2 + - rest-3 + - rest-4 + - rest-5 + - rest-6 + - rest-7 + - rest-8 + # macOS/Windows whole-suite compatibility runs live in `test-compat`, + # which only runs at merge time (merge_group / push to next) — not on + # every PR — because those runners are slow (~13-25min whole-suite) and + # macOS has only ~5 concurrent slots, so they would dominate the PR wall. + # The PR critical path is this ubuntu fan-out, every shard ≤60s. + + name: Test (${{ matrix.os }}, ${{ matrix.node }}, ${{ matrix.shard }}) + runs-on: ${{ matrix.os }} + + concurrency: + group: test-${{ github.workflow }}-#${{ github.event.pull_request.number || github.head_ref || github.ref }}-(${{ matrix.os }}, ${{ matrix.node }}, ${{ matrix.shard }}) + cancel-in-progress: true + + steps: + - name: Checkout repository + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6 + + - name: Start Redis + uses: shogo82148/actions-setup-redis@cff708d63a30aebc0bfaa7276fb709d173f36cb6 # v1 + with: + redis-version: '7' + auto-start: 'true' + + - name: Start MySQL + uses: shogo82148/actions-setup-mysql@27e74fac04c136a9f4c2dc2ed457df57331b3e0c # v1 + with: + mysql-version: '8' + auto-start: 'true' + - name: Init DB + run: | + mysql -uroot -e "CREATE DATABASE IF NOT EXISTS test;" + + - name: Setup utoo + uses: utooland/setup-utoo@3a51006d0b66afcc32d1b9177a4b200b74f4a8cb # main + + - name: Set up Node.js + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6 + with: + node-version: ${{ matrix.node }} + + - name: Install dependencies + run: ut install --from pnpm || (sleep 5 && ut install --from pnpm) || (sleep 10 && ut install --from pnpm) + + - name: Prepare test fixtures (clean dist + db) + # Mirrors the original `preci` (pretest) step: clears stale dist so tegg + # plugin tests don't double-load src+dist, and runs per-workspace + # pretest (e.g. orm DB table init). The shard runner invokes vitest + # directly, bypassing the npm `preci` lifecycle, so do it explicitly. + run: ut run pretest + + - name: Run tests (shard ${{ matrix.shard }}) + run: node scripts/run-shard.js ${{ matrix.shard }} -- --coverage + + - name: Run example tests + # Run once (on the rest-1 shard) to avoid duplication across shards. + if: ${{ matrix.shard == 'rest-1' }} + run: | + ut run example:test:all + + - name: Code Coverage + uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5 + with: + use_oidc: true + + # macOS / Windows whole-suite compatibility runs. These are slow (~13-25min) + # and macOS has only ~5 concurrent runner slots, so they would dominate the + # PR wall. They run only at merge time (merge_group) and on push to next, so + # the PR critical path stays the ubuntu `test` fan-out (each shard ≤60s) while + # cross-platform coverage is still enforced before code lands. + test-compat: + if: ${{ github.event_name != 'pull_request' }} + strategy: + fail-fast: false + matrix: + os: ['macos-latest', 'windows-latest'] node: ['22', '24'] - name: Test (${{ matrix.os }}, ${{ matrix.node }}) + name: Test compat (${{ matrix.os }}, ${{ matrix.node }}) runs-on: ${{ matrix.os }} concurrency: - group: test-${{ github.workflow }}-#${{ github.event.pull_request.number || github.head_ref || github.ref }}-(${{ matrix.os }}, ${{ matrix.node }}) + group: test-compat-${{ github.workflow }}-#${{ github.event.pull_request.number || github.head_ref || github.ref }}-(${{ matrix.os }}, ${{ matrix.node }}) cancel-in-progress: true steps: - name: Checkout repository uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6 - - name: Start Redis (MacOS or Linux) - if: ${{ matrix.os == 'macos-latest' || matrix.os == 'ubuntu-latest' }} + - name: Start Redis (macOS) + if: ${{ matrix.os == 'macos-latest' }} uses: shogo82148/actions-setup-redis@cff708d63a30aebc0bfaa7276fb709d173f36cb6 # v1 with: redis-version: '7' @@ -129,15 +250,14 @@ jobs: Write-Host "Memurai is ready on 127.0.0.1:6379" - # install and start MySQL (will automatically start mysqld) - - name: Start MySQL (macOS or Linux) - if: ${{ matrix.os == 'macos-latest' || matrix.os == 'ubuntu-latest' }} + - name: Start MySQL (macOS) + if: ${{ matrix.os == 'macos-latest' }} uses: shogo82148/actions-setup-mysql@27e74fac04c136a9f4c2dc2ed457df57331b3e0c # v1 with: mysql-version: '8' auto-start: 'true' - - name: Init DB (macOS or Linux) - if: ${{ matrix.os == 'macos-latest' || matrix.os == 'ubuntu-latest' }} + - name: Init DB (macOS) + if: ${{ matrix.os == 'macos-latest' }} run: | mysql -uroot -e "CREATE DATABASE IF NOT EXISTS test;" @@ -147,8 +267,6 @@ jobs: run: | choco install -y mysql refreshenv - # MySQL default root has no password, set a password and create database/user - # & mysqladmin -u root password root & mysql -uroot -e "CREATE DATABASE IF NOT EXISTS test;" - name: Setup utoo @@ -160,28 +278,31 @@ jobs: node-version: ${{ matrix.node }} - name: Install dependencies - run: ut install --from pnpm + run: ut install --from pnpm || (sleep 5 && ut install --from pnpm) || (sleep 10 && ut install --from pnpm) - - name: Run tests - run: ut run ci + - name: Prepare test fixtures (clean dist + db) + run: ut run pretest - - name: Run example tests - if: ${{ matrix.os != 'windows-latest' }} - run: | - ut run example:test:all + - name: Set HOME on Windows + if: ${{ matrix.os == 'windows-latest' }} + shell: pwsh + run: echo "HOME=$env:USERPROFILE" >> $env:GITHUB_ENV - - name: Code Coverage - # skip on windows, it will hangup on codecov - if: ${{ matrix.os != 'windows-latest' }} - uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5 - with: - use_oidc: true + - name: Run tests (whole suite) + run: node scripts/run-shard.js all -- --coverage + + - name: Run egg-bin tests + # egg-bin has its own vitest project (built first); cover it on + # macos/windows here so the PR-fast egg-bin job can stay ubuntu-only. + run: | + ut run build -- --workspace ./tools/egg-bin + ut run test --workspace @eggjs/bin test-egg-bin: strategy: fail-fast: false matrix: - os: ['ubuntu-latest', 'windows-latest'] + os: ['ubuntu-latest'] node: ['24'] name: Test bin (${{ matrix.os }}, ${{ matrix.node }}) @@ -204,19 +325,21 @@ jobs: node-version: ${{ matrix.node }} - name: Install dependencies - run: ut install --from pnpm + run: ut install --from pnpm || (sleep 5 && ut install --from pnpm) || (sleep 10 && ut install --from pnpm) + + - name: Set HOME on Windows + if: ${{ matrix.os == 'windows-latest' }} + shell: pwsh + run: echo "HOME=$env:USERPROFILE" >> $env:GITHUB_ENV - name: Run tests + # `ci` (vitest --coverage) adds ~50s of instrumentation on egg-bin's + # fork-heavy suite, pushing it past 60s. Run without coverage (`test`) + # to keep the job ≤60s; egg-bin's fork tests can't be sharded (they + # depend on cross-file shared state under isolate:false). run: | ut run build -- --workspace ./tools/egg-bin - ut run ci --workspace @eggjs/bin - - - name: Code Coverage - # skip on windows, it will hangup on codecov https://github.com/codecov/codecov-action/issues/1787 - if: ${{ matrix.os != 'windows-latest' }} - uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5 - with: - use_oidc: true + ut run test --workspace @eggjs/bin test-egg-scripts: strategy: @@ -245,7 +368,7 @@ jobs: node-version: ${{ matrix.node }} - name: Install dependencies - run: ut install --from pnpm + run: ut install --from pnpm || (sleep 5 && ut install --from pnpm) || (sleep 10 && ut install --from pnpm) - name: Run tests run: | @@ -262,9 +385,12 @@ jobs: runs-on: ubuntu-latest needs: - test + - test-compat - test-egg-bin - test-egg-scripts - - typecheck + - quality steps: - run: exit 1 + # test-compat is skipped on pull_request (result 'skipped'), which does + # not trip this gate — only real failures/cancellations do. if: ${{ always() && (contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled')) }} diff --git a/.github/workflows/e2e-test.yml b/.github/workflows/e2e-test.yml index 3ab3c54181..d695ab53a6 100644 --- a/.github/workflows/e2e-test.yml +++ b/.github/workflows/e2e-test.yml @@ -141,8 +141,8 @@ jobs: with: ecosystem-ci-project: ${{ matrix.project.name }} - - name: Install pnpm - uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4 + - name: Install utoo + uses: utooland/setup-utoo@3a51006d0b66afcc32d1b9177a4b200b74f4a8cb # main - name: Set up Node.js uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6 @@ -150,18 +150,37 @@ jobs: node-version: ${{ matrix.project.node-version }} - name: Install dependencies - run: pnpm install --no-frozen-lockfile + run: ut install --from pnpm || (sleep 5 && ut install --from pnpm) || (sleep 10 && ut install --from pnpm) - name: Build all packages - env: - # publint pack defaults to npm (main CI env has no pnpm); in E2E we - # already have pnpm installed and npm pack against pnpm's symlinked - # node_modules is ~10x slower, so prefer pnpm pack here - PUBLINT_PACK: pnpm - run: pnpm build + run: ut run build + + - name: Install pnpm (for `pnpm -r pack`) + # utoo's `ut pm-pack` does not resolve `workspace:` / `catalog:` + # protocols inside the packed manifests, so downstream `npm install` + # in the ecosystem-ci projects fails with EUNSUPPORTEDPROTOCOL. + # Keep pnpm available just for the pack step. The explicit `version` + # is required because `packageManager` in package.json now points at + # utoo, so the action can't infer the pnpm version itself. + # Setup must come AFTER `ut install`: action-setup exports PNPM_HOME, + # which makes utoo's install path read pnpm config and crash with + # exit 141 (SIGPIPE). + uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4 + with: + version: 10 - name: Pack packages into tgz + # `pnpm -r pack` resolves workspace:/catalog: deps in the emitted + # manifests, which `ut pm-pack` does not yet do. pnpm refuses to + # run when `packageManager` points at another tool, so disable + # that strict check for just this step. pnpm needs its own + # node_modules layout to resolve `workspace:` versions, so run + # `pnpm install --no-frozen-lockfile` first (cheap on top of the + # ut install since the deps are already in the global store). + env: + NPM_CONFIG_PACKAGE_MANAGER_STRICT: 'false' run: | + pnpm install --no-frozen-lockfile --ignore-scripts pnpm -r pack - name: Override dependencies from tgz in ${{ matrix.project.name }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2dac019c96..4956f0d8e8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -62,8 +62,8 @@ jobs: fetch-depth: 0 token: ${{ secrets.GIT_TOKEN }} - - name: Setup pnpm - uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4 + - name: Setup utoo + uses: utooland/setup-utoo@3a51006d0b66afcc32d1b9177a4b200b74f4a8cb # main - name: Setup Node.js uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6 @@ -72,7 +72,7 @@ jobs: registry-url: 'https://registry.npmjs.org' - name: Install dependencies - run: pnpm install --no-frozen-lockfile + run: ut install --from pnpm - name: Configure Git run: | @@ -149,7 +149,7 @@ jobs: git push origin ${{ github.event.inputs.branch }} --tags - name: Run build - run: pnpm build + run: ut run build - name: Publish packages (dry run) if: ${{ github.event.inputs.dry_run == 'true' }} diff --git a/.gitignore b/.gitignore index b89f7ba95d..c35799ca6e 100644 --- a/.gitignore +++ b/.gitignore @@ -120,3 +120,8 @@ ecosystem-ci/examples pnpm-lock.yaml .utoo.toml .claude/ + +# benchmark output directories +benchmark/ci-test/baseline* +benchmark/ci-test/run* +benchmark/ci-test/2* diff --git a/AGENTS.md b/AGENTS.md index 30ce7bc48c..263f84b18a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,7 +6,7 @@ If another agent-specific file exists, it should import or defer to this file fo ## Project Map -Egg is maintained as a pnpm monorepo. +Egg is maintained as a utoo monorepo. - `packages/` contains core framework packages and shared internals. - `plugins/` contains optional Egg integrations. @@ -18,18 +18,25 @@ Egg is maintained as a pnpm monorepo. ## Core Commands -- `pnpm install` hydrates the workspace. -- `pnpm run build` builds all packages. -- `pnpm run test` runs the main test suite. -- `pnpm run lint` runs linting. -- `pnpm run typecheck` runs TypeScript checking. -- use filtered commands for focused work, for example `pnpm --filter=egg run test` or `pnpm --filter=site run dev`. +- `corepack enable utoo` enables the pinned utoo version on a clean machine. +- `ut install` hydrates the workspace. +- `ut run build` builds all packages. +- `ut run test` runs the main test suite. +- `ut run lint` runs linting. +- `ut run typecheck` runs TypeScript checking. +- use filtered commands for focused work, for example `ut --filter=egg run test` or `ut --filter=site run dev`. ### Local CI -Run tests **without building first**. The CI workflow (`ut install → ut run ci`) never runs `build` before tests. If `dist/` directories exist from a prior build, tegg plugin tests will fail with `duplicate proto` errors because globby scans both `src/*.ts` and `dist/*.js`, loading the same decorated class twice. +Run tests **without building first**. The CI test jobs run `ut run pretest` +(clean dist + per-workspace pretest) then a vitest shard; they never `build` +before tests. If `dist/` directories exist from a prior build, tegg plugin tests +will fail with `duplicate proto` errors because globby scans both `src/*.ts` and +`dist/*.js`, loading the same decorated class twice. `scripts/clean-dist.js` +(run by `ut run clean-dist`) removes every `dist/` for you. -When you see `duplicate proto` failures locally: +When you see `duplicate proto` failures locally, run `ut run clean-dist` (or the +equivalent find below) and re-run: ```bash find tegg packages plugins tools -name dist -type d \ @@ -37,8 +44,39 @@ find tegg packages plugins tools -name dist -type d \ -exec rm -rf {} + ``` +### Aggregator scripts (avoid `ut run --workspaces` recursion) + +The root `typecheck` / `pretest` aggregate per-workspace scripts via +`node scripts/run-workspaces.js