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
2 changes: 1 addition & 1 deletion bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

53 changes: 3 additions & 50 deletions cli/src/build/credentials-manage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
removeSavedCredentialKeys,
updateSavedCredentials,
} from './credentials'
import { escapeDotenvValue, renderEnvFile } from './env-render'
import { onboardingBuilderCommand } from './onboarding/command'
import { canUseFilePicker, openSaveFilePicker } from './onboarding/file-picker'

Expand Down Expand Up @@ -1197,7 +1198,7 @@ async function exportToEnvFile(entry: AppEntry): Promise<boolean> {
return false
}

const content = renderEnvFile(entry, platform, creds)
const content = renderEnvFile({ appId: entry.appId, local: entry.local, platform, creds })
await writeFile(target.path, content, { mode: 0o600 })
// Node's writeFile mode option only applies when the file is newly created;
// an overwrite leaves the existing permission bits untouched. Force 0o600
Expand Down Expand Up @@ -1235,7 +1236,7 @@ async function exportCombinedEnvFile(entry: AppEntry): Promise<boolean> {
pLog.info('✗ Export cancelled.')
return false
}
const content = renderEnvFile(entry, platform, creds)
const content = renderEnvFile({ appId: entry.appId, local: entry.local, platform, creds })
await writeFile(target.path, content, { mode: 0o600 })
await chmod(target.path, 0o600)
const fieldCount = Object.values(creds).filter(v => typeof v === 'string' && v.length > 0).length
Expand Down Expand Up @@ -1392,42 +1393,6 @@ async function resolveExportTarget(entry: AppEntry, label: 'ios' | 'android' | '
return { path: resolved }
}

function renderEnvFile(entry: AppEntry, platform: 'ios' | 'android', creds: Partial<BuildCredentials>): string {
const lines: string[] = []
const generated = new Date().toISOString()
lines.push('# Capgo build credentials — CI/CD environment file')
lines.push(`# App: ${entry.appId}`)
lines.push(`# Platform: ${platform}`)
lines.push(`# Source: ${entry.local ? 'local' : 'global'} credentials store`)
lines.push(`# Generated: ${generated}`)
lines.push('#')
lines.push('# Paste these into your CI/CD provider as secrets, or source the file locally:')
lines.push('# set -a; . ./this-file; set +a')
lines.push('#')
lines.push('# DO NOT commit this file. Add to .gitignore: .env.capgo.*')
lines.push('')

const provisioningMapRaw = creds.CAPGO_IOS_PROVISIONING_MAP
for (const [key, value] of Object.entries(creds)) {
if (typeof value !== 'string' || value.length === 0)
continue
if (key === 'CAPGO_IOS_PROVISIONING_MAP')
continue
lines.push(`${key}=${escapeDotenvValue(value)}`)
}

if (provisioningMapRaw) {
const base64 = Buffer.from(provisioningMapRaw, 'utf-8').toString('base64')
lines.push('')
lines.push('# Provisioning map — base64 form is preferred to avoid newline/quoting issues in CI.')
lines.push(`CAPGO_IOS_PROVISIONING_MAP_BASE64=${base64}`)
lines.push(`# CAPGO_IOS_PROVISIONING_MAP=${escapeDotenvValue(provisioningMapRaw)}`)
}

lines.push('')
return lines.join('\n')
}

