From f41d83fd10f01f7e7a2ed63d741b1796a8993e96 Mon Sep 17 00:00:00 2001 From: Daniel Bannert Date: Sat, 17 Oct 2026 23:07:37 +0200 Subject: [PATCH 1/6] feat(vis): add test command backed by Vitest `vis test` runs the project's tests with Vitest without needing a `test` script or a vis config entry. It finds the nearest `vitest` install by walking up from the current directory, so a hoisted install works from a workspace package. Arguments and unknown flags are forwarded to Vitest, `--watch` keeps Vitest in watch mode, and the exit code is Vitest's. Vitest is not bundled; the command errors with the `vis add -D vitest` hint when it is missing. Co-Authored-By: Claude Haiku 5.5 --- .../commands/test/resolve-vitest.test.ts | 47 +++++++++++++ packages/tooling/vis/docs/commands/test.mdx | 42 ++++++++++++ packages/tooling/vis/docs/meta.json | 1 + .../tooling/vis/src/commands/test/handler.ts | 67 +++++++++++++++++++ .../tooling/vis/src/commands/test/index.ts | 33 +++++++++ packages/tooling/vis/src/register-commands.ts | 2 + 6 files changed, 192 insertions(+) create mode 100644 packages/tooling/vis/__tests__/commands/test/resolve-vitest.test.ts create mode 100644 packages/tooling/vis/docs/commands/test.mdx create mode 100644 packages/tooling/vis/src/commands/test/handler.ts create mode 100644 packages/tooling/vis/src/commands/test/index.ts diff --git a/packages/tooling/vis/__tests__/commands/test/resolve-vitest.test.ts b/packages/tooling/vis/__tests__/commands/test/resolve-vitest.test.ts new file mode 100644 index 00000000..f0b7f879 --- /dev/null +++ b/packages/tooling/vis/__tests__/commands/test/resolve-vitest.test.ts @@ -0,0 +1,47 @@ +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { resolveVitestEntry } from "../../../src/commands/test/handler"; + +const readText = (path: string) => readFile(path, "utf8").catch(() => undefined); + +describe(resolveVitestEntry, () => { + let root: string; + + beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), "vis-test-resolve-")); + }); + + afterEach(async () => { + await rm(root, { force: true, recursive: true }); + }); + + it("returns the bin entry from a hoisted vitest install found in an ancestor", async () => { + expect.hasAssertions(); + + const vitestDirectory = join(root, "node_modules", "vitest"); + const packageDirectory = join(root, "packages", "app", "src"); + + await mkdir(vitestDirectory, { recursive: true }); + await mkdir(packageDirectory, { recursive: true }); + await writeFile(join(vitestDirectory, "package.json"), JSON.stringify({ bin: { vitest: "./vitest.mjs" } })); + + await expect(resolveVitestEntry(packageDirectory, readText)).resolves.toBe(join(vitestDirectory, "vitest.mjs")); + }); + + it("returns undefined when no ancestor has vitest installed", async () => { + expect.hasAssertions(); + + const packageDirectory = join(root, "app"); + + await mkdir(packageDirectory, { recursive: true }); + + // The temp directory lives under the OS tmpdir, which should not contain a + // vitest install in any ancestor; this guards against a false positive from + // the walk stopping early. + await expect(resolveVitestEntry(packageDirectory, readText)).resolves.toBeUndefined(); + }); +}); diff --git a/packages/tooling/vis/docs/commands/test.mdx b/packages/tooling/vis/docs/commands/test.mdx new file mode 100644 index 00000000..23081794 --- /dev/null +++ b/packages/tooling/vis/docs/commands/test.mdx @@ -0,0 +1,42 @@ +--- +title: "vis test" +description: "Run the workspace tests with Vitest, no test script required" +--- + +# vis test + +Runs the project's tests with [Vitest](https://vitest.dev). It needs no `test` script in `package.json` and no `vis.config.ts` entry: it finds the `vitest` install in the nearest `node_modules` (walking up from the current directory, so a hoisted install works from a workspace package) and runs it. + +Vitest is not bundled. Add it to your project first: + +```bash +vis add -D vitest +``` + +## Usage + +```bash +vis test [files…] [options] +``` + +Positional arguments and any other flags are forwarded to Vitest unchanged. + +## Examples + +```bash +# Run all tests once +vis test + +# Re-run tests on change (Vitest's watch mode) +vis test --watch + +# Run a single test file +vis test src/foo.test.ts + +# Forward flags to Vitest +vis test -- --reporter=dot +``` + +## Exit codes + +The exit code is Vitest's: `0` when every test passes, non-zero otherwise. diff --git a/packages/tooling/vis/docs/meta.json b/packages/tooling/vis/docs/meta.json index 50e7548c..69d81767 100644 --- a/packages/tooling/vis/docs/meta.json +++ b/packages/tooling/vis/docs/meta.json @@ -35,6 +35,7 @@ "commands/deps", "commands/lint", "commands/fmt", + "commands/test", "commands/update", "commands/upgrade", "commands/install", diff --git a/packages/tooling/vis/src/commands/test/handler.ts b/packages/tooling/vis/src/commands/test/handler.ts new file mode 100644 index 00000000..0f7a83af --- /dev/null +++ b/packages/tooling/vis/src/commands/test/handler.ts @@ -0,0 +1,67 @@ +import { spawnSync } from "node:child_process"; + +import type { CommandExecute, Toolbox } from "@visulima/cerebro"; +import { dirname, join } from "@visulima/path"; + +import { VisUserError } from "../../errors/vis-user-error"; +import type { TestOptions } from "./index"; + +/** Reads a file's text, or `undefined` when it does not exist. */ +export type ReadText = (path: string) => Promise; + +/** + * Locate the Vitest CLI entry by walking up from `start` through each parent's + * `node_modules/vitest`, so a test run from a workspace package still finds the + * hoisted install. Returns `undefined` when no ancestor has Vitest installed. + */ +export const resolveVitestEntry = async (start: string, readText: ReadText): Promise => { + let directory = start; + + for (;;) { + const packageDirectory = join(directory, "node_modules", "vitest"); + const manifestText = await readText(join(packageDirectory, "package.json")); + + if (manifestText !== undefined) { + const manifest = JSON.parse(manifestText) as { bin?: string | Record }; + const bin = typeof manifest.bin === "string" ? manifest.bin : manifest.bin?.["vitest"]; + + if (bin) { + return join(packageDirectory, bin); + } + } + + const parent = dirname(directory); + + if (parent === directory) { + return undefined; + } + + directory = parent; + } +}; + +const execute = async ({ argument, fs, options, rawUnknown }: Toolbox): Promise => { + const cwd = process.cwd(); + const vitestEntry = await resolveVitestEntry(cwd, (path) => fs.readFile(path, "utf8").catch(() => undefined)); + + if (vitestEntry === undefined) { + throw new VisUserError("Vitest is not installed. Add it with `vis add -D vitest`, then run `vis test` again."); + } + + // Vitest's `run` subcommand exits after one pass; without it Vitest watches, + // which is what `--watch` asks for. + const vitestArguments = [...(options.watch ? [] : ["run"]), ...(argument ?? []), ...(rawUnknown ?? [])]; + + const result = spawnSync(process.execPath, [vitestEntry, ...vitestArguments], { cwd, stdio: "inherit" }); + + if (result.error) { + throw result.error; + } + + if (result.status !== 0) { + process.exitCode = result.status ?? 1; + } +}; + +// fallow-ignore-next-line unused-export -- lazy-loaded command entry (cerebro loader/lazyNamed dynamic import) +export default execute as CommandExecute; diff --git a/packages/tooling/vis/src/commands/test/index.ts b/packages/tooling/vis/src/commands/test/index.ts new file mode 100644 index 00000000..f54da5f9 --- /dev/null +++ b/packages/tooling/vis/src/commands/test/index.ts @@ -0,0 +1,33 @@ +import type { InferOptions } from "@visulima/cerebro"; +import { defineCommand } from "@visulima/cerebro"; + +const testOptionDefinitions = { + watch: { + defaultValue: false, + description: "Keep Vitest running and re-run tests on change (Vitest's default watch mode)", + type: Boolean, + }, +} as const; + +const test = defineCommand({ + argument: { + description: "Test file paths or filters, forwarded to Vitest", + name: "files", + type: String, + }, + description: "Run the workspace tests with Vitest, no test script required", + examples: [ + ["vis test", "Run all tests once with Vitest"], + ["vis test --watch", "Re-run tests on change"], + ["vis test src/foo.test.ts", "Run a single test file"], + ["vis test -- --reporter=dot", "Forward flags after -- to Vitest"], + ], + group: "Run & Execute", + loader: () => import("./handler"), + name: "test", + options: testOptionDefinitions, +}); + +export default test; + +export type TestOptions = InferOptions; diff --git a/packages/tooling/vis/src/register-commands.ts b/packages/tooling/vis/src/register-commands.ts index 6f3ccfc2..27e68975 100644 --- a/packages/tooling/vis/src/register-commands.ts +++ b/packages/tooling/vis/src/register-commands.ts @@ -61,6 +61,7 @@ import stagedCommand from "./commands/staged"; import statusCommand from "./commands/status"; import syncCommand from "./commands/sync"; import taskWhyCommand from "./commands/task-why"; +import testCommand from "./commands/test"; import toolchainCommands from "./commands/toolchain"; import unlinkCommand from "./commands/unlink"; import updateCommand from "./commands/update"; @@ -97,6 +98,7 @@ const registerCommands = (cli: Cli): void => { cli.addCommand(statusCommand); cli.addCommand(dashboardCommand); cli.addCommand(syncCommand); + cli.addCommand(testCommand); cli.addCommand(listCommand); cli.addCommand(completionCommand); cli.addCommand(versionCommand); From 866e98ed92dc50e9b5d3bffec2350b7966d37801 Mon Sep 17 00:00:00 2001 From: Daniel Bannert Date: Sat, 17 Oct 2026 23:07:46 +0200 Subject: [PATCH 2/6] feat(vis)!: make vis check a lint, format, and type gate `vis check` now runs `vis lint` and then `vis fmt --check` over the same scope, so one command gates a change on every source check. The type check runs too when TypeScript is detected, unless `lint.adapters.typecheck.enabled` is set to `false`. `--fix`, `--since`, `--staged`, and file arguments are forwarded to both tools. The catalog dependency check moves to `vis outdated`, which is now its primary name. It keeps its options, including `--security-config`. A bare file-like argument that does not exist (for example `vis check react`) is rejected with a pointer to `vis outdated`, so a dependency name cannot be skipped silently as a path. BREAKING CHANGE: `vis check` no longer reports outdated dependencies. Replace `vis check` with `vis outdated` in scripts, CI, and hooks, and replace `vis check ` and flags such as `--exit-code`, `--security`, and `--format json` the same way. The `c` alias is removed from the dependency check, so `vis c` no longer runs anything. Old flags on `vis check` fail as unknown options. Co-Authored-By: Claude Haiku 5.5 --- packages/tooling/vis/README.md | 10 +- .../commands/check/check-gate.test.ts | 100 ++++++ .../commands/lazy-commands-contract.test.ts | 2 + .../outdated-sync-guard.test.ts} | 8 +- .../tooling/vis/__tests__/util/tips.test.ts | 2 +- packages/tooling/vis/docs/commands/check.mdx | 94 ++---- packages/tooling/vis/docs/commands/info.mdx | 2 +- .../tooling/vis/docs/commands/outdated.mdx | 91 ++++-- .../vis/docs/guides/best-practices.mdx | 38 +-- packages/tooling/vis/docs/guides/ci-cd.mdx | 12 +- .../tooling/vis/docs/guides/config-model.mdx | 4 +- .../tooling/vis/docs/guides/git-hooks.mdx | 4 +- packages/tooling/vis/docs/quick-start.mdx | 6 +- .../examples/sample-workspace/vis.config.ts | 2 +- .../examples/service-preflight/vis.config.ts | 2 +- .../src/commands/approve-builds/handler.ts | 2 +- .../tooling/vis/src/commands/check/handler.ts | 296 ++++-------------- .../tooling/vis/src/commands/check/index.ts | 94 +----- .../vis/src/commands/doctor/handler.ts | 2 +- .../vis/src/commands/outdated/handler.ts | 251 +++++++++++++++ .../vis/src/commands/outdated/index.ts | 106 +++++++ packages/tooling/vis/src/config/config.ts | 2 +- .../tooling/vis/src/config/types/security.ts | 2 +- .../tooling/vis/src/plugins/config-loader.ts | 2 +- .../vis/src/plugins/security-enforcement.ts | 2 +- packages/tooling/vis/src/register-commands.ts | 2 + .../tooling/vis/src/security/config-check.ts | 2 +- .../tooling/vis/src/util/catalog/registry.ts | 2 +- packages/tooling/vis/src/util/tips.ts | 6 +- 29 files changed, 693 insertions(+), 455 deletions(-) create mode 100644 packages/tooling/vis/__tests__/commands/check/check-gate.test.ts rename packages/tooling/vis/__tests__/commands/{check/check-sync-guard.test.ts => outdated/outdated-sync-guard.test.ts} (69%) create mode 100644 packages/tooling/vis/src/commands/outdated/handler.ts create mode 100644 packages/tooling/vis/src/commands/outdated/index.ts diff --git a/packages/tooling/vis/README.md b/packages/tooling/vis/README.md index 2f9df02c..2ab22762 100644 --- a/packages/tooling/vis/README.md +++ b/packages/tooling/vis/README.md @@ -142,11 +142,14 @@ vis affected test --base=main # Visualize the project dependency graph vis graph -# Check for outdated catalog dependencies +# Lint, format, and type-check the workspace vis check +# Check for outdated catalog dependencies +vis outdated + # Check with security vulnerability scanning -vis check --security +vis outdated --security # Update catalog dependencies interactively, plus any # GitHub Actions, Dockerfile, docker-compose, or GitLab CI @@ -222,7 +225,8 @@ When aube is the installer, `vis doctor` surfaces aube's effective hardening pos | `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" | | `vis graph` | | Visualize the project dependency graph | -| `vis check [packages]` | `c` | Check for outdated dependencies in workspace catalogs | +| `vis check [files]` | | Lint, check formatting, and type-check the workspace (`--fix` applies changes) | +| `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 info ` | `view` | Show npm registry metadata for a package (wraps `npm view` et al.) | diff --git a/packages/tooling/vis/__tests__/commands/check/check-gate.test.ts b/packages/tooling/vis/__tests__/commands/check/check-gate.test.ts new file mode 100644 index 00000000..8361a2ad --- /dev/null +++ b/packages/tooling/vis/__tests__/commands/check/check-gate.test.ts @@ -0,0 +1,100 @@ +import { access, mkdir, mkdtemp, rm, 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 lint = vi.hoisted(() => vi.fn()); +const fmt = vi.hoisted(() => vi.fn()); + +vi.mock(import("../../../src/commands/lint/handler"), () => { return { default: lint }; }); +vi.mock(import("../../../src/commands/fmt/handler"), () => { return { default: fmt }; }); + +const { default: checkExecute } = await import("../../../src/commands/check/handler"); + +let root: string; + +const toolbox = (options: Record, extra: Record = {}) => + ({ + argument: [], + fs: { access }, + logger: { info: vi.fn(), warn: vi.fn() }, + options: { fix: false, since: undefined, staged: false, ...options }, + visConfig: undefined, + workspaceRoot: root, + ...extra, + }) as never; + +const writeTypeScriptWorkspace = async (): Promise => { + await writeFile(join(root, "package.json"), JSON.stringify({ devDependencies: { typescript: "^5.0.0" } })); + await writeFile(join(root, "tsconfig.json"), "{}"); +}; + +describe("vis check", () => { + beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), "vis-check-gate-")); + lint.mockReset(); + fmt.mockReset(); + }); + + afterEach(async () => { + await rm(root, { force: true, recursive: true }); + }); + + it("enables the type check when TypeScript is detected", async () => { + expect.assertions(1); + + await writeTypeScriptWorkspace(); + await checkExecute(toolbox({})); + + expect(lint.mock.calls[0][0].visConfig.lint.adapters.typecheck.enabled).toBe(true); + }); + + it("leaves the type check off when TypeScript is not detected", async () => { + expect.assertions(1); + + await checkExecute(toolbox({})); + + expect(lint.mock.calls[0][0].visConfig.lint.adapters.typecheck.enabled).toBe(false); + }); + + it("respects an explicit typecheck opt-out in config", async () => { + expect.assertions(1); + + await writeTypeScriptWorkspace(); + await checkExecute(toolbox({}, { visConfig: { lint: { adapters: { typecheck: { enabled: false } } } } })); + + expect(lint.mock.calls[0][0].visConfig.lint.adapters.typecheck.enabled).toBe(false); + }); + + it("runs fmt in check mode by default and in fix mode with --fix", async () => { + expect.assertions(3); + + await checkExecute(toolbox({})); + await checkExecute(toolbox({ fix: true })); + + expect(fmt.mock.calls[0][0].options.check).toBe(true); + expect(fmt.mock.calls[1][0].options.check).toBe(false); + expect(lint.mock.calls[1][0].options.fix).toBe(true); + }); + + it("forwards file arguments and git scope to both tools", async () => { + expect.assertions(2); + + await mkdir(join(root, "src")); + await writeFile(join(root, "src", "foo.ts"), ""); + await checkExecute(toolbox({ staged: true }, { argument: ["src/foo.ts"] })); + + expect(lint.mock.calls[0][0].options).toMatchObject({ _: ["src/foo.ts"], staged: true }); + expect(fmt.mock.calls[0][0].options).toMatchObject({ _: ["src/foo.ts"], staged: true }); + }); + + it("rejects a dependency name passed as a path, pointing at vis outdated", async () => { + expect.assertions(2); + + const run = checkExecute(toolbox({}, { argument: ["react"] })); + + await expect(run).rejects.toThrow("Dependency checks moved to `vis outdated`."); + expect(lint).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 36c61c6d..118c458a 100644 --- a/packages/tooling/vis/__tests__/commands/lazy-commands-contract.test.ts +++ b/packages/tooling/vis/__tests__/commands/lazy-commands-contract.test.ts @@ -32,6 +32,7 @@ import linkCommand from "../../src/commands/link"; 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 pmCommand from "../../src/commands/pm"; import removeCommand from "../../src/commands/remove"; import runCommand from "../../src/commands/run"; @@ -74,6 +75,7 @@ const FLAT_COMMANDS: Command[] = [ linkCommand, listCommand, optimizeCommand, + outdatedCommand, pmCommand, removeCommand, runCommand, diff --git a/packages/tooling/vis/__tests__/commands/check/check-sync-guard.test.ts b/packages/tooling/vis/__tests__/commands/outdated/outdated-sync-guard.test.ts similarity index 69% rename from packages/tooling/vis/__tests__/commands/check/check-sync-guard.test.ts rename to packages/tooling/vis/__tests__/commands/outdated/outdated-sync-guard.test.ts index 973eb010..87bab88c 100644 --- a/packages/tooling/vis/__tests__/commands/check/check-sync-guard.test.ts +++ b/packages/tooling/vis/__tests__/commands/outdated/outdated-sync-guard.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from "vitest"; -import checkExecute from "../../../src/commands/check/handler"; +import outdatedExecute from "../../../src/commands/outdated/handler"; const SYNC_WITHOUT_CONFIG_PATTERN = /--sync requires --security-config/; @@ -13,18 +13,18 @@ const toolbox = (options: Record) => workspaceRoot: "/tmp/vis-check-guard", }) as never; -describe("vis check --sync", () => { +describe("vis outdated --sync", () => { it("rejects --sync when --security-config is absent", async () => { expect.assertions(1); // `--sync` only acts inside the security-config branch, so accepting it // alone is a command that reports success without doing the work. - await expect(checkExecute(toolbox({ sync: true }))).rejects.toThrow(SYNC_WITHOUT_CONFIG_PATTERN); + await expect(outdatedExecute(toolbox({ sync: true }))).rejects.toThrow(SYNC_WITHOUT_CONFIG_PATTERN); }); it("does not reject --security-config on its own", async () => { expect.assertions(1); - await expect(checkExecute(toolbox({ securityConfig: true }))).rejects.not.toThrow(SYNC_WITHOUT_CONFIG_PATTERN); + await expect(outdatedExecute(toolbox({ securityConfig: true }))).rejects.not.toThrow(SYNC_WITHOUT_CONFIG_PATTERN); }); }); diff --git a/packages/tooling/vis/__tests__/util/tips.test.ts b/packages/tooling/vis/__tests__/util/tips.test.ts index a25ec9c1..7ad34a4e 100644 --- a/packages/tooling/vis/__tests__/util/tips.test.ts +++ b/packages/tooling/vis/__tests__/util/tips.test.ts @@ -139,7 +139,7 @@ describe("tip matching", () => { const tip = tips.find((t) => t.id === "security-check")!; - expect(tip.matches(makeContext("check", ["--security"]))).toBe(false); + expect(tip.matches(makeContext("outdated", ["--security"]))).toBe(false); }); it("ai-analysis should not match when --ai already present", () => { diff --git a/packages/tooling/vis/docs/commands/check.mdx b/packages/tooling/vis/docs/commands/check.mdx index 711121ca..81933623 100644 --- a/packages/tooling/vis/docs/commands/check.mdx +++ b/packages/tooling/vis/docs/commands/check.mdx @@ -1,91 +1,51 @@ --- title: "vis check" -description: "Check for outdated dependencies, security vulnerabilities, and supply chain settings" +description: "Run every lint, format, and type check across the workspace" --- # vis check -Check for outdated dependencies, known security vulnerabilities, and supply-chain -settings. On **pnpm** / **bun** workspaces it checks catalog dependencies; on -**npm** / **yarn** it checks `dependencies` / `devDependencies` (scope with `--dev` / -`--prod`). +Runs `vis lint` and then `vis fmt --check` over the same scope, so one command gates a change on every source check. The TypeScript type check runs too when TypeScript is detected in the workspace, unless `lint.adapters.typecheck.enabled` is set to `false`. -**Aliases:** `vis c`, `vis outdated` +Dependency checks moved to [`vis outdated`](./outdated). ## Usage ```bash -vis check [packages] [options] +vis check [files…] [options] ``` +With no files, the whole workspace is checked. Pass file paths to check a subset. + ## Examples ```bash -vis check # Check all catalog dependencies -vis check react # Check specific packages -vis check --target minor # Only show minor/patch updates -vis check --exclude '@types/*' # Exclude packages by pattern -vis check --no-security # Skip vulnerability scanning -vis check --security-config # Audit supply-chain security settings -vis check --security-config --sync # Sync security config to pnpm-workspace.yaml -vis check --format json # Machine-readable output -vis check --exit-code # Exit 1 if outdated (for CI) -``` +# Lint, check formatting, and type-check the workspace +vis check -## Options +# Apply lint fixes and formatting in place +vis check --fix + +# Check a specific file +vis check src/foo.ts -| Option | Alias | Default | Description | -| -------------------- | ----- | -------- | ------------------------------------------------------------------------------------- | -| `--target` | `-t` | `latest` | Update target: `latest`, `minor`, or `patch` | -| `--include` | | | Glob pattern to include packages (repeatable) | -| `--exclude` | | | Glob pattern to exclude packages (repeatable) | -| `--prerelease` | | `false` | Include prerelease versions | -| `--no-security` | | `false` | Skip security vulnerability scanning | -| `--security-config` | | `false` | Audit supply-chain security settings | -| `--sync` | | `false` | Sync security settings to `pnpm-workspace.yaml` (pnpm only, with `--security-config`) | -| `--format` | | `table` | Output format: `table`, `json`, or `minimal` | -| `--exit-code` | | `false` | Exit with code 1 if outdated dependencies found (for CI) | -| `--ai` | | `false` | Run AI analysis on outdated packages | -| `--ai-type` | | | AI analysis type: `impact`, `security`, `compatibility`, or `recommend` | -| `--dev` | `-D` | `false` | Check only `devDependencies` (npm/yarn mode; conflicts with `--prod`) | -| `--prod` | `-P` | `false` | Check only `dependencies` (npm/yarn mode; conflicts with `--dev`) | -| `--peer` | | `false` | Include `peerDependencies` in outdated checks | -| `--include-internal` | | `false` | Also check workspace-owned package names against the registry | - -## Output Formats - -### Table (default) - -Displays a color-coded table showing package name, catalog, current version, target version, and update type (patch/minor/major). - -### JSON - -Machine-readable JSON output including both outdated entries and any packages that failed to fetch: - -```json -{ - "failed": [], - "outdated": [ - { - "packageName": "typescript", - "catalog": "default", - "currentVersion": "5.3.0", - "targetVersion": "5.7.0", - "updateType": "minor" - } - ] -} +# Only check files staged in the git index +vis check --staged + +# Only check files changed vs the main branch +vis check --since main ``` -### Minimal +## Options -Compact one-line-per-package output, suitable for scripting. +| Option | Default | Description | +| ---------- | ------- | -------------------------------------------------------------- | +| `--fix` | `false` | Apply lint fixes and formatting in place instead of reporting | +| `--since` | | Only check files changed vs the given git ref | +| `--staged` | `false` | Only check files currently staged in the git index | -## CI Usage +For per-tool output formats (JSON, SARIF, JUnit, GitHub), run `vis lint` or `vis fmt` directly. -Use `--exit-code` to fail CI when outdated dependencies are found: +## Exit codes -```yaml -- name: Check dependencies - run: vis check --exit-code --format json > deps-report.json -``` +Exits `1` when the linter reports errors, the type check fails, or the formatter would change a file (in check mode, without `--fix`). diff --git a/packages/tooling/vis/docs/commands/info.mdx b/packages/tooling/vis/docs/commands/info.mdx index f9992ff2..8f023d8d 100644 --- a/packages/tooling/vis/docs/commands/info.mdx +++ b/packages/tooling/vis/docs/commands/info.mdx @@ -62,4 +62,4 @@ vis view react ## See also - `vis why ` — why a package is installed in _your_ workspace (different question). -- `vis check` / `vis outdated` — compare installed versions against the registry's latest. +- `vis outdated` — compare installed versions against the registry's latest. diff --git a/packages/tooling/vis/docs/commands/outdated.mdx b/packages/tooling/vis/docs/commands/outdated.mdx index 4be8bd87..2cb4a24e 100644 --- a/packages/tooling/vis/docs/commands/outdated.mdx +++ b/packages/tooling/vis/docs/commands/outdated.mdx @@ -1,39 +1,90 @@ --- title: "vis outdated" -description: "Check for outdated packages using the detected package manager" +description: "Check for outdated dependencies, security vulnerabilities, and supply chain settings" --- # vis outdated -Report packages whose installed version is behind the latest published version. Delegates to the detected PM (`pnpm outdated`, `npm outdated`, `yarn outdated`, `bun outdated`) and normalizes the output. +Check for outdated dependencies, known security vulnerabilities, and supply-chain +settings. On **pnpm** / **bun** workspaces it checks catalog dependencies; on +**npm** / **yarn** it checks `dependencies` / `devDependencies` (scope with `--dev` / +`--prod`). + ## Usage ```bash -vis outdated [packages...] [options] +vis outdated [packages] [options] ``` ## Examples ```bash -vis outdated -vis outdated react -vis outdated --format json -vis outdated -r # all workspaces -vis outdated --compatible # only semver-compatible updates (pnpm) +vis outdated # Check all catalog dependencies +vis outdated react # Check specific packages +vis outdated --target minor # Only show minor/patch updates +vis outdated --exclude '@types/*' # Exclude packages by pattern +vis outdated --no-security # Skip vulnerability scanning +vis outdated --security-config # Audit supply-chain security settings +vis outdated --security-config --sync # Sync security config to pnpm-workspace.yaml +vis outdated --format json # Machine-readable output +vis outdated --exit-code # Exit 1 if outdated (for CI) ``` ## Options -| Option | Alias | Default | Description | -| ------------------ | ----- | ------- | --------------------------------------------- | -| `--long` | | `false` | Show extended information | -| `--format` | | `table` | Output format: `table`, `list`, or `json` | -| `--recursive` | `-r` | `false` | Check all workspaces | -| `--workspace-root` | `-w` | `false` | Include workspace root | -| `--prod` | `-P` | `false` | Production dependencies only (pnpm) | -| `--dev` | `-D` | `false` | Dev dependencies only (pnpm) | -| `--no-optional` | | `false` | Exclude optional dependencies (pnpm) | -| `--compatible` | | `false` | Show only semver-compatible updates (pnpm) | -| `--global` | `-g` | `false` | Check globally installed packages | -| `--filter` | `-F` | | Filter by workspace package name (repeatable) | +| Option | Alias | Default | Description | +| -------------------- | ----- | -------- | ------------------------------------------------------------------------------------- | +| `--target` | `-t` | `latest` | Update target: `latest`, `minor`, or `patch` | +| `--include` | | | Glob pattern to include packages (repeatable) | +| `--exclude` | | | Glob pattern to exclude packages (repeatable) | +| `--prerelease` | | `false` | Include prerelease versions | +| `--no-security` | | `false` | Skip security vulnerability scanning | +| `--security-config` | | `false` | Audit supply-chain security settings | +| `--sync` | | `false` | Sync security settings to `pnpm-workspace.yaml` (pnpm only, with `--security-config`) | +| `--format` | | `table` | Output format: `table`, `json`, or `minimal` | +| `--exit-code` | | `false` | Exit with code 1 if outdated dependencies found (for CI) | +| `--ai` | | `false` | Run AI analysis on outdated packages | +| `--ai-type` | | | AI analysis type: `impact`, `security`, `compatibility`, or `recommend` | +| `--dev` | `-D` | `false` | Check only `devDependencies` (npm/yarn mode; conflicts with `--prod`) | +| `--prod` | `-P` | `false` | Check only `dependencies` (npm/yarn mode; conflicts with `--dev`) | +| `--peer` | | `false` | Include `peerDependencies` in outdated checks | +| `--include-internal` | | `false` | Also check workspace-owned package names against the registry | + +## Output Formats + +### Table (default) + +Displays a color-coded table showing package name, catalog, current version, target version, and update type (patch/minor/major). + +### JSON + +Machine-readable JSON output including both outdated entries and any packages that failed to fetch: + +```json +{ + "failed": [], + "outdated": [ + { + "packageName": "typescript", + "catalog": "default", + "currentVersion": "5.3.0", + "targetVersion": "5.7.0", + "updateType": "minor" + } + ] +} +``` + +### Minimal + +Compact one-line-per-package output, suitable for scripting. + +## CI Usage + +Use `--exit-code` to fail CI when outdated dependencies are found: + +```yaml +- name: Check dependencies + run: vis outdated --exit-code --format json > deps-report.json +``` diff --git a/packages/tooling/vis/docs/guides/best-practices.mdx b/packages/tooling/vis/docs/guides/best-practices.mdx index 8f2b16bd..95b19cb2 100644 --- a/packages/tooling/vis/docs/guides/best-practices.mdx +++ b/packages/tooling/vis/docs/guides/best-practices.mdx @@ -33,7 +33,7 @@ Keep your `vis.json` configuration consistent across team members by committing **Pre-Review Checklist:** -- Run `vis check --dry-run` to preview changes +- Run `vis outdated --dry-run` to preview changes - Verify no breaking changes in major version updates - Test critical functionality after dependency updates - Review CHANGELOG files for updated packages @@ -97,7 +97,7 @@ registry=https://npm.company.com/ @types:registry=https://registry.npmjs.org/ ``` -`vis check` and `vis update` automatically read `.npmrc` configuration, including scoped registry mappings and authentication tokens. +`vis outdated` and `vis update` automatically read `.npmrc` configuration, including scoped registry mappings and authentication tokens. ### Audit Trail and Reporting @@ -105,10 +105,10 @@ Maintain records of dependency changes: ```bash # Generate dependency reports -vis check --format json > dependency-report.json +vis outdated --format json > dependency-report.json # Include in CI/CD pipeline -vis check --format json > artifacts/deps-$(date +%Y%m%d).json +vis outdated --format json > artifacts/deps-$(date +%Y%m%d).json ``` ## Release Workflows @@ -121,7 +121,7 @@ Align dependency updates with your release cycle: ```bash # Check for updates without applying -vis check --target patch +vis outdated --target patch # Update only patch versions during freeze vis update --target patch --exclude "major-framework-*" @@ -171,7 +171,7 @@ vis update --rollback ```bash # Check for security vulnerabilities -vis check --security +vis outdated --security # Review and apply security fixes vis update --security --interactive @@ -203,9 +203,9 @@ For workspaces with many catalog entries, filter by scope: ```bash # Process by categories -vis check --include "@company/ui-*" -vis check --include "@company/api-*" -vis check --include "@types/*" --target latest +vis outdated --include "@company/ui-*" +vis outdated --include "@company/api-*" +vis outdated --include "@types/*" --target latest # Use filtering for large operations vis update --include "react*" --exclude "*-experimental" @@ -275,11 +275,11 @@ pnpm install ```json { "scripts": { - "deps:check": "vis check", + "deps:check": "vis outdated", "deps:update:patch": "vis update --target patch", "deps:update:minor": "vis update --target minor --interactive", - "deps:security": "vis check --security", - "prerelease": "vis check && vis check --security" + "deps:security": "vis outdated --security", + "prerelease": "vis outdated && vis outdated --security" } } ``` @@ -292,8 +292,8 @@ vis hook install # Create a pre-commit hook echo '#!/bin/sh -vis check --exit-code --format minimal || { - echo "Dependency issues detected. Run vis check for details." +vis outdated --exit-code --format minimal || { + echo "Dependency issues detected. Run vis outdated for details." exit 1 }' > .vis/hooks/pre-commit chmod +x .vis/hooks/pre-commit @@ -310,26 +310,26 @@ vis hook migrate ### Daily Workflow -- Check for security updates: `vis check --security` -- Review outdated dependencies: `vis check` +- Check for security updates: `vis outdated --security` +- Review outdated dependencies: `vis outdated` - Update patch versions: `vis update --target patch` ### Weekly Workflow -- Comprehensive dependency check: `vis check` +- Comprehensive dependency check: `vis outdated` - Update minor versions: `vis update --target minor --interactive` - Review and update exclusion rules in `vis.json` ### Monthly Workflow -- Review major version updates: `vis check --target latest` +- Review major version updates: `vis outdated --target latest` - Update development dependencies: `vis update --dev` - Audit dependency licenses and compliance - Review and optimize `vis.json` configuration ### Before Releases -- Run full security audit: `vis check --security` +- Run full security audit: `vis outdated --security` - Preview all changes: `vis update --dry-run` - Update in staging: `vis update --target minor` - Test thoroughly, then rollback if needed: `vis update --rollback` diff --git a/packages/tooling/vis/docs/guides/ci-cd.mdx b/packages/tooling/vis/docs/guides/ci-cd.mdx index a4801030..ffe00dda 100644 --- a/packages/tooling/vis/docs/guides/ci-cd.mdx +++ b/packages/tooling/vis/docs/guides/ci-cd.mdx @@ -64,10 +64,10 @@ jobs: - run: pnpm install --frozen-lockfile - name: Check for outdated dependencies - run: vis check --format json > deps-report.json + run: vis outdated --format json > deps-report.json - name: Security audit - run: vis check --security --exit-code + run: vis outdated --security --exit-code - name: Upload report if: always() @@ -326,14 +326,14 @@ Use `--exit-code` to fail the pipeline when outdated dependencies are found: ```yaml - name: Enforce patch updates - run: vis check --target patch --exit-code + run: vis outdated --target patch --exit-code ``` Use `--security` to catch vulnerabilities: ```yaml - name: Security gate - run: vis check --security --exit-code + run: vis outdated --security --exit-code ``` ## Keeping Pipelines Pinned @@ -445,6 +445,6 @@ Export JSON reports for monitoring dashboards or Slack notifications: ```yaml - name: Generate reports run: | - vis check --format json > check-results.json - vis check --security --format json > security-results.json + vis outdated --format json > check-results.json + vis outdated --security --format json > security-results.json ``` diff --git a/packages/tooling/vis/docs/guides/config-model.mdx b/packages/tooling/vis/docs/guides/config-model.mdx index 714c795e..17dcc0c6 100644 --- a/packages/tooling/vis/docs/guides/config-model.mdx +++ b/packages/tooling/vis/docs/guides/config-model.mdx @@ -234,9 +234,9 @@ These don't belong in `tasks` or `scopedTasks` because they don't run as part of | Field | Read by | | ------------- | ------------------------------------------------------------------------------ | -| `security` | `vis audit`, `vis add`, `vis install`, `vis update`, `vis check`, `vis doctor` | +| `security` | `vis audit`, `vis add`, `vis install`, `vis update`, `vis outdated`, `vis doctor` | | `policy` | `vis deps` | -| `constraints` | `vis run` (graph build), `vis check` | +| `constraints` | `vis run` (graph build), `vis outdated` | | `codeowners` | `vis sync codeowners` | | `secrets` | `vis secrets` | | `update` | `vis update`, `vis outdated` | diff --git a/packages/tooling/vis/docs/guides/git-hooks.mdx b/packages/tooling/vis/docs/guides/git-hooks.mdx index d4de41d1..c973bcdb 100644 --- a/packages/tooling/vis/docs/guides/git-hooks.mdx +++ b/packages/tooling/vis/docs/guides/git-hooks.mdx @@ -172,8 +172,8 @@ pnpm run typecheck ```bash #!/bin/sh # .vis/hooks/pre-commit -vis check --exit-code --format minimal || { - echo "Outdated dependencies detected. Run 'vis check' for details." +vis outdated --exit-code --format minimal || { + echo "Outdated dependencies detected. Run 'vis outdated' for details." exit 1 } ``` diff --git a/packages/tooling/vis/docs/quick-start.mdx b/packages/tooling/vis/docs/quick-start.mdx index 68a475ea..5b9dd5b0 100644 --- a/packages/tooling/vis/docs/quick-start.mdx +++ b/packages/tooling/vis/docs/quick-start.mdx @@ -77,19 +77,19 @@ pipelines and cache configuration. For pnpm or bun workspaces with catalogs, check which dependencies are outdated: ```bash -vis check +vis outdated ``` Check only specific packages: ```bash -vis check react typescript +vis outdated react typescript ``` Only show minor and patch updates: ```bash -vis check --target minor +vis outdated --target minor ``` ## Updating Dependencies diff --git a/packages/tooling/vis/examples/sample-workspace/vis.config.ts b/packages/tooling/vis/examples/sample-workspace/vis.config.ts index 346f1ab2..538c75bb 100644 --- a/packages/tooling/vis/examples/sample-workspace/vis.config.ts +++ b/packages/tooling/vis/examples/sample-workspace/vis.config.ts @@ -2,7 +2,7 @@ import { defineConfig } from "@visulima/vis/config"; // Secure defaults are applied automatically by defineConfig(). // You only need to add allowBuilds for packages with build scripts. -// Run 'vis check --security-config' to see all active settings. +// Run 'vis outdated --security-config' to see all active settings. export default defineConfig({ security: { allowBuilds: { diff --git a/packages/tooling/vis/examples/service-preflight/vis.config.ts b/packages/tooling/vis/examples/service-preflight/vis.config.ts index 574f06f9..5fb34ed7 100644 --- a/packages/tooling/vis/examples/service-preflight/vis.config.ts +++ b/packages/tooling/vis/examples/service-preflight/vis.config.ts @@ -2,7 +2,7 @@ import { defineConfig } from "@visulima/vis/config"; // Secure defaults are applied automatically by defineConfig(). // You only need to add allowBuilds for packages with build scripts. -// Run 'vis check --security-config' to see all active settings. +// Run 'vis outdated --security-config' to see all active settings. export default defineConfig({ security: { allowBuilds: { diff --git a/packages/tooling/vis/src/commands/approve-builds/handler.ts b/packages/tooling/vis/src/commands/approve-builds/handler.ts index 49635f4b..caa88306 100644 --- a/packages/tooling/vis/src/commands/approve-builds/handler.ts +++ b/packages/tooling/vis/src/commands/approve-builds/handler.ts @@ -40,7 +40,7 @@ const execute = async ({ options, visConfig, workspaceRoot: wsRoot }: Toolbox): Promise => { - if (!wsRoot) { - throw new Error("Could not determine workspace root. Run this command inside a monorepo."); - } - - const workspaceRoot = wsRoot; - - // `--sync` only means anything alongside `--security-config`. Accepting it - // on its own is how a command "succeeds" without doing what was asked. - if (options.sync && !options.securityConfig) { - throw new VisUserError("--sync requires --security-config. Run `vis check --security-config --sync`."); - } - - if (options.securityConfig) { - const pm = detectPm(workspaceRoot); - - printSecurityReport(visConfig ?? {}, pm.name); - - if (options.sync && pm.name === "pnpm") { - const synced = previewPnpmSync(visConfig ?? {}); - - if (synced.length > 0) { - pail.info("\nSettings that would sync to pnpm-workspace.yaml:"); - - for (const s of synced) { - pail.success(` ${s}`); - } - } else { - pail.info("No security settings to sync."); - } - } else if (options.sync && pm.name !== "pnpm") { - pail.info(`--sync is only available for pnpm projects. Your project uses ${pm.name}.`); - pail.info("vis enforces security settings at the vis layer for non-pnpm projects."); - } - - // If only --security-config was passed (no outdated check), return - if (!(options as Record).security && !argument?.length) { - return; +/** + * Runs `vis lint` then `vis fmt --check` over the same scope. The type check is + * opt-in in lint; it is turned on here only when TypeScript is detected, and an + * explicit `lint.adapters.typecheck.enabled` in config always wins. Both handlers + * only set `process.exitCode` when they fail, so a failure from either one survives. + */ +const execute = async ({ argument, fs, options, visConfig, workspaceRoot, ...toolbox }: Toolbox): Promise => { + const root = workspaceRoot ?? process.cwd(); + const files = argument ?? []; + + // Dependency checks used to live on this name. Without this, `vis check react` + // would hand "react" to lint and fmt as a path they silently skip. + for (const file of files) { + try { + await fs.access(join(root, file)); + } catch { + throw new VisUserError(`"${file}" is not a file or directory. Dependency checks moved to \`vis outdated\`.`); } } - const { packageManager } = findPackageManagerSync(workspaceRoot); - - const npmrcConfig = loadNpmrc(workspaceRoot); - const configDefaults = visConfig?.update ?? {}; - const catalogs = readCatalogs(workspaceRoot, packageManager, { - dev: options.dev, - includeInternal: (options as Record)["include-internal"] as boolean | undefined, - peer: options.peer, - prod: options.prod, - }); - - if (catalogs.size === 0) { - logger.info("No catalogs found."); - - return; - } - - const target = options.target ?? configDefaults.target ?? "latest"; - - if (!["latest", "minor", "patch"].includes(target)) { - throw new Error(`Invalid target "${target}". Use: latest, minor, or patch.`); - } - - const checkOptions: CatalogCheckOptions = { - exclude: [...toFilterArray(options.exclude), ...toFilterArray(configDefaults.exclude)], - ignore: toFilterArray(configDefaults.ignore), - include: [...toFilterArray(options.include), ...toFilterArray(configDefaults.include), ...argument], - includeLocked: Boolean((options as Record).includeLocked), - includePrerelease: options.prerelease || configDefaults.prerelease || false, - security: (options as Record).security !== false, - target: target as UpdateTarget, + const lintConfig = visConfig?.lint; + const typecheckDetected = detectAdapters(root, [typecheckAdapter]).has(typecheckAdapter.id); + const scope = { _: files, since: options.since, staged: options.staged }; + + const lintOptions: LintOptions = { + ...scope, + fix: options.fix, + format: "human", + maxWarnings: undefined, + output: undefined, + quiet: false, + watch: false, }; - let totalDeps = 0; - - for (const deps of catalogs.values()) { - totalDeps += deps.size; - } - - const isTTY = Boolean(process.stdout.isTTY) && !isInCi; - let progressInstance: ReturnType | undefined; - - const onProgress = isTTY - ? (current: number, total: number): void => { - if (progressInstance) { - progressInstance.rerender(React.createElement(CheckProgressApp, { current, total })); - } else { - progressInstance = render(React.createElement(CheckProgressApp, { current, total }), { - interactive: true, - patchConsole: false, - }); - } - } - : (current: number, total: number): void => { - logger.info(`Checking ${String(current)}/${String(total)} dependencies...`); - }; - - if (!isTTY) { - logger.info(`Checking ${String(totalDeps)} catalog dependencies against npm registry...\n`); - } - - const disabledProviders = new Set(); - - if (isMarshallDisabled("socket")) { - disabledProviders.add("socket"); - } - - if (isMarshallDisabled("depsDev")) { - disabledProviders.add("deps-dev"); - } - - const minimumScore = visConfig?.security?.policies?.score?.minimum; - const securityProviders = buildEnabledProviders(visConfig?.security, { - disabled: disabledProviders, - minimumScore, - }); - - const { failed, outdated } = await checkOutdated( - catalogs, - checkOptions, - npmrcConfig, - onProgress, - workspaceRoot, - securityProviders, - visConfig?.security?.acceptedRisks, - ); - - if (progressInstance) { - progressInstance.clear(); - progressInstance.unmount(); - } - - if (failed.length > 0) { - logger.warn(`Failed to fetch: ${failed.join(", ")}`); - } - - if (outdated.length === 0) { - logger.info("All catalog dependencies are up to date."); - - return; - } - - const format = options.format ?? configDefaults.format ?? "table"; - const analysisType = validateAnalysisType(options.aiType ?? "impact"); - const aiResult = options.ai ? await runAiAnalysis(outdated, logger, visConfig?.ai, analysisType) : undefined; - - // Interactive TUI mode: TTY + table format - if (isTTY && format === "table") { - const store = new UpdateStore(outdated, aiResult ?? null); - - const autoExitConfig = visConfig?.tui?.autoExit ?? false; - const autoExitSeconds = autoExitConfig === true ? 3 : typeof autoExitConfig === "number" ? autoExitConfig : 0; - - const instance = render( - React.createElement(VisUpdateApp, { - autoExitSeconds, - isDryRun: true, - store, - }), - { - alternateScreen: true, - exitOnCtrlC: false, - interactive: true, - patchConsole: true, + await lintExecute({ + ...toolbox, + fs, + options: lintOptions, + visConfig: { + ...visConfig, + lint: { + ...lintConfig, + adapters: { + ...lintConfig?.adapters, + typecheck: { ...lintConfig?.adapters?.typecheck, enabled: lintConfig?.adapters?.typecheck?.enabled ?? typecheckDetected }, + }, }, - ); - - await instance.waitUntilExit(); - - // Print post-exit summary - const columns = process.stdout.columns || 80; - - process.stdout.write("\n"); - - for (const entry of outdated) { - const hasSecurityIssue = entry.vulnerabilities?.length || (entry.socketReport && entry.socketReport.alerts.length > 0); - const isAck = Boolean(entry.acceptedRisk); - const icon = hasSecurityIssue ? (isAck ? "✓" : "⚠") : "✓"; - const iconColor = isAck ? "gray" : entry.updateType === "major" ? "red" : entry.updateType === "minor" ? "yellow" : "green"; - const socketOverall = entry.socketReport?.score.overall; - const scoreSuffix = socketOverall === undefined ? "" : ` [${String(Math.round(socketOverall * 100))}%]`; - const socketColorName = socketOverall === undefined ? undefined : scoreColor(socketOverall); - - process.stdout.write( - `${renderToString( - React.createElement( - Text, - null, - " ", - React.createElement(Text, { color: iconColor }, icon), - ` ${entry.packageName} ${entry.currentRange} → ${entry.newRange}`, - React.createElement(Text, { dimColor: true }, ` ${entry.updateType}`), - socketColorName ? React.createElement(Text, { color: socketColorName }, scoreSuffix) : null, - ), - { columns }, - )}\n`, - ); - } - - process.stdout.write("\n"); - logger.info(formatSummary(outdated, minimumScore)); - } else if (format === "json") { - const output: Record = { failed, outdated }; - - if (aiResult) { - output.aiAnalysis = aiResult; - } - - process.stdout.write(`${JSON.stringify(output, undefined, 2)}\n`); - } else if (format === "minimal") { - process.stdout.write(`${formatOutdatedMinimal(outdated)}\n`); - } else { - formatOutdatedTable(outdated, logger); - logger.info(formatSummary(outdated, minimumScore)); - - if (aiResult) { - logger.info(""); - logger.info(formatAiAnalysis(aiResult)); - } - } + }, + workspaceRoot, + } as unknown as Toolbox); + + const fmtOptions: FmtOptions = { + ...scope, + check: !options.fix, + format: "human", + output: undefined, + quiet: false, + watch: false, + }; - if (options.exitCode && outdated.length > 0) { - process.exitCode = 1; - } + await fmtExecute({ ...toolbox, fs, options: fmtOptions, visConfig, workspaceRoot } as unknown as Toolbox); }; // fallow-ignore-next-line unused-export -- lazy-loaded command entry (cerebro loader/lazyNamed dynamic import) diff --git a/packages/tooling/vis/src/commands/check/index.ts b/packages/tooling/vis/src/commands/check/index.ts index b6ea9dc7..f98e2366 100644 --- a/packages/tooling/vis/src/commands/check/index.ts +++ b/packages/tooling/vis/src/commands/check/index.ts @@ -2,101 +2,37 @@ import type { InferOptions } from "@visulima/cerebro"; import { defineCommand } from "@visulima/cerebro"; const checkOptionDefinitions = { - ai: { + fix: { defaultValue: false, - description: "Run AI analysis on outdated packages", + description: "Apply lint fixes and formatting in place instead of only reporting", type: Boolean, }, - "ai-type": { - description: "AI analysis type: impact, security, compatibility, or recommend", + since: { + description: "Only check files changed vs the given git ref (branch, tag, sha)", type: String, }, - dev: { - alias: "D", - conflicts: "prod", - description: "Check only devDependencies (npm/yarn mode)", - type: Boolean, - }, - exclude: { - description: "Glob pattern to exclude packages (repeatable)", - lazyMultiple: true, - type: String, - }, - "exit-code": { - defaultValue: false, - description: "Exit with code 1 if outdated dependencies found (for CI)", - type: Boolean, - }, - format: { - description: "Output format: table, json, or minimal (default: table)", - type: String, - }, - include: { - description: "Glob pattern to include packages (repeatable)", - lazyMultiple: true, - type: String, - }, - "include-internal": { - defaultValue: false, - description: "Also check workspace-owned package names against the registry", - type: Boolean, - }, - "no-security": { - defaultValue: false, - description: "Skip security vulnerability scanning", - type: Boolean, - }, - peer: { - defaultValue: false, - description: "Include peerDependencies in outdated checks", - type: Boolean, - }, - prerelease: { + staged: { defaultValue: false, - description: "Include prerelease versions", - type: Boolean, - }, - prod: { - alias: "P", - conflicts: "dev", - description: "Check only dependencies (npm/yarn mode)", + description: "Only check files currently staged in the git index", type: Boolean, }, - "security-config": { - defaultValue: false, - description: "Audit supply chain security settings", - type: Boolean, - }, - sync: { - defaultValue: false, - description: "Sync security settings to pnpm-workspace.yaml (pnpm only, requires --security-config)", - type: Boolean, - }, - target: { - alias: "t", - description: "Update target: latest, minor, or patch (default: latest)", - type: String, - }, } as const; const check = defineCommand({ - alias: ["c", "outdated"], argument: { - description: "Specific packages to check (checks all if omitted)", - name: "packages", + description: "Files to check (checks the whole workspace if omitted)", + name: "files", type: String, }, - description: "Check for outdated dependencies, security vulnerabilities, and supply chain settings", + description: "Run every lint, format, and type check across the workspace", examples: [ - ["vis check", "Check all catalog dependencies"], - ["vis check react", "Check specific packages"], - ["vis check --target minor", "Only show minor/patch updates"], - ["vis check --exclude '@types/*'", "Exclude packages by pattern"], - ["vis check --no-security", "Skip vulnerability scanning"], - ["vis check --security-config", "Audit supply chain security settings"], - ["vis check --security-config --sync", "Sync security config to pnpm-workspace.yaml"], + ["vis check", "Lint, check formatting, and type-check the workspace"], + ["vis check --fix", "Apply lint fixes and formatting in place"], + ["vis check src/foo.ts", "Check a specific file"], + ["vis check --staged", "Only check files staged in the git index"], + ["vis check --since main", "Only check files changed vs the main branch"], ], - group: "Security & Health", + group: "Lint & Format", loader: () => import("./handler"), name: "check", options: checkOptionDefinitions, diff --git a/packages/tooling/vis/src/commands/doctor/handler.ts b/packages/tooling/vis/src/commands/doctor/handler.ts index 1ab39db2..6374cda9 100644 --- a/packages/tooling/vis/src/commands/doctor/handler.ts +++ b/packages/tooling/vis/src/commands/doctor/handler.ts @@ -691,7 +691,7 @@ const displaySupplyChain = (results: DoctorResults): void => { } if (results.supplyChain.status !== "ok") { - pail.log(` ${dim(SYMBOLS.arrow)} ${dim("Configure with security.* in vis.config.ts. See `vis check --security-config` for details.")}`); + pail.log(` ${dim(SYMBOLS.arrow)} ${dim("Configure with security.* in vis.config.ts. See `vis outdated --security-config` for details.")}`); } }; diff --git a/packages/tooling/vis/src/commands/outdated/handler.ts b/packages/tooling/vis/src/commands/outdated/handler.ts new file mode 100644 index 00000000..e9e25a55 --- /dev/null +++ b/packages/tooling/vis/src/commands/outdated/handler.ts @@ -0,0 +1,251 @@ +import type { CommandExecute, Toolbox } from "@visulima/cerebro"; +import { findPackageManagerSync } from "@visulima/package"; +import { render, renderToString } from "@visulima/tui"; +import { Text } from "@visulima/tui/components/text"; +import isInCi from "is-in-ci"; +import React from "react"; + +import { formatAiAnalysis, runAiAnalysis, validateAnalysisType } from "../../ai/ai-analysis"; +import { VisUserError } from "../../errors/vis-user-error"; +import { pail } from "../../io/logger"; +import { detectPm } from "../../pm/pm-runner"; +import { isMarshallDisabled } from "../../security/marshalls/registry"; +import { buildEnabledProviders } from "../../security/registry"; +import { previewPnpmSync, printSecurityReport } from "../../security/security"; +import { scoreColor } from "../../security/socket-security"; +import CheckProgressApp from "../../tui/components/check-progress-app"; +import { UpdateStore } from "../../tui/components/update/update-store"; +import VisUpdateApp from "../../tui/components/update/vis-update-app"; +import type { CatalogCheckOptions, UpdateTarget } from "../../util/catalog"; +import { checkOutdated, formatOutdatedMinimal, formatOutdatedTable, formatSummary, loadNpmrc, readCatalogs, toFilterArray } from "../../util/catalog"; +import type { OutdatedOptions } from "./index"; + +const execute = async ({ argument, logger, options, visConfig, workspaceRoot: wsRoot }: Toolbox): Promise => { + if (!wsRoot) { + throw new Error("Could not determine workspace root. Run this command inside a monorepo."); + } + + const workspaceRoot = wsRoot; + + // `--sync` only means anything alongside `--security-config`. Accepting it + // on its own is how a command "succeeds" without doing what was asked. + if (options.sync && !options.securityConfig) { + throw new VisUserError("--sync requires --security-config. Run `vis outdated --security-config --sync`."); + } + + if (options.securityConfig) { + const pm = detectPm(workspaceRoot); + + printSecurityReport(visConfig ?? {}, pm.name); + + if (options.sync && pm.name === "pnpm") { + const synced = previewPnpmSync(visConfig ?? {}); + + if (synced.length > 0) { + pail.info("\nSettings that would sync to pnpm-workspace.yaml:"); + + for (const s of synced) { + pail.success(` ${s}`); + } + } else { + pail.info("No security settings to sync."); + } + } else if (options.sync && pm.name !== "pnpm") { + pail.info(`--sync is only available for pnpm projects. Your project uses ${pm.name}.`); + pail.info("vis enforces security settings at the vis layer for non-pnpm projects."); + } + + // If only --security-config was passed (no outdated check), return + if (!(options as Record).security && !argument?.length) { + return; + } + } + + const { packageManager } = findPackageManagerSync(workspaceRoot); + + const npmrcConfig = loadNpmrc(workspaceRoot); + const configDefaults = visConfig?.update ?? {}; + const catalogs = readCatalogs(workspaceRoot, packageManager, { + dev: options.dev, + includeInternal: (options as Record)["include-internal"] as boolean | undefined, + peer: options.peer, + prod: options.prod, + }); + + if (catalogs.size === 0) { + logger.info("No catalogs found."); + + return; + } + + const target = options.target ?? configDefaults.target ?? "latest"; + + if (!["latest", "minor", "patch"].includes(target)) { + throw new Error(`Invalid target "${target}". Use: latest, minor, or patch.`); + } + + const checkOptions: CatalogCheckOptions = { + exclude: [...toFilterArray(options.exclude), ...toFilterArray(configDefaults.exclude)], + ignore: toFilterArray(configDefaults.ignore), + include: [...toFilterArray(options.include), ...toFilterArray(configDefaults.include), ...argument], + includeLocked: Boolean((options as Record).includeLocked), + includePrerelease: options.prerelease || configDefaults.prerelease || false, + security: (options as Record).security !== false, + target: target as UpdateTarget, + }; + + let totalDeps = 0; + + for (const deps of catalogs.values()) { + totalDeps += deps.size; + } + + const isTTY = Boolean(process.stdout.isTTY) && !isInCi; + let progressInstance: ReturnType | undefined; + + const onProgress = isTTY + ? (current: number, total: number): void => { + if (progressInstance) { + progressInstance.rerender(React.createElement(CheckProgressApp, { current, total })); + } else { + progressInstance = render(React.createElement(CheckProgressApp, { current, total }), { + interactive: true, + patchConsole: false, + }); + } + } + : (current: number, total: number): void => { + logger.info(`Checking ${String(current)}/${String(total)} dependencies...`); + }; + + if (!isTTY) { + logger.info(`Checking ${String(totalDeps)} catalog dependencies against npm registry...\n`); + } + + const disabledProviders = new Set(); + + if (isMarshallDisabled("socket")) { + disabledProviders.add("socket"); + } + + if (isMarshallDisabled("depsDev")) { + disabledProviders.add("deps-dev"); + } + + const minimumScore = visConfig?.security?.policies?.score?.minimum; + const securityProviders = buildEnabledProviders(visConfig?.security, { + disabled: disabledProviders, + minimumScore, + }); + + const { failed, outdated } = await checkOutdated( + catalogs, + checkOptions, + npmrcConfig, + onProgress, + workspaceRoot, + securityProviders, + visConfig?.security?.acceptedRisks, + ); + + if (progressInstance) { + progressInstance.clear(); + progressInstance.unmount(); + } + + if (failed.length > 0) { + logger.warn(`Failed to fetch: ${failed.join(", ")}`); + } + + if (outdated.length === 0) { + logger.info("All catalog dependencies are up to date."); + + return; + } + + const format = options.format ?? configDefaults.format ?? "table"; + const analysisType = validateAnalysisType(options.aiType ?? "impact"); + const aiResult = options.ai ? await runAiAnalysis(outdated, logger, visConfig?.ai, analysisType) : undefined; + + // Interactive TUI mode: TTY + table format + if (isTTY && format === "table") { + const store = new UpdateStore(outdated, aiResult ?? null); + + const autoExitConfig = visConfig?.tui?.autoExit ?? false; + const autoExitSeconds = autoExitConfig === true ? 3 : typeof autoExitConfig === "number" ? autoExitConfig : 0; + + const instance = render( + React.createElement(VisUpdateApp, { + autoExitSeconds, + isDryRun: true, + store, + }), + { + alternateScreen: true, + exitOnCtrlC: false, + interactive: true, + patchConsole: true, + }, + ); + + await instance.waitUntilExit(); + + // Print post-exit summary + const columns = process.stdout.columns || 80; + + process.stdout.write("\n"); + + for (const entry of outdated) { + const hasSecurityIssue = entry.vulnerabilities?.length || (entry.socketReport && entry.socketReport.alerts.length > 0); + const isAck = Boolean(entry.acceptedRisk); + const icon = hasSecurityIssue ? (isAck ? "✓" : "⚠") : "✓"; + const iconColor = isAck ? "gray" : entry.updateType === "major" ? "red" : entry.updateType === "minor" ? "yellow" : "green"; + const socketOverall = entry.socketReport?.score.overall; + const scoreSuffix = socketOverall === undefined ? "" : ` [${String(Math.round(socketOverall * 100))}%]`; + const socketColorName = socketOverall === undefined ? undefined : scoreColor(socketOverall); + + process.stdout.write( + `${renderToString( + React.createElement( + Text, + null, + " ", + React.createElement(Text, { color: iconColor }, icon), + ` ${entry.packageName} ${entry.currentRange} → ${entry.newRange}`, + React.createElement(Text, { dimColor: true }, ` ${entry.updateType}`), + socketColorName ? React.createElement(Text, { color: socketColorName }, scoreSuffix) : null, + ), + { columns }, + )}\n`, + ); + } + + process.stdout.write("\n"); + logger.info(formatSummary(outdated, minimumScore)); + } else if (format === "json") { + const output: Record = { failed, outdated }; + + if (aiResult) { + output.aiAnalysis = aiResult; + } + + process.stdout.write(`${JSON.stringify(output, undefined, 2)}\n`); + } else if (format === "minimal") { + process.stdout.write(`${formatOutdatedMinimal(outdated)}\n`); + } else { + formatOutdatedTable(outdated, logger); + logger.info(formatSummary(outdated, minimumScore)); + + if (aiResult) { + logger.info(""); + logger.info(formatAiAnalysis(aiResult)); + } + } + + if (options.exitCode && outdated.length > 0) { + process.exitCode = 1; + } +}; + +// fallow-ignore-next-line unused-export -- lazy-loaded command entry (cerebro loader/lazyNamed dynamic import) +export default execute as CommandExecute; diff --git a/packages/tooling/vis/src/commands/outdated/index.ts b/packages/tooling/vis/src/commands/outdated/index.ts new file mode 100644 index 00000000..f9ebf689 --- /dev/null +++ b/packages/tooling/vis/src/commands/outdated/index.ts @@ -0,0 +1,106 @@ +import type { InferOptions } from "@visulima/cerebro"; +import { defineCommand } from "@visulima/cerebro"; + +const outdatedOptionDefinitions = { + ai: { + defaultValue: false, + description: "Run AI analysis on outdated packages", + type: Boolean, + }, + "ai-type": { + description: "AI analysis type: impact, security, compatibility, or recommend", + type: String, + }, + dev: { + alias: "D", + conflicts: "prod", + description: "Check only devDependencies (npm/yarn mode)", + type: Boolean, + }, + exclude: { + description: "Glob pattern to exclude packages (repeatable)", + lazyMultiple: true, + type: String, + }, + "exit-code": { + defaultValue: false, + description: "Exit with code 1 if outdated dependencies found (for CI)", + type: Boolean, + }, + format: { + description: "Output format: table, json, or minimal (default: table)", + type: String, + }, + include: { + description: "Glob pattern to include packages (repeatable)", + lazyMultiple: true, + type: String, + }, + "include-internal": { + defaultValue: false, + description: "Also check workspace-owned package names against the registry", + type: Boolean, + }, + "no-security": { + defaultValue: false, + description: "Skip security vulnerability scanning", + type: Boolean, + }, + peer: { + defaultValue: false, + description: "Include peerDependencies in outdated checks", + type: Boolean, + }, + prerelease: { + defaultValue: false, + description: "Include prerelease versions", + type: Boolean, + }, + prod: { + alias: "P", + conflicts: "dev", + description: "Check only dependencies (npm/yarn mode)", + type: Boolean, + }, + "security-config": { + defaultValue: false, + description: "Audit supply chain security settings", + type: Boolean, + }, + sync: { + defaultValue: false, + description: "Sync security settings to pnpm-workspace.yaml (pnpm only, requires --security-config)", + type: Boolean, + }, + target: { + alias: "t", + description: "Update target: latest, minor, or patch (default: latest)", + type: String, + }, +} as const; + +const outdated = defineCommand({ + argument: { + description: "Specific packages to check (checks all if omitted)", + name: "packages", + type: String, + }, + description: "Check for outdated dependencies, security vulnerabilities, and supply chain settings", + examples: [ + ["vis outdated", "Check all catalog dependencies"], + ["vis outdated react", "Check specific packages"], + ["vis outdated --target minor", "Only show minor/patch updates"], + ["vis outdated --exclude '@types/*'", "Exclude packages by pattern"], + ["vis outdated --no-security", "Skip vulnerability scanning"], + ["vis outdated --security-config", "Audit supply chain security settings"], + ["vis outdated --security-config --sync", "Sync security config to pnpm-workspace.yaml"], + ], + group: "Security & Health", + loader: () => import("./handler"), + name: "outdated", + options: outdatedOptionDefinitions, +}); + +export default outdated; + +export type OutdatedOptions = InferOptions; diff --git a/packages/tooling/vis/src/config/config.ts b/packages/tooling/vis/src/config/config.ts index 16d8e1aa..e0d8d019 100644 --- a/packages/tooling/vis/src/config/config.ts +++ b/packages/tooling/vis/src/config/config.ts @@ -619,7 +619,7 @@ const defineTaskConfig = (config: VisTaskConfig): VisTaskConfig => config; * editor autocomplete and structural type-checking on the literal you pass * in. Secure defaults are applied by `loadVisConfig` at load time, not here, * so wrapping vs. using `satisfies VisConfig` produces the exact same - * runtime behavior. To see the active defaults, run `vis check --security-config`. + * runtime behavior. To see the active defaults, run `vis outdated --security-config`. * @example * ```typescript * // vis.config.ts — minimal config, fully secured by defaults diff --git a/packages/tooling/vis/src/config/types/security.ts b/packages/tooling/vis/src/config/types/security.ts index e2a902de..7326ed08 100644 --- a/packages/tooling/vis/src/config/types/security.ts +++ b/packages/tooling/vis/src/config/types/security.ts @@ -570,7 +570,7 @@ export interface VisSecurityConfig { * Minimum overall Socket.dev score (0–1). Set to 0 to * disable the gate while keeping Socket data fetched. * - * Consulted by `vis add`, `audit`, `doctor`, `check`, and + * Consulted 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. diff --git a/packages/tooling/vis/src/plugins/config-loader.ts b/packages/tooling/vis/src/plugins/config-loader.ts index f1584f07..01432c40 100644 --- a/packages/tooling/vis/src/plugins/config-loader.ts +++ b/packages/tooling/vis/src/plugins/config-loader.ts @@ -166,7 +166,7 @@ const configLoaderPlugin: Plugin = { "", "// Secure defaults are applied automatically by defineConfig().", "// You only need to add allowBuilds for packages with build scripts.", - "// Run 'vis check --security-config' to see all active settings.", + "// Run 'vis outdated --security-config' to see all active settings.", "export default defineConfig({", " security: {", " allowBuilds: {", diff --git a/packages/tooling/vis/src/plugins/security-enforcement.ts b/packages/tooling/vis/src/plugins/security-enforcement.ts index de96597a..de365c12 100644 --- a/packages/tooling/vis/src/plugins/security-enforcement.ts +++ b/packages/tooling/vis/src/plugins/security-enforcement.ts @@ -151,7 +151,7 @@ const securityEnforcementPlugin: Plugin = { if (criticalHighCount > 0) { pail.warn( `${String(criticalHighCount)} critical/high severity alert${criticalHighCount === 1 ? "" : "s"} detected. ` - + "Run 'vis check --security' for details.", + + "Run 'vis outdated --security' for details.", ); } } diff --git a/packages/tooling/vis/src/register-commands.ts b/packages/tooling/vis/src/register-commands.ts index 27e68975..7f35dd60 100644 --- a/packages/tooling/vis/src/register-commands.ts +++ b/packages/tooling/vis/src/register-commands.ts @@ -44,6 +44,7 @@ import lintCommand from "./commands/lint"; import listCommand from "./commands/list"; import migrateCommands from "./commands/migrate"; import optimizeCommand from "./commands/optimize"; +import outdatedCommand from "./commands/outdated"; import pmCommand from "./commands/pm"; import releaseCommands from "./commands/release"; import releaseRootCommand from "./commands/release/root-command"; @@ -91,6 +92,7 @@ const registerCommands = (cli: Cli): void => { cli.addCommand(ignoreCommand); cli.addCommand(updateCommand); cli.addCommand(checkCommand); + cli.addCommand(outdatedCommand); cli.addCommand(depsCommand); cli.addCommand(analyzeCommand); cli.addCommand(sortPackageJsonCommand); diff --git a/packages/tooling/vis/src/security/config-check.ts b/packages/tooling/vis/src/security/config-check.ts index bc4a1ef8..8d65f227 100644 --- a/packages/tooling/vis/src/security/config-check.ts +++ b/packages/tooling/vis/src/security/config-check.ts @@ -105,7 +105,7 @@ const emitSecurityWarnings = (config: VisConfig, packageManager: string, enforce : result.warnings; if (summarized.length > 0) { - pail.warn(`${summarized.length} security recommendation${summarized.length === 1 ? "" : "s"} found. Run 'vis check --security-config' for details.`); + pail.warn(`${summarized.length} security recommendation${summarized.length === 1 ? "" : "s"} found. Run 'vis outdated --security-config' for details.`); } }; diff --git a/packages/tooling/vis/src/util/catalog/registry.ts b/packages/tooling/vis/src/util/catalog/registry.ts index 33ce00ad..ed7502cd 100644 --- a/packages/tooling/vis/src/util/catalog/registry.ts +++ b/packages/tooling/vis/src/util/catalog/registry.ts @@ -93,7 +93,7 @@ export const fetchVersionsBatched = async ( // from the queue the instant any one settles, instead of waiting for a // whole fixed-size batch to finish. This stops a single slow registry // response (up to DEFAULT_FETCH_TIMEOUT) from head-of-line-blocking the - // next batch, which meaningfully cuts `vis check` wall time on large + // next batch, which meaningfully cuts `vis outdated` wall time on large // catalogs behind a flaky private registry. const worker = async (): Promise => { while (nextIndex < uniquePackages.length) { diff --git a/packages/tooling/vis/src/util/tips.ts b/packages/tooling/vis/src/util/tips.ts index e751e652..973cc793 100644 --- a/packages/tooling/vis/src/util/tips.ts +++ b/packages/tooling/vis/src/util/tips.ts @@ -97,21 +97,21 @@ const tips: Tip[] = [ }, { id: "security-check", - matches: (context) => (context.command === "check" || context.command === "update") && context.success && !context.args.includes("--security"), + matches: (context) => (context.command === "outdated" || context.command === "update") && context.success && !context.args.includes("--security"), message: () => "Add --security to check for known vulnerabilities via OSV.dev", probability: 0.3, }, { cooldownMs: 60 * 60 * 1000, // 1 hour id: "ai-analysis", - matches: (context) => (context.command === "check" || context.command === "update") && context.success && !context.args.includes("--ai"), + matches: (context) => (context.command === "outdated" || context.command === "update") && context.success && !context.args.includes("--ai"), message: () => "Add --ai to run AI analysis on outdated packages before updating.", probability: 0.2, }, { cooldownMs: 24 * 60 * 60 * 1000, // 24 hours id: "socket-security", - matches: (context) => (context.command === "install" || context.command === "update" || context.command === "check") && context.success, + matches: (context) => (context.command === "install" || context.command === "update" || context.command === "outdated") && context.success, message: () => "Enable Socket.dev in vis.config.ts for security scores and supply chain alerts: security.socket.enabled = true", probability: 0.15, }, From c3a1346b23636ed460ee7e72dfc263eb524100f0 Mon Sep 17 00:00:00 2001 From: Daniel Bannert Date: Sat, 17 Oct 2026 23:14:27 +0200 Subject: [PATCH 3/6] feat(vis): run the package test script in vis test when one exists `vis test` now prefers the package's own `test` script over the Vitest fallback, so the command a team already runs stays the source of truth. It runs ` run test` with the detected package manager, forwards arguments after `--`, and prints a warning first so it is clear which runner ran. npm's `init` placeholder does not count as a script. `--watch` is rejected when a test script is used: it only applies to Vitest, and dropping it silently would look like it had worked. Co-Authored-By: Claude Haiku 5.5 --- .../commands/test/read-test-script.test.ts | 47 ++++++++++++ packages/tooling/vis/docs/commands/test.mdx | 12 ++-- .../tooling/vis/src/commands/test/handler.ts | 72 ++++++++++++++++--- .../tooling/vis/src/commands/test/index.ts | 2 +- 4 files changed, 118 insertions(+), 15 deletions(-) create mode 100644 packages/tooling/vis/__tests__/commands/test/read-test-script.test.ts diff --git a/packages/tooling/vis/__tests__/commands/test/read-test-script.test.ts b/packages/tooling/vis/__tests__/commands/test/read-test-script.test.ts new file mode 100644 index 00000000..af163451 --- /dev/null +++ b/packages/tooling/vis/__tests__/commands/test/read-test-script.test.ts @@ -0,0 +1,47 @@ +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { readTestScript } from "../../../src/commands/test/handler"; + +const readText = (path: string) => readFile(path, "utf8").catch(() => undefined); + +describe(readTestScript, () => { + let root: string; + + beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), "vis-test-script-")); + }); + + afterEach(async () => { + await rm(root, { force: true, recursive: true }); + }); + + it("returns the package's test script", async () => { + expect.assertions(1); + + await writeFile(join(root, "package.json"), JSON.stringify({ scripts: { test: "vitest run" } })); + + await expect(readTestScript(root, readText)).resolves.toBe("vitest run"); + }); + + it("ignores npm's init placeholder", async () => { + expect.assertions(1); + + await writeFile(join(root, "package.json"), JSON.stringify({ scripts: { test: "echo \"Error: no test specified\" && exit 1" } })); + + await expect(readTestScript(root, readText)).resolves.toBeUndefined(); + }); + + it("returns undefined when there is no package.json or no test script", async () => { + expect.assertions(2); + + await expect(readTestScript(root, readText)).resolves.toBeUndefined(); + + await writeFile(join(root, "package.json"), JSON.stringify({ scripts: { build: "packem build" } })); + + await expect(readTestScript(root, readText)).resolves.toBeUndefined(); + }); +}); diff --git a/packages/tooling/vis/docs/commands/test.mdx b/packages/tooling/vis/docs/commands/test.mdx index 23081794..d597c6f3 100644 --- a/packages/tooling/vis/docs/commands/test.mdx +++ b/packages/tooling/vis/docs/commands/test.mdx @@ -1,11 +1,13 @@ --- title: "vis test" -description: "Run the workspace tests with Vitest, no test script required" +description: "Run the package's test script, or Vitest when it has none" --- # vis test -Runs the project's tests with [Vitest](https://vitest.dev). It needs no `test` script in `package.json` and no `vis.config.ts` entry: it finds the `vitest` install in the nearest `node_modules` (walking up from the current directory, so a hoisted install works from a workspace package) and runs it. +Runs the project's tests. If the package has a `test` script in its `package.json`, `vis test` runs that script through the workspace's package manager, so the command your team already uses stays the source of truth. Otherwise it runs [Vitest](https://vitest.dev) directly, with no script or config entry needed: it finds the `vitest` install in the nearest `node_modules` (walking up from the current directory, so a hoisted install works from a workspace package). + +When the `test` script is used, `vis test` prints a warning first, so it is clear which runner ran. Remove the script to fall back to Vitest. Vitest is not bundled. Add it to your project first: @@ -19,7 +21,9 @@ vis add -D vitest vis test [files…] [options] ``` -Positional arguments and any other flags are forwarded to Vitest unchanged. +Positional arguments and any other flags are forwarded unchanged: to Vitest, or after `--` to the `test` script. + +`--watch` applies to Vitest only. Combining it with a `test` script is an error, so the flag is never dropped silently; run the script directly for watch mode. ## Examples @@ -39,4 +43,4 @@ vis test -- --reporter=dot ## Exit codes -The exit code is Vitest's: `0` when every test passes, non-zero otherwise. +The exit code is that of the runner: `0` when every test passes, non-zero otherwise. diff --git a/packages/tooling/vis/src/commands/test/handler.ts b/packages/tooling/vis/src/commands/test/handler.ts index 0f7a83af..7b71a6f7 100644 --- a/packages/tooling/vis/src/commands/test/handler.ts +++ b/packages/tooling/vis/src/commands/test/handler.ts @@ -40,9 +40,67 @@ export const resolveVitestEntry = async (start: string, readText: ReadText): Pro } }; -const execute = async ({ argument, fs, options, rawUnknown }: Toolbox): Promise => { +/** + * The package's own `test` script, or `undefined` when there is none. npm's + * `init` placeholder ("no test specified") does not count as a script. + */ +export const readTestScript = async (directory: string, readText: ReadText): Promise => { + const manifestText = await readText(join(directory, "package.json")); + + if (manifestText === undefined) { + return undefined; + } + + const { scripts } = (JSON.parse(manifestText) as { scripts?: Record }); + const script = scripts?.["test"]; + + if (typeof script !== "string" || script.trim() === "" || script.includes("no test specified")) { + return undefined; + } + + return script; +}; + +const exitWithStatus = (status: number | null, error: Error | undefined): void => { + if (error) { + throw error; + } + + if (status !== 0) { + process.exitCode = status ?? 1; + } +}; + +const execute = async ({ argument, fs, logger, options, rawUnknown }: Toolbox): Promise => { const cwd = process.cwd(); - const vitestEntry = await resolveVitestEntry(cwd, (path) => fs.readFile(path, "utf8").catch(() => undefined)); + const readText: ReadText = (path) => fs.readFile(path, "utf8").catch(() => undefined); + const forwarded = [...(argument ?? []), ...(rawUnknown ?? [])]; + const testScript = await readTestScript(cwd, readText); + + // A package that already has a `test` script keeps it: that script is what the + // team runs, so it wins over the Vitest fallback. The warning says so up front. + if (testScript !== undefined) { + if (options.watch) { + throw new VisUserError("--watch applies to Vitest, but this package runs its own `test` script. Run that script directly for watch mode."); + } + + logger.warn(`vis test: running the package's "test" script (\`${testScript}\`) instead of Vitest. Remove the script to use Vitest.`); + + // Loaded here, not at module top: pm-runner pulls in the native binding, and the + // Vitest path and the helpers above should stay usable without it. + const { detectPm } = await import("../../pm/pm-runner"); + const result = spawnSync(detectPm(cwd).name, ["run", "test", ...(forwarded.length > 0 ? ["--", ...forwarded] : [])], { + cwd, + shell: process.platform === "win32", + stdio: "inherit", + }); + + exitWithStatus(result.status, result.error); + + return; + } + + const vitestEntry = await resolveVitestEntry(cwd, readText); if (vitestEntry === undefined) { throw new VisUserError("Vitest is not installed. Add it with `vis add -D vitest`, then run `vis test` again."); @@ -50,17 +108,11 @@ const execute = async ({ argument, fs, options, rawUnknown }: Toolbox Date: Sat, 17 Oct 2026 23:59:38 +0200 Subject: [PATCH 4/6] feat(vis): fall back to Rstest in vis test when Vitest is not installed `vis test` still runs the package's own `test` script first, then Vitest. When Vitest is not installed, it now runs Rstest (`rstest run`, or `rstest watch` with `--watch`) instead of failing. Vitest keeps priority when both are installed, the same order the target inference uses. The bin lookup is shared between the two runners, so a hoisted install is found from a workspace package for either one. Co-Authored-By: Claude Haiku 5.5 --- .../commands/test/resolve-rstest.test.ts | 44 +++++++++++++++++ packages/tooling/vis/docs/commands/test.mdx | 2 +- .../tooling/vis/src/commands/test/handler.ts | 47 ++++++++++++++----- 3 files changed, 79 insertions(+), 14 deletions(-) create mode 100644 packages/tooling/vis/__tests__/commands/test/resolve-rstest.test.ts diff --git a/packages/tooling/vis/__tests__/commands/test/resolve-rstest.test.ts b/packages/tooling/vis/__tests__/commands/test/resolve-rstest.test.ts new file mode 100644 index 00000000..9ed037cb --- /dev/null +++ b/packages/tooling/vis/__tests__/commands/test/resolve-rstest.test.ts @@ -0,0 +1,44 @@ +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { resolveRstestEntry } from "../../../src/commands/test/handler"; + +const readText = (path: string) => readFile(path, "utf8").catch(() => undefined); + +describe(resolveRstestEntry, () => { + let root: string; + + beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), "vis-test-rstest-")); + }); + + afterEach(async () => { + await rm(root, { force: true, recursive: true }); + }); + + it("returns the rstest bin from an @rstest/core install found in an ancestor", async () => { + expect.assertions(1); + + const coreDirectory = join(root, "node_modules", "@rstest", "core"); + const packageDirectory = join(root, "packages", "app"); + + await mkdir(coreDirectory, { recursive: true }); + await mkdir(packageDirectory, { recursive: true }); + await writeFile(join(coreDirectory, "package.json"), JSON.stringify({ bin: { rstest: "./bin/rstest.js" } })); + + await expect(resolveRstestEntry(packageDirectory, readText)).resolves.toBe(join(coreDirectory, "bin", "rstest.js")); + }); + + it("returns undefined when no ancestor has @rstest/core", async () => { + expect.assertions(1); + + const packageDirectory = join(root, "app"); + + await mkdir(packageDirectory, { recursive: true }); + + await expect(resolveRstestEntry(packageDirectory, readText)).resolves.toBeUndefined(); + }); +}); diff --git a/packages/tooling/vis/docs/commands/test.mdx b/packages/tooling/vis/docs/commands/test.mdx index d597c6f3..cf395843 100644 --- a/packages/tooling/vis/docs/commands/test.mdx +++ b/packages/tooling/vis/docs/commands/test.mdx @@ -5,7 +5,7 @@ description: "Run the package's test script, or Vitest when it has none" # vis test -Runs the project's tests. If the package has a `test` script in its `package.json`, `vis test` runs that script through the workspace's package manager, so the command your team already uses stays the source of truth. Otherwise it runs [Vitest](https://vitest.dev) directly, with no script or config entry needed: it finds the `vitest` install in the nearest `node_modules` (walking up from the current directory, so a hoisted install works from a workspace package). +Runs the project's tests. If the package has a `test` script in its `package.json`, `vis test` runs that script through the workspace's package manager, so the command your team already uses stays the source of truth. Otherwise it runs [Vitest](https://vitest.dev) directly, with no script or config entry needed. When Vitest is not installed, it runs [Rstest](https://rstest.rs) instead (`rstest run`, or `rstest watch` with `--watch`). Either runner is found in the nearest `node_modules`, walking up from the current directory, so a hoisted install works from a workspace package. When the `test` script is used, `vis test` prints a warning first, so it is clear which runner ran. Remove the script to fall back to Vitest. diff --git a/packages/tooling/vis/src/commands/test/handler.ts b/packages/tooling/vis/src/commands/test/handler.ts index 7b71a6f7..cd81ef19 100644 --- a/packages/tooling/vis/src/commands/test/handler.ts +++ b/packages/tooling/vis/src/commands/test/handler.ts @@ -10,20 +10,20 @@ import type { TestOptions } from "./index"; export type ReadText = (path: string) => Promise; /** - * Locate the Vitest CLI entry by walking up from `start` through each parent's - * `node_modules/vitest`, so a test run from a workspace package still finds the - * hoisted install. Returns `undefined` when no ancestor has Vitest installed. + * Locate a package's bin entry by walking up from `start` through each parent's + * `node_modules/<packageName>`, so a test run from a workspace package still finds + * a hoisted install. Returns `undefined` when no ancestor has the package. */ -export const resolveVitestEntry = async (start: string, readText: ReadText): Promise => { +export const resolveBinEntry = async (start: string, readText: ReadText, packageName: string, binName: string): Promise => { let directory = start; for (;;) { - const packageDirectory = join(directory, "node_modules", "vitest"); + const packageDirectory = join(directory, "node_modules", packageName); const manifestText = await readText(join(packageDirectory, "package.json")); if (manifestText !== undefined) { const manifest = JSON.parse(manifestText) as { bin?: string | Record }; - const bin = typeof manifest.bin === "string" ? manifest.bin : manifest.bin?.["vitest"]; + const bin = typeof manifest.bin === "string" ? manifest.bin : manifest.bin?.[binName]; if (bin) { return join(packageDirectory, bin); @@ -40,6 +40,14 @@ export const resolveVitestEntry = async (start: string, readText: ReadText): Pro } }; +/** Locate the Vitest CLI entry. */ +export const resolveVitestEntry = (start: string, readText: ReadText): Promise => + resolveBinEntry(start, readText, "vitest", "vitest"); + +/** Locate the Rstest CLI entry (shipped by `@rstest/core`). */ +export const resolveRstestEntry = (start: string, readText: ReadText): Promise => + resolveBinEntry(start, readText, "@rstest/core", "rstest"); + /** * The package's own `test` script, or `undefined` when there is none. npm's * `init` placeholder ("no test specified") does not count as a script. @@ -61,6 +69,12 @@ export const readTestScript = async (directory: string, readText: ReadText): Pro return script; }; +const runNodeEntry = (entry: string, args: string[], cwd: string): [number | null, Error | undefined] => { + const result = spawnSync(process.execPath, [entry, ...args], { cwd, stdio: "inherit" }); + + return [result.status, result.error]; +}; + const exitWithStatus = (status: number | null, error: Error | undefined): void => { if (error) { throw error; @@ -100,19 +114,26 @@ const execute = async ({ argument, fs, logger, options, rawUnknown }: Toolbox Date: Sun, 18 Oct 2026 00:04:31 +0200 Subject: [PATCH 5/6] feat(vis): ask which test runner to install when none is found `vis test` used to fail when neither Vitest nor Rstest was installed. Now, when the package has no `test` script and no runner, it asks which runner to add: Vitest or Rstest. The choice is the consent. The install goes through `vis add -D`, so workspace root handling and the supply-chain checks apply, and nothing runs after a failed install. Outside an interactive terminal it does not choose for the user. It stops with an error listing each runner and its install command. Cancelling (Enter, `q`, or an out-of-range number) stops before anything is installed. The prompt and installer are shared helpers in `util/tool-choice`, so the dev and build commands can use the same flow. Co-Authored-By: Claude Haiku 5.5 --- .../commands/test/run-tests-prompt.test.ts | 90 +++++++++++++++++++ .../vis/__tests__/util/tool-choice.test.ts | 51 +++++++++++ packages/tooling/vis/docs/commands/test.mdx | 2 + .../tooling/vis/src/commands/test/handler.ts | 83 +++++++++++++---- packages/tooling/vis/src/util/tool-choice.ts | 85 ++++++++++++++++++ 5 files changed, 295 insertions(+), 16 deletions(-) create mode 100644 packages/tooling/vis/__tests__/commands/test/run-tests-prompt.test.ts create mode 100644 packages/tooling/vis/__tests__/util/tool-choice.test.ts create mode 100644 packages/tooling/vis/src/util/tool-choice.ts diff --git a/packages/tooling/vis/__tests__/commands/test/run-tests-prompt.test.ts b/packages/tooling/vis/__tests__/commands/test/run-tests-prompt.test.ts new file mode 100644 index 00000000..4e5e4d81 --- /dev/null +++ b/packages/tooling/vis/__tests__/commands/test/run-tests-prompt.test.ts @@ -0,0 +1,90 @@ +import { mkdirSync, writeFileSync } from "node:fs"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { runTests } from "../../../src/commands/test/handler"; + +let root: string; +let cwd: string; + +const toolbox = (options: Record = {}) => + ({ + argument: [], + fs: { readFile }, + logger: { error: vi.fn(), info: vi.fn(), warn: vi.fn() }, + options: { watch: false, ...options }, + rawUnknown: [], + }) as never; + +/** Writes a fake `@rstest/core` whose bin prints its arguments and exits with `exitCode`. */ +const writeFakeRstest = (exitCode: number): void => { + const core = join(root, "node_modules", "@rstest", "core"); + + mkdirSync(join(core, "bin"), { recursive: true }); + writeFileSync(join(core, "package.json"), JSON.stringify({ bin: { rstest: "./bin/rstest.js" } })); + writeFileSync(join(core, "bin", "rstest.js"), `console.log("ARGS " + JSON.stringify(process.argv.slice(2)));process.exit(${String(exitCode)});`); +}; + +describe("runTests when no runner is installed", () => { + beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), "vis-test-prompt-")); + cwd = process.cwd(); + process.chdir(root); + process.exitCode = undefined; + }); + + afterEach(async () => { + process.chdir(cwd); + process.exitCode = undefined; + await rm(root, { force: true, recursive: true }); + }); + + it("errors in a non-interactive run, listing each runner and its install command, and installs nothing", async () => { + expect.assertions(2); + + const install = vi.fn(() => 0); + + await expect(runTests(toolbox(), { install, isInteractive: () => false })).rejects.toThrow("vis add -D @rstest/core"); + expect(install).not.toHaveBeenCalled(); + }); + + it("cancels without installing when the user declines", async () => { + expect.assertions(2); + + const install = vi.fn(() => 0); + + await expect(runTests(toolbox(), { install, isInteractive: () => true, readline: async () => "q" })).rejects.toThrow("No test runner selected"); + expect(install).not.toHaveBeenCalled(); + }); + + it("installs the picked runner, then runs it with the forwarded arguments", async () => { + expect.assertions(3); + + // The installer stands in for `vis add -D @rstest/core`: the runner appears on disk. + const install = vi.fn((packageName: string) => { + writeFakeRstest(0); + + return packageName === "@rstest/core" ? 0 : 1; + }); + + await runTests({ ...toolbox(), argument: ["a.test.ts"] } as never, { install, isInteractive: () => true, readline: async () => "2" }); + + expect(install).toHaveBeenCalledWith("@rstest/core", process.cwd()); + expect(process.exitCode).toBeUndefined(); + expect(install).toHaveBeenCalledTimes(1); + }); + + it("does not run the runner when the install fails", async () => { + expect.assertions(2); + + const install = vi.fn(() => 3); + + await runTests(toolbox(), { install, isInteractive: () => true, readline: async () => "2" }); + + expect(process.exitCode).toBe(3); + expect(install).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/tooling/vis/__tests__/util/tool-choice.test.ts b/packages/tooling/vis/__tests__/util/tool-choice.test.ts new file mode 100644 index 00000000..998ca296 --- /dev/null +++ b/packages/tooling/vis/__tests__/util/tool-choice.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it, vi } from "vitest"; + +import { chooseTool, installDevDependency } from "../../src/util/tool-choice"; + +const choices = [ + { hint: "vis add -D vitest", label: "Vitest", value: "vitest" }, + { hint: "vis add -D @rstest/core", label: "Rstest", value: "@rstest/core" }, +]; + +const answer = (text: string) => vi.fn(async () => text); + +describe(chooseTool, () => { + it("returns the option picked by number", async () => { + expect.assertions(2); + + await expect(chooseTool("Pick", choices, { isInteractive: () => true, readline: answer("1") })).resolves.toStrictEqual(choices[0]); + await expect(chooseTool("Pick", choices, { isInteractive: () => true, readline: answer(" 2 ") })).resolves.toStrictEqual(choices[1]); + }); + + it("cancels on empty input, q, or a number out of range", async () => { + expect.assertions(4); + + await expect(chooseTool("Pick", choices, { isInteractive: () => true, readline: answer("") })).resolves.toBeUndefined(); + await expect(chooseTool("Pick", choices, { isInteractive: () => true, readline: answer("Q") })).resolves.toBeUndefined(); + await expect(chooseTool("Pick", choices, { isInteractive: () => true, readline: answer("9") })).resolves.toBeUndefined(); + await expect(chooseTool("Pick", choices, { isInteractive: () => true, readline: answer("vitest") })).resolves.toBeUndefined(); + }); + + it("throws outside an interactive terminal, without reading input", async () => { + expect.assertions(3); + + const readline = vi.fn(async () => "1"); + + await expect(chooseTool("No runner.", choices, { isInteractive: () => false, readline })).rejects.toThrow("vis add -D vitest"); + await expect(chooseTool("No runner.", choices, { isInteractive: () => false, readline })).rejects.toThrow("vis add -D @rstest/core"); + expect(readline).not.toHaveBeenCalled(); + }); +}); + +describe(installDevDependency, () => { + it("installs through the given installer and reports its exit code", () => { + expect.assertions(3); + + const install = vi.fn(() => 0); + const logger = { info: vi.fn() }; + + expect(installDevDependency("vitest", "/tmp/app", logger, install)).toBe(0); + expect(install).toHaveBeenCalledWith("vitest", "/tmp/app"); + expect(logger.info).toHaveBeenCalledWith(expect.stringContaining("vitest")); + }); +}); diff --git a/packages/tooling/vis/docs/commands/test.mdx b/packages/tooling/vis/docs/commands/test.mdx index cf395843..06625412 100644 --- a/packages/tooling/vis/docs/commands/test.mdx +++ b/packages/tooling/vis/docs/commands/test.mdx @@ -7,6 +7,8 @@ description: "Run the package's test script, or Vitest when it has none" Runs the project's tests. If the package has a `test` script in its `package.json`, `vis test` runs that script through the workspace's package manager, so the command your team already uses stays the source of truth. Otherwise it runs [Vitest](https://vitest.dev) directly, with no script or config entry needed. When Vitest is not installed, it runs [Rstest](https://rstest.rs) instead (`rstest run`, or `rstest watch` with `--watch`). Either runner is found in the nearest `node_modules`, walking up from the current directory, so a hoisted install works from a workspace package. +If neither runner is installed, `vis test` asks which one to add as a devDependency, then runs it. Nothing is installed without that choice. In a non-interactive run (CI, or no terminal) it stops and lists the install commands instead. + When the `test` script is used, `vis test` prints a warning first, so it is clear which runner ran. Remove the script to fall back to Vitest. Vitest is not bundled. Add it to your project first: diff --git a/packages/tooling/vis/src/commands/test/handler.ts b/packages/tooling/vis/src/commands/test/handler.ts index cd81ef19..d867c4a7 100644 --- a/packages/tooling/vis/src/commands/test/handler.ts +++ b/packages/tooling/vis/src/commands/test/handler.ts @@ -4,6 +4,8 @@ import type { CommandExecute, Toolbox } from "@visulima/cerebro"; import { dirname, join } from "@visulima/path"; import { VisUserError } from "../../errors/vis-user-error"; +import type { ChooseToolDeps, Installer } from "../../util/tool-choice"; +import { chooseTool, installDevDependency } from "../../util/tool-choice"; import type { TestOptions } from "./index"; /** Reads a file's text, or `undefined` when it does not exist. */ @@ -85,14 +87,45 @@ const exitWithStatus = (status: number | null, error: Error | undefined): void = } }; -const execute = async ({ argument, fs, logger, options, rawUnknown }: Toolbox): Promise => { +export interface TestRunDeps extends ChooseToolDeps { + /** Installs the chosen runner. Defaults to `vis add -D`. */ + install?: Installer; +} + +interface Runner { + entry: string; + kind: "rstest" | "vitest"; +} + +/** Vitest first, then Rstest: the same order the inference detectors use. */ +const findRunner = async (cwd: string, readText: ReadText): Promise => { + const vitestEntry = await resolveVitestEntry(cwd, readText); + + if (vitestEntry !== undefined) { + return { entry: vitestEntry, kind: "vitest" }; + } + + const rstestEntry = await resolveRstestEntry(cwd, readText); + + return rstestEntry === undefined ? undefined : { entry: rstestEntry, kind: "rstest" }; +}; + +/** + * Run the package's tests. Without a `test` script and without a runner, ask which + * runner to install. The choice is the consent: the install is `vis add -D`, and the + * run follows it. Cancelling, or a failed install, stops before anything runs. + */ +export const runTests = async ( + { argument, fs, logger, options, rawUnknown }: Pick, "argument" | "fs" | "logger" | "options" | "rawUnknown">, + deps: TestRunDeps = {}, +): Promise => { const cwd = process.cwd(); const readText: ReadText = (path) => fs.readFile(path, "utf8").catch(() => undefined); const forwarded = [...(argument ?? []), ...(rawUnknown ?? [])]; const testScript = await readTestScript(cwd, readText); // A package that already has a `test` script keeps it: that script is what the - // team runs, so it wins over the Vitest fallback. The warning says so up front. + // team runs, so it wins over the runner fallback. The warning says so up front. if (testScript !== undefined) { if (options.watch) { throw new VisUserError("--watch applies to Vitest, but this package runs its own `test` script. Run that script directly for watch mode."); @@ -101,7 +134,7 @@ const execute = async ({ argument, fs, logger, options, rawUnknown }: Toolbox 0 ? ["--", ...forwarded] : [])], { cwd, @@ -114,27 +147,45 @@ const execute = async ({ argument, fs, logger, options, rawUnknown }: Toolbox): Promise => runTests(toolbox); + // fallow-ignore-next-line unused-export -- lazy-loaded command entry (cerebro loader/lazyNamed dynamic import) export default execute as CommandExecute; diff --git a/packages/tooling/vis/src/util/tool-choice.ts b/packages/tooling/vis/src/util/tool-choice.ts new file mode 100644 index 00000000..c2d0091c --- /dev/null +++ b/packages/tooling/vis/src/util/tool-choice.ts @@ -0,0 +1,85 @@ +/** + * Pick one of several tools for a command that needs one and finds none. + * + * The choice is the consent: the question says what will be installed. Outside + * an interactive terminal nothing is chosen for the user; the caller gets an + * error that lists every option. + */ + +import { spawnSync } from "node:child_process"; + +import { VisUserError } from "../errors/vis-user-error"; +import { isInteractive as isInteractiveTerminal } from "./interactive"; +import { defaultReadline } from "./prompt"; + +export interface ToolChoice { + /** What picking this option does, e.g. the install command. */ + hint: string; + /** Shown in the list, e.g. "Vitest". */ + label: string; + /** Returned to the caller when this option is picked. */ + value: string; +} + +export interface ChooseToolDeps { + /** Override the interactivity check. */ + isInteractive?: () => boolean; + /** Override the line reader. */ + readline?: (question: string) => Promise; +} + +/** Installs `packageName` as a devDependency in `cwd`. Returns the process exit code. */ +export type Installer = (packageName: string, cwd: string) => number; + +/** + * Ask the user to pick one option. Returns the chosen option, or `undefined` when + * the user cancels (empty input, `q`, or a number out of range). Throws outside an + * interactive terminal, listing every option, so a CI run fails instead of guessing. + */ +export const chooseTool = async (question: string, choices: ReadonlyArray, deps: ChooseToolDeps = {}): Promise => { + const interactive = deps.isInteractive ?? isInteractiveTerminal; + + if (!interactive()) { + const options = choices.map((choice) => ` ${choice.label}: ${choice.hint}`).join("\n"); + + throw new VisUserError(`${question}\n${options}\nRun this in an interactive terminal to pick one.`); + } + + const readline = deps.readline ?? defaultReadline; + const list = choices.map((choice, index) => ` ${String(index + 1)}) ${choice.label} (${choice.hint})`).join("\n"); + const response = await readline(`${question}\n${list}\nEnter a number, or press Enter to cancel: `); + const answer = response.trim().toLowerCase(); + + if (answer === "" || answer === "q") { + return undefined; + } + + const index = Number(answer) - 1; + + if (!Number.isInteger(index) || index < 0 || index >= choices.length) { + return undefined; + } + + return choices[index]; +}; + +/** + * Install `packageName` with `vis add -D`, so workspace root handling, catalogs, + * and supply-chain checks apply. Runs vis as a subprocess, the same way the run + * services do. Returns the exit code, and never runs a tool after a failed install. + */ +export const installDevDependency = (packageName: string, cwd: string, logger: Pick, install: Installer = spawnVisAdd): number => { + logger.info(`Installing ${packageName} as a devDependency with \`vis add -D\`.`); + + return install(packageName, cwd); +}; + +const spawnVisAdd: Installer = (packageName, cwd) => { + const result = spawnSync(process.execPath, [process.argv[1]!, "add", "-D", packageName], { cwd, stdio: "inherit" }); + + if (result.error) { + throw result.error; + } + + return result.status ?? 1; +}; From caca0bf508ca2dfb73d90b14f7b371b7c39ec7c0 Mon Sep 17 00:00:00 2001 From: Daniel Bannert Date: Sun, 18 Oct 2026 00:56:33 +0200 Subject: [PATCH 6/6] docs(vis): list vis test in the command table Co-Authored-By: Claude Haiku 5.5 --- packages/tooling/vis/README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/tooling/vis/README.md b/packages/tooling/vis/README.md index 2ab22762..d18e1c92 100644 --- a/packages/tooling/vis/README.md +++ b/packages/tooling/vis/README.md @@ -239,6 +239,7 @@ When aube is the installer, `vis doctor` surfaces aube's effective hardening pos | `vis toolchain ` | | Inspect / delegate to the version manager (proto, mise, fnm, volta…) | | `vis lint [files]` | | Orchestrate detected source-code linters (oxlint, biome, eslint, stylelint) | | `vis fmt [files]` | | Orchestrate detected formatters (oxfmt, biome, dprint, prettier) | +| `vis test [files]` | | Run the package test script, or Vitest / Rstest (asks which to install when neither exists) | | `vis staged` | | Run tasks on staged files (built-in `lint-staged` replacement) | | `vis migrate ` | | Migrate from other tools — now including `gitleaks` and `secretlint` |