From ec30995f280e588934d34590bc991d638bdf13cf Mon Sep 17 00:00:00 2001 From: Daniel Bannert Date: Sat, 17 Oct 2026 23:58:52 +0200 Subject: [PATCH 1/9] feat(vis): add dev, build, and rebuild commands with Rstack inference `vis dev` and `vis build` run the dev and build targets through `vis run`, so they share run's filters, caching, and option definitions. A package's own `dev` or `build` script still wins over the inferred target. `vis rebuild` runs the package manager's rebuild. Package managers without a rebuild command (yarn 1, bun) fail with a message instead of exiting 0. Target inference gains detectors for Rsbuild (build, dev, preview), Rslib (library build and watch), Rspack (build, dev, preview), Rstest (test and test:watch), and Rslint (lint). Each is ordered after the Vite detectors and before webpack, or after the Vitest and Jest detectors for test. Co-Authored-By: Claude Haiku 5.5 --- packages/tooling/vis/README.md | 3 + .../commands/dev/dev-build-forwarding.test.ts | 49 +++++++++ .../commands/lazy-commands-contract.test.ts | 6 ++ .../commands/rebuild/rebuild.test.ts | 58 ++++++++++ .../inference/rspack-detector.test.ts | 64 +++++++++++ .../inference/rstack-detectors.test.ts | 102 ++++++++++++++++++ packages/tooling/vis/docs/commands/build.mdx | 42 ++++++++ packages/tooling/vis/docs/commands/dev.mdx | 40 +++++++ .../tooling/vis/docs/commands/rebuild.mdx | 23 ++++ packages/tooling/vis/docs/configuration.mdx | 4 +- packages/tooling/vis/docs/meta.json | 3 + .../tooling/vis/src/commands/build/handler.ts | 12 +++ .../tooling/vis/src/commands/build/index.ts | 26 +++++ .../tooling/vis/src/commands/dev/handler.ts | 12 +++ .../tooling/vis/src/commands/dev/index.ts | 26 +++++ .../vis/src/commands/rebuild/handler.ts | 35 ++++++ .../tooling/vis/src/commands/rebuild/index.ts | 13 +++ .../tooling/vis/src/commands/run/index.ts | 2 +- .../vis/src/inference/detectors/rsbuild.ts | 31 ++++++ .../vis/src/inference/detectors/rslib.ts | 26 +++++ .../vis/src/inference/detectors/rslint.ts | 28 +++++ .../vis/src/inference/detectors/rspack.ts | 32 ++++++ .../vis/src/inference/detectors/rstest.ts | 38 +++++++ packages/tooling/vis/src/inference/index.ts | 16 ++- packages/tooling/vis/src/register-commands.ts | 6 ++ 25 files changed, 691 insertions(+), 6 deletions(-) create mode 100644 packages/tooling/vis/__tests__/commands/dev/dev-build-forwarding.test.ts create mode 100644 packages/tooling/vis/__tests__/commands/rebuild/rebuild.test.ts create mode 100644 packages/tooling/vis/__tests__/inference/rspack-detector.test.ts create mode 100644 packages/tooling/vis/__tests__/inference/rstack-detectors.test.ts create mode 100644 packages/tooling/vis/docs/commands/build.mdx create mode 100644 packages/tooling/vis/docs/commands/dev.mdx create mode 100644 packages/tooling/vis/docs/commands/rebuild.mdx create mode 100644 packages/tooling/vis/src/commands/build/handler.ts create mode 100644 packages/tooling/vis/src/commands/build/index.ts create mode 100644 packages/tooling/vis/src/commands/dev/handler.ts create mode 100644 packages/tooling/vis/src/commands/dev/index.ts create mode 100644 packages/tooling/vis/src/commands/rebuild/handler.ts create mode 100644 packages/tooling/vis/src/commands/rebuild/index.ts create mode 100644 packages/tooling/vis/src/inference/detectors/rsbuild.ts create mode 100644 packages/tooling/vis/src/inference/detectors/rslib.ts create mode 100644 packages/tooling/vis/src/inference/detectors/rslint.ts create mode 100644 packages/tooling/vis/src/inference/detectors/rspack.ts create mode 100644 packages/tooling/vis/src/inference/detectors/rstest.ts diff --git a/packages/tooling/vis/README.md b/packages/tooling/vis/README.md index 2f9df02c..474221bf 100644 --- a/packages/tooling/vis/README.md +++ b/packages/tooling/vis/README.md @@ -218,6 +218,8 @@ When aube is the installer, `vis doctor` surfaces aube's effective hardening pos | `vis generate [name]` | | Scaffold files from an in-repo template (native TS or moon-format) | | `vis init` | | Initialize vis.config.ts with security defaults | | `vis run ` | | Run a target across workspace projects with caching | +| `vis dev` | | Start the dev server: the package dev script, or the inferred Vite, Rsbuild, Rslib, or Rspack server | +| `vis build` | | Run the build: the package build script, or the inferred Vite, Rsbuild, Rslib, or Rspack build | | `vis affected ` | | Run tasks only on projects affected by git changes | | `vis ci ` | | One-shot CI entry point: toolchain, install, then affected runs for each comma-separated target | | `vis ignore ` | | CI build gating for Vercel / Netlify "Ignored Build Step" | @@ -225,6 +227,7 @@ When aube is the installer, `vis doctor` surfaces aube's effective hardening pos | `vis check [packages]` | `c` | Check for outdated dependencies in workspace catalogs | | `vis update [packages]` | `up` | Update packages, GitHub Actions, Dockerfiles, and GitLab CI refs | | `vis install` | `i` | Install dependencies via the detected package manager | +| `vis rebuild` | | Rebuild native dependencies through the detected package manager | | `vis info ` | `view` | Show npm registry metadata for a package (wraps `npm view` et al.) | | `vis dlx ` | | Execute a remote package without permanent installation | | `vis audit` | | Audit dependencies for security vulnerabilities | diff --git a/packages/tooling/vis/__tests__/commands/dev/dev-build-forwarding.test.ts b/packages/tooling/vis/__tests__/commands/dev/dev-build-forwarding.test.ts new file mode 100644 index 00000000..84157d23 --- /dev/null +++ b/packages/tooling/vis/__tests__/commands/dev/dev-build-forwarding.test.ts @@ -0,0 +1,49 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const runExecute = vi.hoisted(() => vi.fn()); + +vi.mock(import("../../../src/commands/run/handler"), () => { return { default: runExecute }; }); + +const { default: devExecute } = await import("../../../src/commands/dev/handler"); +const { default: buildExecute } = await import("../../../src/commands/build/handler"); + +const toolbox = (argument: string[] | undefined, rawUnknown: string[] | undefined, options: Record = {}) => + ({ argument, logger: {}, options, rawUnknown }) as never; + +describe("vis dev and vis build", () => { + beforeEach(() => { + runExecute.mockReset(); + }); + + it("runs the dev target through the run handler", async () => { + expect.assertions(1); + + await devExecute(toolbox(undefined, undefined)); + + expect(runExecute.mock.calls[0][0].argument).toStrictEqual(["dev"]); + }); + + it("forwards positional and flag arguments after the target", async () => { + expect.assertions(1); + + await devExecute(toolbox(["web"], ["--port", "3000"])); + + expect(runExecute.mock.calls[0][0].argument).toStrictEqual(["dev", "web", "--port", "3000"]); + }); + + it("passes run's options through unchanged", async () => { + expect.assertions(1); + + await buildExecute(toolbox(undefined, undefined, { filter: ["@org/web"], preflight: undefined })); + + expect(runExecute.mock.calls[0][0].options).toStrictEqual({ filter: ["@org/web"], preflight: undefined }); + }); + + it("runs the build target", async () => { + expect.assertions(1); + + await buildExecute(toolbox(undefined, ["--mode", "production"])); + + expect(runExecute.mock.calls[0][0].argument).toStrictEqual(["build", "--mode", "production"]); + }); +}); diff --git a/packages/tooling/vis/__tests__/commands/lazy-commands-contract.test.ts b/packages/tooling/vis/__tests__/commands/lazy-commands-contract.test.ts index 36c61c6d..a435a1ac 100644 --- a/packages/tooling/vis/__tests__/commands/lazy-commands-contract.test.ts +++ b/packages/tooling/vis/__tests__/commands/lazy-commands-contract.test.ts @@ -8,6 +8,7 @@ import aiCommands from "../../src/commands/ai"; import analyzeCommand from "../../src/commands/analyze"; import approveBuildsCommand from "../../src/commands/approve-builds"; import auditCommand from "../../src/commands/audit"; +import buildCommand from "../../src/commands/build"; import cacheCommands from "../../src/commands/cache"; import checkCommand from "../../src/commands/check"; import ciCommand from "../../src/commands/ci"; @@ -15,6 +16,7 @@ import ciIgnoreCommand from "../../src/commands/ci-ignore"; import cleanCommand from "../../src/commands/clean"; import createCommand from "../../src/commands/create"; import dedupeCommand from "../../src/commands/dedupe"; +import devCommand from "../../src/commands/dev"; import devcontainerCommand from "../../src/commands/devcontainer"; import dlxCommand from "../../src/commands/dlx"; import dockerCommands from "../../src/commands/docker"; @@ -33,6 +35,7 @@ import listCommand from "../../src/commands/list"; import migrateCommands from "../../src/commands/migrate"; import optimizeCommand from "../../src/commands/optimize"; import pmCommand from "../../src/commands/pm"; +import rebuildCommand from "../../src/commands/rebuild"; import removeCommand from "../../src/commands/remove"; import runCommand from "../../src/commands/run"; import sbomCommand from "../../src/commands/sbom"; @@ -55,11 +58,13 @@ const FLAT_COMMANDS: Command[] = [ analyzeCommand, approveBuildsCommand, auditCommand, + buildCommand, checkCommand, ciCommand, cleanCommand, createCommand, dedupeCommand, + devCommand, devcontainerCommand, dlxCommand, doctorCommand, @@ -75,6 +80,7 @@ const FLAT_COMMANDS: Command[] = [ listCommand, optimizeCommand, pmCommand, + rebuildCommand, removeCommand, runCommand, sbomCommand, diff --git a/packages/tooling/vis/__tests__/commands/rebuild/rebuild.test.ts b/packages/tooling/vis/__tests__/commands/rebuild/rebuild.test.ts new file mode 100644 index 00000000..095d7eb0 --- /dev/null +++ b/packages/tooling/vis/__tests__/commands/rebuild/rebuild.test.ts @@ -0,0 +1,58 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const pm = vi.hoisted(() => { + return { + resolveInstaller: vi.fn(), + runPmSubcommand: vi.fn(), + }; +}); + +vi.mock(import("../../../src/pm/pm-runner"), () => pm); + +const { default: rebuildExecute } = await import("../../../src/commands/rebuild/handler"); + +const logger = { error: vi.fn(), info: vi.fn(), warn: vi.fn() }; + +describe("vis rebuild", () => { + beforeEach(() => { + pm.resolveInstaller.mockReset(); + pm.runPmSubcommand.mockReset(); + process.exitCode = undefined; + }); + + it("rejects yarn 1, which has no rebuild, instead of exiting 0", async () => { + expect.assertions(2); + + pm.resolveInstaller.mockReturnValue({ name: "yarn", version: "1.22.19" }); + + await expect(rebuildExecute({ logger, options: {}, workspaceRoot: "/tmp/ws" } as never)).rejects.toThrow("yarn 1 has no `rebuild`"); + expect(pm.runPmSubcommand).not.toHaveBeenCalled(); + }); + + it("runs rebuild through the detected package manager", async () => { + expect.assertions(2); + + const installer = { name: "pnpm", version: "10.0.0" }; + + pm.resolveInstaller.mockReturnValue(installer); + pm.runPmSubcommand.mockReturnValue(0); + + await rebuildExecute({ logger, options: {}, workspaceRoot: "/tmp/ws" } as never); + + expect(pm.runPmSubcommand).toHaveBeenCalledWith(installer, "rebuild", [], "/tmp/ws", logger); + expect(process.exitCode).toBeUndefined(); + }); + + it("propagates a failing rebuild's exit code", async () => { + expect.assertions(1); + + pm.resolveInstaller.mockReturnValue({ name: "npm", version: "10.0.0" }); + pm.runPmSubcommand.mockReturnValue(2); + + await rebuildExecute({ logger, options: {}, workspaceRoot: "/tmp/ws" } as never); + + expect(process.exitCode).toBe(2); + + process.exitCode = undefined; + }); +}); diff --git a/packages/tooling/vis/__tests__/inference/rspack-detector.test.ts b/packages/tooling/vis/__tests__/inference/rspack-detector.test.ts new file mode 100644 index 00000000..6fc1016e --- /dev/null +++ b/packages/tooling/vis/__tests__/inference/rspack-detector.test.ts @@ -0,0 +1,64 @@ +import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { discoverWorkspace } from "../../src/config/workspace"; + +const writeProject = (root: string, name: string, configFile: string): void => { + const directory = join(root, "packages", name); + + mkdirSync(directory, { recursive: true }); + writeFileSync(join(directory, "package.json"), JSON.stringify({ name: `@fix/${name}`, scripts: {} }, undefined, 2)); + writeFileSync(join(directory, configFile), "export default {}"); +}; + +describe("rspack target inference", () => { + let scratch: string; + + beforeEach(() => { + scratch = mkdtempSync(join(realpathSync(tmpdir()), "vis-rspack-")); + mkdirSync(join(scratch, "node_modules"), { recursive: true }); + writeFileSync(join(scratch, "pnpm-workspace.yaml"), "packages:\n - 'packages/*'\n"); + writeFileSync(join(scratch, "package.json"), JSON.stringify({ name: "fixture-root", private: true }, undefined, 2)); + }); + + afterEach(() => { + rmSync(scratch, { force: true, recursive: true }); + }); + + it("infers build, dev, and preview from rspack.config.ts", () => { + expect.assertions(3); + + writeProject(scratch, "alpha", "rspack.config.ts"); + + const targets = discoverWorkspace(scratch, {}).workspace.projects["@fix/alpha"]?.targets ?? {}; + + expect(targets["build"]?.command).toBe("rspack build"); + expect(targets["dev"]?.command).toBe("rspack serve"); + expect(targets["preview"]?.command).toBe("rspack preview"); + }); + + it("prefers rspack over webpack when both configs exist", () => { + expect.assertions(1); + + writeProject(scratch, "beta", "rspack.config.mjs"); + writeFileSync(join(scratch, "packages", "beta", "webpack.config.js"), "module.exports = {}"); + + const targets = discoverWorkspace(scratch, {}).workspace.projects["@fix/beta"]?.targets ?? {}; + + expect(targets["build"]?.command).toBe("rspack build"); + }); + + it("keeps vite ahead of rspack when both configs exist", () => { + expect.assertions(1); + + writeProject(scratch, "gamma", "rspack.config.ts"); + writeFileSync(join(scratch, "packages", "gamma", "vite.config.ts"), "export default {}"); + + const targets = discoverWorkspace(scratch, {}).workspace.projects["@fix/gamma"]?.targets ?? {}; + + expect(targets["build"]?.command).toBe("vite build"); + }); +}); diff --git a/packages/tooling/vis/__tests__/inference/rstack-detectors.test.ts b/packages/tooling/vis/__tests__/inference/rstack-detectors.test.ts new file mode 100644 index 00000000..cd65520c --- /dev/null +++ b/packages/tooling/vis/__tests__/inference/rstack-detectors.test.ts @@ -0,0 +1,102 @@ +import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { discoverWorkspace } from "../../src/config/workspace"; + +const writeProject = (root: string, name: string, files: string[], packageJson: Record = {}): void => { + const directory = join(root, "packages", name); + + mkdirSync(directory, { recursive: true }); + writeFileSync(join(directory, "package.json"), JSON.stringify({ name: `@fix/${name}`, scripts: {}, ...packageJson }, undefined, 2)); + + for (const file of files) { + writeFileSync(join(directory, file), "export default {}"); + } +}; + +describe("rstack target inference", () => { + let scratch: string; + + const targetsOf = (name: string): Record => + discoverWorkspace(scratch, {}).workspace.projects[`@fix/${name}`]?.targets ?? {}; + + beforeEach(() => { + scratch = mkdtempSync(join(realpathSync(tmpdir()), "vis-rstack-")); + mkdirSync(join(scratch, "node_modules"), { recursive: true }); + writeFileSync(join(scratch, "pnpm-workspace.yaml"), "packages:\n - 'packages/*'\n"); + writeFileSync(join(scratch, "package.json"), JSON.stringify({ name: "fixture-root", private: true }, undefined, 2)); + }); + + afterEach(() => { + rmSync(scratch, { force: true, recursive: true }); + }); + + it("infers build, dev, and preview from rsbuild.config.ts", () => { + expect.assertions(3); + + writeProject(scratch, "app", ["rsbuild.config.ts"]); + + const targets = targetsOf("app"); + + expect(targets["build"]?.command).toBe("rsbuild build"); + expect(targets["dev"]?.command).toBe("rsbuild dev"); + expect(targets["preview"]?.command).toBe("rsbuild preview"); + }); + + it("prefers rsbuild over rspack when both configs exist", () => { + expect.assertions(1); + + writeProject(scratch, "app", ["rsbuild.config.ts", "rspack.config.ts"]); + + expect(targetsOf("app")["build"]?.command).toBe("rsbuild build"); + }); + + it("infers a library build and watch from rslib.config.ts", () => { + expect.assertions(2); + + writeProject(scratch, "lib", ["rslib.config.ts"]); + + const targets = targetsOf("lib"); + + expect(targets["build"]?.command).toBe("rslib build"); + expect(targets["dev"]?.command).toBe("rslib build --watch"); + }); + + it("infers rstest run from the dependency, without a watcher", () => { + expect.assertions(2); + + writeProject(scratch, "unit", [], { devDependencies: { "@rstest/core": "^0.12.0" } }); + + const targets = targetsOf("unit"); + + expect(targets["test"]?.command).toBe("rstest run"); + expect(targets["test:watch"]).toBeUndefined(); + }); + + it("adds rstest watch when a config file exists", () => { + expect.assertions(1); + + writeProject(scratch, "unit", ["rstest.config.ts"], { devDependencies: { "@rstest/core": "^0.12.0" } }); + + expect(targetsOf("unit")["test:watch"]?.command).toBe("rstest watch"); + }); + + it("keeps vitest ahead of rstest when both are present", () => { + expect.assertions(1); + + writeProject(scratch, "unit", ["rstest.config.ts", "vitest.config.ts"], { devDependencies: { "@rstest/core": "^0.12.0", vitest: "^4.0.0" } }); + + expect(targetsOf("unit")["test"]?.command).toBe("vitest run"); + }); + + it("infers rslint from rslint.config.ts", () => { + expect.assertions(1); + + writeProject(scratch, "style", ["rslint.config.ts"]); + + expect(targetsOf("style")["lint"]?.command).toBe("rslint ."); + }); +}); diff --git a/packages/tooling/vis/docs/commands/build.mdx b/packages/tooling/vis/docs/commands/build.mdx new file mode 100644 index 00000000..b348a1da --- /dev/null +++ b/packages/tooling/vis/docs/commands/build.mdx @@ -0,0 +1,42 @@ +--- +title: "vis build" +description: "Run the production build: the package's build script, or the inferred Vite, Rsbuild, Rslib, or Rspack build" +--- + +# vis build + +Runs the `build` target. `vis build` is `vis run build`, so it uses the same target resolution, filters, and caching. + +A package's own `build` script in `package.json` always wins. When a project has no `build` script, vis infers one from its config file: + +- `vite.config.*` → `vite build` +- `rsbuild.config.*` → `rsbuild build` +- `rslib.config.*` → `rslib build` (a library build) +- `rspack.config.*` → `rspack build` + +When a project has more than one bundler config, the first match in this order wins: Vite, then Rsbuild, then Rslib, then Rspack. + +## Usage + +```bash +vis build [args…] [options] +``` + +Positional arguments and flags after `--` are passed to the `build` target. + +## Examples + +```bash +# Build every project that has a build target +vis build + +# Build only one package +vis build --filter @org/web + +# Forward flags to the build +vis build -- --mode production +``` + +## Scope + +Without `--filter`, `vis build` builds every project that has a `build` target. diff --git a/packages/tooling/vis/docs/commands/dev.mdx b/packages/tooling/vis/docs/commands/dev.mdx new file mode 100644 index 00000000..cbeaae90 --- /dev/null +++ b/packages/tooling/vis/docs/commands/dev.mdx @@ -0,0 +1,40 @@ +--- +title: "vis dev" +description: "Start the dev server: the package's dev script, or the inferred Vite, Rsbuild, Rslib, or Rspack dev server" +--- + +# vis dev + +Starts the `dev` target. `vis dev` is `vis run dev`, so it uses the same target resolution, filters, and caching. + +A package's own `dev` script in `package.json` always wins. When a project has no `dev` script, vis infers one from its config file: + +- `vite.config.*` → `vite` +- `rsbuild.config.*` → `rsbuild dev` +- `rslib.config.*` → `rslib build --watch` (a watch build, since Rslib has no dev server) +- `rspack.config.*` → `rspack serve` + +## Usage + +```bash +vis dev [args…] [options] +``` + +Positional arguments and flags after `--` are passed to the `dev` target. + +## Examples + +```bash +# Start the dev target across the workspace +vis dev + +# Start only one package +vis dev --filter @org/web + +# Forward flags to the dev server +vis dev -- --port 3000 +``` + +## Scope + +Without `--filter`, `vis dev` starts every project that has a `dev` target. In a large workspace, pass `--filter` to start one package. diff --git a/packages/tooling/vis/docs/commands/rebuild.mdx b/packages/tooling/vis/docs/commands/rebuild.mdx new file mode 100644 index 00000000..652db397 --- /dev/null +++ b/packages/tooling/vis/docs/commands/rebuild.mdx @@ -0,0 +1,23 @@ +--- +title: "vis rebuild" +description: "Rebuild native dependencies through the detected package manager" +--- + +# vis rebuild + +Rebuilds native dependencies through the detected package manager, for example after switching Node versions. It runs `pnpm rebuild` or `npm rebuild`; Yarn berry and aube pass through to the package manager's own command. + +Yarn 1 and Bun have no `rebuild` command. `vis rebuild` fails with a message on those package managers instead of exiting successfully without rebuilding anything. Reinstall the dependencies there instead. + +## Usage + +```bash +vis rebuild +``` + +## Examples + +```bash +# Rebuild native dependencies in the current workspace +vis rebuild +``` diff --git a/packages/tooling/vis/docs/configuration.mdx b/packages/tooling/vis/docs/configuration.mdx index 06970554..f1a26eca 100644 --- a/packages/tooling/vis/docs/configuration.mdx +++ b/packages/tooling/vis/docs/configuration.mdx @@ -304,9 +304,9 @@ To opt **in** to only specific detectors, set the rest to `false` — there is n | Category | Detectors (target names) | | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | | App frameworks | `nuxt`, `next`, `remix`, `astro`, `gatsby`, `docusaurus` — emit `build`, `dev`, `start`/`preview`/`serve`, plus framework-specific (`generate`, `develop`, …) | -| Bundlers | `vite`, `rolldown`, `tsdown`, `tsup`, `packem`, `rollup`, `webpack` — emit `build` (and `dev`/`preview` where the tool supports it) | +| Bundlers | `vite`, `rsbuild`, `rslib`, `rspack`, `rolldown`, `tsdown`, `tsup`, `packem`, `rollup`, `webpack` — emit `build` (and `dev`/`preview` where the tool supports it) | | Server frameworks | `nest` — emits `build`, `dev`, `start` | -| Test runners | `vitest`, `jest`, `bun`, `playwright`, `cypress` — emit `test`, `test:watch`, `test:e2e`, `cypress:open` | +| Test runners | `vitest`, `rstest`, `jest`, `bun`, `playwright`, `cypress` — emit `test`, `test:watch`, `test:e2e`, `cypress:open` | | Stories | `storybook` — emits `storybook`, `build-storybook` | | Typecheck / docs | `typescript` (→ `typecheck`), `typedoc` (→ `docs`), `vitepress` (→ `docs:dev`, `docs:build`, `docs:preview`) | | Lint / format | `eslint`, `prettier`, `biome`, `oxlint`, `oxfmt`, `stylelint` (→ `lint:css`), `knip` (→ `knip`) | diff --git a/packages/tooling/vis/docs/meta.json b/packages/tooling/vis/docs/meta.json index 50e7548c..33091ddc 100644 --- a/packages/tooling/vis/docs/meta.json +++ b/packages/tooling/vis/docs/meta.json @@ -9,6 +9,8 @@ "commands/create", "commands/init", "commands/run", + "commands/dev", + "commands/build", "commands/exec", "commands/staged", "commands/affected", @@ -43,6 +45,7 @@ "commands/outdated", "commands/why", "commands/pm", + "commands/rebuild", "commands/info", "commands/doctor", "commands/audit", diff --git a/packages/tooling/vis/src/commands/build/handler.ts b/packages/tooling/vis/src/commands/build/handler.ts new file mode 100644 index 00000000..8e00de82 --- /dev/null +++ b/packages/tooling/vis/src/commands/build/handler.ts @@ -0,0 +1,12 @@ +import type { CommandExecute, Toolbox } from "@visulima/cerebro"; + +import runExecute from "../run/handler"; +import type { BuildOptions } from "./index"; + +/** `vis build` is `vis run build`: the target goes first, then the forwarded arguments. */ +const execute = async ({ argument, rawUnknown, ...toolbox }: Toolbox): Promise => { + await runExecute({ ...toolbox, argument: ["build", ...(argument ?? []), ...(rawUnknown ?? [])], rawUnknown }); +}; + +// fallow-ignore-next-line unused-export -- lazy-loaded command entry (cerebro loader/lazyNamed dynamic import) +export default execute as CommandExecute; diff --git a/packages/tooling/vis/src/commands/build/index.ts b/packages/tooling/vis/src/commands/build/index.ts new file mode 100644 index 00000000..d089b957 --- /dev/null +++ b/packages/tooling/vis/src/commands/build/index.ts @@ -0,0 +1,26 @@ +import type { InferOptions } from "@visulima/cerebro"; +import { defineCommand } from "@visulima/cerebro"; + +import { runOptionDefinitions } from "../run"; + +const build = defineCommand({ + argument: { + description: "Extra arguments forwarded to the build target", + name: "args", + type: String, + }, + description: "Run the production build: the package's build script, or the inferred Vite, Rsbuild, Rslib, or Rspack build", + examples: [ + ["vis build", "Run the build target across the workspace"], + ["vis build --filter @org/web", "Build only one package"], + ["vis build -- --mode production", "Forward flags after -- to the build target"], + ], + group: "Run & Execute", + loader: () => import("./handler"), + name: "build", + options: runOptionDefinitions, +}); + +export default build; + +export type BuildOptions = InferOptions; diff --git a/packages/tooling/vis/src/commands/dev/handler.ts b/packages/tooling/vis/src/commands/dev/handler.ts new file mode 100644 index 00000000..e0694be2 --- /dev/null +++ b/packages/tooling/vis/src/commands/dev/handler.ts @@ -0,0 +1,12 @@ +import type { CommandExecute, Toolbox } from "@visulima/cerebro"; + +import runExecute from "../run/handler"; +import type { DevOptions } from "./index"; + +/** `vis dev` is `vis run dev`: the target goes first, then the forwarded arguments. */ +const execute = async ({ argument, rawUnknown, ...toolbox }: Toolbox): Promise => { + await runExecute({ ...toolbox, argument: ["dev", ...(argument ?? []), ...(rawUnknown ?? [])], rawUnknown }); +}; + +// fallow-ignore-next-line unused-export -- lazy-loaded command entry (cerebro loader/lazyNamed dynamic import) +export default execute as CommandExecute; diff --git a/packages/tooling/vis/src/commands/dev/index.ts b/packages/tooling/vis/src/commands/dev/index.ts new file mode 100644 index 00000000..8adcab94 --- /dev/null +++ b/packages/tooling/vis/src/commands/dev/index.ts @@ -0,0 +1,26 @@ +import type { InferOptions } from "@visulima/cerebro"; +import { defineCommand } from "@visulima/cerebro"; + +import { runOptionDefinitions } from "../run"; + +const dev = defineCommand({ + argument: { + description: "Extra arguments forwarded to the dev target", + name: "args", + type: String, + }, + description: "Start the dev server: the package's dev script, or the inferred Vite, Rsbuild, Rslib, or Rspack dev server", + examples: [ + ["vis dev", "Start the dev target across the workspace"], + ["vis dev --filter @org/web", "Start only one package's dev target"], + ["vis dev -- --port 3000", "Forward flags after -- to the dev target"], + ], + group: "Run & Execute", + loader: () => import("./handler"), + name: "dev", + options: runOptionDefinitions, +}); + +export default dev; + +export type DevOptions = InferOptions; diff --git a/packages/tooling/vis/src/commands/rebuild/handler.ts b/packages/tooling/vis/src/commands/rebuild/handler.ts new file mode 100644 index 00000000..b49768cc --- /dev/null +++ b/packages/tooling/vis/src/commands/rebuild/handler.ts @@ -0,0 +1,35 @@ +import type { CommandExecute, Toolbox } from "@visulima/cerebro"; + +import { VisUserError } from "../../errors/vis-user-error"; +import { dispatchSubcommand } from "../../pm/pm-subcommand-dispatch"; +import { resolveCommandRuntime, runtimeInstallerBackend } from "../../runtime/command-runtime"; + +const execute = async ({ logger, options, visConfig, workspaceRoot: wsRoot }: Toolbox): Promise => { + const cwd = wsRoot ?? process.cwd(); + const runtime = resolveCommandRuntime({ options, visConfig }, cwd); + + // Loaded here, not at module top: pm-runner pulls in the native binding. + const { resolveInstaller, runPmSubcommand } = await import("../../pm/pm-runner"); + const pm = resolveInstaller(cwd, { + backend: runtimeInstallerBackend(runtime), + configBackend: visConfig?.install?.backend, + configCorepack: visConfig?.install?.corepack, + }); + + // Some package managers have no `rebuild`. Surface that as an error rather than + // letting the run exit 0 after rebuilding nothing. + const action = dispatchSubcommand(pm, "rebuild", []); + + if (action.kind === "skip") { + throw new VisUserError(action.warning); + } + + const code = runPmSubcommand(pm, "rebuild", [], cwd, logger); + + if (code !== 0) { + process.exitCode = code; + } +}; + +// fallow-ignore-next-line unused-export -- lazy-loaded command entry (cerebro loader/lazyNamed dynamic import) +export default execute as CommandExecute; diff --git a/packages/tooling/vis/src/commands/rebuild/index.ts b/packages/tooling/vis/src/commands/rebuild/index.ts new file mode 100644 index 00000000..e5fad2ab --- /dev/null +++ b/packages/tooling/vis/src/commands/rebuild/index.ts @@ -0,0 +1,13 @@ +import { defineCommand } from "@visulima/cerebro"; + +const rebuild = defineCommand({ + description: "Rebuild native dependencies through the detected package manager", + examples: [ + ["vis rebuild", "Rebuild native dependencies in the current workspace"], + ], + group: "Dependencies", + loader: () => import("./handler"), + name: "rebuild", +}); + +export default rebuild; diff --git a/packages/tooling/vis/src/commands/run/index.ts b/packages/tooling/vis/src/commands/run/index.ts index dbddb7b0..8cb8d09f 100644 --- a/packages/tooling/vis/src/commands/run/index.ts +++ b/packages/tooling/vis/src/commands/run/index.ts @@ -3,7 +3,7 @@ import { defineCommand } from "@visulima/cerebro"; import { negatable } from "../../util/negatable-option"; -const runOptionDefinitions = { +export const runOptionDefinitions = { filter: { alias: "F", description: diff --git a/packages/tooling/vis/src/inference/detectors/rsbuild.ts b/packages/tooling/vis/src/inference/detectors/rsbuild.ts new file mode 100644 index 00000000..247b4a85 --- /dev/null +++ b/packages/tooling/vis/src/inference/detectors/rsbuild.ts @@ -0,0 +1,31 @@ +import type { Detector } from "../types"; + +export const rsbuildDetector: Detector = { + configFiles: ["rsbuild.config.ts", "rsbuild.config.js", "rsbuild.config.mjs", "rsbuild.config.mts", "rsbuild.config.cjs", "rsbuild.config.cts"], + // No `fallbackDependency`, same as rspack: a root-level `rsbuild.config.*` is the + // signal that the project builds itself with Rsbuild. + detect: ({ matchedConfigs }) => { + return { + targets: { + build: { + command: "rsbuild build", + description: "rsbuild production build (inferred)", + inputs: ["{projectRoot}/src/**/*", `{projectRoot}/${matchedConfigs[0]!}`, "{projectRoot}/package.json"], + outputs: ["{projectRoot}/dist"], + type: "build", + }, + dev: { + command: "rsbuild dev", + description: "rsbuild dev server (inferred)", + preset: "server", + }, + preview: { + command: "rsbuild preview", + description: "rsbuild preview server (inferred)", + preset: "server", + }, + }, + }; + }, + name: "rsbuild", +}; diff --git a/packages/tooling/vis/src/inference/detectors/rslib.ts b/packages/tooling/vis/src/inference/detectors/rslib.ts new file mode 100644 index 00000000..21e95aff --- /dev/null +++ b/packages/tooling/vis/src/inference/detectors/rslib.ts @@ -0,0 +1,26 @@ +import type { Detector } from "../types"; + +export const rslibDetector: Detector = { + configFiles: ["rslib.config.ts", "rslib.config.js", "rslib.config.mjs", "rslib.config.mts", "rslib.config.cjs", "rslib.config.cts"], + // Library builds (Rslib) have no dev server; `dev` is the watch build, which + // runs until stopped, so it is marked as a server preset. + detect: ({ matchedConfigs }) => { + return { + targets: { + build: { + command: "rslib build", + description: "rslib library build (inferred)", + inputs: ["{projectRoot}/src/**/*", `{projectRoot}/${matchedConfigs[0]!}`, "{projectRoot}/package.json", "{projectRoot}/tsconfig.json"], + outputs: ["{projectRoot}/dist"], + type: "build", + }, + dev: { + command: "rslib build --watch", + description: "rslib watch build (inferred)", + preset: "server", + }, + }, + }; + }, + name: "rslib", +}; diff --git a/packages/tooling/vis/src/inference/detectors/rslint.ts b/packages/tooling/vis/src/inference/detectors/rslint.ts new file mode 100644 index 00000000..ebb3a384 --- /dev/null +++ b/packages/tooling/vis/src/inference/detectors/rslint.ts @@ -0,0 +1,28 @@ +import type { Detector } from "../types"; + +export const rslintDetector: Detector = { + configFiles: ["rslint.config.ts", "rslint.config.js", "rslint.config.mjs"], + detect: ({ matchedConfigs }) => { + return { + targets: { + lint: { + command: "rslint .", + description: "rslint . (inferred)", + inputs: [ + "{projectRoot}/src/**/*", + "{projectRoot}/__tests__/**/*", + "{projectRoot}/test/**/*", + "{projectRoot}/tests/**/*", + ...matchedConfigs.map((file) => `{projectRoot}/${file}`), + "{projectRoot}/package.json", + ], + // Rslint emits diagnostics, not files, so the result is cacheable on input hash. + outputs: [], + type: "build", + }, + }, + }; + }, + fallbackDependency: "@rslint/core", + name: "rslint", +}; diff --git a/packages/tooling/vis/src/inference/detectors/rspack.ts b/packages/tooling/vis/src/inference/detectors/rspack.ts new file mode 100644 index 00000000..a49e2d4c --- /dev/null +++ b/packages/tooling/vis/src/inference/detectors/rspack.ts @@ -0,0 +1,32 @@ +import type { Detector } from "../types"; + +export const rspackDetector: Detector = { + configFiles: ["rspack.config.ts", "rspack.config.js", "rspack.config.mjs", "rspack.config.mts", "rspack.config.cjs", "rspack.config.cts"], + // No `fallbackDependency`, same as vite: @rspack/core is pulled in by other + // toolchains. A root-level `rspack.config.*` is the signal that the project + // builds itself with Rspack. + detect: ({ matchedConfigs }) => { + return { + targets: { + build: { + command: "rspack build", + description: "rspack production build (inferred)", + inputs: ["{projectRoot}/src/**/*", `{projectRoot}/${matchedConfigs[0]!}`, "{projectRoot}/package.json"], + outputs: ["{projectRoot}/dist"], + type: "build", + }, + dev: { + command: "rspack serve", + description: "rspack dev server (inferred)", + preset: "server", + }, + preview: { + command: "rspack preview", + description: "rspack preview server (inferred)", + preset: "server", + }, + }, + }; + }, + name: "rspack", +}; diff --git a/packages/tooling/vis/src/inference/detectors/rstest.ts b/packages/tooling/vis/src/inference/detectors/rstest.ts new file mode 100644 index 00000000..d3f31868 --- /dev/null +++ b/packages/tooling/vis/src/inference/detectors/rstest.ts @@ -0,0 +1,38 @@ +import type { DetectedTargets, Detector } from "../types"; + +export const rstestDetector: Detector = { + configFiles: ["rstest.config.ts", "rstest.config.js", "rstest.config.mjs", "rstest.config.mts", "rstest.config.cjs"], + detect: ({ hasConfigFile, matchedConfigs }) => { + const configRef = matchedConfigs[0] ? `{projectRoot}/${matchedConfigs[0]}` : undefined; + const targets: DetectedTargets["targets"] = { + test: { + command: "rstest run", + description: "rstest run (inferred)", + inputs: [ + "{projectRoot}/src/**/*", + "{projectRoot}/__tests__/**/*", + "{projectRoot}/test/**/*", + "{projectRoot}/tests/**/*", + ...(configRef ? [configRef] : []), + "{projectRoot}/package.json", + ], + outputs: ["{projectRoot}/coverage"], + type: "test", + }, + }; + + // Same rule as vitest: watch mode only when a config file exists, so a + // dependency-only match does not add a long-running watcher. + if (hasConfigFile) { + targets["test:watch"] = { + command: "rstest watch", + description: "rstest watch mode (inferred)", + preset: "server", + }; + } + + return { targets }; + }, + fallbackDependency: "@rstest/core", + name: "rstest", +}; diff --git a/packages/tooling/vis/src/inference/index.ts b/packages/tooling/vis/src/inference/index.ts index 2776c529..ceb3baf3 100644 --- a/packages/tooling/vis/src/inference/index.ts +++ b/packages/tooling/vis/src/inference/index.ts @@ -40,6 +40,11 @@ import { pythonDetector } from "./detectors/python"; import { remixDetector } from "./detectors/remix"; import { rolldownDetector } from "./detectors/rolldown"; import { rollupDetector } from "./detectors/rollup"; +import { rsbuildDetector } from "./detectors/rsbuild"; +import { rslibDetector } from "./detectors/rslib"; +import { rslintDetector } from "./detectors/rslint"; +import { rspackDetector } from "./detectors/rspack"; +import { rstestDetector } from "./detectors/rstest"; import { storybookDetector } from "./detectors/storybook"; import { stylelintDetector } from "./detectors/stylelint"; import { swiftDetector } from "./detectors/swift"; @@ -62,12 +67,12 @@ export type { DetectContext, Detector } from "./types"; * priorities baked into this list: * * - `build`: nuxt > next > remix > astro > gatsby > docusaurus > vite > - * nest > rolldown > tsdown > tsup > packem > rollup > webpack + * nest > rolldown > tsdown > tsup > packem > rollup > rsbuild > rslib > rspack > webpack * - `dev` / `preview` / `start` / `serve`: cascades from `build` order; * gatsby owns `develop` uniquely - * - `test`: vitest > jest > bun (`test:e2e` is playwright > + * - `test`: vitest > jest > rstest > bun (`test:e2e` is playwright > * cypress) - * - `lint`: eslint > biome > oxlint + * - `lint`: eslint > biome > oxlint > rslint * - `format` / `format:check`: prettier > biome > oxfmt > dprint * * Single-owner targets (no collision possible): `typecheck` (typescript), @@ -94,9 +99,13 @@ export const BUILT_IN_DETECTORS: ReadonlyArray = [ tsupDetector, packemDetector, rollupDetector, + rsbuildDetector, + rslibDetector, + rspackDetector, webpackDetector, vitestDetector, jestDetector, + rstestDetector, bunDetector, playwrightDetector, cypressDetector, @@ -107,6 +116,7 @@ export const BUILT_IN_DETECTORS: ReadonlyArray = [ prettierDetector, biomeDetector, oxlintDetector, + rslintDetector, oxfmtDetector, dprintDetector, stylelintDetector, diff --git a/packages/tooling/vis/src/register-commands.ts b/packages/tooling/vis/src/register-commands.ts index 6f3ccfc2..2ae051f5 100644 --- a/packages/tooling/vis/src/register-commands.ts +++ b/packages/tooling/vis/src/register-commands.ts @@ -14,6 +14,7 @@ import analyzeCommand from "./commands/analyze"; import approveBuildsCommand from "./commands/approve-builds"; import attestCommands from "./commands/attest"; import auditCommand from "./commands/audit"; +import buildCommand from "./commands/build"; import cacheCommands from "./commands/cache"; import checkCommand from "./commands/check"; import ciCommand from "./commands/ci"; @@ -23,6 +24,7 @@ import createCommand from "./commands/create"; import dashboardCommand from "./commands/dashboard"; import dedupeCommand from "./commands/dedupe"; import depsCommand from "./commands/deps"; +import devCommand from "./commands/dev"; import devcontainerCommand from "./commands/devcontainer"; import dlxCommand from "./commands/dlx"; import dockerCommands from "./commands/docker"; @@ -45,6 +47,7 @@ import listCommand from "./commands/list"; import migrateCommands from "./commands/migrate"; import optimizeCommand from "./commands/optimize"; import pmCommand from "./commands/pm"; +import rebuildCommand from "./commands/rebuild"; import releaseCommands from "./commands/release"; import releaseRootCommand from "./commands/release/root-command"; import removeCommand from "./commands/remove"; @@ -81,6 +84,8 @@ const registerCommands = (cli: Cli): void => { // Workspace commands cli.addCommand(runCommand); + cli.addCommand(devCommand); + cli.addCommand(buildCommand); cli.addCommand(ciCommand); cli.addCommand(graphCommand); cli.addCommand(actionGraphCommand); @@ -127,6 +132,7 @@ const registerCommands = (cli: Cli): void => { cli.addCommand(execCommand); cli.addCommand(xCommand); cli.addCommand(pmCommand); + cli.addCommand(rebuildCommand); // Project & environment commands cli.addCommand(initCommand); From 7496dddf398fcc6185101b09c9a85a75691fce84 Mon Sep 17 00:00:00 2001 From: Daniel Bannert Date: Sun, 18 Oct 2026 00:09:14 +0200 Subject: [PATCH 2/9] feat(vis): offer a dev or build tool when none is configured `vis dev` and `vis build` used to fail when no project defined the target. Now, when none does, they ask which tool to set up: Vite, Rsbuild, Rspack, or Rslib for build. The choice is the consent. The tool is installed with `vis add -D` into the nearest package, and a matching `dev` or `build` script is added to that package's package.json, so later runs use it. The script is what makes the choice stick. Inference needs a config file, and the offer does not create one. Non-interactive runs, and runs at the workspace root, stop with an error that lists the options. Co-Authored-By: Claude Haiku 5.5 --- .../commands/dev/dev-build-forwarding.test.ts | 15 +- .../commands/dev/dev-build-offer.test.ts | 78 ++++++++++ .../vis/__tests__/util/target-tool.test.ts | 132 ++++++++++++++++ packages/tooling/vis/docs/commands/build.mdx | 2 + packages/tooling/vis/docs/commands/dev.mdx | 4 + .../tooling/vis/src/commands/build/handler.ts | 26 +++- .../tooling/vis/src/commands/dev/handler.ts | 25 ++- packages/tooling/vis/src/util/target-tool.ts | 146 ++++++++++++++++++ 8 files changed, 418 insertions(+), 10 deletions(-) create mode 100644 packages/tooling/vis/__tests__/commands/dev/dev-build-offer.test.ts create mode 100644 packages/tooling/vis/__tests__/util/target-tool.test.ts create mode 100644 packages/tooling/vis/src/util/target-tool.ts diff --git a/packages/tooling/vis/__tests__/commands/dev/dev-build-forwarding.test.ts b/packages/tooling/vis/__tests__/commands/dev/dev-build-forwarding.test.ts index 84157d23..823299ff 100644 --- a/packages/tooling/vis/__tests__/commands/dev/dev-build-forwarding.test.ts +++ b/packages/tooling/vis/__tests__/commands/dev/dev-build-forwarding.test.ts @@ -4,8 +4,11 @@ const runExecute = vi.hoisted(() => vi.fn()); vi.mock(import("../../../src/commands/run/handler"), () => { return { default: runExecute }; }); -const { default: devExecute } = await import("../../../src/commands/dev/handler"); -const { default: buildExecute } = await import("../../../src/commands/build/handler"); +const { runDev } = await import("../../../src/commands/dev/handler"); +const { runBuild } = await import("../../../src/commands/build/handler"); + +// The target is already defined, so these tests exercise forwarding, not the offer. +const DEFINED = { definesTarget: async () => true }; const toolbox = (argument: string[] | undefined, rawUnknown: string[] | undefined, options: Record = {}) => ({ argument, logger: {}, options, rawUnknown }) as never; @@ -18,7 +21,7 @@ describe("vis dev and vis build", () => { it("runs the dev target through the run handler", async () => { expect.assertions(1); - await devExecute(toolbox(undefined, undefined)); + await runDev(toolbox(undefined, undefined), DEFINED); expect(runExecute.mock.calls[0][0].argument).toStrictEqual(["dev"]); }); @@ -26,7 +29,7 @@ describe("vis dev and vis build", () => { it("forwards positional and flag arguments after the target", async () => { expect.assertions(1); - await devExecute(toolbox(["web"], ["--port", "3000"])); + await runDev(toolbox(["web"], ["--port", "3000"]), DEFINED); expect(runExecute.mock.calls[0][0].argument).toStrictEqual(["dev", "web", "--port", "3000"]); }); @@ -34,7 +37,7 @@ describe("vis dev and vis build", () => { it("passes run's options through unchanged", async () => { expect.assertions(1); - await buildExecute(toolbox(undefined, undefined, { filter: ["@org/web"], preflight: undefined })); + await runBuild(toolbox(undefined, undefined, { filter: ["@org/web"], preflight: undefined }), DEFINED); expect(runExecute.mock.calls[0][0].options).toStrictEqual({ filter: ["@org/web"], preflight: undefined }); }); @@ -42,7 +45,7 @@ describe("vis dev and vis build", () => { it("runs the build target", async () => { expect.assertions(1); - await buildExecute(toolbox(undefined, ["--mode", "production"])); + await runBuild(toolbox(undefined, ["--mode", "production"]), DEFINED); expect(runExecute.mock.calls[0][0].argument).toStrictEqual(["build", "--mode", "production"]); }); diff --git a/packages/tooling/vis/__tests__/commands/dev/dev-build-offer.test.ts b/packages/tooling/vis/__tests__/commands/dev/dev-build-offer.test.ts new file mode 100644 index 00000000..4b4ddfdc --- /dev/null +++ b/packages/tooling/vis/__tests__/commands/dev/dev-build-offer.test.ts @@ -0,0 +1,78 @@ +import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { access, readFile, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const runExecute = vi.hoisted(() => vi.fn()); + +vi.mock(import("../../../src/commands/run/handler"), () => { return { default: runExecute }; }); + +const { runDev } = await import("../../../src/commands/dev/handler"); +const { runBuild } = await import("../../../src/commands/build/handler"); + +describe("vis dev and vis build with no target", () => { + let root: string; + + beforeEach(() => { + root = mkdtempSync(join(realpathSync(tmpdir()), "vis-dev-offer-")); + mkdirSync(join(root, "node_modules"), { recursive: true }); + mkdirSync(join(root, "packages", "app"), { recursive: true }); + writeFileSync(join(root, "pnpm-workspace.yaml"), "packages:\n - 'packages/*'\n"); + writeFileSync(join(root, "package.json"), JSON.stringify({ name: "root", private: true })); + writeFileSync(join(root, "packages", "app", "package.json"), JSON.stringify({ name: "@fix/app", scripts: {} })); + runExecute.mockReset(); + process.exitCode = undefined; + }); + + afterEach(() => { + rmSync(root, { force: true, recursive: true }); + process.exitCode = undefined; + }); + + const toolbox = (workspaceRoot: string) => + ({ + argument: [], + fs: { access, readFile, writeFile }, + logger: { info: vi.fn() }, + options: {}, + rawUnknown: [], + visConfig: undefined, + workspaceRoot, + }) as never; + + it("runs the dev target without prompting when a package already defines it", async () => { + expect.assertions(2); + + writeFileSync(join(root, "packages", "app", "package.json"), JSON.stringify({ name: "@fix/app", scripts: { dev: "node server.js" } })); + const cwd = process.cwd(); + + process.chdir(join(root, "packages", "app")); + + try { + await runDev(toolbox(root), { definesTarget: async () => true, install: () => 0, isInteractive: () => false }); + } finally { + process.chdir(cwd); + } + + expect(runExecute).toHaveBeenCalledTimes(1); + expect(runExecute.mock.calls[0][0].argument).toStrictEqual(["dev"]); + }); + + it("errors for build in a non-interactive run, and does not run the target", async () => { + expect.assertions(2); + + const cwd = process.cwd(); + + process.chdir(join(root, "packages", "app")); + + try { + await expect(runBuild(toolbox(root), { definesTarget: async () => false, install: () => 0, isInteractive: () => false })).rejects.toThrow("vis add -D @rslib/core"); + } finally { + process.chdir(cwd); + } + + expect(runExecute).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/tooling/vis/__tests__/util/target-tool.test.ts b/packages/tooling/vis/__tests__/util/target-tool.test.ts new file mode 100644 index 00000000..0d3e9fb7 --- /dev/null +++ b/packages/tooling/vis/__tests__/util/target-tool.test.ts @@ -0,0 +1,132 @@ +import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { access, readFile, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import type { CerebroFs } from "@visulima/cerebro"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import type { TargetToolChoice } from "../../src/util/target-tool"; +import { offerTargetTool } from "../../src/util/target-tool"; + +const CHOICES: ReadonlyArray = [ + { label: "Vite", packages: ["vite"], script: "vite" }, + { label: "Rspack", packages: ["@rspack/cli", "@rspack/core"], script: "rspack serve" }, +]; + +const fs = { access, readFile, writeFile } as unknown as CerebroFs; + +describe(offerTargetTool, () => { + let root: string; + let app: string; + + const manifest = (): string => readFileSync(join(app, "package.json"), "utf8"); + + beforeEach(() => { + root = mkdtempSync(join(realpathSync(tmpdir()), "vis-target-tool-")); + app = join(root, "packages", "app"); + + mkdirSync(join(root, "node_modules"), { recursive: true }); + mkdirSync(app, { recursive: true }); + writeFileSync(join(root, "pnpm-workspace.yaml"), "packages:\n - 'packages/*'\n"); + writeFileSync(join(root, "package.json"), JSON.stringify({ name: "root", private: true })); + writeFileSync(join(app, "package.json"), JSON.stringify({ name: "@fix/app", scripts: {} }, undefined, 2)); + process.exitCode = undefined; + }); + + afterEach(() => { + rmSync(root, { force: true, recursive: true }); + process.exitCode = undefined; + }); + + const params = (overrides: Partial[0]> = {}) => { + return { + choices: CHOICES, + cwd: app, + fs, + logger: { info: vi.fn() }, + target: "dev" as const, + visConfig: undefined, + workspaceRoot: root, + ...overrides, + }; + }; + + it("does nothing when a project already defines the target", async () => { + expect.assertions(2); + + writeFileSync(join(app, "package.json"), JSON.stringify({ name: "@fix/app", scripts: { dev: "node server.js" } })); + const install = vi.fn(() => 0); + + await expect(offerTargetTool(params(), { definesTarget: async () => true, install })).resolves.toBe(true); + expect(install).not.toHaveBeenCalled(); + }); + + it("errors in a non-interactive run, listing each tool, and changes nothing", async () => { + expect.assertions(3); + + const install = vi.fn(() => 0); + + await expect(offerTargetTool(params(), { definesTarget: async () => false, install, isInteractive: () => false })).rejects.toThrow("vis add -D vite"); + expect(install).not.toHaveBeenCalled(); + expect(manifest()).not.toContain("\"dev\""); + }); + + it("installs the picked tool into the package and adds its script", async () => { + expect.assertions(4); + + const install = vi.fn(() => 0); + + await expect(offerTargetTool(params(), { definesTarget: async () => false, install, isInteractive: () => true, readline: async () => "1" })).resolves.toBe(true); + expect(install).toHaveBeenCalledWith("vite", expect.stringMatching(/packages\/app$/)); + expect(JSON.parse(manifest())).toMatchObject({ scripts: { dev: "vite" } }); + expect(process.exitCode).toBeUndefined(); + }); + + it("installs every package a tool needs", async () => { + expect.assertions(1); + + const install = vi.fn(() => 0); + + await offerTargetTool(params(), { definesTarget: async () => false, install, isInteractive: () => true, readline: async () => "2" }); + + expect(install.mock.calls.map(([name]) => name)).toStrictEqual(["@rspack/cli", "@rspack/core"]); + }); + + it("keeps the package's own indentation when it adds the script", async () => { + expect.assertions(1); + + writeFileSync(join(app, "package.json"), "{\n\t\"name\": \"@fix/app\"\n}\n"); + + await offerTargetTool(params(), { definesTarget: async () => false, install: () => 0, isInteractive: () => true, readline: async () => "1" }); + + expect(manifest()).toBe("{\n\t\"name\": \"@fix/app\",\n\t\"scripts\": {\n\t\t\"dev\": \"vite\"\n\t}\n}\n"); + }); + + it("stops without adding the script when an install fails", async () => { + expect.assertions(3); + + const install = vi.fn(() => 2); + + await expect(offerTargetTool(params(), { definesTarget: async () => false, install, isInteractive: () => true, readline: async () => "1" })).resolves.toBe(false); + expect(process.exitCode).toBe(2); + expect(manifest()).not.toContain("\"dev\""); + }); + + it("cancels without installing when the user declines", async () => { + expect.assertions(2); + + const install = vi.fn(() => 0); + + await expect(offerTargetTool(params(), { definesTarget: async () => false, install, isInteractive: () => true, readline: async () => "" })).rejects.toThrow("No dev tool selected"); + expect(install).not.toHaveBeenCalled(); + }); + + it("refuses to add the script to the workspace root", async () => { + expect.assertions(1); + + await expect(offerTargetTool(params({ cwd: root }), { definesTarget: async () => false, install: () => 0, isInteractive: () => true, readline: async () => "1" })).rejects.toThrow( + "no package to add", + ); + }); +}); diff --git a/packages/tooling/vis/docs/commands/build.mdx b/packages/tooling/vis/docs/commands/build.mdx index b348a1da..b3ad7402 100644 --- a/packages/tooling/vis/docs/commands/build.mdx +++ b/packages/tooling/vis/docs/commands/build.mdx @@ -14,6 +14,8 @@ A package's own `build` script in `package.json` always wins. When a project has - `rslib.config.*` → `rslib build` (a library build) - `rspack.config.*` → `rspack build` +If no project defines a `build` target, `vis build` asks which bundler to set up: Vite, Rsbuild, Rslib, or Rspack. The choice works the same way as for `vis dev`: the tool is installed with `vis add -D` into the nearest package, and a `build` script is added to its `package.json`. Non-interactive runs stop and list the options instead. + When a project has more than one bundler config, the first match in this order wins: Vite, then Rsbuild, then Rslib, then Rspack. ## Usage diff --git a/packages/tooling/vis/docs/commands/dev.mdx b/packages/tooling/vis/docs/commands/dev.mdx index cbeaae90..10cd62d0 100644 --- a/packages/tooling/vis/docs/commands/dev.mdx +++ b/packages/tooling/vis/docs/commands/dev.mdx @@ -14,6 +14,10 @@ A package's own `dev` script in `package.json` always wins. When a project has n - `rslib.config.*` → `rslib build --watch` (a watch build, since Rslib has no dev server) - `rspack.config.*` → `rspack serve` +If no project defines a `dev` target, `vis dev` asks which dev server to set up: Vite, Rsbuild, or Rspack. Your choice is the consent. vis installs the tool with `vis add -D` into the nearest package, and adds the matching `dev` script to that package's `package.json`. The script is what keeps the choice, so later runs use it without asking. + +In a non-interactive run (CI, or no terminal) it stops and lists each tool and its install command instead. It never picks one for you. Run it from inside a package: at the workspace root there is no package to add the script to. + ## Usage ```bash diff --git a/packages/tooling/vis/src/commands/build/handler.ts b/packages/tooling/vis/src/commands/build/handler.ts index 8e00de82..f7fb66ef 100644 --- a/packages/tooling/vis/src/commands/build/handler.ts +++ b/packages/tooling/vis/src/commands/build/handler.ts @@ -1,12 +1,34 @@ import type { CommandExecute, Toolbox } from "@visulima/cerebro"; +import type { TargetToolChoice, TargetToolDeps } from "../../util/target-tool"; +import { offerTargetTool } from "../../util/target-tool"; import runExecute from "../run/handler"; import type { BuildOptions } from "./index"; -/** `vis build` is `vis run build`: the target goes first, then the forwarded arguments. */ -const execute = async ({ argument, rawUnknown, ...toolbox }: Toolbox): Promise => { +/** Bundlers vis can set up when no project defines a `build` target. */ +export const BUILD_CHOICES: ReadonlyArray = [ + { label: "Vite", packages: ["vite"], script: "vite build" }, + { label: "Rsbuild", packages: ["@rsbuild/core"], script: "rsbuild build" }, + { label: "Rslib", packages: ["@rslib/core"], script: "rslib build" }, + { label: "Rspack", packages: ["@rspack/cli", "@rspack/core"], script: "rspack build" }, +]; + +/** Offers a bundler when none is configured, then runs the `build` target like `vis run build`. */ +export const runBuild = async (toolbox: Toolbox, deps: TargetToolDeps = {}): Promise => { + const { argument, fs, logger, rawUnknown, visConfig, workspaceRoot } = toolbox; + const ready = await offerTargetTool( + { choices: BUILD_CHOICES, cwd: process.cwd(), fs, logger, target: "build", visConfig, workspaceRoot: workspaceRoot ?? process.cwd() }, + deps, + ); + + if (!ready) { + return; + } + await runExecute({ ...toolbox, argument: ["build", ...(argument ?? []), ...(rawUnknown ?? [])], rawUnknown }); }; +const execute = (toolbox: Toolbox): Promise => runBuild(toolbox); + // fallow-ignore-next-line unused-export -- lazy-loaded command entry (cerebro loader/lazyNamed dynamic import) export default execute as CommandExecute; diff --git a/packages/tooling/vis/src/commands/dev/handler.ts b/packages/tooling/vis/src/commands/dev/handler.ts index e0694be2..7bd4db8c 100644 --- a/packages/tooling/vis/src/commands/dev/handler.ts +++ b/packages/tooling/vis/src/commands/dev/handler.ts @@ -1,12 +1,33 @@ import type { CommandExecute, Toolbox } from "@visulima/cerebro"; +import type { TargetToolChoice, TargetToolDeps } from "../../util/target-tool"; +import { offerTargetTool } from "../../util/target-tool"; import runExecute from "../run/handler"; import type { DevOptions } from "./index"; -/** `vis dev` is `vis run dev`: the target goes first, then the forwarded arguments. */ -const execute = async ({ argument, rawUnknown, ...toolbox }: Toolbox): Promise => { +/** Dev servers vis can set up when no project defines a `dev` target. Rslib has no dev server. */ +export const DEV_CHOICES: ReadonlyArray = [ + { label: "Vite", packages: ["vite"], script: "vite" }, + { label: "Rsbuild", packages: ["@rsbuild/core"], script: "rsbuild dev" }, + { label: "Rspack", packages: ["@rspack/cli", "@rspack/core", "@rspack/dev-server"], script: "rspack serve" }, +]; + +/** Offers a dev server when none is configured, then runs the `dev` target like `vis run dev`. */ +export const runDev = async (toolbox: Toolbox, deps: TargetToolDeps = {}): Promise => { + const { argument, fs, logger, rawUnknown, visConfig, workspaceRoot } = toolbox; + const ready = await offerTargetTool( + { choices: DEV_CHOICES, cwd: process.cwd(), fs, logger, target: "dev", visConfig, workspaceRoot: workspaceRoot ?? process.cwd() }, + deps, + ); + + if (!ready) { + return; + } + await runExecute({ ...toolbox, argument: ["dev", ...(argument ?? []), ...(rawUnknown ?? [])], rawUnknown }); }; +const execute = (toolbox: Toolbox): Promise => runDev(toolbox); + // fallow-ignore-next-line unused-export -- lazy-loaded command entry (cerebro loader/lazyNamed dynamic import) export default execute as CommandExecute; diff --git a/packages/tooling/vis/src/util/target-tool.ts b/packages/tooling/vis/src/util/target-tool.ts new file mode 100644 index 00000000..07c19e9c --- /dev/null +++ b/packages/tooling/vis/src/util/target-tool.ts @@ -0,0 +1,146 @@ +/** + * Offer to set up a missing `dev` or `build` target. + * + * When no project defines the target, by script or by inference, vis asks which + * tool to use. The answer is the consent: it installs the tool with `vis add -D` + * into the nearest package and adds a matching script to that package.json. The + * script is what makes the choice stick, since inference needs a config file. + */ + +import type { CerebroFs } from "@visulima/cerebro"; +import { dirname, join, relative } from "@visulima/path"; + +import { discoverWorkspace, loadVisTaskConfigsForWorkspace } from "../config/workspace"; +import { VisUserError } from "../errors/vis-user-error"; +import type { ChooseToolDeps, Installer } from "./tool-choice"; +import { chooseTool, installDevDependency } from "./tool-choice"; +import { pathExists } from "./toolbox-fs"; + +export interface TargetToolChoice { + /** Shown in the list, e.g. "Vite". */ + label: string; + /** Installed as devDependencies with `vis add -D`. */ + packages: ReadonlyArray; + /** Written to package.json under the target's name. */ + script: string; +} + +export interface TargetToolParams { + choices: ReadonlyArray; + cwd: string; + fs: CerebroFs; + logger: Pick; + target: "build" | "dev"; + visConfig: Parameters[1]; + workspaceRoot: string; +} + +export interface TargetToolDeps extends ChooseToolDeps { + /** Overrides the workspace check. Defaults to discovering the workspace. */ + definesTarget?: (root: string, visConfig: TargetToolParams["visConfig"], target: string) => Promise; + /** Installs each package. Defaults to `vis add -D`. */ + install?: Installer; +} + +/** Whether any project in the workspace already defines `target`, by script or by inference. */ +export const workspaceDefinesTarget = async (root: string, visConfig: TargetToolParams["visConfig"], target: string): Promise => { + const taskConfigs = await loadVisTaskConfigsForWorkspace(root, visConfig); + const { workspace } = discoverWorkspace(root, visConfig, taskConfigs); + + return Object.values(workspace.projects).some((project) => project.targets?.[target] !== undefined); +}; + +/** The nearest package directory at or below the workspace root, or `undefined` at the root itself. */ +const nearestPackageDirectory = async (cwd: string, root: string, fs: CerebroFs): Promise => { + let directory = cwd; + + for (;;) { + // The root's package.json belongs to the workspace, so the script is never added there. + if (directory === root) { + return undefined; + } + + if (await pathExists(fs, join(directory, "package.json"))) { + return directory; + } + + const parent = dirname(directory); + + if (parent === directory) { + return undefined; + } + + directory = parent; + } +}; + +/** Adds `scripts[name] = command`, keeping the file's indentation. */ +const addScript = async (fs: CerebroFs, file: string, name: string, command: string): Promise => { + const text = await fs.readFile(file, "utf8"); + const manifest = JSON.parse(text) as Record & { scripts?: Record }; + + manifest.scripts = { ...manifest.scripts, [name]: command }; + + const indent = /^([ \t]+)"/m.exec(text)?.[1] ?? " "; + + await fs.writeFile(file, `${JSON.stringify(manifest, undefined, indent)}\n`, "utf8"); +}; + +/** + * Make sure the workspace can run `target`. Returns `true` when the caller should + * run it now, and `false` when an install failed (the exit code is already set). + * Throws when there is nowhere to add the script, or when the user cancels. + */ +export const offerTargetTool = async (params: TargetToolParams, deps: TargetToolDeps = {}): Promise => { + const { cwd, fs, logger, target, visConfig, workspaceRoot } = params; + + const definesTarget = deps.definesTarget ?? workspaceDefinesTarget; + + if (await definesTarget(workspaceRoot, visConfig, target)) { + return true; + } + + const packageDirectory = await nearestPackageDirectory(cwd, workspaceRoot, fs); + + if (packageDirectory === undefined) { + throw new VisUserError(`vis ${target}: no package to add a "${target}" script to. Run it from inside a package, or pass --filter to pick one.`); + } + + const where = relative(workspaceRoot, join(packageDirectory, "package.json")) || "package.json"; + const choice = await chooseTool( + `No "${target}" target is configured. Pick a tool: vis will install it and add a "${target}" script to ${where}.`, + params.choices.map((option) => { + return { + hint: `vis add -D ${option.packages.join(" ")}, then "${target}": "${option.script}"`, + label: option.label, + value: option.label, + }; + }), + deps, + ); + + if (choice === undefined) { + throw new VisUserError(`No ${target} tool selected. Add a "${target}" script to your package.json, or install one of the tools listed above.`); + } + + const picked = params.choices.find((option) => option.label === choice.value); + + if (picked === undefined) { + throw new VisUserError(`Unknown ${target} tool: ${choice.value}.`); + } + + for (const packageName of picked.packages) { + const code = installDevDependency(packageName, packageDirectory, logger, deps.install); + + if (code !== 0) { + process.exitCode = code; + + return false; + } + } + + await addScript(fs, join(packageDirectory, "package.json"), target, picked.script); + logger.info(`Added "${target}": "${picked.script}" to ${where}.`); + + return true; +}; From a5d3dc49237a19244282feaeb17c2c04bd017802 Mon Sep 17 00:00:00 2001 From: Daniel Bannert Date: Sun, 18 Oct 2026 00:33:15 +0200 Subject: [PATCH 3/9] feat(vis): write the tool's config file when the dev or build tool is added When `vis dev` or `vis build` installs a tool, it now also writes that tool's minimal config file (vite.config.ts, rsbuild.config.ts, rslib.config.ts, or rspack.config.mjs) next to the new script. The config lets inference find the target on later runs, not just the script. An existing config file is left untouched. Co-Authored-By: Claude Haiku 5.5 --- .../vis/__tests__/util/target-tool.test.ts | 20 ++++++++++++- packages/tooling/vis/docs/commands/build.mdx | 2 +- packages/tooling/vis/docs/commands/dev.mdx | 2 +- .../tooling/vis/src/commands/build/handler.ts | 28 ++++++++++++++++--- .../tooling/vis/src/commands/dev/handler.ts | 21 ++++++++++++-- packages/tooling/vis/src/util/target-tool.ts | 10 ++++++- 6 files changed, 72 insertions(+), 11 deletions(-) diff --git a/packages/tooling/vis/__tests__/util/target-tool.test.ts b/packages/tooling/vis/__tests__/util/target-tool.test.ts index 0d3e9fb7..6e8a4b2c 100644 --- a/packages/tooling/vis/__tests__/util/target-tool.test.ts +++ b/packages/tooling/vis/__tests__/util/target-tool.test.ts @@ -10,7 +10,7 @@ import type { TargetToolChoice } from "../../src/util/target-tool"; import { offerTargetTool } from "../../src/util/target-tool"; const CHOICES: ReadonlyArray = [ - { label: "Vite", packages: ["vite"], script: "vite" }, + { config: { contents: "export default {};\n", file: "vite.config.ts" }, label: "Vite", packages: ["vite"], script: "vite" }, { label: "Rspack", packages: ["@rspack/cli", "@rspack/core"], script: "rspack serve" }, ]; @@ -93,6 +93,24 @@ describe(offerTargetTool, () => { expect(install.mock.calls.map(([name]) => name)).toStrictEqual(["@rspack/cli", "@rspack/core"]); }); + it("writes the tool's config file next to the script", async () => { + expect.assertions(1); + + await offerTargetTool(params(), { definesTarget: async () => false, install: () => 0, isInteractive: () => true, readline: async () => "1" }); + + expect(readFileSync(join(app, "vite.config.ts"), "utf8")).toBe("export default {};\n"); + }); + + it("keeps an existing config file untouched", async () => { + expect.assertions(1); + + writeFileSync(join(app, "vite.config.ts"), "export default { base: '/app/' };\n"); + + await offerTargetTool(params(), { definesTarget: async () => false, install: () => 0, isInteractive: () => true, readline: async () => "1" }); + + expect(readFileSync(join(app, "vite.config.ts"), "utf8")).toBe("export default { base: '/app/' };\n"); + }); + it("keeps the package's own indentation when it adds the script", async () => { expect.assertions(1); diff --git a/packages/tooling/vis/docs/commands/build.mdx b/packages/tooling/vis/docs/commands/build.mdx index b3ad7402..44c2fe16 100644 --- a/packages/tooling/vis/docs/commands/build.mdx +++ b/packages/tooling/vis/docs/commands/build.mdx @@ -14,7 +14,7 @@ A package's own `build` script in `package.json` always wins. When a project has - `rslib.config.*` → `rslib build` (a library build) - `rspack.config.*` → `rspack build` -If no project defines a `build` target, `vis build` asks which bundler to set up: Vite, Rsbuild, Rslib, or Rspack. The choice works the same way as for `vis dev`: the tool is installed with `vis add -D` into the nearest package, and a `build` script is added to its `package.json`. Non-interactive runs stop and list the options instead. +If no project defines a `build` target, `vis build` asks which bundler to set up: Vite, Rsbuild, Rslib, or Rspack. The choice works the same way as for `vis dev`: the tool is installed with `vis add -D` into the nearest package, and a `build` script is added to its `package.json`, along with the tool's config file unless one already exists. Non-interactive runs stop and list the options instead. When a project has more than one bundler config, the first match in this order wins: Vite, then Rsbuild, then Rslib, then Rspack. diff --git a/packages/tooling/vis/docs/commands/dev.mdx b/packages/tooling/vis/docs/commands/dev.mdx index 10cd62d0..b6f6d053 100644 --- a/packages/tooling/vis/docs/commands/dev.mdx +++ b/packages/tooling/vis/docs/commands/dev.mdx @@ -14,7 +14,7 @@ A package's own `dev` script in `package.json` always wins. When a project has n - `rslib.config.*` → `rslib build --watch` (a watch build, since Rslib has no dev server) - `rspack.config.*` → `rspack serve` -If no project defines a `dev` target, `vis dev` asks which dev server to set up: Vite, Rsbuild, or Rspack. Your choice is the consent. vis installs the tool with `vis add -D` into the nearest package, and adds the matching `dev` script to that package's `package.json`. The script is what keeps the choice, so later runs use it without asking. +If no project defines a `dev` target, `vis dev` asks which dev server to set up: Vite, Rsbuild, or Rspack. Your choice is the consent. vis installs the tool with `vis add -D` into the nearest package, and adds the matching `dev` script to that package's `package.json`. The tool's config file (for example `vite.config.ts`) is also written, unless one already exists. The script and config are what keep the choice, so later runs use it without asking. In a non-interactive run (CI, or no terminal) it stops and lists each tool and its install command instead. It never picks one for you. Run it from inside a package: at the workspace root there is no package to add the script to. diff --git a/packages/tooling/vis/src/commands/build/handler.ts b/packages/tooling/vis/src/commands/build/handler.ts index f7fb66ef..5b397ebc 100644 --- a/packages/tooling/vis/src/commands/build/handler.ts +++ b/packages/tooling/vis/src/commands/build/handler.ts @@ -7,10 +7,30 @@ import type { BuildOptions } from "./index"; /** Bundlers vis can set up when no project defines a `build` target. */ export const BUILD_CHOICES: ReadonlyArray = [ - { label: "Vite", packages: ["vite"], script: "vite build" }, - { label: "Rsbuild", packages: ["@rsbuild/core"], script: "rsbuild build" }, - { label: "Rslib", packages: ["@rslib/core"], script: "rslib build" }, - { label: "Rspack", packages: ["@rspack/cli", "@rspack/core"], script: "rspack build" }, + { + config: { contents: "import { defineConfig } from \"vite\";\n\nexport default defineConfig({});\n", file: "vite.config.ts" }, + label: "Vite", + packages: ["vite"], + script: "vite build", + }, + { + config: { contents: "import { defineConfig } from \"@rsbuild/core\";\n\nexport default defineConfig({});\n", file: "rsbuild.config.ts" }, + label: "Rsbuild", + packages: ["@rsbuild/core"], + script: "rsbuild build", + }, + { + config: { contents: "import { defineConfig } from \"@rslib/core\";\n\nexport default defineConfig({ lib: [{ format: \"esm\" }] });\n", file: "rslib.config.ts" }, + label: "Rslib", + packages: ["@rslib/core"], + script: "rslib build", + }, + { + config: { contents: "export default {};\n", file: "rspack.config.mjs" }, + label: "Rspack", + packages: ["@rspack/cli", "@rspack/core"], + script: "rspack build", + }, ]; /** Offers a bundler when none is configured, then runs the `build` target like `vis run build`. */ diff --git a/packages/tooling/vis/src/commands/dev/handler.ts b/packages/tooling/vis/src/commands/dev/handler.ts index 7bd4db8c..3b36511a 100644 --- a/packages/tooling/vis/src/commands/dev/handler.ts +++ b/packages/tooling/vis/src/commands/dev/handler.ts @@ -7,9 +7,24 @@ import type { DevOptions } from "./index"; /** Dev servers vis can set up when no project defines a `dev` target. Rslib has no dev server. */ export const DEV_CHOICES: ReadonlyArray = [ - { label: "Vite", packages: ["vite"], script: "vite" }, - { label: "Rsbuild", packages: ["@rsbuild/core"], script: "rsbuild dev" }, - { label: "Rspack", packages: ["@rspack/cli", "@rspack/core", "@rspack/dev-server"], script: "rspack serve" }, + { + config: { contents: "import { defineConfig } from \"vite\";\n\nexport default defineConfig({});\n", file: "vite.config.ts" }, + label: "Vite", + packages: ["vite"], + script: "vite", + }, + { + config: { contents: "import { defineConfig } from \"@rsbuild/core\";\n\nexport default defineConfig({});\n", file: "rsbuild.config.ts" }, + label: "Rsbuild", + packages: ["@rsbuild/core"], + script: "rsbuild dev", + }, + { + config: { contents: "export default {};\n", file: "rspack.config.mjs" }, + label: "Rspack", + packages: ["@rspack/cli", "@rspack/core", "@rspack/dev-server"], + script: "rspack serve", + }, ]; /** Offers a dev server when none is configured, then runs the `dev` target like `vis run dev`. */ diff --git a/packages/tooling/vis/src/util/target-tool.ts b/packages/tooling/vis/src/util/target-tool.ts index 07c19e9c..82d98fc9 100644 --- a/packages/tooling/vis/src/util/target-tool.ts +++ b/packages/tooling/vis/src/util/target-tool.ts @@ -4,7 +4,8 @@ * When no project defines the target, by script or by inference, vis asks which * tool to use. The answer is the consent: it installs the tool with `vis add -D` * into the nearest package and adds a matching script to that package.json. The - * script is what makes the choice stick, since inference needs a config file. + * script and config file are what make the choice stick: the script runs the tool, + * and the config file lets inference find the target on later runs. */ import type { CerebroFs } from "@visulima/cerebro"; @@ -17,6 +18,8 @@ import { chooseTool, installDevDependency } from "./tool-choice"; import { pathExists } from "./toolbox-fs"; export interface TargetToolChoice { + /** Config file the tool's inference needs. Written only when it does not exist yet. */ + config?: { contents: string; file: string }; /** Shown in the list, e.g. "Vite". */ label: string; /** Installed as devDependencies with `vis add -D`. */ @@ -142,5 +145,10 @@ export const offerTargetTool = async (params: TargetToolParams, deps: TargetTool await addScript(fs, join(packageDirectory, "package.json"), target, picked.script); logger.info(`Added "${target}": "${picked.script}" to ${where}.`); + if (picked.config !== undefined && !(await pathExists(fs, join(packageDirectory, picked.config.file)))) { + await fs.writeFile(join(packageDirectory, picked.config.file), picked.config.contents, "utf8"); + logger.info(`Added ${picked.config.file}.`); + } + return true; }; From 3d8009743615b4dbdf4f9411e4f6facebdfab418 Mon Sep 17 00:00:00 2001 From: Daniel Bannert Date: Sun, 18 Oct 2026 00:56:33 +0200 Subject: [PATCH 4/9] docs(vis): list vis test in the command table Co-Authored-By: Claude Haiku 5.5 --- packages/tooling/vis/README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/tooling/vis/README.md b/packages/tooling/vis/README.md index 06dc8964..b817d813 100644 --- a/packages/tooling/vis/README.md +++ b/packages/tooling/vis/README.md @@ -242,6 +242,7 @@ When aube is the installer, `vis doctor` surfaces aube's effective hardening pos | `vis toolchain ` | | Inspect / delegate to the version manager (proto, mise, fnm, volta…) | | `vis lint [files]` | | Orchestrate detected source-code linters (oxlint, biome, eslint, stylelint) | | `vis fmt [files]` | | Orchestrate detected formatters (oxfmt, biome, dprint, prettier) | +| `vis test [files]` | | Run the package test script, or Vitest / Rstest (asks which to install when neither exists) | | `vis staged` | | Run tasks on staged files (built-in `lint-staged` replacement) | | `vis migrate ` | | Migrate from other tools — now including `gitleaks` and `secretlint` | From 36989a4696c69c0ba80dcfe33b8bb264f956d3b7 Mon Sep 17 00:00:00 2001 From: Daniel Bannert Date: Sun, 18 Oct 2026 00:56:39 +0200 Subject: [PATCH 5/9] fix(vis): choose the project for the dev or build script, not the working directory vis treats the directory it runs from as the workspace root, so walking up from a package never found the workspace. The offer also refused to write at the root, so it could not succeed. It now picks the project: the one named by --filter, or the only project in the workspace. With several projects and no --filter, it asks for --filter instead of guessing. Generated TypeScript configs are .mts, so Vite does not load them as CommonJS. The docs describe the root and --filter behaviour. Co-Authored-By: Claude Haiku 5.5 --- .../commands/dev/dev-build-forwarding.test.ts | 2 +- .../commands/dev/dev-build-offer.test.ts | 23 +---- .../vis/__tests__/util/target-tool.test.ts | 55 +++++++---- packages/tooling/vis/docs/commands/build.mdx | 2 +- packages/tooling/vis/docs/commands/dev.mdx | 2 +- .../tooling/vis/src/commands/build/handler.ts | 10 +- .../tooling/vis/src/commands/dev/handler.ts | 8 +- packages/tooling/vis/src/util/target-tool.ts | 98 ++++++++++--------- 8 files changed, 106 insertions(+), 94 deletions(-) diff --git a/packages/tooling/vis/__tests__/commands/dev/dev-build-forwarding.test.ts b/packages/tooling/vis/__tests__/commands/dev/dev-build-forwarding.test.ts index 823299ff..6f6ed6bc 100644 --- a/packages/tooling/vis/__tests__/commands/dev/dev-build-forwarding.test.ts +++ b/packages/tooling/vis/__tests__/commands/dev/dev-build-forwarding.test.ts @@ -8,7 +8,7 @@ const { runDev } = await import("../../../src/commands/dev/handler"); const { runBuild } = await import("../../../src/commands/build/handler"); // The target is already defined, so these tests exercise forwarding, not the offer. -const DEFINED = { definesTarget: async () => true }; +const DEFINED = { projects: async () => [{ name: "app", root: "/tmp/app", targets: { build: {}, dev: {} } }] }; const toolbox = (argument: string[] | undefined, rawUnknown: string[] | undefined, options: Record = {}) => ({ argument, logger: {}, options, rawUnknown }) as never; diff --git a/packages/tooling/vis/__tests__/commands/dev/dev-build-offer.test.ts b/packages/tooling/vis/__tests__/commands/dev/dev-build-offer.test.ts index 4b4ddfdc..79b28c2e 100644 --- a/packages/tooling/vis/__tests__/commands/dev/dev-build-offer.test.ts +++ b/packages/tooling/vis/__tests__/commands/dev/dev-build-offer.test.ts @@ -45,16 +45,7 @@ describe("vis dev and vis build with no target", () => { it("runs the dev target without prompting when a package already defines it", async () => { expect.assertions(2); - writeFileSync(join(root, "packages", "app", "package.json"), JSON.stringify({ name: "@fix/app", scripts: { dev: "node server.js" } })); - const cwd = process.cwd(); - - process.chdir(join(root, "packages", "app")); - - try { - await runDev(toolbox(root), { definesTarget: async () => true, install: () => 0, isInteractive: () => false }); - } finally { - process.chdir(cwd); - } + await runDev(toolbox(root), { install: () => 0, isInteractive: () => false, projects: async () => [{ name: "@fix/app", root: join(root, "packages", "app"), targets: { dev: {} } }] }); expect(runExecute).toHaveBeenCalledTimes(1); expect(runExecute.mock.calls[0][0].argument).toStrictEqual(["dev"]); @@ -63,15 +54,9 @@ describe("vis dev and vis build with no target", () => { it("errors for build in a non-interactive run, and does not run the target", async () => { expect.assertions(2); - const cwd = process.cwd(); - - process.chdir(join(root, "packages", "app")); - - try { - await expect(runBuild(toolbox(root), { definesTarget: async () => false, install: () => 0, isInteractive: () => false })).rejects.toThrow("vis add -D @rslib/core"); - } finally { - process.chdir(cwd); - } + await expect( + runBuild(toolbox(root), { install: () => 0, isInteractive: () => false, projects: async () => [{ name: "@fix/app", root: join(root, "packages", "app"), targets: undefined }] }), + ).rejects.toThrow("vis add -D @rslib/core"); expect(runExecute).not.toHaveBeenCalled(); }); diff --git a/packages/tooling/vis/__tests__/util/target-tool.test.ts b/packages/tooling/vis/__tests__/util/target-tool.test.ts index 6e8a4b2c..2a323092 100644 --- a/packages/tooling/vis/__tests__/util/target-tool.test.ts +++ b/packages/tooling/vis/__tests__/util/target-tool.test.ts @@ -10,7 +10,7 @@ import type { TargetToolChoice } from "../../src/util/target-tool"; import { offerTargetTool } from "../../src/util/target-tool"; const CHOICES: ReadonlyArray = [ - { config: { contents: "export default {};\n", file: "vite.config.ts" }, label: "Vite", packages: ["vite"], script: "vite" }, + { config: { contents: "export default {};\n", file: "vite.config.mts" }, label: "Vite", packages: ["vite"], script: "vite" }, { label: "Rspack", packages: ["@rspack/cli", "@rspack/core"], script: "rspack serve" }, ]; @@ -20,6 +20,16 @@ describe(offerTargetTool, () => { let root: string; let app: string; + /** One project, the app, with no targets yet. */ + const oneProject = async () => [{ name: "@fix/app", root: app, targets: undefined }]; + /** One project that already has a dev target. */ + const withDev = async () => [{ name: "@fix/app", root: app, targets: { dev: {} } }]; + /** Two projects, neither with a dev target. */ + const twoProjects = async () => [ + { name: "@fix/app", root: app, targets: undefined }, + { name: "@fix/lib", root: join(root, "packages", "lib"), targets: undefined }, + ]; + const manifest = (): string => readFileSync(join(app, "package.json"), "utf8"); beforeEach(() => { @@ -42,7 +52,6 @@ describe(offerTargetTool, () => { const params = (overrides: Partial[0]> = {}) => { return { choices: CHOICES, - cwd: app, fs, logger: { info: vi.fn() }, target: "dev" as const, @@ -58,7 +67,7 @@ describe(offerTargetTool, () => { writeFileSync(join(app, "package.json"), JSON.stringify({ name: "@fix/app", scripts: { dev: "node server.js" } })); const install = vi.fn(() => 0); - await expect(offerTargetTool(params(), { definesTarget: async () => true, install })).resolves.toBe(true); + await expect(offerTargetTool(params(), { install, projects: withDev })).resolves.toBe(true); expect(install).not.toHaveBeenCalled(); }); @@ -67,7 +76,7 @@ describe(offerTargetTool, () => { const install = vi.fn(() => 0); - await expect(offerTargetTool(params(), { definesTarget: async () => false, install, isInteractive: () => false })).rejects.toThrow("vis add -D vite"); + await expect(offerTargetTool(params(), { install, isInteractive: () => false, projects: oneProject })).rejects.toThrow("vis add -D vite"); expect(install).not.toHaveBeenCalled(); expect(manifest()).not.toContain("\"dev\""); }); @@ -77,7 +86,7 @@ describe(offerTargetTool, () => { const install = vi.fn(() => 0); - await expect(offerTargetTool(params(), { definesTarget: async () => false, install, isInteractive: () => true, readline: async () => "1" })).resolves.toBe(true); + await expect(offerTargetTool(params(), { install, isInteractive: () => true, projects: oneProject, readline: async () => "1" })).resolves.toBe(true); expect(install).toHaveBeenCalledWith("vite", expect.stringMatching(/packages\/app$/)); expect(JSON.parse(manifest())).toMatchObject({ scripts: { dev: "vite" } }); expect(process.exitCode).toBeUndefined(); @@ -88,7 +97,7 @@ describe(offerTargetTool, () => { const install = vi.fn(() => 0); - await offerTargetTool(params(), { definesTarget: async () => false, install, isInteractive: () => true, readline: async () => "2" }); + await offerTargetTool(params(), { install, isInteractive: () => true, projects: oneProject, readline: async () => "2" }); expect(install.mock.calls.map(([name]) => name)).toStrictEqual(["@rspack/cli", "@rspack/core"]); }); @@ -96,19 +105,19 @@ describe(offerTargetTool, () => { it("writes the tool's config file next to the script", async () => { expect.assertions(1); - await offerTargetTool(params(), { definesTarget: async () => false, install: () => 0, isInteractive: () => true, readline: async () => "1" }); + await offerTargetTool(params(), { install: () => 0, isInteractive: () => true, projects: oneProject, readline: async () => "1" }); - expect(readFileSync(join(app, "vite.config.ts"), "utf8")).toBe("export default {};\n"); + expect(readFileSync(join(app, "vite.config.mts"), "utf8")).toBe("export default {};\n"); }); it("keeps an existing config file untouched", async () => { expect.assertions(1); - writeFileSync(join(app, "vite.config.ts"), "export default { base: '/app/' };\n"); + writeFileSync(join(app, "vite.config.mts"), "export default { base: '/app/' };\n"); - await offerTargetTool(params(), { definesTarget: async () => false, install: () => 0, isInteractive: () => true, readline: async () => "1" }); + await offerTargetTool(params(), { install: () => 0, isInteractive: () => true, projects: oneProject, readline: async () => "1" }); - expect(readFileSync(join(app, "vite.config.ts"), "utf8")).toBe("export default { base: '/app/' };\n"); + expect(readFileSync(join(app, "vite.config.mts"), "utf8")).toBe("export default { base: '/app/' };\n"); }); it("keeps the package's own indentation when it adds the script", async () => { @@ -116,7 +125,7 @@ describe(offerTargetTool, () => { writeFileSync(join(app, "package.json"), "{\n\t\"name\": \"@fix/app\"\n}\n"); - await offerTargetTool(params(), { definesTarget: async () => false, install: () => 0, isInteractive: () => true, readline: async () => "1" }); + await offerTargetTool(params(), { install: () => 0, isInteractive: () => true, projects: oneProject, readline: async () => "1" }); expect(manifest()).toBe("{\n\t\"name\": \"@fix/app\",\n\t\"scripts\": {\n\t\t\"dev\": \"vite\"\n\t}\n}\n"); }); @@ -126,7 +135,7 @@ describe(offerTargetTool, () => { const install = vi.fn(() => 2); - await expect(offerTargetTool(params(), { definesTarget: async () => false, install, isInteractive: () => true, readline: async () => "1" })).resolves.toBe(false); + await expect(offerTargetTool(params(), { install, isInteractive: () => true, projects: oneProject, readline: async () => "1" })).resolves.toBe(false); expect(process.exitCode).toBe(2); expect(manifest()).not.toContain("\"dev\""); }); @@ -136,15 +145,25 @@ describe(offerTargetTool, () => { const install = vi.fn(() => 0); - await expect(offerTargetTool(params(), { definesTarget: async () => false, install, isInteractive: () => true, readline: async () => "" })).rejects.toThrow("No dev tool selected"); + await expect(offerTargetTool(params(), { install, isInteractive: () => true, projects: oneProject, readline: async () => "" })).rejects.toThrow("No dev tool selected"); expect(install).not.toHaveBeenCalled(); }); - it("refuses to add the script to the workspace root", async () => { + it("errors when the workspace has several projects and none is picked", async () => { + expect.assertions(1); + + const install = vi.fn(() => 0); + + await expect(offerTargetTool(params(), { install, isInteractive: () => true, projects: twoProjects, readline: async () => "1" })).rejects.toThrow("--filter"); + }); + + it("uses the project named by --filter when the workspace has several", async () => { expect.assertions(1); - await expect(offerTargetTool(params({ cwd: root }), { definesTarget: async () => false, install: () => 0, isInteractive: () => true, readline: async () => "1" })).rejects.toThrow( - "no package to add", - ); + const install = vi.fn(() => 0); + + await offerTargetTool(params({ filter: ["@fix/app"] }), { install, isInteractive: () => true, projects: twoProjects, readline: async () => "1" }); + + expect(install).toHaveBeenCalledWith("vite", app); }); }); diff --git a/packages/tooling/vis/docs/commands/build.mdx b/packages/tooling/vis/docs/commands/build.mdx index 44c2fe16..ad8f6cc0 100644 --- a/packages/tooling/vis/docs/commands/build.mdx +++ b/packages/tooling/vis/docs/commands/build.mdx @@ -14,7 +14,7 @@ A package's own `build` script in `package.json` always wins. When a project has - `rslib.config.*` → `rslib build` (a library build) - `rspack.config.*` → `rspack build` -If no project defines a `build` target, `vis build` asks which bundler to set up: Vite, Rsbuild, Rslib, or Rspack. The choice works the same way as for `vis dev`: the tool is installed with `vis add -D` into the nearest package, and a `build` script is added to its `package.json`, along with the tool's config file unless one already exists. Non-interactive runs stop and list the options instead. +If no project defines a `build` target, `vis build` asks which bundler to set up: Vite, Rsbuild, Rslib, or Rspack. The choice works the same way as for `vis dev`: the tool is installed with `vis add -D` into the chosen project, and a `build` script is added to its `package.json`, along with the tool's config file unless one already exists. Non-interactive runs stop and list the options instead. As with `vis dev`, pass `--filter ` when the workspace has more than one project. When a project has more than one bundler config, the first match in this order wins: Vite, then Rsbuild, then Rslib, then Rspack. diff --git a/packages/tooling/vis/docs/commands/dev.mdx b/packages/tooling/vis/docs/commands/dev.mdx index b6f6d053..85131364 100644 --- a/packages/tooling/vis/docs/commands/dev.mdx +++ b/packages/tooling/vis/docs/commands/dev.mdx @@ -16,7 +16,7 @@ A package's own `dev` script in `package.json` always wins. When a project has n If no project defines a `dev` target, `vis dev` asks which dev server to set up: Vite, Rsbuild, or Rspack. Your choice is the consent. vis installs the tool with `vis add -D` into the nearest package, and adds the matching `dev` script to that package's `package.json`. The tool's config file (for example `vite.config.ts`) is also written, unless one already exists. The script and config are what keep the choice, so later runs use it without asking. -In a non-interactive run (CI, or no terminal) it stops and lists each tool and its install command instead. It never picks one for you. Run it from inside a package: at the workspace root there is no package to add the script to. +In a non-interactive run (CI, or no terminal) it stops and lists each tool and its install command instead. It never picks one for you. Run it from the workspace root, the same as `vis run`. If the workspace has more than one project, pass `--filter ` to choose where the script goes. ## Usage diff --git a/packages/tooling/vis/src/commands/build/handler.ts b/packages/tooling/vis/src/commands/build/handler.ts index 5b397ebc..19f0619d 100644 --- a/packages/tooling/vis/src/commands/build/handler.ts +++ b/packages/tooling/vis/src/commands/build/handler.ts @@ -8,19 +8,19 @@ import type { BuildOptions } from "./index"; /** Bundlers vis can set up when no project defines a `build` target. */ export const BUILD_CHOICES: ReadonlyArray = [ { - config: { contents: "import { defineConfig } from \"vite\";\n\nexport default defineConfig({});\n", file: "vite.config.ts" }, + config: { contents: "import { defineConfig } from \"vite\";\n\nexport default defineConfig({});\n", file: "vite.config.mts" }, label: "Vite", packages: ["vite"], script: "vite build", }, { - config: { contents: "import { defineConfig } from \"@rsbuild/core\";\n\nexport default defineConfig({});\n", file: "rsbuild.config.ts" }, + config: { contents: "import { defineConfig } from \"@rsbuild/core\";\n\nexport default defineConfig({});\n", file: "rsbuild.config.mts" }, label: "Rsbuild", packages: ["@rsbuild/core"], script: "rsbuild build", }, { - config: { contents: "import { defineConfig } from \"@rslib/core\";\n\nexport default defineConfig({ lib: [{ format: \"esm\" }] });\n", file: "rslib.config.ts" }, + config: { contents: "import { defineConfig } from \"@rslib/core\";\n\nexport default defineConfig({ lib: [{ format: \"esm\" }] });\n", file: "rslib.config.mts" }, label: "Rslib", packages: ["@rslib/core"], script: "rslib build", @@ -35,9 +35,9 @@ export const BUILD_CHOICES: ReadonlyArray = [ /** Offers a bundler when none is configured, then runs the `build` target like `vis run build`. */ export const runBuild = async (toolbox: Toolbox, deps: TargetToolDeps = {}): Promise => { - const { argument, fs, logger, rawUnknown, visConfig, workspaceRoot } = toolbox; + const { argument, fs, logger, options, rawUnknown, visConfig, workspaceRoot } = toolbox; const ready = await offerTargetTool( - { choices: BUILD_CHOICES, cwd: process.cwd(), fs, logger, target: "build", visConfig, workspaceRoot: workspaceRoot ?? process.cwd() }, + { choices: BUILD_CHOICES, filter: options.filter, fs, logger, target: "build", visConfig, workspaceRoot: workspaceRoot ?? process.cwd() }, deps, ); diff --git a/packages/tooling/vis/src/commands/dev/handler.ts b/packages/tooling/vis/src/commands/dev/handler.ts index 3b36511a..3b0f5da2 100644 --- a/packages/tooling/vis/src/commands/dev/handler.ts +++ b/packages/tooling/vis/src/commands/dev/handler.ts @@ -8,13 +8,13 @@ import type { DevOptions } from "./index"; /** Dev servers vis can set up when no project defines a `dev` target. Rslib has no dev server. */ export const DEV_CHOICES: ReadonlyArray = [ { - config: { contents: "import { defineConfig } from \"vite\";\n\nexport default defineConfig({});\n", file: "vite.config.ts" }, + config: { contents: "import { defineConfig } from \"vite\";\n\nexport default defineConfig({});\n", file: "vite.config.mts" }, label: "Vite", packages: ["vite"], script: "vite", }, { - config: { contents: "import { defineConfig } from \"@rsbuild/core\";\n\nexport default defineConfig({});\n", file: "rsbuild.config.ts" }, + config: { contents: "import { defineConfig } from \"@rsbuild/core\";\n\nexport default defineConfig({});\n", file: "rsbuild.config.mts" }, label: "Rsbuild", packages: ["@rsbuild/core"], script: "rsbuild dev", @@ -29,9 +29,9 @@ export const DEV_CHOICES: ReadonlyArray = [ /** Offers a dev server when none is configured, then runs the `dev` target like `vis run dev`. */ export const runDev = async (toolbox: Toolbox, deps: TargetToolDeps = {}): Promise => { - const { argument, fs, logger, rawUnknown, visConfig, workspaceRoot } = toolbox; + const { argument, fs, logger, options, rawUnknown, visConfig, workspaceRoot } = toolbox; const ready = await offerTargetTool( - { choices: DEV_CHOICES, cwd: process.cwd(), fs, logger, target: "dev", visConfig, workspaceRoot: workspaceRoot ?? process.cwd() }, + { choices: DEV_CHOICES, filter: options.filter, fs, logger, target: "dev", visConfig, workspaceRoot: workspaceRoot ?? process.cwd() }, deps, ); diff --git a/packages/tooling/vis/src/util/target-tool.ts b/packages/tooling/vis/src/util/target-tool.ts index 82d98fc9..2e01e49b 100644 --- a/packages/tooling/vis/src/util/target-tool.ts +++ b/packages/tooling/vis/src/util/target-tool.ts @@ -3,13 +3,13 @@ * * When no project defines the target, by script or by inference, vis asks which * tool to use. The answer is the consent: it installs the tool with `vis add -D` - * into the nearest package and adds a matching script to that package.json. The - * script and config file are what make the choice stick: the script runs the tool, - * and the config file lets inference find the target on later runs. + * into the chosen project and adds a matching script to its package.json. The + * script and config file are what make the choice stick: the script runs the + * tool, and the config lets inference find the target on later runs. */ import type { CerebroFs } from "@visulima/cerebro"; -import { dirname, join, relative } from "@visulima/path"; +import { join, relative } from "@visulima/path"; import { discoverWorkspace, loadVisTaskConfigsForWorkspace } from "../config/workspace"; import { VisUserError } from "../errors/vis-user-error"; @@ -28,9 +28,18 @@ export interface TargetToolChoice { script: string; } +/** A workspace project as far as this flow needs it. */ +export interface WorkspaceProject { + name: string; + /** Absolute directory of the project. */ + root: string; + targets: Record | undefined; +} + export interface TargetToolParams { choices: ReadonlyArray; - cwd: string; + /** `--filter` selectors from the command line. A single exact project name picks where to add the script. */ + filter?: ReadonlyArray; fs: CerebroFs; logger: Pick; target: "build" | "dev"; @@ -39,42 +48,47 @@ export interface TargetToolParams { } export interface TargetToolDeps extends ChooseToolDeps { - /** Overrides the workspace check. Defaults to discovering the workspace. */ - definesTarget?: (root: string, visConfig: TargetToolParams["visConfig"], target: string) => Promise; /** Installs each package. Defaults to `vis add -D`. */ install?: Installer; + /** Lists the workspace projects. Defaults to discovering the workspace. */ + projects?: (root: string, visConfig: TargetToolParams["visConfig"]) => Promise>; } -/** Whether any project in the workspace already defines `target`, by script or by inference. */ -export const workspaceDefinesTarget = async (root: string, visConfig: TargetToolParams["visConfig"], target: string): Promise => { +/** Every project in the workspace, with its targets. */ +export const discoverProjects = async (root: string, visConfig: TargetToolParams["visConfig"]): Promise> => { const taskConfigs = await loadVisTaskConfigsForWorkspace(root, visConfig); const { workspace } = discoverWorkspace(root, visConfig, taskConfigs); - return Object.values(workspace.projects).some((project) => project.targets?.[target] !== undefined); + return Object.entries(workspace.projects).map(([name, project]) => { + return { + name, + root: project.root, + targets: project.targets, + }; + }); }; -/** The nearest package directory at or below the workspace root, or `undefined` at the root itself. */ -const nearestPackageDirectory = async (cwd: string, root: string, fs: CerebroFs): Promise => { - let directory = cwd; - - for (;;) { - // The root's package.json belongs to the workspace, so the script is never added there. - if (directory === root) { - return undefined; - } +/** + * Pick the project the script goes into. `--filter` with one exact project name + * wins. Otherwise the workspace must have exactly one project. Anything else is + * ambiguous, so the user is asked to say which one. + */ +const chooseProject = (projects: ReadonlyArray, filter: ReadonlyArray, target: string): WorkspaceProject => { + if (filter.length > 0) { + const matches = projects.filter((project) => filter.includes(project.name)); - if (await pathExists(fs, join(directory, "package.json"))) { - return directory; + if (matches.length === 1) { + return matches[0]!; } - const parent = dirname(directory); - - if (parent === directory) { - return undefined; - } + throw new VisUserError(`vis ${target}: --filter ${filter.join(", ")} does not match exactly one project. Pass the package name as it appears in its package.json.`); + } - directory = parent; + if (projects.length === 1) { + return projects[0]!; } + + throw new VisUserError(`vis ${target}: no project defines a "${target}" target, and the workspace has ${String(projects.length)} projects. Pass --filter to choose where to add it.`); }; /** Adds `scripts[name] = command`, keeping the file's indentation. */ @@ -92,27 +106,21 @@ const addScript = async (fs: CerebroFs, file: string, name: string, command: str /** * Make sure the workspace can run `target`. Returns `true` when the caller should * run it now, and `false` when an install failed (the exit code is already set). - * Throws when there is nowhere to add the script, or when the user cancels. + * Throws when no project can take the script, or when the user cancels. */ export const offerTargetTool = async (params: TargetToolParams, deps: TargetToolDeps = {}): Promise => { - const { cwd, fs, logger, target, visConfig, workspaceRoot } = params; - - const definesTarget = deps.definesTarget ?? workspaceDefinesTarget; + const { choices, fs, logger, target, visConfig, workspaceRoot } = params; + const projects = await (deps.projects ?? discoverProjects)(workspaceRoot, visConfig); - if (await definesTarget(workspaceRoot, visConfig, target)) { + if (projects.some((project) => project.targets?.[target] !== undefined)) { return true; } - const packageDirectory = await nearestPackageDirectory(cwd, workspaceRoot, fs); - - if (packageDirectory === undefined) { - throw new VisUserError(`vis ${target}: no package to add a "${target}" script to. Run it from inside a package, or pass --filter to pick one.`); - } - - const where = relative(workspaceRoot, join(packageDirectory, "package.json")) || "package.json"; + const project = chooseProject(projects, params.filter ?? [], target); + const where = relative(workspaceRoot, join(project.root, "package.json")) || "package.json"; const choice = await chooseTool( `No "${target}" target is configured. Pick a tool: vis will install it and add a "${target}" script to ${where}.`, - params.choices.map((option) => { + choices.map((option) => { return { hint: `vis add -D ${option.packages.join(" ")}, then "${target}": "${option.script}"`, label: option.label, @@ -126,14 +134,14 @@ export const offerTargetTool = async (params: TargetToolParams, deps: TargetTool throw new VisUserError(`No ${target} tool selected. Add a "${target}" script to your package.json, or install one of the tools listed above.`); } - const picked = params.choices.find((option) => option.label === choice.value); + const picked = choices.find((option) => option.label === choice.value); if (picked === undefined) { throw new VisUserError(`Unknown ${target} tool: ${choice.value}.`); } for (const packageName of picked.packages) { - const code = installDevDependency(packageName, packageDirectory, logger, deps.install); + const code = installDevDependency(packageName, project.root, logger, deps.install); if (code !== 0) { process.exitCode = code; @@ -142,11 +150,11 @@ export const offerTargetTool = async (params: TargetToolParams, deps: TargetTool } } - await addScript(fs, join(packageDirectory, "package.json"), target, picked.script); + await addScript(fs, join(project.root, "package.json"), target, picked.script); logger.info(`Added "${target}": "${picked.script}" to ${where}.`); - if (picked.config !== undefined && !(await pathExists(fs, join(packageDirectory, picked.config.file)))) { - await fs.writeFile(join(packageDirectory, picked.config.file), picked.config.contents, "utf8"); + if (picked.config !== undefined && !(await pathExists(fs, join(project.root, picked.config.file)))) { + await fs.writeFile(join(project.root, picked.config.file), picked.config.contents, "utf8"); logger.info(`Added ${picked.config.file}.`); } From 8a9dbe0744511e5f6fffaa1c0b7c144f2fb0a271 Mon Sep 17 00:00:00 2001 From: Daniel Bannert Date: Sun, 18 Oct 2026 09:18:54 +0200 Subject: [PATCH 6/9] fix(vis): write entry files for dev and build tools, and fix project roots The offer for a missing dev or build target now writes the entry file each tool needs as well as its config: index.html for Vite, src/index.ts for Rsbuild and Rslib, and src/index.js for Rspack. Each is written only when it does not exist, and missing directories are created. Each was confirmed to build with these files. Discovery stores project roots relative to the workspace root. The offer used them as if they were relative to the current directory, so run from a package it installed and wrote into the wrong place. Roots are now resolved against the workspace root. The vis.config.ts first-run question is no longer asked again by the `vis add` subprocess the offer starts, and that subprocess skips the sponsor and MCP promo lines. Running from a package with no workspace now names the workspace above it, so the next step is clear. Co-Authored-By: Claude Haiku 5.5 --- .../vis/__tests__/util/target-tool.test.ts | 16 +++++++-- packages/tooling/vis/docs/commands/build.mdx | 2 +- packages/tooling/vis/docs/commands/dev.mdx | 6 +++- .../tooling/vis/src/commands/build/handler.ts | 8 ++--- .../tooling/vis/src/commands/dev/handler.ts | 6 ++-- packages/tooling/vis/src/config/workspace.ts | 33 +++++++++++++++++-- .../tooling/vis/src/plugins/config-loader.ts | 5 ++- packages/tooling/vis/src/util/target-tool.ts | 20 +++++++---- packages/tooling/vis/src/util/tool-choice.ts | 4 ++- 9 files changed, 76 insertions(+), 24 deletions(-) diff --git a/packages/tooling/vis/__tests__/util/target-tool.test.ts b/packages/tooling/vis/__tests__/util/target-tool.test.ts index 2a323092..2b5aeec7 100644 --- a/packages/tooling/vis/__tests__/util/target-tool.test.ts +++ b/packages/tooling/vis/__tests__/util/target-tool.test.ts @@ -1,5 +1,5 @@ import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; -import { access, readFile, writeFile } from "node:fs/promises"; +import { access, mkdir, readFile, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -10,11 +10,11 @@ import type { TargetToolChoice } from "../../src/util/target-tool"; import { offerTargetTool } from "../../src/util/target-tool"; const CHOICES: ReadonlyArray = [ - { config: { contents: "export default {};\n", file: "vite.config.mts" }, label: "Vite", packages: ["vite"], script: "vite" }, + { files: [{ contents: "export default {};\n", file: "vite.config.mts" }, { contents: "\n", file: "index.html" }], label: "Vite", packages: ["vite"], script: "vite" }, { label: "Rspack", packages: ["@rspack/cli", "@rspack/core"], script: "rspack serve" }, ]; -const fs = { access, readFile, writeFile } as unknown as CerebroFs; +const fs = { access, mkdir, readFile, writeFile } as unknown as CerebroFs; describe(offerTargetTool, () => { let root: string; @@ -110,6 +110,16 @@ describe(offerTargetTool, () => { expect(readFileSync(join(app, "vite.config.mts"), "utf8")).toBe("export default {};\n"); }); + it("writes the entry file the tool needs, creating its directory", async () => { + expect.assertions(1); + + const choices = [{ files: [{ contents: "export {};\n", file: "src/index.ts" }], label: "Entry", packages: ["x"], script: "x" }]; + + await offerTargetTool(params({ choices }), { install: () => 0, isInteractive: () => true, projects: oneProject, readline: async () => "1" }); + + expect(readFileSync(join(app, "src", "index.ts"), "utf8")).toBe("export {};\n"); + }); + it("keeps an existing config file untouched", async () => { expect.assertions(1); diff --git a/packages/tooling/vis/docs/commands/build.mdx b/packages/tooling/vis/docs/commands/build.mdx index ad8f6cc0..63de431a 100644 --- a/packages/tooling/vis/docs/commands/build.mdx +++ b/packages/tooling/vis/docs/commands/build.mdx @@ -14,7 +14,7 @@ A package's own `build` script in `package.json` always wins. When a project has - `rslib.config.*` → `rslib build` (a library build) - `rspack.config.*` → `rspack build` -If no project defines a `build` target, `vis build` asks which bundler to set up: Vite, Rsbuild, Rslib, or Rspack. The choice works the same way as for `vis dev`: the tool is installed with `vis add -D` into the chosen project, and a `build` script is added to its `package.json`, along with the tool's config file unless one already exists. Non-interactive runs stop and list the options instead. As with `vis dev`, pass `--filter ` when the workspace has more than one project. +If no project defines a `build` target, `vis build` asks which bundler to set up: Vite, Rsbuild, Rslib, or Rspack. The choice works the same way as for `vis dev`: the tool is installed with `vis add -D` into the chosen project, and a `build` script is added to its `package.json`, along with the tool's config and entry files, unless they already exist. Non-interactive runs stop and list the options instead. As with `vis dev`, pass `--filter ` when the workspace has more than one project. When a project has more than one bundler config, the first match in this order wins: Vite, then Rsbuild, then Rslib, then Rspack. diff --git a/packages/tooling/vis/docs/commands/dev.mdx b/packages/tooling/vis/docs/commands/dev.mdx index 85131364..a92dffd3 100644 --- a/packages/tooling/vis/docs/commands/dev.mdx +++ b/packages/tooling/vis/docs/commands/dev.mdx @@ -14,7 +14,7 @@ A package's own `dev` script in `package.json` always wins. When a project has n - `rslib.config.*` → `rslib build --watch` (a watch build, since Rslib has no dev server) - `rspack.config.*` → `rspack serve` -If no project defines a `dev` target, `vis dev` asks which dev server to set up: Vite, Rsbuild, or Rspack. Your choice is the consent. vis installs the tool with `vis add -D` into the nearest package, and adds the matching `dev` script to that package's `package.json`. The tool's config file (for example `vite.config.ts`) is also written, unless one already exists. The script and config are what keep the choice, so later runs use it without asking. +If no project defines a `dev` target, `vis dev` asks which dev server to set up: Vite, Rsbuild, or Rspack. Your choice is the consent. vis installs the tool with `vis add -D` into the nearest package, and adds the matching `dev` script to that package's `package.json`. The tool's config file and the entry file it needs are also written, unless they already exist. The script and config are what keep the choice, so later runs use it without asking. In a non-interactive run (CI, or no terminal) it stops and lists each tool and its install command instead. It never picks one for you. Run it from the workspace root, the same as `vis run`. If the workspace has more than one project, pass `--filter ` to choose where the script goes. @@ -42,3 +42,7 @@ vis dev -- --port 3000 ## Scope Without `--filter`, `vis dev` starts every project that has a `dev` target. In a large workspace, pass `--filter` to start one package. + +## Entry files + +The offer writes the entry each tool needs, unless the file exists: Vite gets `index.html`, Rsbuild gets `src/index.ts`, and Rspack gets `src/index.js`. Rslib builds from `src/index.ts`. These are minimal starting points. Replace them with your app. diff --git a/packages/tooling/vis/src/commands/build/handler.ts b/packages/tooling/vis/src/commands/build/handler.ts index 19f0619d..fb62edb4 100644 --- a/packages/tooling/vis/src/commands/build/handler.ts +++ b/packages/tooling/vis/src/commands/build/handler.ts @@ -8,25 +8,25 @@ import type { BuildOptions } from "./index"; /** Bundlers vis can set up when no project defines a `build` target. */ export const BUILD_CHOICES: ReadonlyArray = [ { - config: { contents: "import { defineConfig } from \"vite\";\n\nexport default defineConfig({});\n", file: "vite.config.mts" }, + files: [{ contents: "import { defineConfig } from \"vite\";\n\nexport default defineConfig({});\n", file: "vite.config.mts" }, { contents: "\n\n \n \n App\n \n \n\n", file: "index.html" }], label: "Vite", packages: ["vite"], script: "vite build", }, { - config: { contents: "import { defineConfig } from \"@rsbuild/core\";\n\nexport default defineConfig({});\n", file: "rsbuild.config.mts" }, + files: [{ contents: "import { defineConfig } from \"@rsbuild/core\";\n\nexport default defineConfig({});\n", file: "rsbuild.config.mts" }, { contents: "export {};\n", file: "src/index.ts" }], label: "Rsbuild", packages: ["@rsbuild/core"], script: "rsbuild build", }, { - config: { contents: "import { defineConfig } from \"@rslib/core\";\n\nexport default defineConfig({ lib: [{ format: \"esm\" }] });\n", file: "rslib.config.mts" }, + files: [{ contents: "import { defineConfig } from \"@rslib/core\";\n\nexport default defineConfig({ lib: [{ format: \"esm\" }] });\n", file: "rslib.config.mts" }, { contents: "export {};\n", file: "src/index.ts" }], label: "Rslib", packages: ["@rslib/core"], script: "rslib build", }, { - config: { contents: "export default {};\n", file: "rspack.config.mjs" }, + files: [{ contents: "export default {};\n", file: "rspack.config.mjs" }, { contents: "export {};\n", file: "src/index.js" }], label: "Rspack", packages: ["@rspack/cli", "@rspack/core"], script: "rspack build", diff --git a/packages/tooling/vis/src/commands/dev/handler.ts b/packages/tooling/vis/src/commands/dev/handler.ts index 3b0f5da2..bfc6c0e6 100644 --- a/packages/tooling/vis/src/commands/dev/handler.ts +++ b/packages/tooling/vis/src/commands/dev/handler.ts @@ -8,19 +8,19 @@ import type { DevOptions } from "./index"; /** Dev servers vis can set up when no project defines a `dev` target. Rslib has no dev server. */ export const DEV_CHOICES: ReadonlyArray = [ { - config: { contents: "import { defineConfig } from \"vite\";\n\nexport default defineConfig({});\n", file: "vite.config.mts" }, + files: [{ contents: "import { defineConfig } from \"vite\";\n\nexport default defineConfig({});\n", file: "vite.config.mts" }, { contents: "\n\n \n \n App\n \n \n\n", file: "index.html" }], label: "Vite", packages: ["vite"], script: "vite", }, { - config: { contents: "import { defineConfig } from \"@rsbuild/core\";\n\nexport default defineConfig({});\n", file: "rsbuild.config.mts" }, + files: [{ contents: "import { defineConfig } from \"@rsbuild/core\";\n\nexport default defineConfig({});\n", file: "rsbuild.config.mts" }, { contents: "export {};\n", file: "src/index.ts" }], label: "Rsbuild", packages: ["@rsbuild/core"], script: "rsbuild dev", }, { - config: { contents: "export default {};\n", file: "rspack.config.mjs" }, + files: [{ contents: "export default {};\n", file: "rspack.config.mjs" }, { contents: "export {};\n", file: "src/index.js" }], label: "Rspack", packages: ["@rspack/cli", "@rspack/core", "@rspack/dev-server"], script: "rspack serve", diff --git a/packages/tooling/vis/src/config/workspace.ts b/packages/tooling/vis/src/config/workspace.ts index 85161213..14712942 100644 --- a/packages/tooling/vis/src/config/workspace.ts +++ b/packages/tooling/vis/src/config/workspace.ts @@ -1,5 +1,5 @@ import { isAccessibleSync, readFileSync, readJsonSync, walkSync } from "@visulima/fs"; -import { basename, join, resolve } from "@visulima/path"; +import { basename, dirname, join, resolve } from "@visulima/path"; import type { DependencyType, InputDefinition, @@ -76,7 +76,28 @@ const hasPackageJson = (directory: string): boolean => isAccessibleSync(join(dir /** * Reads and parses a JSON file, returning undefined on failure. */ -// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-parameters -- T is the caller-supplied typed-cast convenience; the alternative is a verbose `as X | undefined` at every callsite + +/** The nearest ancestor of `start` that declares a workspace, or `undefined` when there is none. */ +const findWorkspaceAbove = (start: string): string | undefined => { + let directory = dirname(start); + + for (;;) { + const rootPackage = readJsonFileSafe(join(directory, "package.json")); + + if (isAccessibleSync(join(directory, "pnpm-workspace.yaml")) || rootPackage?.workspaces !== undefined) { + return directory; + } + + const parent = dirname(directory); + + if (parent === directory) { + return undefined; + } + + directory = parent; + } +}; + const readJsonFileSafe = (filePath: string): T | undefined => { try { return readJsonSync(filePath) as T; @@ -1100,7 +1121,13 @@ const discoverWorkspace = ( } if (!workspacePatterns) { - throw new VisUserError("No workspace configuration found. Expected pnpm-workspace.yaml or package.json workspaces field."); + const above = findWorkspaceAbove(workspaceRoot); + + throw new VisUserError( + above === undefined + ? "No workspace configuration found. Expected pnpm-workspace.yaml or package.json workspaces field." + : `No workspace configuration found in ${workspaceRoot}, but ${above} is one. vis uses the directory it runs from as the workspace root, so run it from ${above}.`, + ); } const { directories: projectDirectories, nonJsEcosystems } = resolveProjectDirectories( diff --git a/packages/tooling/vis/src/plugins/config-loader.ts b/packages/tooling/vis/src/plugins/config-loader.ts index 01432c40..362d3cf2 100644 --- a/packages/tooling/vis/src/plugins/config-loader.ts +++ b/packages/tooling/vis/src/plugins/config-loader.ts @@ -141,7 +141,10 @@ const configLoaderPlugin: Plugin = { const command = process.argv[2] ?? ""; const skipHint = new Set(["--help", "--version", "-h", "-V", "create", "help", "implode", "init"]); - if (!skipHint.has(command) && !configFilePath && !isInCi) { + // `VIS_NO_CONFIG_PROMPT` is set by vis when it runs its own subcommands (for + // example `vis add` from a tool offer), so the parent process has already + // answered this question and the child does not ask it again. + if (!skipHint.has(command) && !configFilePath && !isInCi && process.env.VIS_NO_CONFIG_PROMPT !== "1") { if (process.stdin.isTTY) { const rl = createInterface({ input: process.stdin, output: process.stderr }); const answer = await new Promise((_resolve) => { diff --git a/packages/tooling/vis/src/util/target-tool.ts b/packages/tooling/vis/src/util/target-tool.ts index 2e01e49b..e844da77 100644 --- a/packages/tooling/vis/src/util/target-tool.ts +++ b/packages/tooling/vis/src/util/target-tool.ts @@ -9,7 +9,7 @@ */ import type { CerebroFs } from "@visulima/cerebro"; -import { join, relative } from "@visulima/path"; +import { dirname, join, relative, resolve } from "@visulima/path"; import { discoverWorkspace, loadVisTaskConfigsForWorkspace } from "../config/workspace"; import { VisUserError } from "../errors/vis-user-error"; @@ -18,8 +18,8 @@ import { chooseTool, installDevDependency } from "./tool-choice"; import { pathExists } from "./toolbox-fs"; export interface TargetToolChoice { - /** Config file the tool's inference needs. Written only when it does not exist yet. */ - config?: { contents: string; file: string }; + /** Files the tool needs to run: its config and an entry. Each is written only when it does not exist yet. */ + files?: ReadonlyArray<{ contents: string; file: string }>; /** Shown in the list, e.g. "Vite". */ label: string; /** Installed as devDependencies with `vis add -D`. */ @@ -59,10 +59,11 @@ export const discoverProjects = async (root: string, visConfig: TargetToolParams const taskConfigs = await loadVisTaskConfigsForWorkspace(root, visConfig); const { workspace } = discoverWorkspace(root, visConfig, taskConfigs); + // Discovery stores project roots relative to the workspace root, not the current directory. return Object.entries(workspace.projects).map(([name, project]) => { return { name, - root: project.root, + root: resolve(root, project.root), targets: project.targets, }; }); @@ -153,9 +154,14 @@ export const offerTargetTool = async (params: TargetToolParams, deps: TargetTool await addScript(fs, join(project.root, "package.json"), target, picked.script); logger.info(`Added "${target}": "${picked.script}" to ${where}.`); - if (picked.config !== undefined && !(await pathExists(fs, join(project.root, picked.config.file)))) { - await fs.writeFile(join(project.root, picked.config.file), picked.config.contents, "utf8"); - logger.info(`Added ${picked.config.file}.`); + for (const file of picked.files ?? []) { + const path = join(project.root, file.file); + + if (!(await pathExists(fs, path))) { + await fs.mkdir(dirname(path), { recursive: true }); + await fs.writeFile(path, file.contents, "utf8"); + logger.info(`Added ${file.file}.`); + } } return true; diff --git a/packages/tooling/vis/src/util/tool-choice.ts b/packages/tooling/vis/src/util/tool-choice.ts index c2d0091c..fe619eb3 100644 --- a/packages/tooling/vis/src/util/tool-choice.ts +++ b/packages/tooling/vis/src/util/tool-choice.ts @@ -75,7 +75,9 @@ export const installDevDependency = (packageName: string, cwd: string, logger: P }; const spawnVisAdd: Installer = (packageName, cwd) => { - const result = spawnSync(process.execPath, [process.argv[1]!, "add", "-D", packageName], { cwd, stdio: "inherit" }); + // The child is vis itself, so suppress the questions and promos the parent already handled. + const env = { ...process.env, VIS_NO_CONFIG_PROMPT: "1", VIS_NO_MCP_PROMOTE: "1", VIS_NO_SPONSOR: "1" }; + const result = spawnSync(process.execPath, [process.argv[1]!, "add", "-D", packageName], { cwd, env, stdio: "inherit" }); if (result.error) { throw result.error; From 502d2ed16c5256e2d03301f76a1bc83fc167ace1 Mon Sep 17 00:00:00 2001 From: Daniel Bannert Date: Sun, 18 Oct 2026 09:18:54 +0200 Subject: [PATCH 7/9] feat(vis): add preview, node, and env commands `vis preview` runs the preview target through `vis run`, the same way `vis dev` and `vis build` run theirs, and has the same filters and caching. It offers no tool, since a preview needs a build first. `vis node` runs the node binary that is running vis, with its arguments passed through, and exits with its status. `vis env` prints the Node version and platform, the directory, the workspace root, and the package manager vis would use from here. It changes nothing. Co-Authored-By: Claude Haiku 5.5 --- packages/tooling/vis/README.md | 3 ++ .../__tests__/commands/env/env-report.test.ts | 34 ++++++++++++++++++ .../commands/lazy-commands-contract.test.ts | 6 ++++ .../commands/node/node-passthrough.test.ts | 17 +++++++++ packages/tooling/vis/docs/commands/env.mdx | 16 +++++++++ packages/tooling/vis/docs/commands/node.mdx | 24 +++++++++++++ .../tooling/vis/docs/commands/preview.mdx | 35 +++++++++++++++++++ packages/tooling/vis/docs/meta.json | 3 ++ .../tooling/vis/src/commands/env/handler.ts | 17 +++++++++ .../tooling/vis/src/commands/env/index.ts | 11 ++++++ .../tooling/vis/src/commands/node/handler.ts | 19 ++++++++++ .../tooling/vis/src/commands/node/index.ts | 19 ++++++++++ .../vis/src/commands/preview/handler.ts | 12 +++++++ .../tooling/vis/src/commands/preview/index.ts | 26 ++++++++++++++ packages/tooling/vis/src/register-commands.ts | 6 ++++ 15 files changed, 248 insertions(+) create mode 100644 packages/tooling/vis/__tests__/commands/env/env-report.test.ts create mode 100644 packages/tooling/vis/__tests__/commands/node/node-passthrough.test.ts create mode 100644 packages/tooling/vis/docs/commands/env.mdx create mode 100644 packages/tooling/vis/docs/commands/node.mdx create mode 100644 packages/tooling/vis/docs/commands/preview.mdx create mode 100644 packages/tooling/vis/src/commands/env/handler.ts create mode 100644 packages/tooling/vis/src/commands/env/index.ts create mode 100644 packages/tooling/vis/src/commands/node/handler.ts create mode 100644 packages/tooling/vis/src/commands/node/index.ts create mode 100644 packages/tooling/vis/src/commands/preview/handler.ts create mode 100644 packages/tooling/vis/src/commands/preview/index.ts diff --git a/packages/tooling/vis/README.md b/packages/tooling/vis/README.md index b817d813..ab8edc2d 100644 --- a/packages/tooling/vis/README.md +++ b/packages/tooling/vis/README.md @@ -223,6 +223,9 @@ When aube is the installer, `vis doctor` surfaces aube's effective hardening pos | `vis run ` | | Run a target across workspace projects with caching | | `vis dev` | | Start the dev server: the package dev script, or the inferred Vite, Rsbuild, Rslib, or Rspack server | | `vis build` | | Run the build: the package build script, or the inferred Vite, Rsbuild, Rslib, or Rspack build | +| `vis preview` | | Serve the production build: the package preview script, or the inferred Vite, Rsbuild, or Rspack preview | +| `vis node [args]` | | Run node with the Node version vis runs on, passing arguments through | +| `vis env` | | Print the Node, platform, package manager, and workspace vis sees from this directory | | `vis affected ` | | Run tasks only on projects affected by git changes | | `vis ci ` | | One-shot CI entry point: toolchain, install, then affected runs for each comma-separated target | | `vis ignore ` | | CI build gating for Vercel / Netlify "Ignored Build Step" | diff --git a/packages/tooling/vis/__tests__/commands/env/env-report.test.ts b/packages/tooling/vis/__tests__/commands/env/env-report.test.ts new file mode 100644 index 00000000..be9bfbe8 --- /dev/null +++ b/packages/tooling/vis/__tests__/commands/env/env-report.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it, vi } from "vitest"; + +const pm = vi.hoisted(() => { return { detectPm: vi.fn(() => { return { name: "pnpm", version: "10.0.0" }; }) }; }); + +vi.mock(import("../../../src/pm/pm-runner"), () => pm); + +const { default: envExecute } = await import("../../../src/commands/env/handler"); + +describe("vis env", () => { + it("reports node, the directory, the workspace, and the package manager", async () => { + expect.assertions(4); + + const logger = { info: vi.fn() }; + + await envExecute({ logger, workspaceRoot: "/repo" } as never); + + const lines = logger.info.mock.calls.map(([line]) => String(line)); + + expect(lines.find((line) => line.startsWith("node"))).toContain(process.version); + expect(lines.find((line) => line.startsWith("workspace"))).toContain("/repo"); + expect(lines.find((line) => line.startsWith("pm"))).toContain("pnpm 10.0.0"); + expect(pm.detectPm).toHaveBeenCalledWith(process.cwd()); + }); + + it("says so when no workspace is found", async () => { + expect.assertions(1); + + const logger = { info: vi.fn() }; + + await envExecute({ logger, workspaceRoot: undefined } as never); + + expect(logger.info).toHaveBeenCalledWith(expect.stringContaining("not found from this directory")); + }); +}); diff --git a/packages/tooling/vis/__tests__/commands/lazy-commands-contract.test.ts b/packages/tooling/vis/__tests__/commands/lazy-commands-contract.test.ts index cba875ee..26f5e89d 100644 --- a/packages/tooling/vis/__tests__/commands/lazy-commands-contract.test.ts +++ b/packages/tooling/vis/__tests__/commands/lazy-commands-contract.test.ts @@ -21,6 +21,7 @@ import devcontainerCommand from "../../src/commands/devcontainer"; import dlxCommand from "../../src/commands/dlx"; import dockerCommands from "../../src/commands/docker"; import doctorCommand from "../../src/commands/doctor"; +import envCommand from "../../src/commands/env"; import execCommand from "../../src/commands/exec"; import generateCommand from "../../src/commands/generate"; import graphCommand from "../../src/commands/graph"; @@ -33,9 +34,11 @@ import installCommand from "../../src/commands/install"; import linkCommand from "../../src/commands/link"; import listCommand from "../../src/commands/list"; import migrateCommands from "../../src/commands/migrate"; +import nodeCommand from "../../src/commands/node"; import optimizeCommand from "../../src/commands/optimize"; import outdatedCommand from "../../src/commands/outdated"; import pmCommand from "../../src/commands/pm"; +import previewCommand from "../../src/commands/preview"; import rebuildCommand from "../../src/commands/rebuild"; import removeCommand from "../../src/commands/remove"; import runCommand from "../../src/commands/run"; @@ -65,6 +68,7 @@ const FLAT_COMMANDS: Command[] = [ cleanCommand, createCommand, dedupeCommand, + envCommand, devCommand, devcontainerCommand, dlxCommand, @@ -81,7 +85,9 @@ const FLAT_COMMANDS: Command[] = [ listCommand, optimizeCommand, outdatedCommand, + nodeCommand, pmCommand, + previewCommand, rebuildCommand, removeCommand, runCommand, diff --git a/packages/tooling/vis/__tests__/commands/node/node-passthrough.test.ts b/packages/tooling/vis/__tests__/commands/node/node-passthrough.test.ts new file mode 100644 index 00000000..0a98e4b2 --- /dev/null +++ b/packages/tooling/vis/__tests__/commands/node/node-passthrough.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from "vitest"; + +import nodeExecute from "../../../src/commands/node/handler"; + +describe("vis node", () => { + it("passes its arguments to node and exits with node's status", async () => { + expect.assertions(1); + + process.exitCode = undefined; + + await nodeExecute({ argument: ["-e", "process.exit(5)"], rawUnknown: [] } as never); + + expect(process.exitCode).toBe(5); + + process.exitCode = undefined; + }); +}); diff --git a/packages/tooling/vis/docs/commands/env.mdx b/packages/tooling/vis/docs/commands/env.mdx new file mode 100644 index 00000000..114be200 --- /dev/null +++ b/packages/tooling/vis/docs/commands/env.mdx @@ -0,0 +1,16 @@ +--- +title: "vis env" +description: "Print the Node, platform, package manager, and workspace that vis would use here" +--- + +# vis env + +Prints what vis sees from the current directory: the Node version and platform, the directory, the workspace root, and the package manager it would use. It changes nothing. + +If the workspace is not found from this directory, `vis env` says so. vis treats the directory it runs from as the workspace root, so run it from the root of the workspace. + +## Usage + +```bash +vis env +``` diff --git a/packages/tooling/vis/docs/commands/node.mdx b/packages/tooling/vis/docs/commands/node.mdx new file mode 100644 index 00000000..e6cfcf13 --- /dev/null +++ b/packages/tooling/vis/docs/commands/node.mdx @@ -0,0 +1,24 @@ +--- +title: "vis node" +description: "Run node in the current directory, with its arguments passed through" +--- + +# vis node + +Runs the `node` binary that is running vis, with the arguments you pass, in the current directory. It exits with node's status. Use it when you want the same Node version as vis, without changing your shell's `PATH`. + +## Usage + +```bash +vis node [args…] +``` + +## Examples + +```bash +# Run a script +vis node scripts/release.mjs + +# Evaluate an expression +vis node -e "console.log(process.version)" +``` diff --git a/packages/tooling/vis/docs/commands/preview.mdx b/packages/tooling/vis/docs/commands/preview.mdx new file mode 100644 index 00000000..7191968d --- /dev/null +++ b/packages/tooling/vis/docs/commands/preview.mdx @@ -0,0 +1,35 @@ +--- +title: "vis preview" +description: "Serve the production build: the package's preview script, or the inferred Vite, Rsbuild, or Rspack preview" +--- + +# vis preview + +Serves the built output. `vis preview` is `vis run preview`, so it uses the same target resolution, filters, and caching. + +A package's own `preview` script always wins. Otherwise vis infers one from the config file: + +- `vite.config.*` → `vite preview` +- `rsbuild.config.*` → `rsbuild preview` +- `rspack.config.*` → `rspack preview` + +Unlike `vis dev` and `vis build`, `vis preview` does not offer to install a tool. A preview needs a build first, so run `vis build` before it. + +## Usage + +```bash +vis preview [args…] [options] +``` + +## Examples + +```bash +# Serve the built output across the workspace +vis preview + +# Serve one package's build output +vis preview --filter @org/web + +# Forward flags to the preview server +vis preview -- --port 4173 +``` diff --git a/packages/tooling/vis/docs/meta.json b/packages/tooling/vis/docs/meta.json index 5454ea72..11dd5fff 100644 --- a/packages/tooling/vis/docs/meta.json +++ b/packages/tooling/vis/docs/meta.json @@ -10,6 +10,9 @@ "commands/init", "commands/run", "commands/dev", + "commands/preview", + "commands/node", + "commands/env", "commands/build", "commands/exec", "commands/staged", diff --git a/packages/tooling/vis/src/commands/env/handler.ts b/packages/tooling/vis/src/commands/env/handler.ts new file mode 100644 index 00000000..9e5c8316 --- /dev/null +++ b/packages/tooling/vis/src/commands/env/handler.ts @@ -0,0 +1,17 @@ +import type { CommandExecute, Toolbox } from "@visulima/cerebro"; + +/** Read-only report of what vis sees from this directory: nothing is installed or changed. */ +const execute = async ({ logger, workspaceRoot }: Toolbox): Promise => { + // Loaded here, not at module top: pm-runner pulls in the native binding. + const { detectPm } = await import("../../pm/pm-runner"); + const cwd = process.cwd(); + const pm = detectPm(cwd); + + logger.info(`node ${process.version} (${process.platform} ${process.arch})`); + logger.info(`directory ${cwd}`); + logger.info(`workspace ${workspaceRoot ?? "not found from this directory"}`); + logger.info(`pm ${pm.name}${pm.version ? ` ${pm.version}` : ""}`); +}; + +// fallow-ignore-next-line unused-export -- lazy-loaded command entry (cerebro loader/lazyNamed dynamic import) +export default execute as CommandExecute; diff --git a/packages/tooling/vis/src/commands/env/index.ts b/packages/tooling/vis/src/commands/env/index.ts new file mode 100644 index 00000000..fe972e68 --- /dev/null +++ b/packages/tooling/vis/src/commands/env/index.ts @@ -0,0 +1,11 @@ +import { defineCommand } from "@visulima/cerebro"; + +const env = defineCommand({ + description: "Print the Node, platform, package manager, and workspace that vis would use here", + examples: [["vis env", "Show the environment vis sees from this directory"]], + group: "System", + loader: () => import("./handler"), + name: "env", +}); + +export default env; diff --git a/packages/tooling/vis/src/commands/node/handler.ts b/packages/tooling/vis/src/commands/node/handler.ts new file mode 100644 index 00000000..96e689fd --- /dev/null +++ b/packages/tooling/vis/src/commands/node/handler.ts @@ -0,0 +1,19 @@ +import { spawnSync } from "node:child_process"; + +import type { CommandExecute, Toolbox } from "@visulima/cerebro"; + +/** Runs the node binary that is running vis, so the same version applies, and exits with its status. */ +const execute = async ({ argument, rawUnknown }: Toolbox): Promise => { + const result = spawnSync(process.execPath, [...(argument ?? []), ...(rawUnknown ?? [])], { cwd: process.cwd(), stdio: "inherit" }); + + if (result.error) { + throw result.error; + } + + if (result.status !== 0) { + process.exitCode = result.status ?? 1; + } +}; + +// fallow-ignore-next-line unused-export -- lazy-loaded command entry (cerebro loader/lazyNamed dynamic import) +export default execute as CommandExecute; diff --git a/packages/tooling/vis/src/commands/node/index.ts b/packages/tooling/vis/src/commands/node/index.ts new file mode 100644 index 00000000..2fde77dc --- /dev/null +++ b/packages/tooling/vis/src/commands/node/index.ts @@ -0,0 +1,19 @@ +import { defineCommand } from "@visulima/cerebro"; + +const node = defineCommand({ + argument: { + description: "Arguments passed to node (a script path, or flags such as -e)", + name: "args", + type: String, + }, + description: "Run node in the current directory, with its arguments passed through", + examples: [ + ["vis node script.mjs", "Run a script with the node that runs vis"], + ["vis node -e \"console.log(process.version)\"", "Evaluate an expression"], + ], + group: "Run & Execute", + loader: () => import("./handler"), + name: "node", +}); + +export default node; diff --git a/packages/tooling/vis/src/commands/preview/handler.ts b/packages/tooling/vis/src/commands/preview/handler.ts new file mode 100644 index 00000000..05f3d919 --- /dev/null +++ b/packages/tooling/vis/src/commands/preview/handler.ts @@ -0,0 +1,12 @@ +import type { CommandExecute, Toolbox } from "@visulima/cerebro"; + +import runExecute from "../run/handler"; +import type { PreviewOptions } from "./index"; + +/** `vis preview` is `vis run preview`. Unlike `dev` and `build`, it offers no tool: a preview needs a build first. */ +const execute = async ({ argument, rawUnknown, ...toolbox }: Toolbox): Promise => { + await runExecute({ ...toolbox, argument: ["preview", ...(argument ?? []), ...(rawUnknown ?? [])], rawUnknown }); +}; + +// fallow-ignore-next-line unused-export -- lazy-loaded command entry (cerebro loader/lazyNamed dynamic import) +export default execute as CommandExecute; diff --git a/packages/tooling/vis/src/commands/preview/index.ts b/packages/tooling/vis/src/commands/preview/index.ts new file mode 100644 index 00000000..497d1f00 --- /dev/null +++ b/packages/tooling/vis/src/commands/preview/index.ts @@ -0,0 +1,26 @@ +import type { InferOptions } from "@visulima/cerebro"; +import { defineCommand } from "@visulima/cerebro"; + +import { runOptionDefinitions } from "../run"; + +const preview = defineCommand({ + argument: { + description: "Extra arguments forwarded to the preview target", + name: "args", + type: String, + }, + description: "Serve the production build: the package's preview script, or the inferred Vite, Rsbuild, or Rspack preview", + examples: [ + ["vis preview", "Serve the built output across the workspace"], + ["vis preview --filter @org/web", "Serve one package's build output"], + ["vis preview -- --port 4173", "Forward flags after -- to the preview target"], + ], + group: "Run & Execute", + loader: () => import("./handler"), + name: "preview", + options: runOptionDefinitions, +}); + +export default preview; + +export type PreviewOptions = InferOptions; diff --git a/packages/tooling/vis/src/register-commands.ts b/packages/tooling/vis/src/register-commands.ts index d25c4483..75692008 100644 --- a/packages/tooling/vis/src/register-commands.ts +++ b/packages/tooling/vis/src/register-commands.ts @@ -29,6 +29,7 @@ import devcontainerCommand from "./commands/devcontainer"; import dlxCommand from "./commands/dlx"; import dockerCommands from "./commands/docker"; import doctorCommand from "./commands/doctor"; +import envCommand from "./commands/env"; import execCommand from "./commands/exec"; import fmtCommand from "./commands/fmt"; import generateCommand from "./commands/generate"; @@ -45,9 +46,11 @@ import linkCommand from "./commands/link"; import lintCommand from "./commands/lint"; import listCommand from "./commands/list"; import migrateCommands from "./commands/migrate"; +import nodeCommand from "./commands/node"; import optimizeCommand from "./commands/optimize"; import outdatedCommand from "./commands/outdated"; import pmCommand from "./commands/pm"; +import previewCommand from "./commands/preview"; import rebuildCommand from "./commands/rebuild"; import releaseCommands from "./commands/release"; import releaseRootCommand from "./commands/release/root-command"; @@ -88,6 +91,9 @@ const registerCommands = (cli: Cli): void => { cli.addCommand(runCommand); cli.addCommand(devCommand); cli.addCommand(buildCommand); + cli.addCommand(previewCommand); + cli.addCommand(envCommand); + cli.addCommand(nodeCommand); cli.addCommand(ciCommand); cli.addCommand(graphCommand); cli.addCommand(actionGraphCommand); From c22ce4498833c424f06aa20ce4be0b3448a542df Mon Sep 17 00:00:00 2001 From: Daniel Bannert Date: Sun, 18 Oct 2026 09:50:51 +0200 Subject: [PATCH 8/9] feat(vis): add vis pack, backed by Rslib or packem; drop vis node and vis env `vis pack` runs the `pack` target: the package's pack script, or the inferred library build. The Rslib and packem detectors now emit a `pack` target next to their `build`, so `vis build` for existing packem projects is unchanged. When no project defines `pack`, `vis pack` offers Rslib or packem, the same flow as `vis dev` and `vis build`. Rslib installs @rslib/core and typescript. packem installs @visulima/packem, esbuild (the transformer its config uses), and typescript. Each writes a minimal config with ESM output, and src/index.ts as the entry. A new packem library fails its first build on package.json warnings about exports, engines, and sideEffects. packem exits non-zero on warnings, so the docs say to add the fields it names. `vis preview` docs now note that the server runs in an interactive terminal. `vis node` and `vis env` are removed for now. Co-Authored-By: Claude Haiku 5.5 --- packages/tooling/vis/README.md | 3 +- .../__tests__/commands/env/env-report.test.ts | 34 --------- .../commands/lazy-commands-contract.test.ts | 6 +- .../commands/node/node-passthrough.test.ts | 17 ----- .../commands/pack/pack-offer.test.ts | 76 +++++++++++++++++++ .../vis/__tests__/inference/inference.test.ts | 7 +- .../__tests__/inference/pack-targets.test.ts | 51 +++++++++++++ packages/tooling/vis/docs/commands/env.mdx | 16 ---- packages/tooling/vis/docs/commands/node.mdx | 24 ------ packages/tooling/vis/docs/commands/pack.mdx | 47 ++++++++++++ .../tooling/vis/docs/commands/preview.mdx | 2 + packages/tooling/vis/docs/meta.json | 3 +- .../tooling/vis/src/commands/env/handler.ts | 17 ----- .../tooling/vis/src/commands/env/index.ts | 11 --- .../tooling/vis/src/commands/node/handler.ts | 19 ----- .../tooling/vis/src/commands/node/index.ts | 19 ----- .../tooling/vis/src/commands/pack/handler.ts | 54 +++++++++++++ .../tooling/vis/src/commands/pack/index.ts | 26 +++++++ .../vis/src/inference/detectors/packem.ts | 13 ++++ .../vis/src/inference/detectors/rslib.ts | 8 ++ packages/tooling/vis/src/register-commands.ts | 6 +- packages/tooling/vis/src/util/target-tool.ts | 2 +- 22 files changed, 287 insertions(+), 174 deletions(-) delete mode 100644 packages/tooling/vis/__tests__/commands/env/env-report.test.ts delete mode 100644 packages/tooling/vis/__tests__/commands/node/node-passthrough.test.ts create mode 100644 packages/tooling/vis/__tests__/commands/pack/pack-offer.test.ts create mode 100644 packages/tooling/vis/__tests__/inference/pack-targets.test.ts delete mode 100644 packages/tooling/vis/docs/commands/env.mdx delete mode 100644 packages/tooling/vis/docs/commands/node.mdx create mode 100644 packages/tooling/vis/docs/commands/pack.mdx delete mode 100644 packages/tooling/vis/src/commands/env/handler.ts delete mode 100644 packages/tooling/vis/src/commands/env/index.ts delete mode 100644 packages/tooling/vis/src/commands/node/handler.ts delete mode 100644 packages/tooling/vis/src/commands/node/index.ts create mode 100644 packages/tooling/vis/src/commands/pack/handler.ts create mode 100644 packages/tooling/vis/src/commands/pack/index.ts diff --git a/packages/tooling/vis/README.md b/packages/tooling/vis/README.md index ab8edc2d..95cbfc4e 100644 --- a/packages/tooling/vis/README.md +++ b/packages/tooling/vis/README.md @@ -224,8 +224,7 @@ When aube is the installer, `vis doctor` surfaces aube's effective hardening pos | `vis dev` | | Start the dev server: the package dev script, or the inferred Vite, Rsbuild, Rslib, or Rspack server | | `vis build` | | Run the build: the package build script, or the inferred Vite, Rsbuild, Rslib, or Rspack build | | `vis preview` | | Serve the production build: the package preview script, or the inferred Vite, Rsbuild, or Rspack preview | -| `vis node [args]` | | Run node with the Node version vis runs on, passing arguments through | -| `vis env` | | Print the Node, platform, package manager, and workspace vis sees from this directory | +| `vis pack` | | Build a library for publishing: the package pack script, or the inferred Rslib or packem build | | `vis affected ` | | Run tasks only on projects affected by git changes | | `vis ci ` | | One-shot CI entry point: toolchain, install, then affected runs for each comma-separated target | | `vis ignore ` | | CI build gating for Vercel / Netlify "Ignored Build Step" | diff --git a/packages/tooling/vis/__tests__/commands/env/env-report.test.ts b/packages/tooling/vis/__tests__/commands/env/env-report.test.ts deleted file mode 100644 index be9bfbe8..00000000 --- a/packages/tooling/vis/__tests__/commands/env/env-report.test.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; - -const pm = vi.hoisted(() => { return { detectPm: vi.fn(() => { return { name: "pnpm", version: "10.0.0" }; }) }; }); - -vi.mock(import("../../../src/pm/pm-runner"), () => pm); - -const { default: envExecute } = await import("../../../src/commands/env/handler"); - -describe("vis env", () => { - it("reports node, the directory, the workspace, and the package manager", async () => { - expect.assertions(4); - - const logger = { info: vi.fn() }; - - await envExecute({ logger, workspaceRoot: "/repo" } as never); - - const lines = logger.info.mock.calls.map(([line]) => String(line)); - - expect(lines.find((line) => line.startsWith("node"))).toContain(process.version); - expect(lines.find((line) => line.startsWith("workspace"))).toContain("/repo"); - expect(lines.find((line) => line.startsWith("pm"))).toContain("pnpm 10.0.0"); - expect(pm.detectPm).toHaveBeenCalledWith(process.cwd()); - }); - - it("says so when no workspace is found", async () => { - expect.assertions(1); - - const logger = { info: vi.fn() }; - - await envExecute({ logger, workspaceRoot: undefined } as never); - - expect(logger.info).toHaveBeenCalledWith(expect.stringContaining("not found from this directory")); - }); -}); diff --git a/packages/tooling/vis/__tests__/commands/lazy-commands-contract.test.ts b/packages/tooling/vis/__tests__/commands/lazy-commands-contract.test.ts index 26f5e89d..390ebd3f 100644 --- a/packages/tooling/vis/__tests__/commands/lazy-commands-contract.test.ts +++ b/packages/tooling/vis/__tests__/commands/lazy-commands-contract.test.ts @@ -21,7 +21,6 @@ import devcontainerCommand from "../../src/commands/devcontainer"; import dlxCommand from "../../src/commands/dlx"; import dockerCommands from "../../src/commands/docker"; import doctorCommand from "../../src/commands/doctor"; -import envCommand from "../../src/commands/env"; import execCommand from "../../src/commands/exec"; import generateCommand from "../../src/commands/generate"; import graphCommand from "../../src/commands/graph"; @@ -34,9 +33,9 @@ import installCommand from "../../src/commands/install"; import linkCommand from "../../src/commands/link"; import listCommand from "../../src/commands/list"; import migrateCommands from "../../src/commands/migrate"; -import nodeCommand from "../../src/commands/node"; import optimizeCommand from "../../src/commands/optimize"; import outdatedCommand from "../../src/commands/outdated"; +import packCommand from "../../src/commands/pack"; import pmCommand from "../../src/commands/pm"; import previewCommand from "../../src/commands/preview"; import rebuildCommand from "../../src/commands/rebuild"; @@ -68,7 +67,6 @@ const FLAT_COMMANDS: Command[] = [ cleanCommand, createCommand, dedupeCommand, - envCommand, devCommand, devcontainerCommand, dlxCommand, @@ -84,8 +82,8 @@ const FLAT_COMMANDS: Command[] = [ linkCommand, listCommand, optimizeCommand, + packCommand, outdatedCommand, - nodeCommand, pmCommand, previewCommand, rebuildCommand, diff --git a/packages/tooling/vis/__tests__/commands/node/node-passthrough.test.ts b/packages/tooling/vis/__tests__/commands/node/node-passthrough.test.ts deleted file mode 100644 index 0a98e4b2..00000000 --- a/packages/tooling/vis/__tests__/commands/node/node-passthrough.test.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import nodeExecute from "../../../src/commands/node/handler"; - -describe("vis node", () => { - it("passes its arguments to node and exits with node's status", async () => { - expect.assertions(1); - - process.exitCode = undefined; - - await nodeExecute({ argument: ["-e", "process.exit(5)"], rawUnknown: [] } as never); - - expect(process.exitCode).toBe(5); - - process.exitCode = undefined; - }); -}); diff --git a/packages/tooling/vis/__tests__/commands/pack/pack-offer.test.ts b/packages/tooling/vis/__tests__/commands/pack/pack-offer.test.ts new file mode 100644 index 00000000..e0ffa487 --- /dev/null +++ b/packages/tooling/vis/__tests__/commands/pack/pack-offer.test.ts @@ -0,0 +1,76 @@ +import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { access, mkdir, readFile, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import type { CerebroFs } from "@visulima/cerebro"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const runExecute = vi.hoisted(() => vi.fn()); + +vi.mock(import("../../../src/commands/run/handler"), () => { return { default: runExecute }; }); + +const { runPack } = await import("../../../src/commands/pack/handler"); + +const fs = { access, mkdir, readFile, writeFile } as unknown as CerebroFs; + +describe("vis pack", () => { + let root: string; + let app: string; + + beforeEach(() => { + root = mkdtempSync(join(realpathSync(tmpdir()), "vis-pack-")); + app = join(root, "packages", "lib"); + mkdirSync(join(root, "node_modules"), { recursive: true }); + mkdirSync(app, { recursive: true }); + writeFileSync(join(root, "pnpm-workspace.yaml"), "packages:\n - 'packages/*'\n"); + writeFileSync(join(root, "package.json"), JSON.stringify({ name: "root", private: true })); + writeFileSync(join(app, "package.json"), JSON.stringify({ name: "@fix/lib", scripts: {} }, undefined, 2)); + runExecute.mockReset(); + process.exitCode = undefined; + }); + + afterEach(() => { + rmSync(root, { force: true, recursive: true }); + process.exitCode = undefined; + }); + + const toolbox = (options: Record = {}, argument: string[] = []) => + ({ argument, fs, logger: { info: vi.fn() }, options, rawUnknown: [], visConfig: undefined, workspaceRoot: root }) as never; + + const noTarget = async () => [{ name: "@fix/lib", root: app, targets: undefined }]; + const withPack = async () => [{ name: "@fix/lib", root: app, targets: { pack: {} } }]; + + it("runs the pack target without prompting when a project already defines it", async () => { + expect.assertions(2); + + await runPack(toolbox({}, ["--sourcemap"]), { install: () => 0, isInteractive: () => false, projects: withPack }); + + expect(runExecute.mock.calls[0][0].argument).toStrictEqual(["pack", "--sourcemap"]); + expect(process.exitCode).toBeUndefined(); + }); + + it("errors in a non-interactive run, listing Rslib and packem with their install commands", async () => { + expect.assertions(3); + + const install = vi.fn(() => 0); + + await expect(runPack(toolbox(), { install, isInteractive: () => false, projects: noTarget })).rejects.toThrow("vis add -D @rslib/core"); + await expect(runPack(toolbox(), { install, isInteractive: () => false, projects: noTarget })).rejects.toThrow("vis add -D @visulima/packem"); + expect(runExecute).not.toHaveBeenCalled(); + }); + + it("installs packem with its transformer and typescript, writes the config and entry, then runs pack", async () => { + expect.assertions(4); + + const install = vi.fn(() => 0); + + // Install is faked; the run is mocked. Only the offer's effects are checked here. + await runPack(toolbox(), { install, isInteractive: () => true, projects: noTarget, readline: async () => "2" }); + + expect(install.mock.calls.map(([name]) => name)).toStrictEqual(["@visulima/packem", "esbuild", "typescript"]); + expect(readFileSync(join(app, "packem.config.ts"), "utf8")).toContain("emitESM: true"); + expect(readFileSync(join(app, "src", "index.ts"), "utf8")).toBe("export {};\n"); + expect(runExecute.mock.calls[0][0].argument).toStrictEqual(["pack"]); + }); +}); diff --git a/packages/tooling/vis/__tests__/inference/inference.test.ts b/packages/tooling/vis/__tests__/inference/inference.test.ts index 034c7da0..790c4377 100644 --- a/packages/tooling/vis/__tests__/inference/inference.test.ts +++ b/packages/tooling/vis/__tests__/inference/inference.test.ts @@ -100,10 +100,9 @@ describe("inference", () => { }); expect(result.targets["build"]?.command).toBe("vite build"); - // packem still ran, but didn't contribute a unique target — its - // name should be absent from sources, since packem only had the - // (now-shadowed) `build` target on offer in v1. - expect(result.sources).toStrictEqual(["vite"]); + // packem's `build` is shadowed by vite, but packem still contributes its + // unique `pack` target, so it is a source. Its `build` is not used. + expect(result.sources).toStrictEqual(["vite", "packem"]); }); it("should combine targets across detectors that don't collide", () => { diff --git a/packages/tooling/vis/__tests__/inference/pack-targets.test.ts b/packages/tooling/vis/__tests__/inference/pack-targets.test.ts new file mode 100644 index 00000000..af5cd23d --- /dev/null +++ b/packages/tooling/vis/__tests__/inference/pack-targets.test.ts @@ -0,0 +1,51 @@ +import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { discoverWorkspace } from "../../src/config/workspace"; + +const writeProject = (root: string, name: string, configFile: string): void => { + const directory = join(root, "packages", name); + + mkdirSync(directory, { recursive: true }); + writeFileSync(join(directory, "package.json"), JSON.stringify({ name: `@fix/${name}`, scripts: {} }, undefined, 2)); + writeFileSync(join(directory, configFile), "export default {}"); +}; + +describe("pack target inference", () => { + let scratch: string; + + const targetsOf = (name: string): Record => + discoverWorkspace(scratch, {}).workspace.projects[`@fix/${name}`]?.targets ?? {}; + + beforeEach(() => { + scratch = mkdtempSync(join(realpathSync(tmpdir()), "vis-pack-infer-")); + mkdirSync(join(scratch, "node_modules"), { recursive: true }); + writeFileSync(join(scratch, "pnpm-workspace.yaml"), "packages:\n - 'packages/*'\n"); + writeFileSync(join(scratch, "package.json"), JSON.stringify({ name: "fixture-root", private: true }, undefined, 2)); + }); + + afterEach(() => { + rmSync(scratch, { force: true, recursive: true }); + }); + + it("infers pack from rslib.config.ts, and keeps build", () => { + expect.assertions(2); + + writeProject(scratch, "lib", "rslib.config.mts"); + + expect(targetsOf("lib")["pack"]?.command).toBe("rslib build"); + expect(targetsOf("lib")["build"]?.command).toBe("rslib build"); + }); + + it("infers pack from packem.config.ts, and keeps build", () => { + expect.assertions(2); + + writeProject(scratch, "tool", "packem.config.ts"); + + expect(targetsOf("tool")["pack"]?.command).toBe("packem build"); + expect(targetsOf("tool")["build"]?.command).toBe("packem build"); + }); +}); diff --git a/packages/tooling/vis/docs/commands/env.mdx b/packages/tooling/vis/docs/commands/env.mdx deleted file mode 100644 index 114be200..00000000 --- a/packages/tooling/vis/docs/commands/env.mdx +++ /dev/null @@ -1,16 +0,0 @@ ---- -title: "vis env" -description: "Print the Node, platform, package manager, and workspace that vis would use here" ---- - -# vis env - -Prints what vis sees from the current directory: the Node version and platform, the directory, the workspace root, and the package manager it would use. It changes nothing. - -If the workspace is not found from this directory, `vis env` says so. vis treats the directory it runs from as the workspace root, so run it from the root of the workspace. - -## Usage - -```bash -vis env -``` diff --git a/packages/tooling/vis/docs/commands/node.mdx b/packages/tooling/vis/docs/commands/node.mdx deleted file mode 100644 index e6cfcf13..00000000 --- a/packages/tooling/vis/docs/commands/node.mdx +++ /dev/null @@ -1,24 +0,0 @@ ---- -title: "vis node" -description: "Run node in the current directory, with its arguments passed through" ---- - -# vis node - -Runs the `node` binary that is running vis, with the arguments you pass, in the current directory. It exits with node's status. Use it when you want the same Node version as vis, without changing your shell's `PATH`. - -## Usage - -```bash -vis node [args…] -``` - -## Examples - -```bash -# Run a script -vis node scripts/release.mjs - -# Evaluate an expression -vis node -e "console.log(process.version)" -``` diff --git a/packages/tooling/vis/docs/commands/pack.mdx b/packages/tooling/vis/docs/commands/pack.mdx new file mode 100644 index 00000000..65a4b208 --- /dev/null +++ b/packages/tooling/vis/docs/commands/pack.mdx @@ -0,0 +1,47 @@ +--- +title: "vis pack" +description: "Build a library for publishing: the package's pack script, or the inferred Rslib or packem build" +--- + +# vis pack + +Builds a library for publishing. `vis pack` runs the `pack` target, so it uses the same target resolution, filters, and caching as `vis run pack`. + +A package's own `pack` script in `package.json` always wins. When a project has no `pack` script, vis infers one from its config file: + +- `rslib.config.*` → `rslib build` +- `packem.config.*` → `packem build` + +A project with a `packem.config.*` also gets `vis build`, the same command. `vis pack` is the name to use for libraries. + +## Setting one up + +If no project defines a `pack` target, `vis pack` asks which library builder to set up: Rslib or packem. Your choice is the consent. vis installs the tool with `vis add -D` into the chosen project, and adds a `pack` script to its `package.json`. It also writes the tool's config file and `src/index.ts`, unless they already exist. + +- **Rslib** installs `@rslib/core` and `typescript`, and writes `rslib.config.mts` with ESM output. +- **packem** installs `@visulima/packem`, `esbuild` (the transformer packem's config uses), and `typescript`, and writes `packem.config.ts` with ESM output and `src/index.ts` as the entry. + +A new packem library can fail its first build on `package.json` warnings: missing `exports`, `engines`, or `sideEffects`. packem exits non-zero on warnings by default. Add the fields it names, or set `failOnWarn: false` in `packem.config.ts`. + +In a non-interactive run (CI, or no terminal) it stops and lists the options instead. + +## Usage + +```bash +vis pack [args…] [options] +``` + +## Examples + +```bash +# Build every library that has a pack target +vis pack + +# Build one package +vis pack --filter @org/utils + +# Forward flags to the pack target +vis pack -- --sourcemap +``` + +Like `vis dev` and `vis build`, vis treats the directory it runs from as the workspace root. Run it from the root, and use `--filter` when the workspace has several projects. diff --git a/packages/tooling/vis/docs/commands/preview.mdx b/packages/tooling/vis/docs/commands/preview.mdx index 7191968d..dab72a02 100644 --- a/packages/tooling/vis/docs/commands/preview.mdx +++ b/packages/tooling/vis/docs/commands/preview.mdx @@ -13,6 +13,8 @@ A package's own `preview` script always wins. Otherwise vis infers one from the - `rsbuild.config.*` → `rsbuild preview` - `rspack.config.*` → `rspack preview` +Preview is a long-running server, so it runs in an interactive terminal. In CI or with piped input, vis skips it and reports that instead. + Unlike `vis dev` and `vis build`, `vis preview` does not offer to install a tool. A preview needs a build first, so run `vis build` before it. ## Usage diff --git a/packages/tooling/vis/docs/meta.json b/packages/tooling/vis/docs/meta.json index 11dd5fff..3771fcdf 100644 --- a/packages/tooling/vis/docs/meta.json +++ b/packages/tooling/vis/docs/meta.json @@ -11,8 +11,7 @@ "commands/run", "commands/dev", "commands/preview", - "commands/node", - "commands/env", + "commands/pack", "commands/build", "commands/exec", "commands/staged", diff --git a/packages/tooling/vis/src/commands/env/handler.ts b/packages/tooling/vis/src/commands/env/handler.ts deleted file mode 100644 index 9e5c8316..00000000 --- a/packages/tooling/vis/src/commands/env/handler.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { CommandExecute, Toolbox } from "@visulima/cerebro"; - -/** Read-only report of what vis sees from this directory: nothing is installed or changed. */ -const execute = async ({ logger, workspaceRoot }: Toolbox): Promise => { - // Loaded here, not at module top: pm-runner pulls in the native binding. - const { detectPm } = await import("../../pm/pm-runner"); - const cwd = process.cwd(); - const pm = detectPm(cwd); - - logger.info(`node ${process.version} (${process.platform} ${process.arch})`); - logger.info(`directory ${cwd}`); - logger.info(`workspace ${workspaceRoot ?? "not found from this directory"}`); - logger.info(`pm ${pm.name}${pm.version ? ` ${pm.version}` : ""}`); -}; - -// fallow-ignore-next-line unused-export -- lazy-loaded command entry (cerebro loader/lazyNamed dynamic import) -export default execute as CommandExecute; diff --git a/packages/tooling/vis/src/commands/env/index.ts b/packages/tooling/vis/src/commands/env/index.ts deleted file mode 100644 index fe972e68..00000000 --- a/packages/tooling/vis/src/commands/env/index.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { defineCommand } from "@visulima/cerebro"; - -const env = defineCommand({ - description: "Print the Node, platform, package manager, and workspace that vis would use here", - examples: [["vis env", "Show the environment vis sees from this directory"]], - group: "System", - loader: () => import("./handler"), - name: "env", -}); - -export default env; diff --git a/packages/tooling/vis/src/commands/node/handler.ts b/packages/tooling/vis/src/commands/node/handler.ts deleted file mode 100644 index 96e689fd..00000000 --- a/packages/tooling/vis/src/commands/node/handler.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { spawnSync } from "node:child_process"; - -import type { CommandExecute, Toolbox } from "@visulima/cerebro"; - -/** Runs the node binary that is running vis, so the same version applies, and exits with its status. */ -const execute = async ({ argument, rawUnknown }: Toolbox): Promise => { - const result = spawnSync(process.execPath, [...(argument ?? []), ...(rawUnknown ?? [])], { cwd: process.cwd(), stdio: "inherit" }); - - if (result.error) { - throw result.error; - } - - if (result.status !== 0) { - process.exitCode = result.status ?? 1; - } -}; - -// fallow-ignore-next-line unused-export -- lazy-loaded command entry (cerebro loader/lazyNamed dynamic import) -export default execute as CommandExecute; diff --git a/packages/tooling/vis/src/commands/node/index.ts b/packages/tooling/vis/src/commands/node/index.ts deleted file mode 100644 index 2fde77dc..00000000 --- a/packages/tooling/vis/src/commands/node/index.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { defineCommand } from "@visulima/cerebro"; - -const node = defineCommand({ - argument: { - description: "Arguments passed to node (a script path, or flags such as -e)", - name: "args", - type: String, - }, - description: "Run node in the current directory, with its arguments passed through", - examples: [ - ["vis node script.mjs", "Run a script with the node that runs vis"], - ["vis node -e \"console.log(process.version)\"", "Evaluate an expression"], - ], - group: "Run & Execute", - loader: () => import("./handler"), - name: "node", -}); - -export default node; diff --git a/packages/tooling/vis/src/commands/pack/handler.ts b/packages/tooling/vis/src/commands/pack/handler.ts new file mode 100644 index 00000000..b1a7d290 --- /dev/null +++ b/packages/tooling/vis/src/commands/pack/handler.ts @@ -0,0 +1,54 @@ +import type { CommandExecute, Toolbox } from "@visulima/cerebro"; + +import type { TargetToolChoice, TargetToolDeps } from "../../util/target-tool"; +import { offerTargetTool } from "../../util/target-tool"; +import runExecute from "../run/handler"; +import type { PackOptions } from "./index"; + +/** Library builders vis can set up when no project defines a `pack` target. */ +export const PACK_CHOICES: ReadonlyArray = [ + { + files: [ + { + contents: "import { defineConfig } from \"@rslib/core\";\n\nexport default defineConfig({ lib: [{ format: \"esm\" }] });\n", + file: "rslib.config.mts", + }, + { contents: "export {};\n", file: "src/index.ts" }, + ], + label: "Rslib", + packages: ["@rslib/core", "typescript"], + script: "rslib build", + }, + { + files: [ + { + contents: "import { defineConfig } from \"@visulima/packem/config\";\nimport transformer from \"@visulima/packem/transformer/esbuild\";\n\nexport default defineConfig({ emitESM: true, entries: [\"src/index.ts\"], runtime: \"node\", transformer });\n", + file: "packem.config.ts", + }, + { contents: "export {};\n", file: "src/index.ts" }, + ], + label: "packem", + packages: ["@visulima/packem", "esbuild", "typescript"], + script: "packem build", + }, +]; + +/** Offers a library builder when none is configured, then runs the `pack` target like `vis run pack`. */ +export const runPack = async (toolbox: Toolbox, deps: TargetToolDeps = {}): Promise => { + const { argument, fs, logger, options, rawUnknown, visConfig, workspaceRoot } = toolbox; + const ready = await offerTargetTool( + { choices: PACK_CHOICES, filter: options.filter, fs, logger, target: "pack", visConfig, workspaceRoot: workspaceRoot ?? process.cwd() }, + deps, + ); + + if (!ready) { + return; + } + + await runExecute({ ...toolbox, argument: ["pack", ...(argument ?? []), ...(rawUnknown ?? [])], rawUnknown }); +}; + +const execute = (toolbox: Toolbox): Promise => runPack(toolbox); + +// fallow-ignore-next-line unused-export -- lazy-loaded command entry (cerebro loader/lazyNamed dynamic import) +export default execute as CommandExecute; diff --git a/packages/tooling/vis/src/commands/pack/index.ts b/packages/tooling/vis/src/commands/pack/index.ts new file mode 100644 index 00000000..8fb07d4b --- /dev/null +++ b/packages/tooling/vis/src/commands/pack/index.ts @@ -0,0 +1,26 @@ +import type { InferOptions } from "@visulima/cerebro"; +import { defineCommand } from "@visulima/cerebro"; + +import { runOptionDefinitions } from "../run"; + +const pack = defineCommand({ + argument: { + description: "Extra arguments forwarded to the pack target", + name: "args", + type: String, + }, + description: "Build a library for publishing: the package's pack script, or the inferred Rslib or packem build", + examples: [ + ["vis pack", "Build every library that has a pack target"], + ["vis pack --filter @org/utils", "Build one package"], + ["vis pack -- --sourcemap", "Forward flags after -- to the pack target"], + ], + group: "Run & Execute", + loader: () => import("./handler"), + name: "pack", + options: runOptionDefinitions, +}); + +export default pack; + +export type PackOptions = InferOptions; diff --git a/packages/tooling/vis/src/inference/detectors/packem.ts b/packages/tooling/vis/src/inference/detectors/packem.ts index d6ea82b8..6248b9ec 100644 --- a/packages/tooling/vis/src/inference/detectors/packem.ts +++ b/packages/tooling/vis/src/inference/detectors/packem.ts @@ -17,6 +17,19 @@ export const packemDetector: Detector = { outputs: ["{projectRoot}/dist"], type: "build", }, + // `pack` is the library build for `vis pack`. `build` stays for `vis build`. + pack: { + command: "packem build", + description: "@visulima/packem library build (inferred)", + inputs: [ + "{projectRoot}/src/**/*", + `{projectRoot}/${matchedConfigs[0] ?? "packem.config.ts"}`, + "{projectRoot}/package.json", + "{projectRoot}/tsconfig.json", + ], + outputs: ["{projectRoot}/dist"], + type: "build", + }, }, }; }, diff --git a/packages/tooling/vis/src/inference/detectors/rslib.ts b/packages/tooling/vis/src/inference/detectors/rslib.ts index 21e95aff..56dd81a0 100644 --- a/packages/tooling/vis/src/inference/detectors/rslib.ts +++ b/packages/tooling/vis/src/inference/detectors/rslib.ts @@ -19,6 +19,14 @@ export const rslibDetector: Detector = { description: "rslib watch build (inferred)", preset: "server", }, + // `pack` is the library build for `vis pack`. `build` stays for `vis build`. + pack: { + command: "rslib build", + description: "rslib library build (inferred)", + inputs: ["{projectRoot}/src/**/*", `{projectRoot}/${matchedConfigs[0]!}`, "{projectRoot}/package.json", "{projectRoot}/tsconfig.json"], + outputs: ["{projectRoot}/dist"], + type: "build", + }, }, }; }, diff --git a/packages/tooling/vis/src/register-commands.ts b/packages/tooling/vis/src/register-commands.ts index 75692008..77d3a452 100644 --- a/packages/tooling/vis/src/register-commands.ts +++ b/packages/tooling/vis/src/register-commands.ts @@ -29,7 +29,6 @@ import devcontainerCommand from "./commands/devcontainer"; import dlxCommand from "./commands/dlx"; import dockerCommands from "./commands/docker"; import doctorCommand from "./commands/doctor"; -import envCommand from "./commands/env"; import execCommand from "./commands/exec"; import fmtCommand from "./commands/fmt"; import generateCommand from "./commands/generate"; @@ -46,9 +45,9 @@ import linkCommand from "./commands/link"; import lintCommand from "./commands/lint"; import listCommand from "./commands/list"; import migrateCommands from "./commands/migrate"; -import nodeCommand from "./commands/node"; import optimizeCommand from "./commands/optimize"; import outdatedCommand from "./commands/outdated"; +import packCommand from "./commands/pack"; import pmCommand from "./commands/pm"; import previewCommand from "./commands/preview"; import rebuildCommand from "./commands/rebuild"; @@ -92,8 +91,7 @@ const registerCommands = (cli: Cli): void => { cli.addCommand(devCommand); cli.addCommand(buildCommand); cli.addCommand(previewCommand); - cli.addCommand(envCommand); - cli.addCommand(nodeCommand); + cli.addCommand(packCommand); cli.addCommand(ciCommand); cli.addCommand(graphCommand); cli.addCommand(actionGraphCommand); diff --git a/packages/tooling/vis/src/util/target-tool.ts b/packages/tooling/vis/src/util/target-tool.ts index e844da77..b5c1c3d3 100644 --- a/packages/tooling/vis/src/util/target-tool.ts +++ b/packages/tooling/vis/src/util/target-tool.ts @@ -42,7 +42,7 @@ export interface TargetToolParams { filter?: ReadonlyArray; fs: CerebroFs; logger: Pick; - target: "build" | "dev"; + target: "build" | "dev" | "pack"; visConfig: Parameters[1]; workspaceRoot: string; } From 99aa82e39ffd94aaef23278065ea4f97c0ae2820 Mon Sep 17 00:00:00 2001 From: Daniel Bannert Date: Thu, 22 Oct 2026 13:57:43 +0200 Subject: [PATCH 9/9] feat(vis): add an rslint adapter to vis lint, and fix workspace root and scope (#42) Co-authored-by: Claude Haiku 5.5 --- .../commands/dev/dev-build-offer.test.ts | 3 +- .../commands/pack/pack-offer.test.ts | 5 +- .../inference/rstack-detectors.test.ts | 8 + .../lint-fmt/adapters/rslint.test.ts | 114 +++++++++++++ .../vis/__tests__/util/scoped-target.test.ts | 24 +++ .../vis/__tests__/util/workspace-root.test.ts | 54 ++++++ packages/tooling/vis/docs/commands/build.mdx | 2 +- packages/tooling/vis/docs/commands/dev.mdx | 2 +- packages/tooling/vis/docs/commands/lint.mdx | 8 +- packages/tooling/vis/docs/commands/pack.mdx | 6 +- .../tooling/vis/docs/commands/preview.mdx | 2 + .../vis/schemas/vis-config.schema.json | 6 +- packages/tooling/vis/src/cli-main.ts | 8 +- .../tooling/vis/src/commands/build/handler.ts | 4 +- .../tooling/vis/src/commands/dev/handler.ts | 4 +- .../tooling/vis/src/commands/lint/handler.ts | 4 +- .../tooling/vis/src/commands/pack/handler.ts | 6 +- .../vis/src/commands/preview/handler.ts | 7 +- packages/tooling/vis/src/config/workspace.ts | 2 + .../vis/src/inference/detectors/rslint.ts | 6 +- .../vis/src/lint-fmt/adapters/rslint.ts | 156 ++++++++++++++++++ .../tooling/vis/src/lint-fmt/config-types.ts | 3 +- packages/tooling/vis/src/lint-fmt/registry.ts | 1 + .../tooling/vis/src/plugins/config-loader.ts | 11 +- packages/tooling/vis/src/util/target-tool.ts | 14 ++ packages/tooling/vis/src/util/tips.ts | 4 +- .../tooling/vis/src/util/tool-signatures.ts | 4 + .../tooling/vis/src/util/workspace-root.ts | 48 ++++++ 28 files changed, 486 insertions(+), 30 deletions(-) create mode 100644 packages/tooling/vis/__tests__/lint-fmt/adapters/rslint.test.ts create mode 100644 packages/tooling/vis/__tests__/util/scoped-target.test.ts create mode 100644 packages/tooling/vis/__tests__/util/workspace-root.test.ts create mode 100644 packages/tooling/vis/src/lint-fmt/adapters/rslint.ts create mode 100644 packages/tooling/vis/src/util/workspace-root.ts diff --git a/packages/tooling/vis/__tests__/commands/dev/dev-build-offer.test.ts b/packages/tooling/vis/__tests__/commands/dev/dev-build-offer.test.ts index 79b28c2e..fa6a6cf5 100644 --- a/packages/tooling/vis/__tests__/commands/dev/dev-build-offer.test.ts +++ b/packages/tooling/vis/__tests__/commands/dev/dev-build-offer.test.ts @@ -48,7 +48,8 @@ describe("vis dev and vis build with no target", () => { await runDev(toolbox(root), { install: () => 0, isInteractive: () => false, projects: async () => [{ name: "@fix/app", root: join(root, "packages", "app"), targets: { dev: {} } }] }); expect(runExecute).toHaveBeenCalledTimes(1); - expect(runExecute.mock.calls[0][0].argument).toStrictEqual(["dev"]); + // The current directory is not the workspace root here, so the selector is scoped to the package. + expect(runExecute.mock.calls[0][0].argument).toStrictEqual(["~:dev"]); }); it("errors for build in a non-interactive run, and does not run the target", async () => { diff --git a/packages/tooling/vis/__tests__/commands/pack/pack-offer.test.ts b/packages/tooling/vis/__tests__/commands/pack/pack-offer.test.ts index e0ffa487..3202f4ac 100644 --- a/packages/tooling/vis/__tests__/commands/pack/pack-offer.test.ts +++ b/packages/tooling/vis/__tests__/commands/pack/pack-offer.test.ts @@ -46,7 +46,8 @@ describe("vis pack", () => { await runPack(toolbox({}, ["--sourcemap"]), { install: () => 0, isInteractive: () => false, projects: withPack }); - expect(runExecute.mock.calls[0][0].argument).toStrictEqual(["pack", "--sourcemap"]); + // The current directory is not the workspace root here, so the selector is scoped to the package. + expect(runExecute.mock.calls[0][0].argument).toStrictEqual(["~:pack", "--sourcemap"]); expect(process.exitCode).toBeUndefined(); }); @@ -71,6 +72,6 @@ describe("vis pack", () => { expect(install.mock.calls.map(([name]) => name)).toStrictEqual(["@visulima/packem", "esbuild", "typescript"]); expect(readFileSync(join(app, "packem.config.ts"), "utf8")).toContain("emitESM: true"); expect(readFileSync(join(app, "src", "index.ts"), "utf8")).toBe("export {};\n"); - expect(runExecute.mock.calls[0][0].argument).toStrictEqual(["pack"]); + expect(runExecute.mock.calls[0][0].argument).toStrictEqual(["~:pack"]); }); }); diff --git a/packages/tooling/vis/__tests__/inference/rstack-detectors.test.ts b/packages/tooling/vis/__tests__/inference/rstack-detectors.test.ts index cd65520c..0885e841 100644 --- a/packages/tooling/vis/__tests__/inference/rstack-detectors.test.ts +++ b/packages/tooling/vis/__tests__/inference/rstack-detectors.test.ts @@ -92,6 +92,14 @@ describe("rstack target inference", () => { expect(targetsOf("unit")["test"]?.command).toBe("vitest run"); }); + it("does not infer rslint from the dependency alone, since it needs a config", () => { + expect.assertions(1); + + writeProject(scratch, "style", [], { devDependencies: { "@rslint/core": "^0.9.5" } }); + + expect(targetsOf("style")["lint"]).toBeUndefined(); + }); + it("infers rslint from rslint.config.ts", () => { expect.assertions(1); diff --git a/packages/tooling/vis/__tests__/lint-fmt/adapters/rslint.test.ts b/packages/tooling/vis/__tests__/lint-fmt/adapters/rslint.test.ts new file mode 100644 index 00000000..786ea0f2 --- /dev/null +++ b/packages/tooling/vis/__tests__/lint-fmt/adapters/rslint.test.ts @@ -0,0 +1,114 @@ +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; + +import { join } from "@visulima/path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { rslintAdapter } from "../../../src/lint-fmt/adapters/rslint"; +import type { RunResult, ToolPresence } from "../../../src/lint-fmt/config-types"; + +let workspaceRoot: string; + +const stubResult = (overrides: Partial): RunResult => { + return { durationMs: 1, exitCode: 0, stderr: "", stdout: "", ...overrides }; +}; + +const presence = (): ToolPresence => { + return { adapter: "rslint", declared: true, declaredVersion: "0.9.5", root: workspaceRoot }; +}; + +// Captured from `rslint --format jsonline --rule 'no-var: error' src/a.ts src/c.ts` (@rslint/core 0.9.5). +const TWO_FILES_OUTPUT = [ + "{\"ruleName\":\"no-var\",\"message\":\"Unexpected var, use let or const instead.\",\"filePath\":\"src/a.ts\",\"range\":{\"start\":{\"line\":1,\"column\":1},\"end\":{\"line\":1,\"column\":11}},\"severity\":\"error\"}", + "{\"ruleName\":\"no-unused-vars\",\"message\":\"'c' is assigned a value but never used.\",\"filePath\":\"src/c.ts\",\"range\":{\"start\":{\"line\":1,\"column\":5},\"end\":{\"line\":1,\"column\":6}},\"severity\":\"error\"}", + "", +].join("\n"); + +// Captured from `rslint --format jsonline --rule 'no-var: warn' src/a.ts`: the severity is "warn". +const WARNING_OUTPUT = "{\"ruleName\":\"no-var\",\"message\":\"Unexpected var, use let or const instead.\",\"filePath\":\"src/a.ts\",\"range\":{\"start\":{\"line\":1,\"column\":1},\"end\":{\"line\":1,\"column\":11}},\"severity\":\"warn\"}\n"; + +describe("rslint adapter", () => { + beforeEach(() => { + workspaceRoot = mkdtempSync(join(tmpdir(), "vis-rslint-")); + }); + + afterEach(() => { + rmSync(workspaceRoot, { force: true, recursive: true }); + }); + + it("parses each JSON line into a finding, resolving the path against the workspace", () => { + expect.assertions(2); + + const findings = rslintAdapter.parse(stubResult({ exitCode: 1, stdout: TWO_FILES_OUTPUT }), presence()); + + expect(findings).toHaveLength(2); + expect(findings[0]).toMatchObject({ + adapter: "rslint", + column: 1, + endColumn: 11, + endLine: 1, + file: join(workspaceRoot, "src/a.ts"), + line: 1, + ruleId: "no-var", + severity: "error", + }); + }); + + it("maps the warn severity to warning", () => { + expect.assertions(1); + + const [finding] = rslintAdapter.parse(stubResult({ exitCode: 0, stdout: WARNING_OUTPUT }), presence()); + + expect(finding?.severity).toBe("warning"); + }); + + it("skips lines that are not JSON, so a summary line does not become a finding", () => { + expect.assertions(1); + + const findings = rslintAdapter.parse(stubResult({ exitCode: 1, stdout: `some banner\n${TWO_FILES_OUTPUT}\nsummary: 2 problems\n` }), presence()); + + expect(findings).toHaveLength(2); + }); + + it("returns nothing for a clean run", () => { + expect.assertions(1); + + expect(rslintAdapter.parse(stubResult({ exitCode: 0, stdout: "" }), presence())).toStrictEqual([]); + }); + + it("reports a crash as an error with the first stderr line when a non-zero exit has no findings", () => { + expect.assertions(2); + + // Captured from a config with a syntax error: exit 1, nothing on stdout, the reason on stderr. + const findings = rslintAdapter.parse( + stubResult({ + exitCode: 1, + stderr: "Warning: skipping config /repo/rslint.config.js: Unexpected token ';'\nerror: all discovered JavaScript configs failed to load", + stdout: "", + }), + presence(), + ); + + expect(findings).toHaveLength(1); + expect(findings[0]).toMatchObject({ message: "Warning: skipping config /repo/rslint.config.js: Unexpected token ';'", severity: "error" }); + }); + + it("detects rslint only when a config file exists, not from the dependency alone", () => { + expect.assertions(2); + + const packageJson = { devDependencies: { "@rslint/core": "^0.9.5" } }; + + expect(rslintAdapter.detect(workspaceRoot, packageJson)).toBeUndefined(); + + writeFileSync(join(workspaceRoot, "rslint.config.ts"), "export default [];\n"); + + expect(rslintAdapter.detect(workspaceRoot, packageJson)).toMatchObject({ adapter: "rslint", configFile: join(workspaceRoot, "rslint.config.ts"), declared: true }); + }); + + it("builds check and fix arguments with jsonline output and the max-warnings form that works", () => { + expect.assertions(2); + + expect(rslintAdapter.argsCheck(["."], { maxWarnings: 0, quiet: true })).toStrictEqual(["--format", "jsonline", "--quiet", "--max-warnings=0", "."]); + expect(rslintAdapter.argsFix(["src"], {})).toStrictEqual(["--format", "jsonline", "--fix", "src"]); + }); +}); diff --git a/packages/tooling/vis/__tests__/util/scoped-target.test.ts b/packages/tooling/vis/__tests__/util/scoped-target.test.ts new file mode 100644 index 00000000..a9e533c6 --- /dev/null +++ b/packages/tooling/vis/__tests__/util/scoped-target.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; + +import { scopedTarget } from "../../src/util/target-tool"; + +describe(scopedTarget, () => { + it("selects the package's target when run from inside a package", () => { + expect.assertions(1); + + expect(scopedTarget("pack", {}, "/repo/packages/lib", "/repo")).toBe("~:pack"); + }); + + it("selects the target across the workspace when run from the root", () => { + expect.assertions(1); + + expect(scopedTarget("pack", {}, "/repo", "/repo")).toBe("pack"); + }); + + it("keeps an explicit --filter or --projects selection as given", () => { + expect.assertions(2); + + expect(scopedTarget("dev", { filter: ["@org/web"] }, "/repo/packages/lib", "/repo")).toBe("dev"); + expect(scopedTarget("dev", { projects: "web" }, "/repo/packages/lib", "/repo")).toBe("dev"); + }); +}); diff --git a/packages/tooling/vis/__tests__/util/workspace-root.test.ts b/packages/tooling/vis/__tests__/util/workspace-root.test.ts new file mode 100644 index 00000000..80490068 --- /dev/null +++ b/packages/tooling/vis/__tests__/util/workspace-root.test.ts @@ -0,0 +1,54 @@ +import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { findWorkspaceRootSync } from "../../src/util/workspace-root"; + +describe(findWorkspaceRootSync, () => { + let root: string; + + beforeEach(() => { + root = realpathSync(mkdtempSync(join(tmpdir(), "vis-workspace-root-"))); + }); + + afterEach(() => { + rmSync(root, { force: true, recursive: true }); + }); + + it("returns the workspace root, not a package with its own lockfile", () => { + expect.assertions(1); + + const pkg = join(root, "packages", "app"); + + mkdirSync(pkg, { recursive: true }); + writeFileSync(join(root, "pnpm-workspace.yaml"), "packages:\n - 'packages/*'\n"); + writeFileSync(join(pkg, "package.json"), JSON.stringify({ name: "@x/app" })); + writeFileSync(join(pkg, "package-lock.json"), "{}"); + + expect(findWorkspaceRootSync(pkg)).toBe(root); + }); + + it("finds a declared workspace without a lockfile", () => { + expect.assertions(1); + + const pkg = join(root, "packages", "app"); + + mkdirSync(pkg, { recursive: true }); + writeFileSync(join(root, "pnpm-workspace.yaml"), "packages:\n - 'packages/*'\n"); + + expect(findWorkspaceRootSync(pkg)).toBe(root); + }); + + it("accepts a package.json workspaces field", () => { + expect.assertions(1); + + const pkg = join(root, "packages", "app"); + + mkdirSync(pkg, { recursive: true }); + writeFileSync(join(root, "package.json"), JSON.stringify({ name: "root", workspaces: ["packages/*"] })); + + expect(findWorkspaceRootSync(pkg)).toBe(root); + }); +}); diff --git a/packages/tooling/vis/docs/commands/build.mdx b/packages/tooling/vis/docs/commands/build.mdx index 63de431a..1f19e6c4 100644 --- a/packages/tooling/vis/docs/commands/build.mdx +++ b/packages/tooling/vis/docs/commands/build.mdx @@ -41,4 +41,4 @@ vis build -- --mode production ## Scope -Without `--filter`, `vis build` builds every project that has a `build` target. +Run from a package directory, it builds that package. Run from the workspace root, it builds every project that has the target. `--filter` or `--projects` chooses projects explicitly. diff --git a/packages/tooling/vis/docs/commands/dev.mdx b/packages/tooling/vis/docs/commands/dev.mdx index a92dffd3..29ebc55b 100644 --- a/packages/tooling/vis/docs/commands/dev.mdx +++ b/packages/tooling/vis/docs/commands/dev.mdx @@ -41,7 +41,7 @@ vis dev -- --port 3000 ## Scope -Without `--filter`, `vis dev` starts every project that has a `dev` target. In a large workspace, pass `--filter` to start one package. +Run from a package directory, it starts that package. Run from the workspace root, it starts every project that has the target. `--filter` or `--projects` chooses projects explicitly. ## Entry files diff --git a/packages/tooling/vis/docs/commands/lint.mdx b/packages/tooling/vis/docs/commands/lint.mdx index 97b6fbbf..58858f3a 100644 --- a/packages/tooling/vis/docs/commands/lint.mdx +++ b/packages/tooling/vis/docs/commands/lint.mdx @@ -7,7 +7,7 @@ description: "Orchestrate detected source-code linters (eslint, …) across the Runs every detected source-code linter against the workspace, normalizes their output into a shared `Finding` shape, and prints them in human, JSON, or minimal format. Designed to add new linters over time without changing the command surface. -Ships with adapters for **oxlint**, **biome**, **ESLint**, **stylelint** (CSS), **ruff** (Python), **markdownlint-cli2** (Markdown), **shellcheck** (shell scripts), and an opt-in TypeScript **type check** (`tsc` / `tsgo`). The orchestrator runs each one that's present in the workspace. +Ships with adapters for **oxlint**, **biome**, **ESLint**, **Rslint**, **stylelint** (CSS), **ruff** (Python), **markdownlint-cli2** (Markdown), **shellcheck** (shell scripts), and an opt-in TypeScript **type check** (`tsc` / `tsgo`). The orchestrator runs each one that's present in the workspace. ## Usage @@ -117,13 +117,17 @@ The orchestrator probes each adapter against the workspace root and runs the one | oxlint | `.oxlintrc.json`, `.oxlintrc.jsonc`, `oxlint.json`, `oxlint.jsonc` | `oxlint` | | biome | `biome.json`, `biome.jsonc` | `@biomejs/biome` | | eslint | `eslint.config.{js,mjs,cjs,ts,mts}`, `.eslintrc`, `.eslintrc.{js,cjs,json,yaml,yml}` | `eslint` | +| rslint | `rslint.config.{ts,js,mjs,mts}` (a dependency alone is not enough: rslint needs a config) | `@rslint/core` | | stylelint | `stylelint.config.{ts,js,mjs,cjs}`, `.stylelintrc`, `.stylelintrc.{json,js,cjs,yml,yaml}` | `stylelint` | | ruff-check | `ruff.toml`, `.ruff.toml`, `pyproject.toml` with `[tool.ruff]` | `ruff`, `@astral-sh/ruff` | | markdownlint | `.markdownlint.{json,jsonc,yaml,yml}`, `.markdownlintrc[.{json,yaml,yml}]`, `.markdownlint-cli2.{jsonc,yaml,cjs,mjs}` | `markdownlint-cli2`, `markdownlint-cli` | | shellcheck | `.shellcheckrc` (system binary — not an npm package) | `shellcheck` | | typecheck | `tsconfig.json` at the workspace root (opt-in — see [Type checking](#type-checking)) | `typescript`, `@typescript/native-preview` | -When multiple lint adapters are detected, they run in this default precedence: `oxlint → biome → eslint → stylelint → ruff-check → markdownlint → shellcheck → typecheck`. The Rust-native oxlint pass runs first as a fast pre-filter, biome handles its bespoke rule set, ESLint catches what the others don't, stylelint covers CSS/SCSS, and the remaining adapters (ruff-check, markdownlint, shellcheck) plus the opt-in typecheck run after. Override with `lint.order` in `vis.config.ts`. +Rslint runs without its `--type-check` mode, so TypeScript type errors are reported by the typecheck adapter, not by rslint. Enable `typecheck` (it is opt-in) to get them. + + +When multiple lint adapters are detected, they run in this default precedence: `oxlint → biome → eslint → rslint → stylelint → ruff-check → markdownlint → shellcheck → typecheck`. The Rust-native oxlint pass runs first as a fast pre-filter, biome handles its bespoke rule set, ESLint catches what the others don't, stylelint covers CSS/SCSS, and the remaining adapters (ruff-check, markdownlint, shellcheck) plus the opt-in typecheck run after. Override with `lint.order` in `vis.config.ts`. ## Configuration diff --git a/packages/tooling/vis/docs/commands/pack.mdx b/packages/tooling/vis/docs/commands/pack.mdx index 65a4b208..bfde3c97 100644 --- a/packages/tooling/vis/docs/commands/pack.mdx +++ b/packages/tooling/vis/docs/commands/pack.mdx @@ -19,9 +19,9 @@ A project with a `packem.config.*` also gets `vis build`, the same command. `vis If no project defines a `pack` target, `vis pack` asks which library builder to set up: Rslib or packem. Your choice is the consent. vis installs the tool with `vis add -D` into the chosen project, and adds a `pack` script to its `package.json`. It also writes the tool's config file and `src/index.ts`, unless they already exist. - **Rslib** installs `@rslib/core` and `typescript`, and writes `rslib.config.mts` with ESM output. -- **packem** installs `@visulima/packem`, `esbuild` (the transformer packem's config uses), and `typescript`, and writes `packem.config.ts` with ESM output and `src/index.ts` as the entry. +- **packem** installs `@visulima/packem`, `esbuild` (the transformer packem's config uses), and `typescript`, and writes `packem.config.ts` with ESM output, `src/index.ts` as the entry, and warnings that do not fail the build. -A new packem library can fail its first build on `package.json` warnings: missing `exports`, `engines`, or `sideEffects`. packem exits non-zero on warnings by default. Add the fields it names, or set `failOnWarn: false` in `packem.config.ts`. +packem prints `package.json` warnings for a new library (missing `exports`, `engines`, or `sideEffects`), but the offered config sets `failOnWarn: false`, so they do not fail the build. Add the fields it names when you publish. Set `failOnWarn: true` to make them fail the build. In a non-interactive run (CI, or no terminal) it stops and lists the options instead. @@ -44,4 +44,4 @@ vis pack --filter @org/utils vis pack -- --sourcemap ``` -Like `vis dev` and `vis build`, vis treats the directory it runs from as the workspace root. Run it from the root, and use `--filter` when the workspace has several projects. +Run from a package directory, `vis pack` builds that package. Run from the workspace root, it builds every project that has a `pack` target. `--filter` chooses projects explicitly. Like the other commands, vis treats the directory you run from as the workspace root, and the nearest ancestor that declares a workspace is used. diff --git a/packages/tooling/vis/docs/commands/preview.mdx b/packages/tooling/vis/docs/commands/preview.mdx index dab72a02..48bc07e0 100644 --- a/packages/tooling/vis/docs/commands/preview.mdx +++ b/packages/tooling/vis/docs/commands/preview.mdx @@ -13,6 +13,8 @@ A package's own `preview` script always wins. Otherwise vis infers one from the - `rsbuild.config.*` → `rsbuild preview` - `rspack.config.*` → `rspack preview` +Run from a package directory, `vis preview` serves that package. Run from the workspace root, it serves every project that has a `preview` target. + Preview is a long-running server, so it runs in an interactive terminal. In CI or with piped input, vis skips it and reports that instead. Unlike `vis dev` and `vis build`, `vis preview` does not offer to install a tool. A preview needs a build first, so run `vis build` before it. diff --git a/packages/tooling/vis/schemas/vis-config.schema.json b/packages/tooling/vis/schemas/vis-config.schema.json index 84abaf01..d2b16ae3 100644 --- a/packages/tooling/vis/schemas/vis-config.schema.json +++ b/packages/tooling/vis/schemas/vis-config.schema.json @@ -420,6 +420,9 @@ "ruff-check": { "$ref": "#/$defs/LintFmtAdapterOverride" }, + "rslint": { + "$ref": "#/$defs/LintFmtAdapterOverride" + }, "shellcheck": { "$ref": "#/$defs/LintFmtAdapterOverride" }, @@ -1756,7 +1759,7 @@ "properties": { "minimum": { "type": "number", - "description": "Minimum overall Socket.dev score (0–1). Set to 0 to disable the gate while keeping Socket data fetched.\n\nConsulted by `vis add`, `audit`, `doctor`, `check`, and `update`; resolved once in `buildSocketOptions`, then threaded through every consumer. Falls back to `DEFAULT_LOW_SCORE_THRESHOLD` (`0.4`) when unset." + "description": "Minimum overall Socket.dev score (0–1). Set to 0 to disable the gate while keeping Socket data fetched.\n\nConsulted by `vis add`, `audit`, `doctor`, `outdated`, and `update`; resolved once in `buildSocketOptions`, then threaded through every consumer. Falls back to `DEFAULT_LOW_SCORE_THRESHOLD` (`0.4`) when unset." } }, "additionalProperties": false, @@ -3945,6 +3948,7 @@ "markdownlint", "oxlint", "ruff-check", + "rslint", "shellcheck", "stylelint", "typecheck" diff --git a/packages/tooling/vis/src/cli-main.ts b/packages/tooling/vis/src/cli-main.ts index a2b764dd..5c316f4a 100644 --- a/packages/tooling/vis/src/cli-main.ts +++ b/packages/tooling/vis/src/cli-main.ts @@ -6,7 +6,6 @@ import { createCerebro } from "@visulima/cerebro"; import { errorHandlerPlugin } from "@visulima/cerebro/plugins/error-handler"; import { readJsonSync } from "@visulima/fs"; -import { findMonorepoRootSync } from "@visulima/package"; import { join } from "@visulima/path"; import { runAndExit } from "./cli-run"; @@ -21,6 +20,7 @@ import registerCommands from "./register-commands"; import { parseEarlyCaCert } from "./util/ca-cert"; import getPackageVersion from "./util/package-version"; import { startUpgradeCheck } from "./util/upgrade-check"; +import { findWorkspaceRootSync } from "./util/workspace-root"; /** * Construct and run the full vis CLI. Invoked from `bin.ts` for every command @@ -43,7 +43,11 @@ export const runCli = async (): Promise => { // Set terminal title to the project name and stash the resolved root on an // env var so `config-loader.ts` doesn't walk the tree a second time. try { - const rootDir = findMonorepoRootSync(process.cwd()).path; + const rootDir = findWorkspaceRootSync(process.cwd()); + + if (rootDir === undefined) { + throw new Error("Could not determine workspace root"); + } process.env["VIS_MONOREPO_ROOT"] = rootDir; diff --git a/packages/tooling/vis/src/commands/build/handler.ts b/packages/tooling/vis/src/commands/build/handler.ts index fb62edb4..622e4fd0 100644 --- a/packages/tooling/vis/src/commands/build/handler.ts +++ b/packages/tooling/vis/src/commands/build/handler.ts @@ -1,7 +1,7 @@ import type { CommandExecute, Toolbox } from "@visulima/cerebro"; import type { TargetToolChoice, TargetToolDeps } from "../../util/target-tool"; -import { offerTargetTool } from "../../util/target-tool"; +import { offerTargetTool, scopedTarget } from "../../util/target-tool"; import runExecute from "../run/handler"; import type { BuildOptions } from "./index"; @@ -45,7 +45,7 @@ export const runBuild = async (toolbox: Toolbox, deps: Ta return; } - await runExecute({ ...toolbox, argument: ["build", ...(argument ?? []), ...(rawUnknown ?? [])], rawUnknown }); + await runExecute({ ...toolbox, argument: [scopedTarget("build", options, process.cwd(), workspaceRoot ?? process.cwd()), ...(argument ?? []), ...(rawUnknown ?? [])], rawUnknown }); }; const execute = (toolbox: Toolbox): Promise => runBuild(toolbox); diff --git a/packages/tooling/vis/src/commands/dev/handler.ts b/packages/tooling/vis/src/commands/dev/handler.ts index bfc6c0e6..4e97787f 100644 --- a/packages/tooling/vis/src/commands/dev/handler.ts +++ b/packages/tooling/vis/src/commands/dev/handler.ts @@ -1,7 +1,7 @@ import type { CommandExecute, Toolbox } from "@visulima/cerebro"; import type { TargetToolChoice, TargetToolDeps } from "../../util/target-tool"; -import { offerTargetTool } from "../../util/target-tool"; +import { offerTargetTool, scopedTarget } from "../../util/target-tool"; import runExecute from "../run/handler"; import type { DevOptions } from "./index"; @@ -39,7 +39,7 @@ export const runDev = async (toolbox: Toolbox, deps: Target return; } - await runExecute({ ...toolbox, argument: ["dev", ...(argument ?? []), ...(rawUnknown ?? [])], rawUnknown }); + await runExecute({ ...toolbox, argument: [scopedTarget("dev", options, process.cwd(), workspaceRoot ?? process.cwd()), ...(argument ?? []), ...(rawUnknown ?? [])], rawUnknown }); }; const execute = (toolbox: Toolbox): Promise => runDev(toolbox); diff --git a/packages/tooling/vis/src/commands/lint/handler.ts b/packages/tooling/vis/src/commands/lint/handler.ts index 158a5c68..1fbb2454 100644 --- a/packages/tooling/vis/src/commands/lint/handler.ts +++ b/packages/tooling/vis/src/commands/lint/handler.ts @@ -7,6 +7,7 @@ import { biomeAdapter } from "../../lint-fmt/adapters/biome"; import { eslintAdapter } from "../../lint-fmt/adapters/eslint"; import { markdownlintAdapter } from "../../lint-fmt/adapters/markdownlint"; import { oxlintAdapter } from "../../lint-fmt/adapters/oxlint"; +import { rslintAdapter } from "../../lint-fmt/adapters/rslint"; import { ruffCheckAdapter } from "../../lint-fmt/adapters/ruff"; import { shellcheckAdapter } from "../../lint-fmt/adapters/shellcheck"; import { stylelintAdapter } from "../../lint-fmt/adapters/stylelint"; @@ -33,6 +34,7 @@ const SOURCE_ADAPTERS = [ stylelintAdapter, ruffCheckAdapter, markdownlintAdapter, + rslintAdapter, shellcheckAdapter, typecheckAdapter, ]; @@ -60,7 +62,7 @@ const execute = async ({ logger, options, visConfig, workspaceRoot }: Toolbox = [ { files: [ { - contents: "import { defineConfig } from \"@visulima/packem/config\";\nimport transformer from \"@visulima/packem/transformer/esbuild\";\n\nexport default defineConfig({ emitESM: true, entries: [\"src/index.ts\"], runtime: \"node\", transformer });\n", + contents: "import { defineConfig } from \"@visulima/packem/config\";\nimport transformer from \"@visulima/packem/transformer/esbuild\";\n\nexport default defineConfig({ emitESM: true, entries: [\"src/index.ts\"], failOnWarn: false, runtime: \"node\", transformer });\n", file: "packem.config.ts", }, { contents: "export {};\n", file: "src/index.ts" }, @@ -45,7 +45,7 @@ export const runPack = async (toolbox: Toolbox, deps: Targ return; } - await runExecute({ ...toolbox, argument: ["pack", ...(argument ?? []), ...(rawUnknown ?? [])], rawUnknown }); + await runExecute({ ...toolbox, argument: [scopedTarget("pack", options, process.cwd(), workspaceRoot ?? process.cwd()), ...(argument ?? []), ...(rawUnknown ?? [])], rawUnknown }); }; const execute = (toolbox: Toolbox): Promise => runPack(toolbox); diff --git a/packages/tooling/vis/src/commands/preview/handler.ts b/packages/tooling/vis/src/commands/preview/handler.ts index 05f3d919..a139a928 100644 --- a/packages/tooling/vis/src/commands/preview/handler.ts +++ b/packages/tooling/vis/src/commands/preview/handler.ts @@ -1,11 +1,14 @@ import type { CommandExecute, Toolbox } from "@visulima/cerebro"; +import { scopedTarget } from "../../util/target-tool"; import runExecute from "../run/handler"; import type { PreviewOptions } from "./index"; /** `vis preview` is `vis run preview`. Unlike `dev` and `build`, it offers no tool: a preview needs a build first. */ -const execute = async ({ argument, rawUnknown, ...toolbox }: Toolbox): Promise => { - await runExecute({ ...toolbox, argument: ["preview", ...(argument ?? []), ...(rawUnknown ?? [])], rawUnknown }); +const execute = async ({ argument, options, rawUnknown, workspaceRoot, ...toolbox }: Toolbox): Promise => { + const target = scopedTarget("preview", options, process.cwd(), workspaceRoot ?? process.cwd()); + + await runExecute({ ...toolbox, argument: [target, ...(argument ?? []), ...(rawUnknown ?? [])], options, rawUnknown, workspaceRoot }); }; // fallow-ignore-next-line unused-export -- lazy-loaded command entry (cerebro loader/lazyNamed dynamic import) diff --git a/packages/tooling/vis/src/config/workspace.ts b/packages/tooling/vis/src/config/workspace.ts index 14712942..fb64a339 100644 --- a/packages/tooling/vis/src/config/workspace.ts +++ b/packages/tooling/vis/src/config/workspace.ts @@ -98,6 +98,8 @@ const findWorkspaceAbove = (start: string): string | undefined => { } }; +// The caller names the shape it expects; nothing here checks it at runtime. +// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-parameters const readJsonFileSafe = (filePath: string): T | undefined => { try { return readJsonSync(filePath) as T; diff --git a/packages/tooling/vis/src/inference/detectors/rslint.ts b/packages/tooling/vis/src/inference/detectors/rslint.ts index ebb3a384..d87ff3b3 100644 --- a/packages/tooling/vis/src/inference/detectors/rslint.ts +++ b/packages/tooling/vis/src/inference/detectors/rslint.ts @@ -1,7 +1,10 @@ +import { TOOL_SIGNATURES } from "../../util/tool-signatures"; import type { Detector } from "../types"; +// No `fallbackDependency`: rslint exits with "no rslint config found" when there is no +// config, so a dependency alone would synthesize a lint target that always fails. export const rslintDetector: Detector = { - configFiles: ["rslint.config.ts", "rslint.config.js", "rslint.config.mjs"], + configFiles: TOOL_SIGNATURES.rslint.configFiles, detect: ({ matchedConfigs }) => { return { targets: { @@ -23,6 +26,5 @@ export const rslintDetector: Detector = { }, }; }, - fallbackDependency: "@rslint/core", name: "rslint", }; diff --git a/packages/tooling/vis/src/lint-fmt/adapters/rslint.ts b/packages/tooling/vis/src/lint-fmt/adapters/rslint.ts new file mode 100644 index 00000000..0febd17c --- /dev/null +++ b/packages/tooling/vis/src/lint-fmt/adapters/rslint.ts @@ -0,0 +1,156 @@ +import { createHash } from "node:crypto"; + +import { resolve } from "@visulima/path"; + +import { TOOL_SIGNATURES } from "../../util/tool-signatures"; +import type { AdapterRunOptions, Finding, FindingSeverity, RunResult, ToolAdapter, ToolPresence } from "../config-types"; +import { declaredVersion, findFirstConfig } from "../detect"; + +/** + * Rslint adapter. Rust-native linter from the Rstack family; runs the + * typescript-eslint rule set and emits one JSON object per finding. + * + * Detection requires a config file. Rslint exits 1 with "no rslint config + * found" when there is none, so a dependency alone would make every + * `vis lint` run fail. + * + * Verified against `@rslint/core` 0.9.5: findings go to stdout, a warning's + * severity is "warn", `--max-warnings=N` works, and a broken config exits 1 + * with its reason on stderr and nothing on stdout. + */ + +const SEVERITY_MAP: Record = { + error: "error", + warn: "warning", +}; + +interface RslintPosition { + column?: number; + line?: number; +} + +interface RslintDiagnostic { + filePath?: string; + message?: string; + range?: { end?: RslintPosition; start?: RslintPosition }; + ruleName?: string; + severity?: string; +} + +/** The first non-empty line of stderr, which is where rslint explains a failure. */ +const firstStderrLine = (stderr: string): string | undefined => stderr.split("\n").map((line) => line.trim()).find((line) => line.length > 0); + +export const rslintAdapter: ToolAdapter = { + argsCheck: (files, options) => buildArgs(files, options, false), + argsFix: (files, options) => buildArgs(files, options, true), + + bin: () => ["pnpm", "exec", "rslint"], + + cacheKey: (presence, options) => { + const parts = [ + "rslint", + presence.declaredVersion ?? "unknown", + presence.configFile ?? "no-config", + options.maxWarnings === undefined ? "" : `mw=${options.maxWarnings}`, + options.quiet ? "q" : "", + ...(options.extraArgs ?? []), + ]; + + return createHash("sha256").update(parts.join("|")).digest("hex").slice(0, 16); + }, + + detect: (root, packageJson): ToolPresence | undefined => { + const configFile = findFirstConfig(root, TOOL_SIGNATURES.rslint.configFiles); + + if (!configFile) { + return undefined; + } + + const declared = declaredVersion(packageJson, TOOL_SIGNATURES.rslint.packageNames[0]); + + return { + adapter: "rslint", + configFile, + declared: Boolean(declared), + declaredVersion: declared, + root, + }; + }, + + extensions: ["cjs", "cts", "js", "jsx", "mjs", "mts", "ts", "tsx"], + id: "rslint", + kind: "lint", + + parse: (result: RunResult, presence: ToolPresence): Finding[] => { + const findings: Finding[] = []; + + // One JSON object per line. Lines that are not JSON are skipped rather than + // reported as errors: a summary line must not become a finding. + for (const line of result.stdout.split("\n")) { + const text = line.trim(); + + if (!text.startsWith("{")) { + continue; + } + + let diagnostic: RslintDiagnostic; + + try { + diagnostic = JSON.parse(text) as RslintDiagnostic; + } catch { + continue; + } + + findings.push({ + adapter: "rslint", + column: diagnostic.range?.start?.column, + endColumn: diagnostic.range?.end?.column, + endLine: diagnostic.range?.end?.line, + file: diagnostic.filePath === undefined ? presence.root : resolve(presence.root, diagnostic.filePath), + fixable: false, + line: diagnostic.range?.start?.line, + message: diagnostic.message ?? "", + ruleId: diagnostic.ruleName, + severity: SEVERITY_MAP[diagnostic.severity ?? ""] ?? "warning", + }); + } + + // A non-zero exit with no findings is a crash, such as a broken config. Report it + // as an error so the output does not read "no findings". + if (findings.length === 0 && result.exitCode !== 0 && result.exitCode !== null) { + findings.push({ + adapter: "rslint", + file: presence.root, + fixable: false, + message: firstStderrLine(result.stderr) ?? `rslint exited with code ${String(result.exitCode)} and no output`, + severity: "error", + }); + } + + return findings; + }, +}; + +const buildArgs = (files: ReadonlyArray, options: AdapterRunOptions, fix: boolean): string[] => { + const args = ["--format", "jsonline"]; + + if (fix) { + args.push("--fix"); + } + + if (options.quiet) { + args.push("--quiet"); + } + + if (typeof options.maxWarnings === "number") { + args.push(`--max-warnings=${String(options.maxWarnings)}`); + } + + if (options.extraArgs?.length) { + args.push(...options.extraArgs); + } + + args.push(...files); + + return args; +}; diff --git a/packages/tooling/vis/src/lint-fmt/config-types.ts b/packages/tooling/vis/src/lint-fmt/config-types.ts index 0212afbb..28612c25 100644 --- a/packages/tooling/vis/src/lint-fmt/config-types.ts +++ b/packages/tooling/vis/src/lint-fmt/config-types.ts @@ -18,6 +18,7 @@ export type AdapterId | "prettier" | "ruff-check" | "ruff-fmt" + | "rslint" | "shellcheck" | "stylelint" | "typecheck"; @@ -26,7 +27,7 @@ export type AdapterId export type FmtAdapterId = "biome" | "dprint" | "oxfmt" | "prettier" | "ruff-fmt"; /** Adapter IDs that can lint (adapter `kind` is `"lint"` or `"both"`). */ -export type LintAdapterId = "biome" | "eslint" | "markdownlint" | "oxlint" | "ruff-check" | "shellcheck" | "stylelint" | "typecheck"; +export type LintAdapterId = "biome" | "eslint" | "markdownlint" | "oxlint" | "ruff-check" | "rslint" | "shellcheck" | "stylelint" | "typecheck"; export type FindingSeverity = "error" | "info" | "warning"; diff --git a/packages/tooling/vis/src/lint-fmt/registry.ts b/packages/tooling/vis/src/lint-fmt/registry.ts index 790386b6..6bcf9051 100644 --- a/packages/tooling/vis/src/lint-fmt/registry.ts +++ b/packages/tooling/vis/src/lint-fmt/registry.ts @@ -26,6 +26,7 @@ const ADAPTER_ORDER: ReadonlyArray = [ "oxfmt", "biome", "eslint", + "rslint", "dprint", "prettier", "stylelint", diff --git a/packages/tooling/vis/src/plugins/config-loader.ts b/packages/tooling/vis/src/plugins/config-loader.ts index 362d3cf2..0390ef6e 100644 --- a/packages/tooling/vis/src/plugins/config-loader.ts +++ b/packages/tooling/vis/src/plugins/config-loader.ts @@ -4,7 +4,6 @@ import { createInterface } from "node:readline"; import type { Plugin } from "@visulima/cerebro"; import { bold, cyan, red, yellow } from "@visulima/colorize"; import { isAccessibleSync } from "@visulima/fs"; -import { findMonorepoRootSync } from "@visulima/package"; import { join, resolve } from "@visulima/path"; import isInCi from "is-in-ci"; import { satisfies, validRange } from "semver"; @@ -12,6 +11,7 @@ import { satisfies, validRange } from "semver"; import { findVisConfigFile, loadVisConfig } from "../config/config"; import type { VisConfig } from "../config/workspace"; import getPackageVersion from "../util/package-version"; +import { findWorkspaceRootSync } from "../util/workspace-root"; const DENO_FILES = ["deno.json", "deno.jsonc", "deno.lock"]; const JS_LOCKFILES = ["bun.lock", "bun.lockb", "npm-shrinkwrap.json", "package-lock.json", "pnpm-lock.yaml", "yarn.lock"]; @@ -49,7 +49,14 @@ const configLoaderPlugin: Plugin = { // walking the directory tree a second time. const cached = process.env["VIS_MONOREPO_ROOT"]; - workspaceRoot = cached && cached.length > 0 ? cached : findMonorepoRootSync(process.cwd()).path; + const found = cached && cached.length > 0 ? cached : findWorkspaceRootSync(process.cwd()); + + // The outer catch treats this message as a quiet "no workspace" result. + if (found === undefined) { + throw new Error("Could not find the monorepo root"); + } + + workspaceRoot = found; } toolbox.workspaceRoot = workspaceRoot; diff --git a/packages/tooling/vis/src/util/target-tool.ts b/packages/tooling/vis/src/util/target-tool.ts index b5c1c3d3..cb15b7e7 100644 --- a/packages/tooling/vis/src/util/target-tool.ts +++ b/packages/tooling/vis/src/util/target-tool.ts @@ -166,3 +166,17 @@ export const offerTargetTool = async (params: TargetToolParams, deps: TargetTool return true; }; + +/** + * The run selector for `dev`, `build`, `pack`, and `preview`. Run from a package + * directory with no `--filter` or `--projects`, it selects that package's target + * (`~:<target>`, the project closest to the current directory). Run from the workspace + * root, or with an explicit selection, it selects the target across the workspace. + */ +export const scopedTarget = (target: string, options: { filter?: ReadonlyArray; projects?: string }, cwd: string, workspaceRoot: string): string => { + if ((options.filter?.length ?? 0) > 0 || options.projects !== undefined) { + return target; + } + + return resolve(cwd) === resolve(workspaceRoot) ? target : `~:${target}`; +}; diff --git a/packages/tooling/vis/src/util/tips.ts b/packages/tooling/vis/src/util/tips.ts index 973cc793..efd9be2d 100644 --- a/packages/tooling/vis/src/util/tips.ts +++ b/packages/tooling/vis/src/util/tips.ts @@ -167,8 +167,8 @@ const tips: Tip[] = [ }, { id: "env-pin", - matches: (context) => context.command === "env" && context.args.includes("install") && context.success, - message: () => "Use 'vis env pin ' to pin the Node.js version for your project.", + matches: (context) => context.command === "toolchain" && context.args.includes("install") && context.success, + message: () => "Use 'vis toolchain pin node@' to pin the Node.js version for your project.", probability: 0.5, }, { diff --git a/packages/tooling/vis/src/util/tool-signatures.ts b/packages/tooling/vis/src/util/tool-signatures.ts index 706a9d6f..a89c4128 100644 --- a/packages/tooling/vis/src/util/tool-signatures.ts +++ b/packages/tooling/vis/src/util/tool-signatures.ts @@ -107,6 +107,10 @@ export const TOOL_SIGNATURES = { ], packageNames: ["prettier"], }, + rslint: { + configFiles: ["rslint.config.ts", "rslint.config.js", "rslint.config.mjs", "rslint.config.mts"], + packageNames: ["@rslint/core"], + }, ruff: { // pyproject.toml is *not* listed here — it only signals ruff // presence when it contains a `[tool.ruff]` table, and the diff --git a/packages/tooling/vis/src/util/workspace-root.ts b/packages/tooling/vis/src/util/workspace-root.ts new file mode 100644 index 00000000..57f77a44 --- /dev/null +++ b/packages/tooling/vis/src/util/workspace-root.ts @@ -0,0 +1,48 @@ +import { isAccessibleSync, readJsonSync } from "@visulima/fs"; +import { findMonorepoRootSync } from "@visulima/package"; +import { dirname, join } from "@visulima/path"; + +/** Whether `directory` declares a workspace, through `pnpm-workspace.yaml` or a `workspaces` field. */ +const declaresWorkspace = (directory: string): boolean => { + if (isAccessibleSync(join(directory, "pnpm-workspace.yaml"))) { + return true; + } + + if (!isAccessibleSync(join(directory, "package.json"))) { + return false; + } + + return (readJsonSync(join(directory, "package.json")) as { workspaces?: unknown }).workspaces !== undefined; +}; + +/** + * The workspace root for a command run from `start`: the nearest ancestor that declares a + * workspace. Only when no ancestor does is `findMonorepoRootSync` used. That finder takes the + * nearest lockfile as the root, so a package with its own lockfile was mistaken for the root, + * and a declared workspace without a lockfile was not found. + * @param start Directory the command was run from. + * @returns The workspace root, or `undefined` when neither method finds one. + */ +export const findWorkspaceRootSync = (start: string): string | undefined => { + let directory = start; + + for (;;) { + if (declaresWorkspace(directory)) { + return directory; + } + + const parent = dirname(directory); + + if (parent === directory) { + break; + } + + directory = parent; + } + + try { + return findMonorepoRootSync(start).path; + } catch { + return undefined; + } +};