Skip to content
Draft
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
15 changes: 15 additions & 0 deletions src/lib/__tests__/wizard-tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,21 @@ describe('mergeEnvValues', () => {
'FOO=new\nDB_URL=postgres://new:5432/db?opt=1\nBAR=added\n',
);
});

it('writes and updates xcconfig-style assignments with spaces around the equals', () => {
const result = mergeEnvValues(
'POSTHOG_CLI_API_KEY = phx_old',
{
POSTHOG_CLI_API_KEY: 'phx_new',
OTHER_SETTING: 'added',
},
'xcconfig',
);

expect(result).toBe(
'POSTHOG_CLI_API_KEY = phx_new\nOTHER_SETTING = added\n',
);
});
});

describe('ensureGitignoreCoverage', () => {
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
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
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',
'web',
'nextjs',
'node',
Expand Down Expand Up @@ -305,8 +306,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
72 changes: 52 additions & 20 deletions src/lib/programs/error-tracking-upload-source-maps/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,57 @@ export function buildSourceMapsUploadPrompt(
? `- Project directory (relative to repo root): ${projectPath}`
: '- Project directory: the repo root';

// iOS never reads a .env file — Xcode does not load one at build time. The
// secret personal API key goes in a gitignored xcconfig (surfaced to the
// dSYM-upload Run Script phase as a build setting); the non-secret project
// id + host are exported inside that same Run Script phase. Every other
// platform keeps the dotenv flow.
const isIos = variant === 'ios';
const credentialSteps = isIos
? `STEP 4 — Make the credentials readable at build time. (skill: "Make credentials available at build time")
iOS does NOT use .env — Xcode never loads one. Follow the skill's iOS
step, which routes credentials through Xcode build settings instead of a
dotenv file. Do NOT install \`dotenv\` or any loader, and do NOT create a
.env file for this platform.

STEP 5 — Write the credentials. (skill: "Write credentials to the env file")
The secret goes in a GITIGNORED xcconfig, not a .env:
- Call set_env_values with the xcconfig path the skill names (e.g.
\`PostHog.xcconfig\`). Because the path ends in \`.xcconfig\`, the tool
writes Xcode \`KEY = VALUE\` style and adds the file to .gitignore for
you. Write ONLY the secret there, passing the STEP 1 secretRef as a
value object (never a literal string):
filePath: "PostHog.xcconfig"
values: { "POSTHOG_CLI_API_KEY": { secretRef: "<the ref from STEP 1>" } }
The wizard resolves the ref locally, so you never see the key value.
- The non-secret values are NOT written to the xcconfig. Per the skill,
assign the xcconfig to the target's build configuration (so
\`POSTHOG_CLI_API_KEY\` reaches the Run Script phase as a build setting)
and add these as \`export\` lines inside the dSYM-upload Run Script
phase: POSTHOG_CLI_PROJECT_ID=${projectId} and POSTHOG_CLI_HOST=${host}.
- If the skill asks, also write a committed \`PostHog.example.xcconfig\`
placeholder (empty value) so teammates see the setting exists.`
: `STEP 4 — Make the credentials readable at build time. (skill: "Make credentials available at build time")
Follow the skill's step. Wizard-specific: if it calls for a loader (e.g.
\`dotenv\`), install it SILENTLY — do NOT ask the user or call wizard_ask.
Skip this step entirely if the platform already auto-loads .env.

STEP 5 — Write the credentials to the env file. (skill: "Write credentials to the env file")
Use the wizard-tools MCP server. Reuse the env file the skill tells you to
pick — the prerequisite PostHog integration usually already wrote
POSTHOG_* vars to one, so seed your keys alongside them.
- First call check_env_keys on that file (returns present/absent, never
values — don't read the file directly).
- Then call set_env_values, passing the STEP 1 secretRef as a value
object, not a literal string:
values: {
"POSTHOG_CLI_API_KEY": { secretRef: "<the ref from STEP 1>" },
"POSTHOG_CLI_PROJECT_ID": "${projectId}",
"POSTHOG_CLI_HOST": "${host}"
}
Variable names follow the skill's per-uploader conventions. The wizard
resolves the ref locally before writing, so you never see the key value.`;

return `You are wiring up PostHog Error Tracking source map upload for this ${platformLabel} project.

Project context:
Expand Down Expand Up @@ -117,26 +168,7 @@ STEP 3 — Apply build-config changes. (skill: "Apply build-config changes")
Make the bundler / build-config changes the skill's step instructs. The
skill and its reference are the source of truth for this platform.

STEP 4 — Make the credentials readable at build time. (skill: "Make credentials available at build time")
Follow the skill's step. Wizard-specific: if it calls for a loader (e.g.
\`dotenv\`), install it SILENTLY — do NOT ask the user or call wizard_ask.
Skip this step entirely if the platform already auto-loads .env.

STEP 5 — Write the credentials to the env file. (skill: "Write credentials to the env file")
Use the wizard-tools MCP server. Reuse the env file the skill tells you to
pick — the prerequisite PostHog integration usually already wrote
POSTHOG_* vars to one, so seed your keys alongside them.
- First call check_env_keys on that file (returns present/absent, never
values — don't read the file directly).
- Then call set_env_values, passing the STEP 1 secretRef as a value
object, not a literal string:
values: {
"POSTHOG_CLI_API_KEY": { secretRef: "<the ref from STEP 1>" },
"POSTHOG_CLI_PROJECT_ID": "${projectId}",
"POSTHOG_CLI_HOST": "${host}"
}
Variable names follow the skill's per-uploader conventions. The wizard
resolves the ref locally before writing, so you never see the key value.
${credentialSteps}

STEP 6 — Identify the build AND run commands. (skill: "Identify the build and run commands")
Per the skill, resolve the production BUILD command and the RUN command
Expand Down
Loading
Loading