Skip to content
Open
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
16 changes: 16 additions & 0 deletions src/__tests__/programs-cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,15 +27,23 @@
expect(migrateCommand.name).toBe('migrate');
});

test('nests web analytics doctor under audit', () => {
expect(auditCommand.children).toEqual(
expect.arrayContaining([
expect.objectContaining({ name: 'web-analytics' }),
]),
);
});

test('dispatches to runWizard by default', () => {
auditCommand.handler!(makeArgv({ debug: true }));

Check warning on line 39 in src/__tests__/programs-cli.test.ts

View workflow job for this annotation

GitHub Actions / Lint

Forbidden non-null assertion
expect(mockRunWizard).toHaveBeenCalledTimes(1);
expect(mockRunWizardCI).not.toHaveBeenCalled();
expect(mockRunWizard.mock.calls[0][1]).toMatchObject({ debug: true });
});

test('dispatches to runWizardCI when --ci is set', () => {
auditCommand.handler!(makeArgv({ ci: true }));

Check warning on line 46 in src/__tests__/programs-cli.test.ts

View workflow job for this annotation

GitHub Actions / Lint

Forbidden non-null assertion
expect(mockRunWizardCI).toHaveBeenCalledTimes(1);
expect(mockRunWizard).not.toHaveBeenCalled();
});
Expand Down Expand Up @@ -77,4 +85,12 @@
);
expect(argv.installDir).toBe('/tmp/app');
});

test('parses audit web-analytics through yargs', async () => {
const argv = await parseCommand(
auditCommand,
'audit web-analytics --install-dir /tmp/app',
);
expect(argv.installDir).toBe('/tmp/app');
});
});
36 changes: 28 additions & 8 deletions src/commands/audit.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,43 @@
import { runWizard, runWizardCI } from '@lib/runners';
import { auditConfig } from '@lib/programs/audit/index';
import { webAnalyticsDoctorConfig } from '@lib/programs/web-analytics-doctor/index';
import { skillProgramOptions } from './skill-program-options';
import type { Command } from './command';

const dispatchProgram = (
config: typeof auditConfig | typeof webAnalyticsDoctorConfig,
argv: Record<string, unknown>,
): void => {
const extras = config.mapCliOptions?.(argv) ?? {};
const options = { ...argv, ...extras };
if (options.ci) {
runWizardCI(config, options);
} else {
runWizard(config, options);
}
};

const webAnalyticsCommand: Command = {
name: webAnalyticsDoctorConfig.command!,
description: webAnalyticsDoctorConfig.description,
options: {
...skillProgramOptions,
...(webAnalyticsDoctorConfig.cliOptions ?? {}),
},
handler: (argv) => {
dispatchProgram(webAnalyticsDoctorConfig, argv as Record<string, unknown>);
},
};

export const auditCommand: Command = {
name: 'audit',
description: auditConfig.description,
children: [webAnalyticsCommand],
options: {
...skillProgramOptions,
...(auditConfig.cliOptions ?? {}),
},
handler: (argv) => {
const extras =
auditConfig.mapCliOptions?.(argv as Record<string, unknown>) ?? {};
const options = { ...argv, ...extras };
if (options.ci) {
runWizardCI(auditConfig, options);
} else {
runWizard(auditConfig, options);
}
dispatchProgram(auditConfig, argv as Record<string, unknown>);
},
};
28 changes: 28 additions & 0 deletions src/lib/programs/__tests__/program-registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,31 @@ describe('getSubcommandPrograms', () => {
}
});
});

describe('parentCommand nesting', () => {
it('nests web-analytics-doctor under the audit command', () => {
const webAnalytics = getProgramConfig('web-analytics-doctor');
expect(webAnalytics.command).toBe('web-analytics');
expect(webAnalytics.parentCommand).toBe('audit');
});

it('keeps audit as a top-level command', () => {
const audit = getProgramConfig('audit');
expect(audit.command).toBe('audit');
expect(audit.parentCommand).toBeUndefined();
});

it('every parentCommand refers to a registered top-level command', () => {
const topLevelCommands = new Set(
getSubcommandPrograms()
.filter((c) => c.parentCommand == null)
.map((c) => c.command),
);
const parentCommands = getSubcommandPrograms()
.map((c) => c.parentCommand)
.filter((p): p is string => p != null);
for (const parent of parentCommands) {
expect(topLevelCommands).toContain(parent);
}
});
});
126 changes: 126 additions & 0 deletions src/lib/programs/__tests__/web-analytics-doctor-detect.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import {
detectWebAnalyticsPrerequisites,
webAnalyticsDoctorConfig,
WEB_ANALYTICS_ABORT_CASES,
} from '@lib/programs/web-analytics-doctor/index';
import { WIZARD_TOOL_NAMES } from '@lib/wizard-tools';
import { buildSession } from '@lib/wizard-session';

