From 44c03845a4d0df2bc26fcf9e4f19250b9c9a32d3 Mon Sep 17 00:00:00 2001 From: oekazuma Date: Wed, 8 Jul 2026 15:58:05 +0900 Subject: [PATCH 1/2] feat(cli): detect SvelteKit apps in a monorepo and offer a picker Running `npx svelte-vitals` at a monorepo root with no path argument used to dead-end with exit 2 ("No SvelteKit project found"). Now, only when no explicit path was given and detection fails, it globs for svelte.config.{js,ts} + src/routes nearby: one match auto-continues with a stderr notice, multiple matches prompt an interactive single-select (clack, cancel = exit 0), and non-TTY environments get exit 2 with the app list and a path hint instead of a prompt. An explicit path's failure is never reinterpreted. Also fixes the "or pass --config" error text, which named a flag that doesn't exist. See docs/superpowers/specs/2026-07-08-monorepo-app-picker-design.md for the full design. --- .changeset/monorepo-app-picker.md | 5 + README.md | 2 + docs/src/content/docs/guides/cli.md | 17 +++ docs/src/content/docs/ja/guides/cli.md | 17 +++ .../2026-07-08-monorepo-app-picker-design.md | 98 ++++++++++++++ packages/cli/src/bin.ts | 12 +- packages/cli/src/discover-apps.ts | 23 ++++ packages/cli/src/index.ts | 66 +++++++++- packages/cli/src/providers/source/project.ts | 3 +- packages/cli/src/resolve-args.ts | 3 + packages/cli/test/discover-apps.test.ts | 38 ++++++ .../apps/admin/src/routes/+page.svelte | 5 + .../apps/admin/svelte.config.js | 1 + .../apps/web/src/routes/+page.svelte | 5 + .../apps/web/svelte.config.js | 1 + .../packages/ui/svelte.config.js | 1 + packages/cli/test/resolve-args.test.ts | 11 ++ packages/cli/test/run-discover.test.ts | 122 ++++++++++++++++++ packages/cli/test/run.test.ts | 8 +- 19 files changed, 427 insertions(+), 11 deletions(-) create mode 100644 .changeset/monorepo-app-picker.md create mode 100644 docs/superpowers/specs/2026-07-08-monorepo-app-picker-design.md create mode 100644 packages/cli/src/discover-apps.ts create mode 100644 packages/cli/test/discover-apps.test.ts create mode 100644 packages/cli/test/fixtures/monorepo-project/apps/admin/src/routes/+page.svelte create mode 100644 packages/cli/test/fixtures/monorepo-project/apps/admin/svelte.config.js create mode 100644 packages/cli/test/fixtures/monorepo-project/apps/web/src/routes/+page.svelte create mode 100644 packages/cli/test/fixtures/monorepo-project/apps/web/svelte.config.js create mode 100644 packages/cli/test/fixtures/monorepo-project/packages/ui/svelte.config.js create mode 100644 packages/cli/test/run-discover.test.ts diff --git a/.changeset/monorepo-app-picker.md b/.changeset/monorepo-app-picker.md new file mode 100644 index 000000000..7830e0ba5 --- /dev/null +++ b/.changeset/monorepo-app-picker.md @@ -0,0 +1,5 @@ +--- +'svelte-vitals': minor +--- + +Running `npx svelte-vitals` at a monorepo root with no path argument no longer dead-ends on "No SvelteKit project found": it detects SvelteKit apps underneath and either analyzes the only one found (with a stderr notice) or, in an interactive terminal, offers a single-select prompt to pick one. Non-interactive environments (CI, agents) still never prompt — they get exit `2` with the detected app list and a hint to pass a path explicitly. Passing an explicit path always skips detection, so existing invocations are unaffected. diff --git a/README.md b/README.md index ee7c35292..ac1082411 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,8 @@ npx svelte-vitals # analyze the current directory npx svelte-vitals ./apps/web # or a specific path ``` +At a monorepo root, running `npx svelte-vitals` with no path detects the SvelteKit apps underneath and either analyzes the only one found or lets you pick interactively — see [CLI: Monorepos](https://oekazuma.github.io/svelte-vitals/guides/cli/#monorepos). + Example output: ``` diff --git a/docs/src/content/docs/guides/cli.md b/docs/src/content/docs/guides/cli.md index e4cfb835b..a055b7cf4 100644 --- a/docs/src/content/docs/guides/cli.md +++ b/docs/src/content/docs/guides/cli.md @@ -17,6 +17,23 @@ svelte-vitals [path] [options] Flags below can also be set once in a `svelte-vitals.config` file at the project root instead of being repeated on every invocation — see [Config file](/svelte-vitals/guides/configuration/). A flag always overrides the config file. +## Monorepos + +Passing an explicit `path` (or running inside the app directory itself) always takes priority — svelte-vitals never second-guesses a target you named. + +When no `path` is given and the current directory isn't a SvelteKit app, svelte-vitals looks for SvelteKit apps nearby (directories with `svelte.config.{js,ts}` and `src/routes`) instead of failing immediately: + +- **Exactly one app found:** it's analyzed automatically, with a notice on stderr (`detected SvelteKit app at apps/web; analyzing it.`). +- **Multiple apps found, interactive terminal:** you get a single-select prompt to choose which one to analyze. Cancelling exits `0` without analyzing anything. +- **Multiple apps found, non-interactive (CI, agents, piped output):** svelte-vitals never prompts — it exits `2` with the list of detected apps and a hint to pass one explicitly, e.g. `npx svelte-vitals apps/web`. +- **No apps found:** the original "not a SvelteKit project" error, exit `2`. + +```bash +cd my-monorepo +npx svelte-vitals # detects apps/web + apps/admin, prompts to pick one (or auto-picks if there's only one) +npx svelte-vitals apps/web # skips detection entirely — analyzes apps/web directly +``` + ## Flags ### `--reporter ` diff --git a/docs/src/content/docs/ja/guides/cli.md b/docs/src/content/docs/ja/guides/cli.md index c99569825..dfb149d1c 100644 --- a/docs/src/content/docs/ja/guides/cli.md +++ b/docs/src/content/docs/ja/guides/cli.md @@ -17,6 +17,23 @@ svelte-vitals [path] [options] 以下のフラグは、毎回の実行で指定する代わりに、プロジェクトルートの `svelte-vitals.config` ファイルにまとめて一度だけ設定することもできます — 詳しくは [設定ファイル](/svelte-vitals/ja/guides/configuration/) を参照してください。フラグは常に設定ファイルより優先されます。 +## モノレポ + +明示的に `path` を渡した場合(またはアプリのディレクトリ自体で実行した場合)は常にそれが優先されます — svelte-vitals が指定されたターゲットを勝手に読み替えることはありません。 + +`path` を渡さず、かつカレントディレクトリが SvelteKit アプリでない場合、svelte-vitals はすぐに失敗する代わりに、近くの SvelteKit アプリ(`svelte.config.{js,ts}` と `src/routes` を持つディレクトリ)を探します: + +- **1 件だけ見つかった場合:** 自動的にそのアプリを解析します。stderr に通知が出ます(`detected SvelteKit app at apps/web; analyzing it.`)。 +- **複数見つかった場合(対話的な TTY):** どれを解析するか単一選択のプロンプトが表示されます。キャンセルすると、何も解析せずに終了コード `0` で終了します。 +- **複数見つかった場合(非対話的 — CI、エージェント、パイプ出力など):** svelte-vitals はプロンプトを一切出しません — 検出したアプリの一覧と、`npx svelte-vitals apps/web` のように明示的にパスを渡すヒントとともに終了コード `2` で終了します。 +- **見つからなかった場合:** 従来どおり「SvelteKit プロジェクトが見つからない」というエラーで終了コード `2` になります。 + +```bash +cd my-monorepo +npx svelte-vitals # apps/web と apps/admin を検出し、どちらか選択を促す(1件だけなら自動選択) +npx svelte-vitals apps/web # 検出をスキップし、apps/web を直接解析する +``` + ## フラグ ### `--reporter ` diff --git a/docs/superpowers/specs/2026-07-08-monorepo-app-picker-design.md b/docs/superpowers/specs/2026-07-08-monorepo-app-picker-design.md new file mode 100644 index 000000000..a7ec63df1 --- /dev/null +++ b/docs/superpowers/specs/2026-07-08-monorepo-app-picker-design.md @@ -0,0 +1,98 @@ +# Monorepo app auto-detection + interactive picker for the CLI + +**Date:** 2026-07-08 +**Status:** Accepted (maintainer-approved in session; implementation plan: `plans/019-monorepo-app-picker.md`) +**Packages:** `svelte-vitals` (CLI only — no core/mcp/vite changes) + +## Goal + +Running `npx svelte-vitals` at a monorepo root currently dead-ends with exit 2 +("No SvelteKit project found in the current directory"), forcing the user to +know and type the app path (`npx svelte-vitals ./apps/web`). Turn that dead end +into a helpful path: detect SvelteKit apps in the repository and either analyze +the only one found or let the user pick interactively. + +Requested by the maintainer on 2026-07-08. This lifts the previous deferral +("monorepo support: wait for real user demand", recorded in `plans/README.md`). + +## Decisions (maintainer-approved) + +1. **Trigger: failure-time only.** Discovery runs only when (a) no path + argument was given AND (b) `detectProject(cwd)` threw `ProjectError`. Every + currently-working invocation is untouched. When an **explicit** path fails + detection, the CLI still errors immediately — the user's stated target is + never silently reinterpreted. +2. **Non-TTY (CI, agents): never prompt.** Exit 2 with the detected app list + embedded in the error and a hint to pass a path + (`npx svelte-vitals apps/web`). No implicit selection in CI. +3. **TTY, exactly one app: auto-run.** Print + `svelte-vitals: detected SvelteKit app at apps/web; analyzing it.` to stderr + and continue. A one-option prompt is noise. +4. **TTY, multiple apps: single-select prompt** via `@clack/prompts` (already a + CLI dependency; same style as the `install` wizard). Cancel exits 0. + +## Detection method + +**Chosen: glob for `svelte.config.{js,ts}`, filtered by `src/routes`.** + +- Glob `**/svelte.config.{js,ts}` from cwd with ignores + `node_modules`, `.svelte-kit`, `build`, `dist`, `.git` and a depth cap of 4 + path segments. +- A candidate qualifies only if `/src/routes` also exists. This excludes + SvelteKit component libraries (svelte.config without routes) — there is + nothing for svelte-vitals to analyze there. +- Results are sorted by path for deterministic ordering. + +**Rejected alternative: workspace-manifest parsing** (`pnpm-workspace.yaml` +globs / `package.json#workspaces`). More "correct" on paper but needs YAML +parsing, misses monorepos that don't declare workspaces (plain dirs, some Nx +setups), and is more code for a narrower net. The glob approach is +tool-agnostic. + +## Flow + +``` +run(opts) + └─ analyzeProject(cwd) throws ProjectError + ├─ opts.cwd was an explicit CLI path → error, exit 2 (unchanged) + └─ no explicit path: + apps = discoverApps(cwd) + ├─ 0 apps → original error (reworded, see below), exit 2 + ├─ 1 app → stderr notice, re-run analysis with that dir as cwd + ├─ >1 apps, TTY → clack select → re-run with chosen dir (cancel → exit 0) + └─ >1 apps, non-TTY → exit 2, error lists apps + "pass a path" hint +``` + +After selection the chosen directory becomes the analysis `cwd`, so the +`svelte-vitals.config.*` file is read from the selected app (existing +semantics) and `--diff`/`--staged`/`--baseline` keep working — their +subdirectory handling shipped in plans 001/014. + +The picker is injected into `run()` as an optional function option +(test-injectable, like the `install` wizard's `InstallPrompts`), defaulting to +a clack implementation in `bin.ts`'s wiring. + +## Targeted fix riding along + +`detectProject`'s error message ends with "or pass --config." — `--config` is +not a flag that exists. Since this message is being reworked anyway, it becomes: +`No SvelteKit project found in the current directory. Run this inside a SvelteKit app, or pass a path (e.g. npx svelte-vitals apps/web).` + +## Non-goals (YAGNI) + +- Analyzing multiple/all detected apps in one run — aggregate-Health semantics + is a separate design question. +- The same discovery in `@svelte-vitals/mcp` or `@svelte-vitals/vite` + (`analyzeProject` keeps throwing; discovery is a CLI-UX concern). +- Workspace-manifest parsing. +- A flag to force the picker (`--pick`) — failure-time trigger covers the need. + +## Test plan + +Fixture: minimal monorepo under `packages/cli/test/fixtures/` with +`apps/web` + `apps/admin` (both SvelteKit apps with routes) and `packages/ui` +(svelte.config, no routes → must be excluded). + +Paths pinned by tests: 0 apps / 1 app auto-run / multiple + TTY select / +multiple + TTY cancel / multiple + non-TTY error / explicit-path failure stays +an immediate error / `packages/ui` exclusion / depth cap and ignore dirs. diff --git a/packages/cli/src/bin.ts b/packages/cli/src/bin.ts index 5efcdb046..bb1d0c08b 100644 --- a/packages/cli/src/bin.ts +++ b/packages/cli/src/bin.ts @@ -1,5 +1,6 @@ #!/usr/bin/env node import mri from 'mri'; +import * as p from '@clack/prompts'; import { run } from './index.js'; import { readPackageVersion } from './version.js'; import { resolveArgs } from './resolve-args.js'; @@ -46,6 +47,15 @@ Exit codes: const VERSION = readPackageVersion(); +/** Monorepo app picker (design doc 2026-07-08-monorepo-app-picker-design.md): single-select via @clack/prompts, same style as the `install` wizard. */ +async function selectApp(apps: string[]): Promise { + const res = await p.select({ + message: 'Multiple SvelteKit apps found — which one should svelte-vitals analyze?', + options: apps.map((a) => ({ value: a, label: a })) + }); + return p.isCancel(res) ? null : (res as string); +} + async function main(): Promise { const rawArgs = process.argv.slice(2); if (rawArgs[0] === 'install') { @@ -102,7 +112,7 @@ async function main(): Promise { minHealth = n; } - const code = await run({ ...options, minHealth, noColor: argv['no-color'] }); + const code = await run({ ...options, minHealth, noColor: argv['no-color'], selectApp }); process.exit(code); } diff --git a/packages/cli/src/discover-apps.ts b/packages/cli/src/discover-apps.ts new file mode 100644 index 000000000..044cad836 --- /dev/null +++ b/packages/cli/src/discover-apps.ts @@ -0,0 +1,23 @@ +import { existsSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { glob } from 'tinyglobby'; + +/** + * Find SvelteKit apps under `cwd` for the monorepo picker (design doc + * 2026-07-08-monorepo-app-picker-design.md): directories containing + * svelte.config.{js,ts} AND src/routes (excludes component libraries, which + * have a config but nothing to analyze). Returns sorted cwd-relative POSIX + * paths. Depth-capped and ignore-listed so a huge repo stays fast. + */ +export async function discoverApps(cwd: string): Promise { + const configs = await glob('**/svelte.config.{js,ts}', { + cwd, + dot: false, + deep: 4, + ignore: ['**/node_modules/**', '**/.svelte-kit/**', '**/build/**', '**/dist/**', '**/.git/**'] + }); + const dirs = [...new Set(configs.map((c) => dirname(c)))].filter( + (d) => d !== '.' && existsSync(join(cwd, d, 'src', 'routes')) + ); + return dirs.sort(); +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index b0028f733..d14ecd790 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -1,5 +1,5 @@ import { mkdirSync, writeFileSync } from 'node:fs'; -import { dirname } from 'node:path'; +import { dirname, join } from 'node:path'; import { allRules, runRules, @@ -26,6 +26,7 @@ import { createNodeRuntime } from './runtime/node.js'; import { collectRoutes } from './providers/source/routes.js'; import { collectComponentFacts } from './providers/source/components.js'; import { detectProject, ProjectError, collectProjectFacts } from './providers/source/project.js'; +import { discoverApps } from './discover-apps.js'; import { readPackageVersion } from './version.js'; import { resolveReporter, isAutoDetectedAgent, isAutoDetectedGithub, type ReporterName } from './reporter-resolve.js'; import { getChangedFiles, filterToChangedFiles } from './changed-files.js'; @@ -72,6 +73,10 @@ export interface RunOptions { stdoutIsTTY?: boolean; /** Override stderr TTY detection (tests). */ stderrIsTTY?: boolean; + /** True when the user passed a path argument — discovery must not run (design: never reinterpret an explicit target). */ + explicitPath?: boolean; + /** Injected picker for the monorepo app selector (bin.ts wires a clack implementation; null = cancelled). */ + selectApp?: (apps: string[]) => Promise; } /** @@ -208,10 +213,12 @@ export async function run(opts: RunOptions = {}): Promise { }) }); + let cwd = opts.cwd ?? process.cwd(); + let analysis: AnalyzeResult; try { analysis = await analyzeProject({ - cwd: opts.cwd ?? process.cwd(), + cwd, metaComponents: opts.metaComponents, treatDynamicAs: opts.treatDynamicAs, route: opts.route, @@ -223,11 +230,58 @@ export async function run(opts: RunOptions = {}): Promise { } catch (err) { spinner.stop(); if (err instanceof ProjectError) { - errorLog(err.message); + // Monorepo app auto-detection + picker (design doc 2026-07-08-monorepo-app-picker-design.md): + // only kicks in when the user didn't name a target — an explicit path's failure is never + // silently reinterpreted. + if (opts.explicitPath) { + errorLog(err.message); + return 2; + } + const apps = await discoverApps(cwd); + if (apps.length === 0) { + errorLog(err.message); + return 2; + } + let chosen: string; + if (apps.length === 1) { + errorLog(`svelte-vitals: detected SvelteKit app at ${apps[0]}; analyzing it.`); + chosen = apps[0]!; + } else if ((opts.stdoutIsTTY ?? !!process.stdout.isTTY) && opts.selectApp) { + const selection = await opts.selectApp(apps); + if (selection === null) { + log('Cancelled.'); + return 0; + } + chosen = selection; + } else { + errorLog(`svelte-vitals: multiple SvelteKit apps found: ${apps.join(', ')}.`); + errorLog(`svelte-vitals: pass one as a path, e.g. \`npx svelte-vitals ${apps[0]}\`.`); + return 2; + } + cwd = join(cwd, chosen); + try { + analysis = await analyzeProject({ + cwd, + metaComponents: opts.metaComponents, + treatDynamicAs: opts.treatDynamicAs, + route: opts.route, + failOn: opts.failOn, + rules: opts.rules, + weights: opts.weights, + categories: opts.categories + }); + } catch (err2) { + if (err2 instanceof ProjectError) { + errorLog(err2.message); + return 2; + } + errorLog(`svelte-vitals: ${err2 instanceof Error ? err2.message : String(err2)}`); + return 2; + } + } else { + errorLog(`svelte-vitals: ${err instanceof Error ? err.message : String(err)}`); return 2; } - errorLog(`svelte-vitals: ${err instanceof Error ? err.message : String(err)}`); - return 2; } spinner.stop(); @@ -239,7 +293,6 @@ export async function run(opts: RunOptions = {}): Promise { // --staged / --diff: scope findings to the changed files (gate "what the agent wrote"). if (opts.staged || opts.diffBase !== undefined) { - const cwd = opts.cwd ?? process.cwd(); const changed = opts.staged ? getChangedFiles(cwd, { staged: true }) : getChangedFiles(cwd, { base: opts.diffBase }); @@ -253,7 +306,6 @@ export async function run(opts: RunOptions = {}): Promise { } if (opts.baseline !== undefined) { - const cwd = opts.cwd ?? process.cwd(); const checkout = checkoutBaseline(cwd, opts.baseline); if (checkout === undefined) { errorLog( diff --git a/packages/cli/src/providers/source/project.ts b/packages/cli/src/providers/source/project.ts index 619b1933e..4b5596eeb 100644 --- a/packages/cli/src/providers/source/project.ts +++ b/packages/cli/src/providers/source/project.ts @@ -43,7 +43,8 @@ export async function detectProject(rt: Runtime, cwd: string): Promise { if (hasKitDep || (hasConfig && hasRoutes)) return; throw new ProjectError( - 'No SvelteKit project found in the current directory. ' + 'Run this inside a SvelteKit app, or pass --config.' + 'No SvelteKit project found in the current directory. ' + + 'Run this inside a SvelteKit app, or pass a path (e.g. npx svelte-vitals apps/web).' ); } diff --git a/packages/cli/src/resolve-args.ts b/packages/cli/src/resolve-args.ts index e2ad12829..93ef34464 100644 --- a/packages/cli/src/resolve-args.ts +++ b/packages/cli/src/resolve-args.ts @@ -221,6 +221,9 @@ export function resolveArgs(argv: mri.Argv): ResolvedArgs { return { options: { cwd: positional ?? process.cwd(), + // Never reinterpret an explicit target (design doc 2026-07-08-monorepo-app-picker-design.md, + // decision 1): the monorepo picker in run() only triggers when this is false. + explicitPath: positional !== undefined, metaComponents, treatDynamicAs, route, diff --git a/packages/cli/test/discover-apps.test.ts b/packages/cli/test/discover-apps.test.ts new file mode 100644 index 000000000..63e85457f --- /dev/null +++ b/packages/cli/test/discover-apps.test.ts @@ -0,0 +1,38 @@ +import { describe, it, expect } from 'vitest'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; +import { discoverApps } from '../src/discover-apps.js'; + +const here = dirname(fileURLToPath(import.meta.url)); +const monorepoFixture = join(here, 'fixtures', 'monorepo-project'); + +describe('discoverApps', () => { + it('returns sorted app dirs with svelte.config.{js,ts} + src/routes', async () => { + expect(await discoverApps(monorepoFixture)).toEqual(['apps/admin', 'apps/web']); + }); + + it('excludes a component library (svelte.config without src/routes)', async () => { + const apps = await discoverApps(monorepoFixture); + expect(apps).not.toContain('packages/ui'); + }); + + it('returns [] in a directory tree with no SvelteKit apps', async () => { + const cwd = await mkdtemp(join(tmpdir(), 'sv-discover-empty-')); + try { + expect(await discoverApps(cwd)).toEqual([]); + } finally { + await rm(cwd, { recursive: true, force: true }); + } + }); + + it('does not include cwd itself even if it has a qualifying svelte.config', async () => { + // basic-project qualifies via package.json's @sveltejs/kit dep, not svelte.config, so + // exercise the exclusion directly against a fixture with a root-level svelte.config. + const rootFixture = join(here, 'fixtures', 'monorepo-project', 'apps', 'web'); + const apps = await discoverApps(rootFixture); + expect(apps).not.toContain('.'); + expect(apps).toEqual([]); + }); +}); diff --git a/packages/cli/test/fixtures/monorepo-project/apps/admin/src/routes/+page.svelte b/packages/cli/test/fixtures/monorepo-project/apps/admin/src/routes/+page.svelte new file mode 100644 index 000000000..89a027d4e --- /dev/null +++ b/packages/cli/test/fixtures/monorepo-project/apps/admin/src/routes/+page.svelte @@ -0,0 +1,5 @@ + + + + +