function renderEnvFileCombined(
entry: AppEntry,
sections: Array<{ platform: 'ios' | 'android', creds: Partial<BuildCredentials> }>,
Expand Down Expand Up @@ -1479,18 +1444,6 @@ function renderEnvFileCombined(
return lines.join('\n')
}

function escapeDotenvValue(value: string): string {
if (/^[\w./+=:-]+$/.test(value))
return value
const escaped = value
.replaceAll('\\', '\\\\')
.replaceAll('"', '\\"')
.replaceAll('$', '\\$')
.replaceAll('`', '\\`')
.replaceAll('\n', '\\n')
return `"${escaped}"`
}

async function deletePlatformInteractive(entry: AppEntry): Promise<boolean> {
if (entry.platforms.length === 0) {
pLog.warn('Nothing to delete — no platforms configured.')
Expand Down
51 changes: 51 additions & 0 deletions cli/src/build/env-render.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import type { BuildCredentials } from '../schemas/build'
import { Buffer } from 'node:buffer'

export function renderEnvFile(args: { appId: string, local: boolean, platform: 'ios' | 'android', creds: Partial<BuildCredentials> }): string {
const { appId, local, platform, creds } = args
const lines: string[] = []
const generated = new Date().toISOString()
lines.push('# Capgo build credentials — CI/CD environment file')
lines.push(`# App: ${appId}`)
lines.push(`# Platform: ${platform}`)
lines.push(`# Source: ${local ? 'local' : 'global'} credentials store`)
lines.push(`# Generated: ${generated}`)
lines.push('#')
lines.push('# Paste these into your CI/CD provider as secrets, or source the file locally:')
lines.push('# set -a; . ./this-file; set +a')
lines.push('#')
lines.push('# DO NOT commit this file. Add to .gitignore: .env.capgo.*')
lines.push('')

const provisioningMapRaw = creds.CAPGO_IOS_PROVISIONING_MAP
for (const [key, value] of Object.entries(creds)) {
if (typeof value !== 'string' || value.length === 0)
continue
if (key === 'CAPGO_IOS_PROVISIONING_MAP')
continue
lines.push(`${key}=${escapeDotenvValue(value)}`)
}

if (provisioningMapRaw) {
const base64 = Buffer.from(provisioningMapRaw, 'utf-8').toString('base64')
lines.push('')
lines.push('# Provisioning map — base64 form is preferred to avoid newline/quoting issues in CI.')
lines.push(`CAPGO_IOS_PROVISIONING_MAP_BASE64=${base64}`)
lines.push(`# CAPGO_IOS_PROVISIONING_MAP=${escapeDotenvValue(provisioningMapRaw)}`)
}

lines.push('')
return lines.join('\n')
}

export function escapeDotenvValue(value: string): string {
if (/^[\w./+=:-]+$/.test(value))
return value
const escaped = value
.replaceAll('\\', '\\\\')
.replaceAll('"', '\\"')
.replaceAll('$', '\\$')
.replaceAll('`', '\\`')
.replaceAll('\n', '\\n')
return `"${escaped}"`
}
117 changes: 117 additions & 0 deletions cli/src/build/onboarding/analytics.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import type { DiffLine } from './diff-utils.js'
import type { BuildScriptChoice, PackageManager } from './workflow-generator.js'
import { createSupabaseClient, findSavedKeySilent, sendEvent } from '../../utils.js'

export type BuildOnboardingWorkflowEvent
= | 'workflow-preview-prepared'
| 'workflow-preview-action'
| 'workflow-diff-opened'
| 'workflow-diff-closed'
| 'workflow-file-written'

export type BuildOnboardingWorkflowDecision = 'write' | 'view' | 'cancel' | 'escape' | 'close'
export type BuildOnboardingWorkflowState = 'new' | 'replace' | 'identical'

export interface WorkflowDiffTelemetry {
workflowState: BuildOnboardingWorkflowState
diffLines: number
diffAdded: number
diffRemoved: number
}

interface TrackBuildOnboardingWorkflowOptions extends WorkflowDiffTelemetry {
event: BuildOnboardingWorkflowEvent
appId: string
platform: 'ios' | 'android'
apikey?: string
decision?: BuildOnboardingWorkflowDecision
packageManager?: PackageManager
buildScriptType?: BuildScriptChoice['type']
}

const WORKFLOW_EVENT_NAMES: Record<BuildOnboardingWorkflowEvent, string> = {
'workflow-preview-prepared': 'Build onboarding workflow preview prepared',
'workflow-preview-action': 'Build onboarding workflow preview action',
'workflow-diff-opened': 'Build onboarding workflow diff opened',
'workflow-diff-closed': 'Build onboarding workflow diff closed',
'workflow-file-written': 'Build onboarding workflow file written',
}

const orgIdCache = new Map<string, Promise<string | undefined>>()

export function getWorkflowDiffTelemetry(lines: DiffLine[], isNew: boolean): WorkflowDiffTelemetry {
const diffAdded = lines.filter(line => line.kind === 'add').length
const diffRemoved = lines.filter(line => line.kind === 'del').length
const workflowState = lines.length > 0 && diffAdded === 0 && diffRemoved === 0
? 'identical'
: (isNew ? 'new' : 'replace')

return {
workflowState,
diffLines: lines.length,
diffAdded,
diffRemoved,
}
}

export function trackBuildOnboardingWorkflowEvent(options: TrackBuildOnboardingWorkflowOptions): void {
void trackBuildOnboardingWorkflowEventAsync(options)
}

async function trackBuildOnboardingWorkflowEventAsync(options: TrackBuildOnboardingWorkflowOptions): Promise<void> {
const apikey = options.apikey?.trim() || findSavedKeySilent()
if (!apikey)
return

const orgId = await resolveOrganizationId(apikey, options.appId)
const tags: Record<string, string | number | boolean> = {
'app-id': options.appId,
'platform': options.platform,
'workflow-state': options.workflowState,
'diff-lines': options.diffLines,
'diff-added': options.diffAdded,
'diff-removed': options.diffRemoved,
}

if (options.decision)
tags.decision = options.decision
if (options.packageManager)
tags['package-manager'] = options.packageManager
if (options.buildScriptType)
tags['build-script-type'] = options.buildScriptType

await sendEvent(apikey, {
channel: 'native-builder',
event: WORKFLOW_EVENT_NAMES[options.event],
icon: '🧭',
user_id: orgId,
tags,
notify: false,
})
}

async function resolveOrganizationId(apikey: string, appId: string): Promise<string | undefined> {
const cacheKey = `${apikey}:${appId}`
const cached = orgIdCache.get(cacheKey)
if (cached)
return cached

const promise = (async () => {
try {
const supabase = await createSupabaseClient(apikey, undefined, undefined, true)
const { data } = await supabase
.from('apps')
.select('owner_org')
.eq('app_id', appId)
.maybeSingle()

return data?.owner_org
}
catch {
return undefined
}
})()

orgIdCache.set(cacheKey, promise)
return promise
}
38 changes: 38 additions & 0 deletions cli/src/build/onboarding/android/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,19 @@ export type AndroidOnboardingStep
| 'confirm-ci-secret-overwrite'
| 'uploading-ci-secrets'
| 'ci-secrets-failed'
// GitHub Actions workflow + .env export sub-flow (post-secrets-upload)
| 'ask-github-actions-setup'
| 'confirm-secrets-push'
| 'ask-export-env'
| 'exporting-env'
| 'confirm-env-export-overwrite'
| 'overwrite-and-export-env'
| 'pick-package-manager'
| 'pick-build-script'
| 'pick-build-script-custom'
| 'preview-workflow-file'
| 'view-workflow-diff'
| 'writing-workflow-file'
| 'ask-build'
| 'requesting-build'
| 'build-complete'
Expand Down Expand Up @@ -226,6 +239,19 @@ export const ANDROID_STEP_PROGRESS: Record<AndroidOnboardingStep, number> = {
'confirm-ci-secret-overwrite': 87,
'uploading-ci-secrets': 88,
'ci-secrets-failed': 88,
// GitHub Actions + .env export branch — post-build, ~96
'ask-github-actions-setup': 86,
'confirm-secrets-push': 87,
'ask-export-env': 96,
'exporting-env': 96,
'confirm-env-export-overwrite': 96,
'overwrite-and-export-env': 96,
'pick-package-manager': 95,
'pick-build-script': 96,
'pick-build-script-custom': 96,
'preview-workflow-file': 97,
'view-workflow-diff': 97,
'writing-workflow-file': 98,
'ask-build': 90,
'requesting-build': 95,
'build-complete': 100,
Expand Down Expand Up @@ -278,10 +304,22 @@ export function getAndroidPhaseLabel(step: AndroidOnboardingStep): string {
case 'ci-secrets-setup':
case 'ci-secrets-target-select':
case 'ask-ci-secrets':
case 'ask-github-actions-setup':
case 'confirm-secrets-push':
case 'checking-ci-secrets':
case 'confirm-ci-secret-overwrite':
case 'uploading-ci-secrets':
case 'ci-secrets-failed':
case 'ask-export-env':
case 'exporting-env':
case 'confirm-env-export-overwrite':
case 'overwrite-and-export-env':
case 'pick-package-manager':
case 'pick-build-script':
case 'pick-build-script-custom':
case 'preview-workflow-file':
case 'view-workflow-diff':
case 'writing-workflow-file':
case 'ask-build':
case 'requesting-build':
return 'Step 4 of 4 · Save & Build'
Expand Down
Loading
Loading