Skip to content

Commit c4c2217

Browse files
committed
fixes
1 parent f206f6e commit c4c2217

File tree

9 files changed

+231
-9
lines changed

9 files changed

+231
-9
lines changed

docs/monorepo-skill.md

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
# Wizard monorepo: the final boss of skills
2+
3+
The wizard now detects and instruments monorepo projects. It's probably the hardest skill I've worked on to date, because no two monorepos are alike. Here's what I built, what broke, and what still needs eyes.
4+
5+
## What makes monorepos hard
6+
7+
Three things:
8+
9+
1. Detection is multi-dimensional
10+
2. Not everything detected is an app
11+
3. Concurrency is hard
12+
13+
Let's take them one at a time.
14+
15+
## Detection
16+
17+
Single-project detection is simple: scan files, match framework, go. Monorepos add two layers.
18+
19+
**Layer 1: What workspace manager is this thing using?** pnpm workspaces? npm? Yarn? Nx? Lerna? Each one declares member packages differently. pnpm uses `pnpm-workspace.yaml`. npm/yarn use a `workspaces` field in `package.json`. Nx uses `project.json` files scattered across subdirectories. And some monorepos don't use any formal config at all — just a bunch of directories with stuff in them.
20+
21+
The detection order in `workspace-detection.ts` is: pnpm → npm/yarn → Lerna → Nx → heuristic fallback. Turborepo isn't its own detector — it's a label upgrade. When `turbo.json` exists alongside a pnpm or npm/yarn workspace, we relabel the type as `'turbo'` so downstream code (like context-mill commandments) can act on it.
22+
23+
```ts
24+
const formal =
25+
(await detectPnpmWorkspace(rootDir)) ??
26+
(await detectNpmOrYarnWorkspace(rootDir)) ??
27+
(await detectLernaWorkspace(rootDir)) ??
28+
(await detectNxWorkspace(rootDir));
29+
30+
if (formal) {
31+
return supplementWithPolyglotProjects(formal);
32+
}
33+
return detectHeuristic(rootDir);
34+
```
35+
36+
**Layer 2: Monorepos are polyglot.** JS workspace configs only know about JS packages. Real monorepos have Python services, PHP backends, mobile apps sitting right alongside. These live outside the workspace config entirely.
37+
38+
So I built a two-pass system. First, detect any formal workspace config. Then scan for non-JS project indicators the workspace config missed — `pyproject.toml`, `manage.py`, `composer.json`, `build.gradle`, `Package.swift`, `.xcodeproj`. Merge them into the member list. The supplement only scans depth 1-2 and deduplicates against existing members.
39+
40+
## Filtering non-apps
41+
42+
In a single project, if we detect Python, it's almost certainly an app the user wants instrumented. In monorepos, half the detected projects are libraries, build tools, or Playwright test suites.
43+
44+
`app-detection.ts` handles this. Framework-specific integrations (Django, FastAPI, Next.js, etc.) always pass — their detection signals are inherently app signals. But language-level fallbacks (generic Python, generic JS) need to prove they're actually apps:
45+
46+
- **Python**: needs entry point files (`main.py`, `app.py`, `wsgi.py`, etc.) OR script definitions in `pyproject.toml`
47+
- **JS**: needs HTML entry points, server entry points, OR `start`/`dev`/`serve` scripts with 8+ dependencies (tiny packages with a start script are usually build watchers)
48+
49+
Before any of that, there's a dev-tool blocklist. If your dependencies include `@storybook/`, `@playwright/`, or `cypress`, you're not an app. Sorry.
50+
51+
## Concurrency
52+
53+
Single projects: one agent, linear flow, direct terminal output. Monorepos need N agents running simultaneously.
54+
55+
Two problems surfaced immediately.
56+
57+
**Terminal output becomes garbage.** Four agents writing to stdout at once means interleaved log messages and fighting spinners. The fix: a Proxy-based output silencer in `clack.ts`. The entire clack module is wrapped in a `Proxy` that checks an `AsyncLocalStorage` context. In silent mode: output functions become no-ops, interactive prompts throw errors (safety net — they should never run in concurrent context).
58+
59+
```ts
60+
export function withSilentOutput<T>(fn: () => Promise<T>): Promise<T> {
61+
return silentStore.run({ silent: true }, fn);
62+
}
63+
```
64+
65+
But users still need to see progress. The progress spinner is captured *before* entering silent mode. Its `.message()` calls still write to real stdout because the spinner object references real clack, not the proxy. Live updates: `2/4 done (latest: apps/web)`.
66+
67+
**Log files are useless.** All agents writing to the same `/tmp/posthog-wizard.log` means you can't debug individual failures. Fix: another `AsyncLocalStorage` in `debug.ts` for per-project log scoping. Each agent runs inside `withLogFile('/tmp/posthog-wizard-{slug}.log', ...)`.
68+
69+
## Architecture
70+
71+
### Three-phase execution
72+
73+
**Phase 1 — Preparation (sequential).** For each project: version check, package detection, `gatherContext()`. Sequential because some frameworks prompt the user interactively.
74+
75+
**Phase 2 — Instrumentation (concurrent).** All agents launched via `Promise.allSettled`. Each wrapped in `withLogFile()` + `withSilentOutput()`. Prompt fencing tells each agent to stay within its project directory. `Promise.allSettled` gives us two things: deterministic result ordering AND error isolation — one project failing doesn't kill the others.
76+
77+
**Phase 3 — Post-flight (sequential).** MCP client installation (once). Env var upload to hosting (once, merged across all successful projects). Combined summary.
78+
79+
### Shared setup
80+
81+
OAuth, region selection, git checks, AI consent — all run once via `runSharedSetup()`. Each agent receives the result via `mode.sharedSetup`.
82+
83+
### Prompt fencing
84+
85+
In concurrent mode, each agent gets a scope-fencing instruction:
86+
87+
```ts
88+
if (mode?.concurrentFence) {
89+
integrationPrompt += `IMPORTANT: This project is being set up as part of a monorepo...
90+
You MUST only modify files within ${options.installDir}...`;
91+
}
92+
```
93+
94+
The actual prompt is longer — it also includes guidance about `set_env_values`/`check_env_keys` relative paths and not touching sibling packages.
95+
96+
### The agent runner decomposition
97+
98+
The original agent runner was one big function. To support both single-project and monorepo runs without duplicating code, I broke it into composable phases:
99+
100+
- **`runSharedSetup(options, docsUrl)`** — AI consent, Anthropic status check, region, git check, OAuth. Returns `Promise<SharedSetupData>`.
101+
- **`runPreflight(config, options)`** — Version check, TS detection, package.json detection, framework version, `gatherContext()`, analytics tags. Returns `Promise<PreflightData | null>`.
102+
- **`runAgentWizard(config, options, mode?)`** — The main agent runner. `mode` is typed as `AgentWizardMode & { preflight?: PreflightData }`. When called without `mode` (single-project), it calls `runSharedSetup` and `runPreflight` inline — same flow as main. When called with `mode` (monorepo), it skips the phases already run externally.
103+
- **`handleAgentErrors()`** — Extracted error handling. Changed from `process.exit(1)` to `throw DisplayedError`, so the monorepo orchestrator can continue with other projects when one fails. `run.ts` checks `instanceof DisplayedError` to avoid double-printing.
104+
105+
**When `runAgentWizard(config, options)` is called with no third argument, behavior is functionally identical to main. The mode parameter is additive.**
106+
107+
### The monorepo flow
108+
109+
`monorepo-flow.ts` is purely new orchestration code. It does NOT contain any code extracted from the original wizard. It imports and composes the exported functions:
110+
111+
```ts
112+
const sharedSetup = await runSharedSetup(options);
113+
const preflight = await runPreflight(config, projectOptions);
114+
await runAgentWizard(config, projectOptions, {
115+
sharedSetup,
116+
preflight,
117+
skipPostAgent: true,
118+
concurrentFence: true,
119+
// also: onStatus callback, additionalContext based on workspaceType
120+
});
121+
```
122+
123+
One area of intentional duplication: the post-flight env upload. The monorepo version merges env vars across all successful sub-projects into a single batch. Fundamentally different from the per-project version, so it can't just delegate.
124+
125+
## Changes from main
126+
127+
**Error handling.** Agent errors now `throw DisplayedError` instead of `process.exit(1)`. The `run.ts` catch block checks `instanceof DisplayedError` to skip double-printing.
128+
129+
**Execution order in single-project mode.** OAuth moved earlier (before framework detection). This is because `runSharedSetup()` groups all "shared" steps together. No step depends on the old ordering. Harmless.
130+
131+
**Abort message.** `runSharedSetup()` says "set up PostHog manually" instead of the framework-specific name. Shared setup runs before any framework config is known. Minor UX regression in single-project mode.
132+
133+
**Detection logic changes:**
134+
- `javascript_web` now requires a browser signal (HTML entry point or `"browser"` field in `package.json`). Intentional — `posthog-js` crashes without `window`/`document`.
135+
- `javascript_node` now excludes projects with known framework packages (Next.js, Nuxt, etc.). Prevents the generic catch-all from claiming framework-specific projects.
136+
- `Integration` enum reordered: `javascript_web` before `javascriptNode` (more-specific before catch-all).
137+
138+
**`resolveEnvPath` prefix stripping.** New logic strips redundant path prefixes when the agent passes a relative `filePath` that duplicates the tail of `workingDirectory`. E.g., if `workingDirectory="/ws/services/mcp"` and the agent passes `filePath="services/mcp/.env"`, it strips to `.env`. The traversal guard still runs after.
139+
140+
## What works well
141+
142+
1. **Workspace detection is solid.** Tests cover all workspace types, polyglot supplement, deduplication, and exclusion patterns. The two-pass approach handles real-world "pnpm for JS + Python services alongside."
143+
144+
2. **App filtering catches real problems.** In PostHog's own monorepo, without filtering we'd try to instrument Storybook, Playwright tests, and internal build tools.
145+
146+
3. **Concurrent execution with proper isolation.** `Promise.allSettled` gives deterministic results AND error isolation. Per-project logs let you debug individual failures. The silent output proxy prevents terminal garbage.
147+
148+
4. **The `FrameworkConfig` abstraction.** Monorepo support "just works" for all 20+ frameworks with zero per-framework monorepo code. Each framework's existing `detect()`, `gatherContext()`, and agent prompt carry over unchanged.
149+
150+
5. **Original wizard code is untouched.** No single-project logic was moved into monorepo-only files. The decomposition is purely additive.
151+
152+
## What needs eyes
153+
154+
1. **Heuristic detection.** When there's no formal workspace config, we look for 2+ unique directories with project indicators at depth 1-2. One directory with both `package.json` and `pyproject.toml` counts as one, not two. Could false-positive on repos with `frontend/` and `backend/` that aren't really a monorepo. The 2-dir threshold is conservative but may need tuning.
155+
156+
2. **App filtering thresholds.** `MIN_JS_APP_DEPENDENCY_COUNT = 8` and the dev-tool blocklist are heuristic. We should monitor `monorepo_projects_detected` vs `monorepo_projects_selected` analytics to see if we're filtering too aggressively or not enough.
157+
158+
3. **Benchmark mode fallback.** Concurrent execution is disabled in benchmark mode (the benchmark middleware mutates global log paths). Sequential fallback works, but it currently skips `runPostFlight()` — no env upload or MCP install happens. This might be intentional for benchmarking but worth confirming.
159+
160+
4. **Detection timeout.** Each framework's `detect()` gets 5 seconds (`DETECTION_TIMEOUT_MS`). Per-member-per-framework. A monorepo with 10 members x 20 frameworks = up to 200 detection calls. Most resolve in <100ms, but worth watching.
161+
162+
5. **Polyglot supplement depth.** Currently scans depth 1-2 only. Deeply nested non-JS projects (depth 3+) won't be discovered. Intentional to avoid scanning the whole tree, but worth documenting.
163+
164+
6. **`resolveEnvPath` prefix stripping.** This is heuristic path manipulation — it matches the longest suffix of `workingDirectory` against the prefix of the relative path. Edge cases could surprise us.
165+
166+
7. **Agent adherence verification.** The `1.2-revise.md` verification checklist from our audit plan hasn't been implemented yet in context-mill. Currently it only has basic "check for errors, run linters." The full 5-point checklist (version pinning, server component conversion, redundant pageviews, env vars) still needs to be added.