Admin app — no title here

diff --git a/packages/cli/test/fixtures/monorepo-project/apps/admin/svelte.config.js b/packages/cli/test/fixtures/monorepo-project/apps/admin/svelte.config.js new file mode 100644 index 000000000..ff8b4c563 --- /dev/null +++ b/packages/cli/test/fixtures/monorepo-project/apps/admin/svelte.config.js @@ -0,0 +1 @@ +export default {}; diff --git a/packages/cli/test/fixtures/monorepo-project/apps/web/src/routes/+page.svelte b/packages/cli/test/fixtures/monorepo-project/apps/web/src/routes/+page.svelte new file mode 100644 index 000000000..31e6bce5b --- /dev/null +++ b/packages/cli/test/fixtures/monorepo-project/apps/web/src/routes/+page.svelte @@ -0,0 +1,5 @@ + + + + +

Web app — no title here

diff --git a/packages/cli/test/fixtures/monorepo-project/apps/web/svelte.config.js b/packages/cli/test/fixtures/monorepo-project/apps/web/svelte.config.js new file mode 100644 index 000000000..ff8b4c563 --- /dev/null +++ b/packages/cli/test/fixtures/monorepo-project/apps/web/svelte.config.js @@ -0,0 +1 @@ +export default {}; diff --git a/packages/cli/test/fixtures/monorepo-project/packages/ui/svelte.config.js b/packages/cli/test/fixtures/monorepo-project/packages/ui/svelte.config.js new file mode 100644 index 000000000..ff8b4c563 --- /dev/null +++ b/packages/cli/test/fixtures/monorepo-project/packages/ui/svelte.config.js @@ -0,0 +1 @@ +export default {}; diff --git a/packages/cli/test/resolve-args.test.ts b/packages/cli/test/resolve-args.test.ts index fcd22739d..3de324baa 100644 --- a/packages/cli/test/resolve-args.test.ts +++ b/packages/cli/test/resolve-args.test.ts @@ -196,4 +196,15 @@ describe('resolveArgs', () => { expect(options?.score).toBe(true); expect(warnings.some((w) => w.includes('--score overrides --reporter'))).toBe(true); }); + + it('sets explicitPath:true when a positional path is passed', () => { + const { options } = resolve('apps/web'); + expect(options?.explicitPath).toBe(true); + expect(options?.cwd).toBe('apps/web'); + }); + + it('sets explicitPath:false when no positional path is passed', () => { + const { options } = resolve('--json'); + expect(options?.explicitPath).toBe(false); + }); }); diff --git a/packages/cli/test/run-discover.test.ts b/packages/cli/test/run-discover.test.ts new file mode 100644 index 000000000..43bce7377 --- /dev/null +++ b/packages/cli/test/run-discover.test.ts @@ -0,0 +1,122 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; +import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { run } from '../src/index.js'; + +const here = dirname(fileURLToPath(import.meta.url)); +const monorepoFixture = join(here, 'fixtures', 'monorepo-project'); +const CLEAN_ENV: NodeJS.ProcessEnv = {}; + +function capture() { + const out: string[] = []; + const err: string[] = []; + return { out, err, log: (l: string) => out.push(l), errorLog: (l: string) => err.push(l) }; +} + +describe('run(): monorepo app discovery + picker (design doc 2026-07-08-monorepo-app-picker-design.md)', () => { + it('TTY + multiple apps: selectApp is offered and the chosen app is analyzed', async () => { + const cap = capture(); + const selectApp = vi.fn(async (apps: string[]) => apps[apps.indexOf('apps/web')] ?? apps[0]!); + const code = await run({ + cwd: monorepoFixture, + log: cap.log, + errorLog: cap.errorLog, + env: CLEAN_ENV, + stdoutIsTTY: true, + selectApp + }); + expect(selectApp).toHaveBeenCalledWith(['apps/admin', 'apps/web']); + expect(code).toBe(1); // both fixture apps are missing a -> SEO001 critical + expect(cap.out.join('\n')).toContain('SEO001'); + }); + + it('TTY + multiple apps + cancel (selectApp returns null): exit 0, "Cancelled."', async () => { + const cap = capture(); + const selectApp = vi.fn(async () => null); + const code = await run({ + cwd: monorepoFixture, + log: cap.log, + errorLog: cap.errorLog, + env: CLEAN_ENV, + stdoutIsTTY: true, + selectApp + }); + expect(code).toBe(0); + expect(cap.out.join('\n')).toContain('Cancelled.'); + }); + + it('non-TTY + multiple apps: exit 2 with the app list and a path hint; selectApp is never called', async () => { + const cap = capture(); + const selectApp = vi.fn(async () => 'apps/web'); + const code = await run({ + cwd: monorepoFixture, + log: cap.log, + errorLog: cap.errorLog, + env: CLEAN_ENV, + stdoutIsTTY: false, + selectApp + }); + expect(code).toBe(2); + expect(selectApp).not.toHaveBeenCalled(); + const errOutput = cap.err.join('\n'); + expect(errOutput).toContain('multiple SvelteKit apps found: apps/admin, apps/web'); + expect(errOutput).toContain('npx svelte-vitals apps/admin'); + }); + + it('explicitPath:true + non-SvelteKit cwd: immediate exit 2, no discovery, selectApp never called', async () => { + const cap = capture(); + const selectApp = vi.fn(async () => 'apps/web'); + const code = await run({ + cwd: monorepoFixture, // has discoverable apps, but explicitPath must suppress discovery + explicitPath: true, + log: cap.log, + errorLog: cap.errorLog, + env: CLEAN_ENV, + stdoutIsTTY: true, + selectApp + }); + expect(code).toBe(2); + expect(selectApp).not.toHaveBeenCalled(); + expect(cap.err.join('\n')).toContain('No SvelteKit project found'); + }); + + it('zero apps found: falls back to the original (reworded) error, exit 2', async () => { + const cwd = await mkdtemp(join(tmpdir(), 'sv-discover-none-')); + try { + const cap = capture(); + const code = await run({ cwd, log: cap.log, errorLog: cap.errorLog, env: CLEAN_ENV, stdoutIsTTY: true }); + expect(code).toBe(2); + expect(cap.err.join('\n')).toContain( + 'No SvelteKit project found in the current directory. Run this inside a SvelteKit app, or pass a path (e.g. npx svelte-vitals apps/web).' + ); + } finally { + await rm(cwd, { recursive: true, force: true }); + } + }); +}); + +describe('run(): monorepo discovery, exactly one app found', () => { + let cwd: string; + beforeEach(async () => { + cwd = await mkdtemp(join(tmpdir(), 'sv-discover-one-')); + await mkdir(join(cwd, 'apps/web/src/routes'), { recursive: true }); + await writeFile(join(cwd, 'apps/web/svelte.config.js'), 'export default {};\n'); + await writeFile( + join(cwd, 'apps/web/src/routes/+page.svelte'), + '<svelte:head>\n <meta property="og:type" content="website" />\n</svelte:head>\n\n<h1>No title</h1>\n' + ); + }); + afterEach(async () => rm(cwd, { recursive: true, force: true })); + + it('auto-continues with a stderr notice and analyzes the sole app', async () => { + const cap = capture(); + const selectApp = vi.fn(async () => 'apps/web'); // must not be called for a single match + const code = await run({ cwd, log: cap.log, errorLog: cap.errorLog, env: CLEAN_ENV, stdoutIsTTY: true, selectApp }); + expect(selectApp).not.toHaveBeenCalled(); + expect(cap.err.join('\n')).toContain('detected SvelteKit app at apps/web; analyzing it.'); + expect(code).toBe(1); + expect(cap.out.join('\n')).toContain('SEO001'); + }); +}); diff --git a/packages/cli/test/run.test.ts b/packages/cli/test/run.test.ts index 2dd574290..409e0c108 100644 --- a/packages/cli/test/run.test.ts +++ b/packages/cli/test/run.test.ts @@ -35,9 +35,13 @@ describe('run() end-to-end', () => { expect(report).toContain('↯ dynamic'); // /dynamic passes with marker }); - it('returns exit 2 for a non-SvelteKit directory', async () => { + it('returns exit 2 for a non-SvelteKit directory (explicit path — no monorepo discovery)', async () => { const cap = capture(); - const code = await run({ cwd: here, log: cap.log, errorLog: cap.errorLog, env: CLEAN_ENV }); + // explicitPath: true because `here` stands in for a user-provided path in this test; + // otherwise run() would try the monorepo picker (design doc + // 2026-07-08-monorepo-app-picker-design.md) since this dir's own subtree contains the + // discover-apps.test.ts fixtures. See run-discover.test.ts for the discovery paths. + const code = await run({ cwd: here, explicitPath: true, log: cap.log, errorLog: cap.errorLog, env: CLEAN_ENV }); expect(code).toBe(2); expect(cap.err.join('\n')).toContain('No SvelteKit project found'); }); From ed982078db5f124458f8ff775370da090b3e96d3 Mon Sep 17 00:00:00 2001 From: oekazuma <oe.kazuma@a-tm.co.jp> Date: Wed, 8 Jul 2026 16:11:12 +0900 Subject: [PATCH 2/2] fix(cli): require an interactive stdin before offering the monorepo picker @clack/prompts reads from stdin, so gating the picker only on stdout TTY could hang the prompt when stdin is piped or redirected while stdout is a terminal. Gate on both streams; a non-interactive stdin now takes the existing non-TTY fallback (app list + path hint, exit 2). --- packages/cli/src/index.ts | 10 +++++++++- packages/cli/test/run-discover.test.ts | 20 ++++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index d14ecd790..bbfbd6a31 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -73,6 +73,8 @@ export interface RunOptions { stdoutIsTTY?: boolean; /** Override stderr TTY detection (tests). */ stderrIsTTY?: boolean; + /** Override stdin TTY detection (tests). */ + stdinIsTTY?: boolean; /** True when the user passed a path argument — discovery must not run (design: never reinterpret an explicit target). */ explicitPath?: boolean; /** Injected picker for the monorepo app selector (bin.ts wires a clack implementation; null = cancelled). */ @@ -246,7 +248,13 @@ export async function run(opts: RunOptions = {}): Promise<number> { if (apps.length === 1) { errorLog(`svelte-vitals: detected SvelteKit app at ${apps[0]}; analyzing it.`); chosen = apps[0]!; - } else if ((opts.stdoutIsTTY ?? !!process.stdout.isTTY) && opts.selectApp) { + } else if ( + // clack reads from stdin and renders to stdout, so both must be interactive — + // a piped/redirected stdin would leave the prompt hanging for input that never comes. + (opts.stdinIsTTY ?? !!process.stdin.isTTY) && + (opts.stdoutIsTTY ?? !!process.stdout.isTTY) && + opts.selectApp + ) { const selection = await opts.selectApp(apps); if (selection === null) { log('Cancelled.'); diff --git a/packages/cli/test/run-discover.test.ts b/packages/cli/test/run-discover.test.ts index 43bce7377..6d3107ef7 100644 --- a/packages/cli/test/run-discover.test.ts +++ b/packages/cli/test/run-discover.test.ts @@ -24,6 +24,7 @@ describe('run(): monorepo app discovery + picker (design doc 2026-07-08-monorepo log: cap.log, errorLog: cap.errorLog, env: CLEAN_ENV, + stdinIsTTY: true, stdoutIsTTY: true, selectApp }); @@ -40,6 +41,7 @@ describe('run(): monorepo app discovery + picker (design doc 2026-07-08-monorepo log: cap.log, errorLog: cap.errorLog, env: CLEAN_ENV, + stdinIsTTY: true, stdoutIsTTY: true, selectApp }); @@ -65,6 +67,24 @@ describe('run(): monorepo app discovery + picker (design doc 2026-07-08-monorepo expect(errOutput).toContain('npx svelte-vitals apps/admin'); }); + it('piped stdin (stdin not a TTY, stdout a TTY): non-TTY fallback — exit 2 with the list, no prompt', async () => { + // clack reads from stdin; prompting with a piped/redirected stdin would hang forever. + const cap = capture(); + const selectApp = vi.fn(async () => 'apps/web'); + const code = await run({ + cwd: monorepoFixture, + log: cap.log, + errorLog: cap.errorLog, + env: CLEAN_ENV, + stdinIsTTY: false, + stdoutIsTTY: true, + selectApp + }); + expect(code).toBe(2); + expect(selectApp).not.toHaveBeenCalled(); + expect(cap.err.join('\n')).toContain('multiple SvelteKit apps found: apps/admin, apps/web'); + }); + it('explicitPath:true + non-SvelteKit cwd: immediate exit 2, no discovery, selectApp never called', async () => { const cap = capture(); const selectApp = vi.fn(async () => 'apps/web');