function makeTmpDir(): string {
return fs.mkdtempSync(path.join(os.tmpdir(), 'wa-detect-'));
}

function cleanup(dir: string): void {
fs.rmSync(dir, { recursive: true, force: true });
}

function writePackageJson(
dir: string,
deps: Record<string, string> = {},
): void {
fs.writeFileSync(
path.join(dir, 'package.json'),
JSON.stringify({ dependencies: deps }),
);
}

describe('detectWebAnalyticsPrerequisites', () => {
let tmpDir: string;
let ctx: Record<string, unknown>;
let setCtx: jest.Mock;

beforeEach(() => {
tmpDir = makeTmpDir();
ctx = {};
setCtx = jest.fn((key: string, value: unknown) => {
ctx[key] = value;
});
});
afterEach(() => cleanup(tmpDir));

it('errors when install directory is invalid', () => {
const session = buildSession({ installDir: '/nonexistent/path' });
detectWebAnalyticsPrerequisites(session, setCtx);

expect(ctx.detectError).toEqual(
expect.objectContaining({ kind: 'bad-directory' }),
);
});

it('errors when no package.json exists', () => {
const session = buildSession({ installDir: tmpDir });
detectWebAnalyticsPrerequisites(session, setCtx);

expect(ctx.detectError).toEqual({ kind: 'no-package-json' });
});

it('errors when no PostHog SDK is found', () => {
writePackageJson(tmpDir, { react: '18.0.0' });

const session = buildSession({ installDir: tmpDir });
detectWebAnalyticsPrerequisites(session, setCtx);

expect(ctx.detectError).toEqual(
expect.objectContaining({ kind: 'no-posthog' }),
);
expect(ctx.detectedPosthogSdks).toBeUndefined();
});

it('succeeds when a PostHog SDK is present', () => {
writePackageJson(tmpDir, { 'posthog-js': '1.0.0' });

const session = buildSession({ installDir: tmpDir });
detectWebAnalyticsPrerequisites(session, setCtx);

expect(ctx.detectError).toBeUndefined();
expect(ctx.detectedPosthogSdks).toEqual(['posthog-js']);
});

it('finds a PostHog SDK in a monorepo subpackage', () => {
writePackageJson(tmpDir, { react: '18.0.0' });

const subDir = path.join(tmpDir, 'packages', 'web');
fs.mkdirSync(subDir, { recursive: true });
writePackageJson(subDir, { 'posthog-js': '1.0.0' });

const session = buildSession({ installDir: tmpDir });
detectWebAnalyticsPrerequisites(session, setCtx);

expect(ctx.detectError).toBeUndefined();
expect(ctx.detectedPosthogSdks).toContain('posthog-js');
});
});

describe('WEB_ANALYTICS_ABORT_CASES', () => {
const reasons = [
'No web analytics events',
'Insufficient permissions',
'PostHog SDK not installed',
];

it.each(reasons)('matches the "%s" abort reason exactly once', (reason) => {
const matched = WEB_ANALYTICS_ABORT_CASES.filter((c) =>
c.match.test(reason),
);
expect(matched).toHaveLength(1);
expect(matched[0].message).toBeTruthy();
expect(matched[0].body).toBeTruthy();
});
});

describe('webAnalyticsDoctorConfig', () => {
it('keeps wizard_ask enabled so the user can pick which fixes to apply', () => {
expect(webAnalyticsDoctorConfig.disallowedTools ?? []).not.toContain(
WIZARD_TOOL_NAMES.wizardAsk,
);
});

it('wires the web-analytics-doctor skill and CLI command', () => {
expect(webAnalyticsDoctorConfig.command).toBe('web-analytics');
expect(webAnalyticsDoctorConfig.skillId).toBe('web-analytics-doctor');
expect(webAnalyticsDoctorConfig.id).toBe('web-analytics-doctor');
});
});
3 changes: 3 additions & 0 deletions src/lib/programs/program-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { auditConfig } from './audit/index.js';
import { eventsAuditConfig } from './events-audit/index.js';
import { audit3000Config } from './audit-3000/index.js';
import { posthogDoctorConfig } from './posthog-doctor/index.js';
import { webAnalyticsDoctorConfig } from './web-analytics-doctor/index.js';
import { migrationConfig } from './migration/index.js';
import { errorTrackingUploadSourceMapsConfig } from './error-tracking-upload-source-maps/index.js';
import { AGENT_SKILL_STEPS } from './agent-skill/index.js';
Expand Down Expand Up @@ -46,6 +47,7 @@ export const PROGRAM_REGISTRY = [
eventsAuditConfig,
audit3000Config,
posthogDoctorConfig,
webAnalyticsDoctorConfig,
migrationConfig,
agentSkillConfig,
mcpAddConfig,
Expand All @@ -67,6 +69,7 @@ export const Program = {
EventsAudit: eventsAuditConfig.id,
Audit3000: audit3000Config.id,
PosthogDoctor: posthogDoctorConfig.id,
WebAnalyticsDoctor: webAnalyticsDoctorConfig.id,
AgentSkill: agentSkillConfig.id,
McpAdd: mcpAddConfig.id,
McpRemove: mcpRemoveConfig.id,
Expand Down
7 changes: 7 additions & 0 deletions src/lib/programs/program-step.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,13 @@ export interface ProgramStep {
export interface ProgramConfig {
/** CLI command name (e.g. 'revenue'). Omit for the default program. */
command?: string;
/**
* Parent CLI command to nest this program under. When set, the program is
* registered as `<parentCommand> <command>` instead of as a top-level
* command. The parent must itself be a registered subcommand program. Omit
* for top-level programs.
*/
parentCommand?: string;
/** CLI description shown in --help */
description: string;
/** Unique program id — matches the Program enum value */
Expand Down
85 changes: 8 additions & 77 deletions src/lib/programs/revenue-analytics/detect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,33 +5,17 @@
* into frameworkContext for the intro screen to render.
*/

import type { Dirent } from 'fs';
import { readFileSync, readdirSync, existsSync, statSync } from 'fs';
import { join, relative } from 'path';
import { IGNORED_DIRS } from '@utils/file-utils';
import { existsSync, statSync } from 'fs';
import type { WizardSession } from '@lib/wizard-session';
import type { AbortCase } from '@lib/agent/agent-runner';
import { findPackageJsons } from '@lib/programs/shared/package-scanning';

export const POSTHOG_SDKS = [
'posthog-js',
'posthog-node',
'posthog-react-native',
'posthog-android',
'posthog-ios',
];

export const STRIPE_SDKS = [
'stripe',
'@stripe/stripe-js',
'@stripe/react-stripe-js',
];

interface PackageMatch {
/** Path to the package.json relative to installDir */
path: string;
posthogSdks: string[];
stripeSdks: string[];
}
export {
findPackageJsons,
POSTHOG_SDKS,
STRIPE_SDKS,
type PackageMatch,
} from '@lib/programs/shared/package-scanning';

/**
* Structured detection errors. The screen renders each kind into JSX
Expand Down Expand Up @@ -72,59 +56,6 @@ export const REVENUE_ABORT_CASES: AbortCase[] = [
},
];

/**
* Recursively find all package.json files under installDir (max depth 3),
* skipping common ignored directories. Returns matches with detected SDKs.
*/
function findPackageJsons(installDir: string, maxDepth = 3): PackageMatch[] {
const matches: PackageMatch[] = [];

function scan(dir: string, depth: number): void {
if (depth > maxDepth) return;

let entries: Dirent[];
try {
entries = readdirSync(dir, { withFileTypes: true });
} catch {
return;
}

for (const entry of entries) {
if (entry.name.startsWith('.') && entry.name !== '.') continue;
if (IGNORED_DIRS.has(entry.name)) continue;

const fullPath = join(dir, entry.name);

if (entry.isFile() && entry.name === 'package.json') {
try {
const pkg = JSON.parse(readFileSync(fullPath, 'utf-8')) as {
dependencies?: Record<string, string>;
devDependencies?: Record<string, string>;
};
const depNames = [
...Object.keys(pkg.dependencies ?? {}),
...Object.keys(pkg.devDependencies ?? {}),
];
const posthogSdks = depNames.filter((d) => POSTHOG_SDKS.includes(d));
const stripeSdks = depNames.filter((d) => STRIPE_SDKS.includes(d));
matches.push({
path: relative(installDir, fullPath) || 'package.json',
posthogSdks,
stripeSdks,
});
} catch {
// Skip malformed package.json
}
} else if (entry.isDirectory()) {
scan(fullPath, depth + 1);
}
}
}

scan(installDir, 0);
return matches;
}

/**
* Scan `session.installDir` for PostHog + Stripe SDKs. Writes detection
* results into frameworkContext via the callback — either the detected
Expand Down
Loading
Loading