src/javascript-web/javascript-web-wizard-agent.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,13 @@ export const JAVASCRIPT_WEB_AGENT_CONFIG: FrameworkConfig<JavaScriptContext> = {
6767

6868
const hasBrowserField = 'browser' in packageJson;
6969

70-
return hasHtmlEntry || hasBrowserField;
70+
// Known browser frameworks without dedicated integrations
71+
const BROWSER_FRAMEWORK_PACKAGES = ['gatsby'];
72+
const hasBrowserFramework = BROWSER_FRAMEWORK_PACKAGES.some((pkg) =>
73+
hasPackageInstalled(pkg, packageJson),
74+
);
75+
76+
return hasHtmlEntry || hasBrowserField || hasBrowserFramework;
7177
},
7278
},
7379

src/javascript-web/utils.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ export const FRAMEWORK_PACKAGES = [
2121
'nuxt',
2222
'vue',
2323
'react-router',
24+
'@remix-run/react',
25+
'@remix-run/node',
2426
'@tanstack/react-start',
2527
'@tanstack/react-router',
2628
'react-native',

src/lib/framework-config.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,9 @@ export interface FrameworkDetection {
7474
getInstalledVersion?: (options: WizardOptions) => Promise<string | undefined>;
7575

7676
/** Detect whether this framework is present in the project. */
77-
detect: (options: Pick<WizardOptions, 'installDir'>) => Promise<boolean>;
77+
detect: (
78+
options: Pick<WizardOptions, 'installDir' | 'workspaceRootDir'>,
79+
) => Promise<boolean>;
7880

7981
/** Detect the project's package manager(s). Used by the in-process MCP tool. */
8082
detectPackageManager: PackageManagerDetector;

src/monorepo/monorepo-flow.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,10 @@ export async function detectWorkspaceProjects(
7171

7272
const results = await Promise.all(
7373
workspace.memberDirs.map(async (memberDir) => {
74-
const integration = await detectIntegration({ installDir: memberDir });
74+
const integration = await detectIntegration({
75+
installDir: memberDir,
76+
workspaceRootDir: workspace.rootDir,
77+
});
7578
if (!integration) return null;
7679

7780
// Filter out library packages for generic language-level integrations
@@ -161,6 +164,7 @@ export async function runMonorepoFlow(
161164
const projectOptions: WizardOptions = {
162165
...options,
163166
installDir: project.dir,
167+
workspaceRootDir: options.installDir,
164168
cloudRegion: sharedSetup.cloudRegion,
165169
};
166170

@@ -344,6 +348,7 @@ async function runMonorepoSequential(
344348
const projectOptions: WizardOptions = {
345349
...options,
346350
installDir: project.dir,
351+
workspaceRootDir: options.installDir,
347352
cloudRegion: sharedSetup.cloudRegion,
348353
};
349354

src/react-router/react-router-wizard-agent.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -45,9 +45,12 @@ export const REACT_ROUTER_AGENT_CONFIG: FrameworkConfig<ReactRouterContext> = {
4545
},
4646
detect: async (options) => {
4747
const packageJson = await tryGetPackageJson(options);
48-
return packageJson
49-
? hasPackageInstalled('react-router', packageJson)
50-
: false;
48+
if (!packageJson) return false;
49+
return (
50+
hasPackageInstalled('react-router', packageJson) ||
51+
hasPackageInstalled('@remix-run/react', packageJson) ||
52+
hasPackageInstalled('@remix-run/node', packageJson)
53+
);
5154
},
5255
detectPackageManager: detectNodePackageManagers,
5356
},

src/run.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,7 @@ export async function runWizard(argv: Args) {
145145
const DETECTION_TIMEOUT_MS = 5000;
146146

147147
export async function detectIntegration(
148-
options: Pick<WizardOptions, 'installDir'>,
148+
options: Pick<WizardOptions, 'installDir' | 'workspaceRootDir'>,
149149
): Promise<Integration | undefined> {
150150
for (const integration of Object.values(Integration)) {
151151
const config = FRAMEWORK_REGISTRY[integration];

src/utils/clack-utils.ts

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -477,13 +477,45 @@ export async function getPackageDotJson({
477477
*/
478478
export async function tryGetPackageJson({
479479
installDir,
480-
}: Pick<WizardOptions, 'installDir'>): Promise<PackageDotJson | null> {
480+
workspaceRootDir,
481+
}: Pick<
482+
WizardOptions,
483+
'installDir' | 'workspaceRootDir'
484+
>): Promise<PackageDotJson | null> {
481485
try {
482486
const packageJsonFileContents = await fs.promises.readFile(
483487
join(installDir, 'package.json'),
484488
'utf8',
485489
);
486-
return JSON.parse(packageJsonFileContents) as PackageDotJson;
490+
const localPkg = JSON.parse(packageJsonFileContents) as PackageDotJson;
491+
492+
// In Nx monorepos, all deps are hoisted to the root package.json.
493+
// Per-project package.json files are stubs with zero deps. When a
494+
// workspace root is available and the local file has no deps, merge
495+
// the root's dependencies so framework detectors can match.
496+
if (workspaceRootDir && workspaceRootDir !== installDir) {
497+
const localDepCount =
498+
Object.keys(localPkg.dependencies ?? {}).length +
499+
Object.keys(localPkg.devDependencies ?? {}).length;
500+
if (localDepCount === 0) {
501+
try {
502+
const rootRaw = await fs.promises.readFile(
503+
join(workspaceRootDir, 'package.json'),
504+
'utf8',
505+
);
506+
const rootPkg = JSON.parse(rootRaw) as PackageDotJson;
507+
return {
508+
...localPkg,
509+
dependencies: { ...rootPkg.dependencies },
510+
devDependencies: { ...rootPkg.devDependencies },
511+
};
512+
} catch {
513+
// Root package.json missing or invalid — fall through
514+
}
515+
}
516+
}
517+
518+
return localPkg;
487519
} catch {
488520
return null;
489521
}

src/utils/types.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,12 @@ export type WizardOptions = {
6767
*/
6868
menu: boolean;
6969

70+
/**
71+
* Root directory of the monorepo workspace. When set, detection merges
72+
* root package.json deps for projects with hoisted dependencies (e.g. Nx).
73+
*/
74+
workspaceRootDir?: string;
75+
7076
/**
7177
* Whether to run in benchmark mode with per-phase token tracking.
7278
* When enabled, the wizard runs each workflow phase as a separate agent call

0 commit comments

Comments
 (0)