diff --git a/packages/tooling/vis/README.md b/packages/tooling/vis/README.md index d18e1c92..95cbfc4e 100644 --- a/packages/tooling/vis/README.md +++ b/packages/tooling/vis/README.md @@ -221,6 +221,10 @@ 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 preview` | | Serve the production build: the package preview script, or the inferred Vite, Rsbuild, or Rspack preview | +| `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" | @@ -229,6 +233,7 @@ When aube is the installer, `vis doctor` surfaces aube's effective hardening pos | `vis outdated [packages]` | | 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..6f6ed6bc --- /dev/null +++ b/packages/tooling/vis/__tests__/commands/dev/dev-build-forwarding.test.ts @@ -0,0 +1,52 @@ +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 { 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 = { 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; + +describe("vis dev and vis build", () => { + beforeEach(() => { + runExecute.mockReset(); + }); + + it("runs the dev target through the run handler", async () => { + expect.assertions(1); + + await runDev(toolbox(undefined, undefined), DEFINED); + + expect(runExecute.mock.calls[0][0].argument).toStrictEqual(["dev"]); + }); + + it("forwards positional and flag arguments after the target", async () => { + expect.assertions(1); + + await runDev(toolbox(["web"], ["--port", "3000"]), DEFINED); + + expect(runExecute.mock.calls[0][0].argument).toStrictEqual(["dev", "web", "--port", "3000"]); + }); + + it("passes run's options through unchanged", async () => { + expect.assertions(1); + + await runBuild(toolbox(undefined, undefined, { filter: ["@org/web"], preflight: undefined }), DEFINED); + + expect(runExecute.mock.calls[0][0].options).toStrictEqual({ filter: ["@org/web"], preflight: undefined }); + }); + + it("runs the build target", async () => { + expect.assertions(1); + + 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..fa6a6cf5 --- /dev/null +++ b/packages/tooling/vis/__tests__/commands/dev/dev-build-offer.test.ts @@ -0,0 +1,64 @@ +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); + + await runDev(toolbox(root), { install: () => 0, isInteractive: () => false, projects: async () => [{ name: "@fix/app", root: join(root, "packages", "app"), targets: { dev: {} } }] }); + + expect(runExecute).toHaveBeenCalledTimes(1); + // 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 () => { + expect.assertions(2); + + 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__/commands/lazy-commands-contract.test.ts b/packages/tooling/vis/__tests__/commands/lazy-commands-contract.test.ts index 118c458a..390ebd3f 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,7 +35,10 @@ import listCommand from "../../src/commands/list"; import migrateCommands from "../../src/commands/migrate"; 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"; import removeCommand from "../../src/commands/remove"; import runCommand from "../../src/commands/run"; import sbomCommand from "../../src/commands/sbom"; @@ -56,11 +61,13 @@ const FLAT_COMMANDS: Command[] = [ analyzeCommand, approveBuildsCommand, auditCommand, + buildCommand, checkCommand, ciCommand, cleanCommand, createCommand, dedupeCommand, + devCommand, devcontainerCommand, dlxCommand, doctorCommand, @@ -75,8 +82,11 @@ const FLAT_COMMANDS: Command[] = [ linkCommand, listCommand, optimizeCommand, + packCommand, outdatedCommand, pmCommand, + previewCommand, + rebuildCommand, removeCommand, runCommand, sbomCommand, 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..3202f4ac --- /dev/null +++ b/packages/tooling/vis/__tests__/commands/pack/pack-offer.test.ts @@ -0,0 +1,77 @@ +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 }); + + // 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(); + }); + + 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__/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/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/__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..0885e841 --- /dev/null +++ b/packages/tooling/vis/__tests__/inference/rstack-detectors.test.ts @@ -0,0 +1,110 @@ +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("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); + + writeProject(scratch, "style", ["rslint.config.ts"]); + + expect(targetsOf("style")["lint"]?.command).toBe("rslint ."); + }); +}); 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/target-tool.test.ts b/packages/tooling/vis/__tests__/util/target-tool.test.ts new file mode 100644 index 00000000..2b5aeec7 --- /dev/null +++ b/packages/tooling/vis/__tests__/util/target-tool.test.ts @@ -0,0 +1,179 @@ +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"; + +import type { TargetToolChoice } from "../../src/util/target-tool"; +import { offerTargetTool } from "../../src/util/target-tool"; + +const CHOICES: ReadonlyArray = [ + { 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, mkdir, readFile, writeFile } as unknown as CerebroFs; + +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(() => { + 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, + 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(), { install, projects: withDev })).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(), { install, isInteractive: () => false, projects: oneProject })).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(), { 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(); + }); + + it("installs every package a tool needs", async () => { + expect.assertions(1); + + const install = vi.fn(() => 0); + + await offerTargetTool(params(), { install, isInteractive: () => true, projects: oneProject, readline: async () => "2" }); + + 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(), { install: () => 0, isInteractive: () => true, projects: oneProject, readline: async () => "1" }); + + 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); + + writeFileSync(join(app, "vite.config.mts"), "export default { base: '/app/' };\n"); + + await offerTargetTool(params(), { install: () => 0, isInteractive: () => true, projects: oneProject, readline: async () => "1" }); + + 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 () => { + expect.assertions(1); + + writeFileSync(join(app, "package.json"), "{\n\t\"name\": \"@fix/app\"\n}\n"); + + 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"); + }); + + it("stops without adding the script when an install fails", async () => { + expect.assertions(3); + + const install = vi.fn(() => 2); + + 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\""); + }); + + it("cancels without installing when the user declines", async () => { + expect.assertions(2); + + const install = vi.fn(() => 0); + + await expect(offerTargetTool(params(), { install, isInteractive: () => true, projects: oneProject, readline: async () => "" })).rejects.toThrow("No dev tool selected"); + expect(install).not.toHaveBeenCalled(); + }); + + 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); + + 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/__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 new file mode 100644 index 00000000..1f19e6c4 --- /dev/null +++ b/packages/tooling/vis/docs/commands/build.mdx @@ -0,0 +1,44 @@ +--- +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` + +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. + +## 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 + +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 new file mode 100644 index 00000000..29ebc55b --- /dev/null +++ b/packages/tooling/vis/docs/commands/dev.mdx @@ -0,0 +1,48 @@ +--- +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` + +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. + +## 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 + +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 + +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/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 new file mode 100644 index 00000000..bfde3c97 --- /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, `src/index.ts` as the entry, and warnings that do not fail the build. + +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. + +## 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 +``` + +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 new file mode 100644 index 00000000..48bc07e0 --- /dev/null +++ b/packages/tooling/vis/docs/commands/preview.mdx @@ -0,0 +1,39 @@ +--- +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` + +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. + +## 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/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 69d81767..3771fcdf 100644 --- a/packages/tooling/vis/docs/meta.json +++ b/packages/tooling/vis/docs/meta.json @@ -9,6 +9,10 @@ "commands/create", "commands/init", "commands/run", + "commands/dev", + "commands/preview", + "commands/pack", + "commands/build", "commands/exec", "commands/staged", "commands/affected", @@ -44,6 +48,7 @@ "commands/outdated", "commands/why", "commands/pm", + "commands/rebuild", "commands/info", "commands/doctor", "commands/audit", 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 new file mode 100644 index 00000000..622e4fd0 --- /dev/null +++ b/packages/tooling/vis/src/commands/build/handler.ts @@ -0,0 +1,54 @@ +import type { CommandExecute, Toolbox } from "@visulima/cerebro"; + +import type { TargetToolChoice, TargetToolDeps } from "../../util/target-tool"; +import { offerTargetTool, scopedTarget } from "../../util/target-tool"; +import runExecute from "../run/handler"; +import type { BuildOptions } from "./index"; + +/** Bundlers vis can set up when no project defines a `build` target. */ +export const BUILD_CHOICES: ReadonlyArray = [ + { + 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", + }, + { + 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", + }, + { + 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", + }, + { + 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", + }, +]; + +/** 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, options, rawUnknown, visConfig, workspaceRoot } = toolbox; + const ready = await offerTargetTool( + { choices: BUILD_CHOICES, filter: options.filter, fs, logger, target: "build", visConfig, workspaceRoot: workspaceRoot ?? process.cwd() }, + deps, + ); + + if (!ready) { + return; + } + + await runExecute({ ...toolbox, argument: [scopedTarget("build", options, process.cwd(), workspaceRoot ?? process.cwd()), ...(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/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..4e97787f --- /dev/null +++ b/packages/tooling/vis/src/commands/dev/handler.ts @@ -0,0 +1,48 @@ +import type { CommandExecute, Toolbox } from "@visulima/cerebro"; + +import type { TargetToolChoice, TargetToolDeps } from "../../util/target-tool"; +import { offerTargetTool, scopedTarget } from "../../util/target-tool"; +import runExecute from "../run/handler"; +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 = [ + { + 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", + }, + { + 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", + }, + { + 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", + }, +]; + +/** 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, options, rawUnknown, visConfig, workspaceRoot } = toolbox; + const ready = await offerTargetTool( + { choices: DEV_CHOICES, filter: options.filter, fs, logger, target: "dev", visConfig, workspaceRoot: workspaceRoot ?? process.cwd() }, + deps, + ); + + if (!ready) { + return; + } + + await runExecute({ ...toolbox, argument: [scopedTarget("dev", options, process.cwd(), workspaceRoot ?? process.cwd()), ...(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/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/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 \"@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\"], failOnWarn: false, 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: [scopedTarget("pack", options, process.cwd(), workspaceRoot ?? process.cwd()), ...(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/commands/preview/handler.ts b/packages/tooling/vis/src/commands/preview/handler.ts new file mode 100644 index 00000000..a139a928 --- /dev/null +++ b/packages/tooling/vis/src/commands/preview/handler.ts @@ -0,0 +1,15 @@ +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, 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) +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/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/config/workspace.ts b/packages/tooling/vis/src/config/workspace.ts index 85161213..fb64a339 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,30 @@ 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; + } +}; + +// 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; @@ -1100,7 +1123,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/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/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..56dd81a0 --- /dev/null +++ b/packages/tooling/vis/src/inference/detectors/rslib.ts @@ -0,0 +1,34 @@ +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", + }, + // `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", + }, + }, + }; + }, + 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..d87ff3b3 --- /dev/null +++ b/packages/tooling/vis/src/inference/detectors/rslint.ts @@ -0,0 +1,30 @@ +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: TOOL_SIGNATURES.rslint.configFiles, + 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", + }, + }, + }; + }, + 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/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 01432c40..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; @@ -141,7 +148,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/register-commands.ts b/packages/tooling/vis/src/register-commands.ts index 7f35dd60..77d3a452 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,7 +47,10 @@ import listCommand from "./commands/list"; import migrateCommands from "./commands/migrate"; 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"; import releaseCommands from "./commands/release"; import releaseRootCommand from "./commands/release/root-command"; import removeCommand from "./commands/remove"; @@ -83,6 +88,10 @@ const registerCommands = (cli: Cli): void => { // Workspace commands cli.addCommand(runCommand); + cli.addCommand(devCommand); + cli.addCommand(buildCommand); + cli.addCommand(previewCommand); + cli.addCommand(packCommand); cli.addCommand(ciCommand); cli.addCommand(graphCommand); cli.addCommand(actionGraphCommand); @@ -131,6 +140,7 @@ const registerCommands = (cli: Cli): void => { cli.addCommand(execCommand); cli.addCommand(xCommand); cli.addCommand(pmCommand); + cli.addCommand(rebuildCommand); // Project & environment commands cli.addCommand(initCommand); 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..cb15b7e7 --- /dev/null +++ b/packages/tooling/vis/src/util/target-tool.ts @@ -0,0 +1,182 @@ +/** + * 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 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, resolve } 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 { + /** 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`. */ + packages: ReadonlyArray; + /** Written to package.json under the target's name. */ + 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; + /** `--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" | "pack"; + visConfig: Parameters[1]; + workspaceRoot: string; +} + +export interface TargetToolDeps extends ChooseToolDeps { + /** 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>; +} + +/** 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); + + // Discovery stores project roots relative to the workspace root, not the current directory. + return Object.entries(workspace.projects).map(([name, project]) => { + return { + name, + root: resolve(root, project.root), + targets: project.targets, + }; + }); +}; + +/** + * 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 (matches.length === 1) { + return matches[0]!; + } + + 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.`); + } + + 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. */ +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 no project can take the script, or when the user cancels. + */ +export const offerTargetTool = async (params: TargetToolParams, deps: TargetToolDeps = {}): Promise => { + const { choices, fs, logger, target, visConfig, workspaceRoot } = params; + const projects = await (deps.projects ?? discoverProjects)(workspaceRoot, visConfig); + + if (projects.some((project) => project.targets?.[target] !== undefined)) { + return true; + } + + 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}.`, + 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 = 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, project.root, logger, deps.install); + + if (code !== 0) { + process.exitCode = code; + + return false; + } + } + + await addScript(fs, join(project.root, "package.json"), target, picked.script); + logger.info(`Added "${target}": "${picked.script}" to ${where}.`); + + 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; +}; + +/** + * 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-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; 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; + } +};