Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/monorepo-app-picker.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

```
Expand Down
17 changes: 17 additions & 0 deletions docs/src/content/docs/guides/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <fmt>`
Expand Down
17 changes: 17 additions & 0 deletions docs/src/content/docs/ja/guides/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <fmt>`
Expand Down
98 changes: 98 additions & 0 deletions docs/superpowers/specs/2026-07-08-monorepo-app-picker-design.md
Original file line number Diff line number Diff line change
@@ -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 `<dir>/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.
12 changes: 11 additions & 1 deletion packages/cli/src/bin.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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<string | null> {
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<void> {
const rawArgs = process.argv.slice(2);
if (rawArgs[0] === 'install') {
Expand Down Expand Up @@ -102,7 +112,7 @@ async function main(): Promise<void> {
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);
}

Expand Down
23 changes: 23 additions & 0 deletions packages/cli/src/discover-apps.ts
Original file line number Diff line number Diff line change
@@ -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<string[]> {
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();
}
74 changes: 67 additions & 7 deletions packages/cli/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { mkdirSync, writeFileSync } from 'node:fs';
import { dirname } from 'node:path';
import { dirname, join } from 'node:path';
import {
allRules,
runRules,
Expand All @@ -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';
Expand Down Expand Up @@ -72,6 +73,12 @@ 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). */
selectApp?: (apps: string[]) => Promise<string | null>;
}

/**
Expand Down Expand Up @@ -208,10 +215,12 @@ export async function run(opts: RunOptions = {}): Promise<number> {
})
});

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,
Expand All @@ -223,11 +232,64 @@ export async function run(opts: RunOptions = {}): Promise<number> {
} 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 (
// 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.');
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();

Expand All @@ -239,7 +301,6 @@ export async function run(opts: RunOptions = {}): Promise<number> {

// --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 });
Expand All @@ -253,7 +314,6 @@ export async function run(opts: RunOptions = {}): Promise<number> {
}

if (opts.baseline !== undefined) {
const cwd = opts.cwd ?? process.cwd();
const checkout = checkoutBaseline(cwd, opts.baseline);
if (checkout === undefined) {
errorLog(
Expand Down
3 changes: 2 additions & 1 deletion packages/cli/src/providers/source/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,8 @@ export async function detectProject(rt: Runtime, cwd: string): Promise<void> {
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).'
);
}

Expand Down
3 changes: 3 additions & 0 deletions packages/cli/src/resolve-args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading