Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
a7539b9
feat: support iOS source-map uploads
ablaszkiewicz Jul 11, 2026
6354eaf
feat: write iOS source-map creds to a gitignored xcconfig, not .env
ablaszkiewicz Jul 14, 2026
7fcbd6a
fix: source-maps prompt writes the API host, not the ingestion host
ablaszkiewicz Jul 15, 2026
50fa2a5
feat: iOS test instructions are Xcode-only, no crash-flow ritual
ablaszkiewicz Jul 15, 2026
d4fe459
fix: iOS prompt enforces the POSTHOG_INCLUDE_SOURCE=1 prefix
ablaszkiewicz Jul 15, 2026
7a966e5
refactor: trim include-source rule to the bare MUST
ablaszkiewicz Jul 15, 2026
4b7726d
refactor: drop include-source rule from the prompt — the skill owns it
ablaszkiewicz Jul 15, 2026
edb6327
refactor: iOS credentials collapse to one xcconfig write
ablaszkiewicz Jul 15, 2026
a877851
fix: POSTHOG_INCLUDE_SOURCE moves into the xcconfig write
ablaszkiewicz Jul 15, 2026
90b82bb
revert: include-source stays an inline prefix, not an xcconfig value
ablaszkiewicz Jul 15, 2026
b1d9e81
feat: iOS source-map credentials move from xcconfig to .env
ablaszkiewicz Jul 15, 2026
5d3be18
fix: env-file paths resolve against the wizard's working directory, l…
ablaszkiewicz Jul 15, 2026
605f602
e2e: platform-blind test-done prompt, drop iOS override
ablaszkiewicz Jul 15, 2026
4fb43c3
e2e: extract test-done prompt override into per-variant lookup
ablaszkiewicz Jul 15, 2026
32defa8
fix: env files
ablaszkiewicz Jul 16, 2026
98f2d82
fix: install cli
ablaszkiewicz Jul 16, 2026
53aed6a
Merge remote-tracking branch 'origin/main' into ab/feat/ios-source-maps
ablaszkiewicz Jul 16, 2026
a94f18b
fix: improve analytics
ablaszkiewicz Jul 17, 2026
8d70528
Merge branch 'main' into ab/feat/ios-source-maps
ablaszkiewicz Jul 17, 2026
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: 3 additions & 2 deletions src/lib/agent/runner/harness/pi/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { defineTool } from '@earendil-works/pi-coding-agent';
import type { ToolDefinition } from '@earendil-works/pi-coding-agent';
import { logToFile } from '@utils/debug';
import {
ENV_FILE_PATH_DESCRIPTION,
fetchSkillMenu,
installSkillById,
mergeEnvValues,
Expand Down Expand Up @@ -114,7 +115,7 @@ export function createWizardPiTools(ctx: PiToolsContext): ToolDefinition[] {
promptSnippet: 'check_env_keys(filePath, keys) — see which .env keys exist',
parameters: Type.Object({
filePath: Type.String({
description: 'Path to the .env file, relative to the project root',
description: ENV_FILE_PATH_DESCRIPTION,
}),
keys: Type.Array(Type.String(), {
description: 'Environment variable key names to check',
Expand Down Expand Up @@ -142,7 +143,7 @@ export function createWizardPiTools(ctx: PiToolsContext): ToolDefinition[] {
'set_env_values(filePath, values) — write .env keys (never hardcode secrets in source)',
parameters: Type.Object({
filePath: Type.String({
description: 'Path to the .env file, relative to the project root',
description: ENV_FILE_PATH_DESCRIPTION,
}),
values: Type.Record(Type.String(), Type.String(), {
description: 'Key → literal value',
Expand Down
82 changes: 81 additions & 1 deletion src/lib/programs/__tests__/source-maps-detect-agentic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,17 @@ describe('SOURCE_MAPS_TARGETS precedence', () => {
const rank = (id: string) => ids.indexOf(id);

it('covers every automatable variant exactly once', () => {
expect([...ids].sort()).toEqual([...AUTOMATABLE_VARIANTS].sort());
for (const variant of AUTOMATABLE_VARIANTS) {
expect(ids.filter((id) => id === variant)).toHaveLength(1);
}
});

it('ranks unsupported native guards ahead of the iOS target', () => {
expect(SOURCE_MAPS_TARGETS).toContainEqual({ id: 'ios', name: 'iOS' });
for (const nativeGuard of ['react-native', 'flutter', 'android']) {
expect(rank(nativeGuard)).toBeLessThan(rank('ios'));
}
expect(rank('ios')).toBeLessThan(rank('nextjs'));
});

it('ranks bundlers ahead of the generic React variant', () => {
Expand Down Expand Up @@ -56,6 +66,76 @@ describe('coerceReport', () => {
expect(p.reason).toBeUndefined();
});

it('retains an iOS project with PostHog as instrumentable', () => {
const report = coerceReport({
repoType: 'single',
projects: [
{
path: '.',
framework: 'iOS',
targetId: 'ios',
hasPostHog: true,
},
],
});

expect(report.projects[0]).toEqual({
path: '.',
framework: 'iOS',
variant: 'ios',
hasPostHog: true,
instrumentable: true,
});
});

it('blocks an iOS project that has no PostHog SDK yet', () => {
const report = coerceReport({
repoType: 'single',
projects: [
{
path: '.',
framework: 'iOS',
targetId: 'ios',
hasPostHog: false,
},
],
});

expect(report.projects[0]).toEqual(
expect.objectContaining({
variant: 'ios',
hasPostHog: false,
instrumentable: false,
reason: expect.stringMatching(/no posthog sdk/i),
}),
);
});

it.each(['React Native', 'Expo', 'Flutter', 'Android'])(
'blocks a native %s project misclassified as iOS',
(framework) => {
const report = coerceReport({
repoType: 'single',
projects: [
{
path: '.',
framework,
targetId: 'ios',
hasPostHog: true,
},
],
});

expect(report.projects[0]).toEqual(
expect.objectContaining({
variant: null,
instrumentable: false,
reason: expect.stringMatching(/isn't supported/i),
}),
);
},
);

it('blocks a supported project that has no PostHog SDK yet', () => {
const report = coerceReport({
repoType: 'single',
Expand Down
38 changes: 38 additions & 0 deletions src/lib/programs/__tests__/source-maps-prompt.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { buildSourceMapsUploadPrompt } from '@lib/programs/error-tracking-upload-source-maps/prompt';

const baseParams = {
displayName: 'Node.js',
variant: 'node' as const,
skillId: 'error-tracking-upload-source-maps-node',
projectId: 123,
host: 'https://us.i.posthog.com',
settingsUrl: 'https://us.posthog.com/settings/user-api-keys',
uiHost: 'https://us.posthog.com',
};

describe('buildSourceMapsUploadPrompt env file paths', () => {
it('scopes env tools to the selected monorepo project', () => {
const prompt = buildSourceMapsUploadPrompt({
...baseParams,
projectPath: 'backend',
});

expect(prompt).toContain(
"Project directory (relative to the wizard's working directory): backend",
);
expect(prompt).toContain('pass `backend/.env`, not `.env`');
});

it.each([undefined, '.'])(
'keeps root-project env files at the wizard working directory (%s)',
(projectPath) => {
const prompt = buildSourceMapsUploadPrompt({
...baseParams,
projectPath,
});

expect(prompt).toContain('pass `.env`');
expect(prompt).not.toContain('pass `./.env`');
},
);
});
Original file line number Diff line number Diff line change
Expand Up @@ -46,12 +46,21 @@ export type DetectionReport = {

/**
* Variant precedence for the agentic picker (most specific first). The detector
* keeps the EARLIEST matching target, so this ordering is what makes a React app
* built with Vite resolve to `vite` (bundler-plugin upload) instead of the
* generic `react` posthog-cli path. Mirrors `pickJsVariant` in detect.ts:
* opinionated frameworks → bundlers → bare React → Node → generic web.
* keeps the EARLIEST matching target. Unsupported native targets are included
* as guards before iOS so React Native, Flutter, and Android projects with
* nested Xcode manifests are not misclassified as instrumentable iOS apps.
* JS ordering mirrors `pickJsVariant` in detect.ts: opinionated frameworks →
* bundlers → bare React → Node → generic web.
*/
const NON_AUTOMATABLE_NATIVE_VARIANTS: readonly SkillVariant[] = [
'react-native',
'flutter',
'android',
];

const VARIANT_PRECEDENCE: readonly SkillVariant[] = [
...NON_AUTOMATABLE_NATIVE_VARIANTS,
'ios',
'nextjs',
'nuxt',
'angular',
Expand All @@ -69,13 +78,14 @@ const precedenceRank = (v: SkillVariant): number => {
};

/**
* Source-maps targets: the variants the wizard can automate, by id → name,
* ordered by VARIANT_PRECEDENCE so the detector's "earliest match wins"
* tie-break selects the most specific variant. Derived from AUTOMATABLE_VARIANTS
* so a newly-automatable variant is never silently dropped (unranked ones sort
* last). Exported for testing.
* Source-map detection targets, ordered by VARIANT_PRECEDENCE. Unsupported
* native variants remain in the target list so the detector can identify and
* block them instead of falling through to iOS or a JS target.
*/
export const SOURCE_MAPS_TARGETS: DetectTarget[] = [...AUTOMATABLE_VARIANTS]
export const SOURCE_MAPS_TARGETS: DetectTarget[] = [
...NON_AUTOMATABLE_NATIVE_VARIANTS,
...AUTOMATABLE_VARIANTS,
]
.sort((a, b) => precedenceRank(a) - precedenceRank(b))
.map((v) => ({ id: v, name: VARIANT_DISPLAY_NAME[v] }));

Expand All @@ -98,12 +108,20 @@ function classify(
return { instrumentable: true };
}

function isAutomatableVariant(value: string | null): value is SkillVariant {
return value !== null && AUTOMATABLE_VARIANTS.includes(value as SkillVariant);
}

/** Map a generic detection report into source-maps projects. */
function toSourceMapsReport(report: AgenticDetectionReport): DetectionReport {
return {
repoType: report.repoType,
projects: report.projects.map((p) => {
const variant = (p.targetId as SkillVariant | null) ?? null;
const variant =
isAutomatableVariant(p.targetId) &&
!/\b(?:react[\s-]*native|expo|flutter|android)\b/i.test(p.framework)
? p.targetId
: null;
return {
path: p.path,
framework: p.framework,
Expand All @@ -117,12 +135,15 @@ function toSourceMapsReport(report: AgenticDetectionReport): DetectionReport {

/**
* Validate the agent's raw JSON into a source-maps detection report. Exported
* for testing — clamps variants to the automatable set and classifies
* instrumentability.
* for testing — recognises detection-only native targets, then clamps them to
* non-instrumentable projects.
*/
export function coerceReport(parsed: unknown): DetectionReport {
return toSourceMapsReport(
coerceAgenticReport(parsed, AUTOMATABLE_VARIANTS as readonly string[]),
coerceAgenticReport(
parsed,
SOURCE_MAPS_TARGETS.map((target) => target.id),
),
);
}

Expand Down
16 changes: 12 additions & 4 deletions src/lib/programs/error-tracking-upload-source-maps/detect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,10 +52,11 @@ const DISPLAY_NAME: Record<SkillVariant, string> = {

/**
* Variants the wizard can wire up source-map upload for automatically. The
* native variants (react-native, android, flutter, ios) are recognised but not
* yet automatable, so the agentic picker treats them as non-instrumentable.
* native variants (react-native, android, flutter) are recognised but not yet
* automatable, so the agentic picker treats them as non-instrumentable.
*/
export const AUTOMATABLE_VARIANTS: readonly SkillVariant[] = [
'ios',
Comment thread
hpouillot marked this conversation as resolved.
'web',
'nextjs',
'node',
Expand All @@ -67,6 +68,13 @@ export const AUTOMATABLE_VARIANTS: readonly SkillVariant[] = [
'rollup',
];

/**
* Variants the wizard pre-installs a machine-global `posthog-cli` for — their
* build shells out to it with no npx / local-dep fallback. JS variants stay out.
*/
export const VARIANTS_REQUIRING_POSTHOG_CLI: ReadonlySet<SkillVariant> =
new Set(['ios']);

const POSTHOG_SDKS = [
'posthog-js',
'posthog-node',
Expand Down Expand Up @@ -305,8 +313,8 @@ export function detectSourceMapsPrerequisites(
const signals = collectSignals(installDir);
const variant = selectVariant(signals);

// This program currently targets JS-like stacks only. Avoid selecting native
// platforms until dedicated skill variants are available.
// The legacy filesystem detector does not automate native platforms. The
// live source-map picker uses detectSourceMapsProjects instead.
if (
variant &&
['react-native', 'flutter', 'ios', 'android'].includes(variant)
Expand Down
35 changes: 35 additions & 0 deletions src/lib/programs/error-tracking-upload-source-maps/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,46 @@ import {
import {
SOURCE_MAPS_ABORT_CASES,
SOURCE_MAPS_CONTEXT_KEYS,
VARIANTS_REQUIRING_POSTHOG_CLI,
type SkillVariant,
} from './detect.js';
import { getContentBlocks } from './content/index.js';
import { getUI } from '@ui';
import { installOrUpdatePostHogCli } from '@steps/install-cli-steering';
import { analytics } from '@utils/analytics';

const REPORT_FILE = 'posthog-source-maps-report.md';
const DOCS_URL = 'https://posthog.com/docs/error-tracking/upload-source-maps';

let postHogCliInstallAttempted = false;

/**
* Pre-install posthog-cli for variants that need a machine-global copy
* (`VARIANTS_REQUIRING_POSTHOG_CLI`). The agent can't — warlock blocks
* `npm install -g` — so the wizard does it in-process. Warn, don't fail.
*/
Comment thread
ablaszkiewicz marked this conversation as resolved.
function ensurePostHogCli(variant: SkillVariant): void {
if (postHogCliInstallAttempted) return;
postHogCliInstallAttempted = true;

const result = installOrUpdatePostHogCli();
if (!result.success) {
analytics.wizardCapture('source maps posthog-cli preinstall failed', {
variant,
error: String(result.error).slice(0, 500),
});
analytics.captureException(
result.errorObject ??
new Error(`posthog-cli pre-install failed: ${result.error}`),
{ source: 'source_maps_cli_preinstall', variant },
);
getUI().log.warn(
Comment thread
ablaszkiewicz marked this conversation as resolved.
`Could not pre-install posthog-cli (${result.error}). Your Xcode build ` +
`will fail to upload dSYMs until it's installed: npm install -g @posthog/cli@latest`,
);
}
}

export const errorTrackingUploadSourceMapsConfig: ProgramConfig = {
command: 'upload-source-maps',
description: 'Upload source maps to PostHog Error Tracking',
Expand Down Expand Up @@ -74,6 +106,9 @@ export const errorTrackingUploadSourceMapsConfig: ProgramConfig = {
return SOURCE_MAPS_DETECTION_FAILED_PROMPT;
}

if (VARIANTS_REQUIRING_POSTHOG_CLI.has(variant))
ensurePostHogCli(variant);

const uiHost = ctx.host.appHost.replace(/\/$/, '');

return buildSourceMapsUploadPrompt({
Expand Down
Loading
Loading