diff --git a/cli/package.json b/cli/package.json index fa7942b435..5413ecae9e 100644 --- a/cli/package.json +++ b/cli/package.json @@ -156,7 +156,7 @@ "test:ios-verify-app": "bun test/test-ios-verify-app.mjs", "test:platform-flow-contract": "bun test/test-platform-flow-contract.mjs", "test:tail-engine-shared": "bun test/test-tail-engine-shared.mjs", - "test": "bun run build && bun run test:helper-dce && bun run test:version-detection:setup && bun run test:bundle && bun run test:functional && bun run test:semver && bun run test:version-edge-cases && bun run test:regex && bun run test:upload && bun run test:fail-on-incompatible && bun run test:credentials && bun run test:credentials-validation && bun run test:android-service-account-validation && bun run test:build-zip-filter && bun run test:checksum && bun run test:build-needed && bun run test:ci-prompts && bun run test:ci-secrets && bun run test:android-onboarding-progress && bun run test:onboarding-telemetry && bun run test:v2-event-migration && bun run test:analytics && bun run test:analytics-error-category && bun run test:analytics-org-resolver && bun run test:supabase-perf && bun run test:preview-qr && bun run test:mcp-analytics && bun run test:mcp-instructions && bun run test:mcp-live-update-onboarding && bun run test:mcp-stdout-guard && bun run test:mcp-platform-select && bun run test:mcp-explain-scopes && bun run test:mcp-oauth-reopen && bun run test:mcp-broker-oauth && bun run test:mcp-broker-session && bun run test:mcp-credentials-manage && bun run test:mcp-resume-prompt && bun run test:mcp-build-job && bun run test:mcp-build-tools && bun run test:app-created-source && bun run test:doctor-analytics && bun run test:posthog-exception && bun run test:build-platform-selection && bun run test:onboarding-recovery && bun run test:onboarding-progress && bun run test:onboarding-run-targets && bun run test:run-device-command && bun run test:init-app-conflict && bun run test:init-guardrails && bun run test:init-replay && bun run test:prompt-preferences && bun run test:esm-sdk && bun run test:mcp && bun run test:mcp-no-key-handshake && bun run test:auth-session && bun run test:version-detection && bun run test:platform-paths && bun run test:project-type-detection && bun run test:payload-split && bun run test:manifest-path-encoding && bun run test:macos-signing && bun run test:asc-key-protocol && bun run test:apple-api-import-helpers && bun run test:apple-api-verify-key && bun run test:bundle-id-detector && bun run test:apple-api-app-list && bun run test:app-verification && bun run test:pbxproj-parser && bun run test:ai-log-capture && bun run test:ai-analyze-flow && bun run test:cicd-failure-help && bun run test:ai-sse-parser && bun run test:ai-render-markdown && bun run test:ai-stream-markdown && bun run test:ai-onboarding-mode && bun run test:ai-fit && bun run test:platform-layout && bun run test:frame-fit && bun run test:onboarding-min-size && bun run test:min-size-gate && bun run test:shell-size-gate && bun run test:build-log-sanitize && bun run test:build-output-viewport && bun run test:diff-viewer-viewport && bun run test:build-complete-exit && bun run test:ai-analyze-stream && bun run test:support-mailto && bun run test:support-redact && bun run test:support-internal-log && bun run test:support-help-menu && bun run test:support-contact && bun run test:support-upload-prompt && bun run test:support-bundle-files && bun run test:self-update && bun run test:update-prompt && bun run test:apple-api-cert-create && bun run test:android-tail-engine && bun run test:android-tail-render && bun run test:android-tail-routing && bun run test:dev-gate-stripped && bun run test:frame-fit-ios-shared && bun run test:ios-confirm-app-id && bun run test:ios-create-new && bun run test:ios-e2e && bun run test:ios-flow-contract && bun run test:ios-import-discovery && bun run test:ios-import-export && bun run test:ios-import-pickers && bun run test:ios-import-recovery && bun run test:ios-recovery && bun run test:ios-resume && bun run test:ios-tail-handoff && bun run test:ios-tui-render && bun run test:p8-error && bun run test:ios-tui-routing && bun run test:ios-updater-sync-validation && bun run test:ios-verify-app && bun run test:platform-flow-contract && bun run test:tail-engine-shared && bun run test:prescan", + "test": "bun run build && bun run test:helper-dce && bun run test:version-detection:setup && bun run test:bundle && bun run test:functional && bun run test:semver && bun run test:version-edge-cases && bun run test:regex && bun run test:upload && bun run test:fail-on-incompatible && bun run test:credentials && bun run test:credentials-validation && bun run test:android-service-account-validation && bun run test:build-zip-filter && bun run test:checksum && bun run test:native-checksum && bun run test:build-needed && bun run test:ci-prompts && bun run test:ci-secrets && bun run test:android-onboarding-progress && bun run test:onboarding-telemetry && bun run test:v2-event-migration && bun run test:analytics && bun run test:analytics-error-category && bun run test:analytics-org-resolver && bun run test:supabase-perf && bun run test:preview-qr && bun run test:mcp-analytics && bun run test:mcp-instructions && bun run test:mcp-live-update-onboarding && bun run test:mcp-stdout-guard && bun run test:mcp-platform-select && bun run test:mcp-explain-scopes && bun run test:mcp-oauth-reopen && bun run test:mcp-broker-oauth && bun run test:mcp-broker-session && bun run test:mcp-credentials-manage && bun run test:mcp-resume-prompt && bun run test:mcp-build-job && bun run test:mcp-build-tools && bun run test:app-created-source && bun run test:doctor-analytics && bun run test:posthog-exception && bun run test:build-platform-selection && bun run test:onboarding-recovery && bun run test:onboarding-progress && bun run test:onboarding-run-targets && bun run test:run-device-command && bun run test:init-app-conflict && bun run test:init-guardrails && bun run test:init-replay && bun run test:prompt-preferences && bun run test:esm-sdk && bun run test:mcp && bun run test:mcp-no-key-handshake && bun run test:auth-session && bun run test:version-detection && bun run test:platform-paths && bun run test:project-type-detection && bun run test:payload-split && bun run test:manifest-path-encoding && bun run test:macos-signing && bun run test:asc-key-protocol && bun run test:apple-api-import-helpers && bun run test:apple-api-verify-key && bun run test:bundle-id-detector && bun run test:apple-api-app-list && bun run test:app-verification && bun run test:pbxproj-parser && bun run test:ai-log-capture && bun run test:ai-analyze-flow && bun run test:cicd-failure-help && bun run test:ai-sse-parser && bun run test:ai-render-markdown && bun run test:ai-stream-markdown && bun run test:ai-onboarding-mode && bun run test:ai-fit && bun run test:platform-layout && bun run test:frame-fit && bun run test:onboarding-min-size && bun run test:min-size-gate && bun run test:shell-size-gate && bun run test:build-log-sanitize && bun run test:build-output-viewport && bun run test:diff-viewer-viewport && bun run test:build-complete-exit && bun run test:ai-analyze-stream && bun run test:support-mailto && bun run test:support-redact && bun run test:support-internal-log && bun run test:support-help-menu && bun run test:support-contact && bun run test:support-upload-prompt && bun run test:support-bundle-files && bun run test:self-update && bun run test:update-prompt && bun run test:apple-api-cert-create && bun run test:android-tail-engine && bun run test:android-tail-render && bun run test:android-tail-routing && bun run test:dev-gate-stripped && bun run test:frame-fit-ios-shared && bun run test:ios-confirm-app-id && bun run test:ios-create-new && bun run test:ios-e2e && bun run test:ios-flow-contract && bun run test:ios-import-discovery && bun run test:ios-import-export && bun run test:ios-import-pickers && bun run test:ios-import-recovery && bun run test:ios-recovery && bun run test:ios-resume && bun run test:ios-tail-handoff && bun run test:ios-tui-render && bun run test:p8-error && bun run test:ios-tui-routing && bun run test:ios-updater-sync-validation && bun run test:ios-verify-app && bun run test:platform-flow-contract && bun run test:tail-engine-shared && bun run test:prescan", "test:build-platform-selection": "bun test/test-build-platform-selection.mjs", "test:ai-log-capture": "bun test/test-ai-log-capture.mjs", "test:ai-analyze-flow": "bun test/test-ai-analyze-flow.mjs", @@ -183,7 +183,8 @@ "test:support-contact": "bun test/test-support-contact.mjs", "test:support-upload-prompt": "bun test/test-support-upload-prompt.mjs", "test:support-bundle-files": "bun test/test-support-bundle-files.mjs", - "test:mcp-live-update-onboarding": "bun test/test-mcp-live-update-onboarding.mjs" + "test:mcp-live-update-onboarding": "bun test/test-mcp-live-update-onboarding.mjs", + "test:native-checksum": "bun test/test-native-checksum.mjs" }, "dependencies": { "@inkjs/ui": "^2.0.0", diff --git a/cli/src/build/onboarding/mcp/engine.ts b/cli/src/build/onboarding/mcp/engine.ts index 656ac3ebed..43977123fe 100644 --- a/cli/src/build/onboarding/mcp/engine.ts +++ b/cli/src/build/onboarding/mcp/engine.ts @@ -7,6 +7,7 @@ import type { ChoiceOption, NextStepResult, Platform } from './contract.js' import type { IosCarried, TailParkedState } from './session-state.js' import type { BuildOutputRecord } from '../../output-record.js' import type { TailEffectProgress, TailStep, TailStepCtx } from '../tail/flow.js' +import { consoleWebUrl } from '../../../utils.js' import { buildAppIdConflictSuggestions } from '../../../init/app-conflict.js' import { ONBOARDING_RULES } from './contract.js' import { explainForState } from './explanations.js' @@ -3421,7 +3422,7 @@ async function enterTailAfterBuild( rec: BuildOutputRecord, ): Promise { try { - const buildUrl = `https://capgo.app/app/${appId}/builds` + const buildUrl = consoleWebUrl(`/app/${appId}/builds`) if (platform === 'ios') { if (!deps.iosEffectDeps?.detectCiSecretTargets || !deps.iosEffectDeps.saveProgress) return null diff --git a/cli/src/build/onboarding/tail/flow.ts b/cli/src/build/onboarding/tail/flow.ts index 7271a8aaa4..013d90aa9c 100644 --- a/cli/src/build/onboarding/tail/flow.ts +++ b/cli/src/build/onboarding/tail/flow.ts @@ -25,6 +25,7 @@ // The core stays IO-free: every concrete helper (createCiSecretEntries, // detectCiSecretTargets, …, requestBuildInternal) is injected by the driver. +import { consoleWebUrl } from '../../../utils.js' import type { BuildCredentials } from '../../../schemas/build.js' import type { BuildLogger, BuildRequestOptions, BuildRequestResult } from '../../request.js' import type { AsyncCommandRunner, CiSecretDiscovery, CiSecretEntry, CiSecretSetupAdvice, CiSecretTarget, CommandRunner } from '../ci-secrets.js' @@ -1099,7 +1100,7 @@ export async function runTailEffect

( return { progress } if (result.success) { - const url = `https://capgo.app/app/${progress.appId}/builds` + const url = consoleWebUrl(`/app/${progress.appId}/builds`) // Blank line + queued line — parity with setBuildOutput([..., '', queued]). deps.onBuildOutput?.('') deps.onBuildOutput?.(`āœ” Build queued — ${url}`) diff --git a/cli/src/init/command.ts b/cli/src/init/command.ts index a006cf60fa..20d65e72c8 100644 --- a/cli/src/init/command.ts +++ b/cli/src/init/command.ts @@ -30,7 +30,7 @@ import { copyToClipboard, revealInFinder } from '../support/clipboard' import { appendInternalLog, getInternalLogPath, startInternalLog } from '../support/internal-log' import { showReplicationProgress } from '../replicationProgress' import { formatRunnerCommand, splitRunnerCommand } from '../runner-command' -import { createSupabaseClient, defaultApiHost, findBuildCommandForProjectType, findMainFile, findMainFileForProjectType, findProjectType, findRoot, findSavedKey, findSavedKeySilent, formatError, getAllPackagesDependencies, getAppId, getBundleVersion, getConfig, getLocalConfig, getNativeProjectResetAdvice, getOrganizationListWithPermission, getPackageScripts, getPMAndCommand, hasCliPermission, PACKNAME, projectIsMonorepo, resolveUserIdFromApiKey, updateConfigbyKey, updateConfigUpdater, validateIosUpdaterSync } from '../utils' +import { consoleWebUrl, createSupabaseClient, defaultApiHost, findBuildCommandForProjectType, findMainFile, findMainFileForProjectType, findProjectType, findRoot, findSavedKey, findSavedKeySilent, formatError, getAllPackagesDependencies, getAppId, getBundleVersion, getConfig, getLocalConfig, getNativeProjectResetAdvice, getOrganizationListWithPermission, getPackageScripts, getPMAndCommand, hasCliPermission, PACKNAME, projectIsMonorepo, resolveUserIdFromApiKey, updateConfigbyKey, updateConfigUpdater, validateIosUpdaterSync } from '../utils' import { buildAppIdConflictSuggestions, isAppAlreadyExistsError } from './app-conflict' import { cancel as pCancel, confirm as pConfirm, intro as pIntro, isCancel as pIsCancel, log as pLog, outro as pOutro, select as pSelect, spinner as pSpinner, text as pText } from './prompts' import { appendInitStreamingLine, clearInitStreamingOutput, setInitCodeDiff, setInitEncryptionSummary, setInitVersionWarning, startInitStreamingOutput, stopInitInkSession, updateInitStreamingStatus } from './runtime' @@ -1791,7 +1791,7 @@ async function selectOrganizationForInit( if (organization.enforcing_2fa && !organization['2fa_has_access']) { pLog.error(`The organization "${organization.name}" requires all members to have 2FA enabled.`) - pLog.error('Enable 2FA at https://web.capgo.app/settings/account and try again.') + pLog.error(`Enable 2FA at ${consoleWebUrl('/settings/account')} and try again.`) throw new Error('2FA required for selected organization') } @@ -4748,7 +4748,7 @@ export async function initApp(apikeyCommand: string, appId: string, options: Sup discardResumedState() } else if (blocked2fa) { - pLog.warn(`Organization "${savedOrg.name}" now requires 2FA. Enable it at https://web.capgo.app/settings/account`) + pLog.warn(`Organization "${savedOrg.name}" now requires 2FA. Enable it at ${consoleWebUrl('/settings/account')}`) pLog.warn('Please select a different organization or enable 2FA and try again.') organization = await selectOrganizationForInit(supabase, options.apikey) discardResumedState() diff --git a/cli/src/native-checksum.ts b/cli/src/native-checksum.ts new file mode 100644 index 0000000000..fb160d41aa --- /dev/null +++ b/cli/src/native-checksum.ts @@ -0,0 +1,246 @@ +import { Buffer } from 'node:buffer' +import { createHash } from 'node:crypto' +import { existsSync, readdirSync, readFileSync } from 'node:fs' +import { basename, join, posix } from 'node:path' + +/** Matches native source files in standard Capacitor plugin layouts. */ +export const NATIVE_PLUGIN_SOURCE_REGEX = /([A-Za-z0-9]+)\.(java|swift|kt|scala)$/ +/** Matches native source files in @capacitor/ios and @capacitor/android layouts. */ +export const NATIVE_PLATFORM_SOURCE_REGEX = /([A-Za-z0-9]+)\.(java|swift|kt|scala|m|mm|h)$/ + +const EXCLUDED_DIR_NAMES = new Set([ + 'build', + 'node_modules', + '.gradle', + '.transforms', + 'intermediates', + 'generated', + 'outputs', + 'tmp', + 'Tests', + 'tests', + '__tests__', +]) + +const IOS_ALTERNATE_ROOTS = ['Capacitor', 'CapacitorCordova'] as const +const ANDROID_ALTERNATE_ROOT = 'capacitor' + +const TEXT_CHECKSUM_EXTENSIONS = new Set([ + '.swift', + '.java', + '.kt', + '.scala', + '.m', + '.mm', + '.h', + '.gradle', + '.kts', + '.podspec', +]) + +function findChildDirectory(dependencyFolderPath: string, expectedName: string): string | undefined { + try { + for (const entry of readdirSync(dependencyFolderPath, { withFileTypes: true })) { + if (entry.isDirectory() && entry.name === expectedName) + return join(dependencyFolderPath, entry.name) + } + } + catch { + // Ignore errors reading directory + } + return undefined +} + +function usesPlatformSourceRegex(scanRootName: string): boolean { + return scanRootName === 'Capacitor' + || scanRootName === 'CapacitorCordova' + || scanRootName === ANDROID_ALTERNATE_ROOT +} + +/** Returns whether a file path is a native source file for the given scan root layout. */ +export function isNativeSourceFilePath(filePath: string, scanRootName: string): boolean { + const regex = usesPlatformSourceRegex(scanRootName) + ? NATIVE_PLATFORM_SOURCE_REGEX + : NATIVE_PLUGIN_SOURCE_REGEX + return regex.test(filePath) +} + +/** Returns whether file content should be normalized to LF before checksum hashing. */ +export function shouldNormalizeNativeFileContent(filePath: string): boolean { + const lower = filePath.toLowerCase() + if (lower.endsWith('package.swift')) + return true + for (const ext of TEXT_CHECKSUM_EXTENSIONS) { + if (lower.endsWith(ext)) + return true + } + return false +} + +/** Normalizes native text file content to LF for deterministic cross-OS checksums. */ +export function normalizeNativeFileContentForChecksum(content: Buffer, filePath: string): Buffer { + if (!shouldNormalizeNativeFileContent(filePath)) + return content + const text = content.toString('utf8') + const normalized = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n') + return Buffer.from(normalized, 'utf8') +} + +/** Converts an absolute file path to a POSIX relative path from the dependency root. */ +export function normalizeChecksumRelativePath(dependencyFolderPath: string, filePath: string): string { + const toPosixPath = (input: string) => input.replace(/\\/g, posix.sep) + return posix.relative(toPosixPath(dependencyFolderPath), toPosixPath(filePath)) +} + +/** Resolves iOS or Android native scan roots, including platform package layouts. */ +export function getNativeScanRoots(dependencyFolderPath: string, platform: 'ios' | 'android'): string[] { + const roots: string[] = [] + const primary = findChildDirectory(dependencyFolderPath, platform) + if (primary) + roots.push(primary) + + if (platform === 'ios') { + for (const alt of IOS_ALTERNATE_ROOTS) { + const altPath = findChildDirectory(dependencyFolderPath, alt) + if (altPath) + roots.push(altPath) + } + } + else { + const altPath = findChildDirectory(dependencyFolderPath, ANDROID_ALTERNATE_ROOT) + if (altPath) + roots.push(altPath) + } + + return roots +} + +function readDirRecursivelyFullPaths(dir: string): string[] { + if (!existsSync(dir)) + return [] + + try { + const entries = readdirSync(dir, { withFileTypes: true }) + return entries.flatMap((entry) => { + const fullPath = join(dir, entry.name) + if (entry.isDirectory()) { + if (EXCLUDED_DIR_NAMES.has(entry.name)) + return [] + return readDirRecursivelyFullPaths(fullPath) + } + if (entry.isFile() || entry.isSymbolicLink()) + return fullPath + return [] + }) + } + catch { + return [] + } +} + +function collectNativeFilesFromRoots(roots: string[]): string[] { + const files: string[] = [] + for (const root of roots) { + const scanRootName = basename(root) + const nativeFiles = readDirRecursivelyFullPaths(root) + .filter(filePath => isNativeSourceFilePath(filePath, scanRootName)) + files.push(...nativeFiles) + } + return [...new Set(files)] +} + +function getPlatformConfigFiles(dependencyFolderPath: string, platform: 'ios' | 'android'): string[] { + const files: string[] = [] + + if (platform === 'ios') { + try { + const rootFiles = readdirSync(dependencyFolderPath) + for (const file of rootFiles) { + if (file.endsWith('.podspec')) + files.push(join(dependencyFolderPath, file)) + } + } + catch { + // Ignore errors reading directory + } + + const packageSwiftRoot = join(dependencyFolderPath, 'Package.swift') + const packageSwiftIos = join(dependencyFolderPath, 'ios', 'Package.swift') + if (existsSync(packageSwiftRoot)) + files.push(packageSwiftRoot) + if (existsSync(packageSwiftIos)) + files.push(packageSwiftIos) + } + else if (platform === 'android') { + for (const gradleDir of [ + join(dependencyFolderPath, 'android'), + join(dependencyFolderPath, ANDROID_ALTERNATE_ROOT), + ]) { + const buildGradle = join(gradleDir, 'build.gradle') + const buildGradleKts = join(gradleDir, 'build.gradle.kts') + if (existsSync(buildGradle)) + files.push(buildGradle) + if (existsSync(buildGradleKts)) + files.push(buildGradleKts) + } + } + + return files +} + +function updateLengthPrefixedHash(hash: ReturnType, data: Buffer | string): void { + const buffer = typeof data === 'string' ? Buffer.from(data, 'utf8') : data + const length = Buffer.alloc(4) + length.writeUInt32BE(buffer.length, 0) + hash.update(length) + hash.update(buffer) +} + +/** Returns true when a dependency contains native sources or platform config files. */ +export function dependencyHasNativeFiles(dependencyFolderPath: string): boolean { + const iosRoots = getNativeScanRoots(dependencyFolderPath, 'ios') + const androidRoots = getNativeScanRoots(dependencyFolderPath, 'android') + const iosConfigFiles = getPlatformConfigFiles(dependencyFolderPath, 'ios') + const androidConfigFiles = getPlatformConfigFiles(dependencyFolderPath, 'android') + return collectNativeFilesFromRoots(iosRoots).length > 0 + || collectNativeFilesFromRoots(androidRoots).length > 0 + || iosConfigFiles.length > 0 + || androidConfigFiles.length > 0 +} + +/** Computes deterministic SHA-256 checksums for iOS and Android native dependency content. */ +export async function calculatePlatformChecksums(dependencyFolderPath: string): Promise<{ ios_checksum?: string, android_checksum?: string }> { + const calculatePlatformChecksum = async (platform: 'ios' | 'android'): Promise => { + const roots = getNativeScanRoots(dependencyFolderPath, platform) + const nativeFiles = collectNativeFilesFromRoots(roots) + const configFiles = getPlatformConfigFiles(dependencyFolderPath, platform) + const allFiles = [...new Set([...nativeFiles, ...configFiles])].sort((a, b) => a.localeCompare(b)) + + if (allFiles.length === 0) + return undefined + + const hash = createHash('sha256') + + for (const file of allFiles) { + try { + const content = readFileSync(file) + const relativePath = normalizeChecksumRelativePath(dependencyFolderPath, file) + const normalizedContent = normalizeNativeFileContentForChecksum(content, file) + updateLengthPrefixedHash(hash, relativePath) + updateLengthPrefixedHash(hash, normalizedContent) + } + catch { + // Skip files that can't be read + } + } + + return hash.digest('hex') + } + + const [ios_checksum, android_checksum] = await Promise.all([ + calculatePlatformChecksum('ios'), + calculatePlatformChecksum('android'), + ]) + + return { ios_checksum, android_checksum } +} diff --git a/cli/src/organization/list.ts b/cli/src/organization/list.ts index b003740249..08edfcb07a 100644 --- a/cli/src/organization/list.ts +++ b/cli/src/organization/list.ts @@ -5,6 +5,7 @@ import { Table } from '@sauber/table' import { trackEvent } from '../analytics/track' import { checkAlerts } from '../api/update' import { + consoleWebUrl, createSupabaseClient, findSavedKey, formatError, @@ -48,7 +49,7 @@ function displayOrganizations(data: Organization[], silent: boolean) { for (const org of noAccessOrgs) { log.warn(` - ${org.name} (${org.gid})`) } - log.warn(`\nTo regain access, enable 2FA on your account at https://web.capgo.app/settings/account`) + log.warn(`\nTo regain access, enable 2FA on your account at ${consoleWebUrl('/settings/account')}`) } } diff --git a/cli/src/utils.ts b/cli/src/utils.ts index 9c10a0964f..2a97556562 100644 --- a/cli/src/utils.ts +++ b/cli/src/utils.ts @@ -27,6 +27,7 @@ import { markSnag } from './app/debug' import { findMonorepoRoot, findNXMonorepoRoot, isMonorepo, isNXMonorepo } from './capacitor-cli' import { getChecksum } from './checksum' import { loadConfig, writeConfig } from './config' +import { calculatePlatformChecksums, dependencyHasNativeFiles } from './native-checksum' import { isTruthyEnvValue } from './posthog' import { nativePackageSchema } from './schemas/common' import { formatApiErrorForCli, parseSecurityPolicyError } from './utils/security_policy_errors' @@ -39,6 +40,13 @@ export const defaultHost = 'https://capgo.app' export const defaultFileHost = 'https://files.capgo.app' export const defaultApiHost = 'https://api.capgo.app' export const defaultHostWeb = 'https://console.capgo.app' + +/** Build a console web-app URL (settings, builds, connect, etc.). */ +export function consoleWebUrl(path = ''): string { + if (!path) + return defaultHostWeb + return `${defaultHostWeb}${path.startsWith('/') ? path : `/${path}`}` +} export const UPLOAD_TIMEOUT = 120000 export const ALERT_UPLOAD_SIZE_BYTES = 1024 * 1024 * 20 // 20MB export const MAX_UPLOAD_LENGTH_BYTES = 1024 * 1024 * 1024 // 1GB @@ -129,7 +137,7 @@ export async function check2FAAccessForOrg(supabase: SupabaseClient, o } if (reject2fa) { if (!silent) - log.error(`šŸ” Access Denied: 2FA Required. Enable 2FA at https://web.capgo.app/settings/account`) + log.error(`šŸ” Access Denied: 2FA Required. Enable 2FA at ${consoleWebUrl('/settings/account')}`) throw new Error('2FA required for this organization') } } @@ -601,9 +609,6 @@ export async function getLocalConfig(silent = false) { } } } -// eslint-disable-next-line regexp/no-unused-capturing-group -const nativeFileRegex = /([A-Za-z0-9]+)\.(java|swift|kt|scala)$/ - interface CapgoConfig { supaHost?: string supaKey?: string @@ -1634,7 +1639,7 @@ export function show2FADeniedError(organizationName?: string): never { log.error(`\nThis organization requires all members to have 2FA enabled.`) } log.error(`\nTo regain access:`) - log.error(` 1. Go to https://web.capgo.app/settings/account`) + log.error(` 1. Go to ${consoleWebUrl('/settings/account')}`) log.error(` 2. Enable Two-Factor Authentication on your account`) log.error(` 3. Try your command again`) log.error(`\nFor more information, visit: https://capgo.app/docs/webapp/2fa-enforcement/\n`) @@ -1835,142 +1840,6 @@ export function getNativeProjectResetAdvice(platformRunner: string, nativePlatfo } } -function readDirRecursively(dir: string): string[] { - const entries = readdirSync(dir, { withFileTypes: true }) - const files = entries.flatMap((entry) => { - const fullPath = join(dir, entry.name) - if (entry.isDirectory()) { - return readDirRecursively(fullPath) - } - else { - // Use relative path to avoid issues with long paths on Windows - return fullPath.split(`node_modules${sep}`)[1] || fullPath - } - }) - return files -} - -/** - * Read directory recursively and return full paths for all files - */ -function readDirRecursivelyFullPaths(dir: string): string[] { - if (!existsSync(dir)) - return [] - - try { - const entries = readdirSync(dir, { withFileTypes: true }) - const files = entries.flatMap((entry) => { - const fullPath = join(dir, entry.name) - if (entry.isDirectory()) { - return readDirRecursivelyFullPaths(fullPath) - } - else { - return fullPath - } - }) - return files - } - catch { - return [] - } -} - -/** - * Get additional platform-specific files that should be included in checksum. - * These files contain platform dependency versions and configurations. - */ -function getPlatformConfigFiles(dependencyFolderPath: string, platform: 'ios' | 'android'): string[] { - const files: string[] = [] - - if (platform === 'ios') { - // Include .podspec files (CocoaPods dependency versions) - try { - const rootFiles = readdirSync(dependencyFolderPath) - for (const file of rootFiles) { - if (file.endsWith('.podspec')) { - files.push(join(dependencyFolderPath, file)) - } - } - } - catch { - // Ignore errors reading directory - } - - // Include Package.swift (SPM dependency versions) - can be at root or in ios folder - const packageSwiftRoot = join(dependencyFolderPath, 'Package.swift') - const packageSwiftIos = join(dependencyFolderPath, 'ios', 'Package.swift') - if (existsSync(packageSwiftRoot)) - files.push(packageSwiftRoot) - if (existsSync(packageSwiftIos)) - files.push(packageSwiftIos) - } - else if (platform === 'android') { - // Include build.gradle files (Android dependency versions) - const androidDir = join(dependencyFolderPath, 'android') - const buildGradle = join(androidDir, 'build.gradle') - const buildGradleKts = join(androidDir, 'build.gradle.kts') - - if (existsSync(buildGradle)) - files.push(buildGradle) - if (existsSync(buildGradleKts)) - files.push(buildGradleKts) - } - - return files -} - -/** - * Calculate checksums for iOS and Android native code in a dependency folder. - * Includes both native source files and platform configuration files - * (podspec, Package.swift, build.gradle) that define platform dependencies. - */ -async function calculatePlatformChecksums(dependencyFolderPath: string): Promise<{ ios_checksum?: string, android_checksum?: string }> { - const iosDir = join(dependencyFolderPath, 'ios') - const androidDir = join(dependencyFolderPath, 'android') - - const calculatePlatformChecksum = async (platformDir: string, platform: 'ios' | 'android'): Promise => { - // Get native code files - const nativeFiles = existsSync(platformDir) - ? readDirRecursivelyFullPaths(platformDir).filter(f => nativeFileRegex.test(f)) - : [] - - // Get platform config files (podspec, Package.swift, build.gradle) - const configFiles = getPlatformConfigFiles(dependencyFolderPath, platform) - - // Combine and sort all files for consistent checksumming - const allFiles = [...nativeFiles, ...configFiles].sort((a, b) => a.localeCompare(b)) - - if (allFiles.length === 0) - return undefined - - const { createHash } = await import('node:crypto') - const hash = createHash('sha256') - - for (const file of allFiles) { - try { - // Include relative path in hash to detect file renames/moves - const relativePath = relative(dependencyFolderPath, file) - hash.update(relativePath) - // Include file content - const content = readFileSync(file) - hash.update(content) - } - catch { - // Skip files that can't be read - } - } - - return hash.digest('hex') - } - - const [ios_checksum, android_checksum] = await Promise.all([ - calculatePlatformChecksum(iosDir, 'ios'), - calculatePlatformChecksum(androidDir, 'android'), - ]) - - return { ios_checksum, android_checksum } -} - export async function getLocalDependencies(packageJsonPath: string | undefined, nodeModulesString: string | undefined) { const nodeModules = nodeModulesString ? nodeModulesString @@ -2045,8 +1914,7 @@ export async function getLocalDependencies(packageJsonPath: string | undefined, // If we can't read the package.json, fall back to declared version } try { - const files = readDirRecursively(dependencyFolderPath) - if (files.some(fileName => nativeFileRegex.test(fileName))) { + if (dependencyHasNativeFiles(dependencyFolderPath)) { hasNativeFiles = true break } diff --git a/cli/src/utils/security_policy_errors.ts b/cli/src/utils/security_policy_errors.ts index 1d19faf6f5..1a5307abff 100644 --- a/cli/src/utils/security_policy_errors.ts +++ b/cli/src/utils/security_policy_errors.ts @@ -28,13 +28,13 @@ export type SecurityPolicyErrorCode = typeof SECURITY_POLICY_ERRORS[keyof typeof export const SECURITY_POLICY_MESSAGES: Record = { [SECURITY_POLICY_ERRORS.ORG_REQUIRES_EXPIRING_KEY]: - 'This organization requires API keys with expiration dates.\n\nPlease generate a new API key with an expiration:\n 1. Go to https://web.capgo.app/dashboard/apikeys\n 2. Create a new API key with an expiration date\n 3. Update your CLI configuration with: capgo login [new-key]\n 4. Try this command again', + 'This organization requires API keys with expiration dates.\n\nPlease generate a new API key with an expiration:\n 1. Go to https://console.capgo.app/dashboard/apikeys\n 2. Create a new API key with an expiration date\n 3. Update your CLI configuration with: capgo login [new-key]\n 4. Try this command again', [SECURITY_POLICY_ERRORS.EXPIRATION_REQUIRED]: - 'This organization requires API keys to have an expiration date.\n\nPlease generate a new API key with an expiration:\n 1. Go to https://web.capgo.app/dashboard/apikeys\n 2. Create a new API key with an expiration date\n 3. Update your CLI configuration with: capgo login [new-key]\n 4. Try this command again', + 'This organization requires API keys to have an expiration date.\n\nPlease generate a new API key with an expiration:\n 1. Go to https://console.capgo.app/dashboard/apikeys\n 2. Create a new API key with an expiration date\n 3. Update your CLI configuration with: capgo login [new-key]\n 4. Try this command again', [SECURITY_POLICY_ERRORS.EXPIRATION_EXCEEDS_MAX]: - 'Your API key expiration date exceeds the maximum allowed by this organization.\n\nPlease generate a new API key with a shorter expiration:\n 1. Go to https://web.capgo.app/dashboard/apikeys\n 2. Create a new API key with a valid expiration date\n 3. Update your CLI configuration with: capgo login [new-key]\n 4. Try this command again', + 'Your API key expiration date exceeds the maximum allowed by this organization.\n\nPlease generate a new API key with a shorter expiration:\n 1. Go to https://console.capgo.app/dashboard/apikeys\n 2. Create a new API key with a valid expiration date\n 3. Update your CLI configuration with: capgo login [new-key]\n 4. Try this command again', } // ============================================================================ diff --git a/cli/test/fixtures/native-checksum/capacitor-android/capacitor/build.gradle b/cli/test/fixtures/native-checksum/capacitor-android/capacitor/build.gradle new file mode 100644 index 0000000000..aca3b26e91 --- /dev/null +++ b/cli/test/fixtures/native-checksum/capacitor-android/capacitor/build.gradle @@ -0,0 +1,3 @@ +android { + namespace = "com.getcapacitor" +} diff --git a/cli/test/fixtures/native-checksum/capacitor-android/capacitor/src/main/java/com/getcapacitor/Bridge.java b/cli/test/fixtures/native-checksum/capacitor-android/capacitor/src/main/java/com/getcapacitor/Bridge.java new file mode 100644 index 0000000000..484deb19fb --- /dev/null +++ b/cli/test/fixtures/native-checksum/capacitor-android/capacitor/src/main/java/com/getcapacitor/Bridge.java @@ -0,0 +1 @@ +public class Bridge {} diff --git a/cli/test/fixtures/native-checksum/capacitor-ios/Capacitor.podspec b/cli/test/fixtures/native-checksum/capacitor-ios/Capacitor.podspec new file mode 100644 index 0000000000..05e6f4b360 --- /dev/null +++ b/cli/test/fixtures/native-checksum/capacitor-ios/Capacitor.podspec @@ -0,0 +1,3 @@ +Pod::Spec.new do |s| + s.name = "Capacitor" +end diff --git a/cli/test/fixtures/native-checksum/capacitor-ios/Capacitor/Capacitor/CAPBridge.h b/cli/test/fixtures/native-checksum/capacitor-ios/Capacitor/Capacitor/CAPBridge.h new file mode 100644 index 0000000000..0b8c4b8fb0 --- /dev/null +++ b/cli/test/fixtures/native-checksum/capacitor-ios/Capacitor/Capacitor/CAPBridge.h @@ -0,0 +1 @@ +#import diff --git a/cli/test/fixtures/native-checksum/capacitor-ios/Capacitor/Capacitor/CAPBridge.m b/cli/test/fixtures/native-checksum/capacitor-ios/Capacitor/Capacitor/CAPBridge.m new file mode 100644 index 0000000000..c69a5e8664 --- /dev/null +++ b/cli/test/fixtures/native-checksum/capacitor-ios/Capacitor/Capacitor/CAPBridge.m @@ -0,0 +1 @@ +#import "CAPBridge.h" diff --git a/cli/test/fixtures/native-checksum/capacitor-ios/Capacitor/Capacitor/CAPBridge.swift b/cli/test/fixtures/native-checksum/capacitor-ios/Capacitor/Capacitor/CAPBridge.swift new file mode 100644 index 0000000000..f7881023bc --- /dev/null +++ b/cli/test/fixtures/native-checksum/capacitor-ios/Capacitor/Capacitor/CAPBridge.swift @@ -0,0 +1,2 @@ +import Foundation +class CAPBridge {} diff --git a/cli/test/fixtures/native-checksum/plugin-changed/android/build.gradle b/cli/test/fixtures/native-checksum/plugin-changed/android/build.gradle new file mode 100644 index 0000000000..69ddb23783 --- /dev/null +++ b/cli/test/fixtures/native-checksum/plugin-changed/android/build.gradle @@ -0,0 +1,3 @@ +android { + changed = true +} diff --git a/cli/test/fixtures/native-checksum/plugin-changed/android/src/main/java/com/example/Example.java b/cli/test/fixtures/native-checksum/plugin-changed/android/src/main/java/com/example/Example.java new file mode 100644 index 0000000000..2c4ad65f88 --- /dev/null +++ b/cli/test/fixtures/native-checksum/plugin-changed/android/src/main/java/com/example/Example.java @@ -0,0 +1 @@ +public class Example { public void changed() {} } diff --git a/cli/test/fixtures/native-checksum/plugin-changed/ios/Sources/Plugin/AppPlugin.swift b/cli/test/fixtures/native-checksum/plugin-changed/ios/Sources/Plugin/AppPlugin.swift new file mode 100644 index 0000000000..2fb606ba74 --- /dev/null +++ b/cli/test/fixtures/native-checksum/plugin-changed/ios/Sources/Plugin/AppPlugin.swift @@ -0,0 +1,3 @@ +class AppPlugin { + func changed() {} +} diff --git a/cli/test/fixtures/native-checksum/plugin-config-only/ConfigOnly.podspec b/cli/test/fixtures/native-checksum/plugin-config-only/ConfigOnly.podspec new file mode 100644 index 0000000000..c262ea584f --- /dev/null +++ b/cli/test/fixtures/native-checksum/plugin-config-only/ConfigOnly.podspec @@ -0,0 +1,3 @@ +Pod::Spec.new do |s| + s.name = "ConfigOnly" +end diff --git a/cli/test/fixtures/native-checksum/plugin-config-only/android/build.gradle b/cli/test/fixtures/native-checksum/plugin-config-only/android/build.gradle new file mode 100644 index 0000000000..8b3b4fc5e1 --- /dev/null +++ b/cli/test/fixtures/native-checksum/plugin-config-only/android/build.gradle @@ -0,0 +1,2 @@ +android { +} diff --git a/cli/test/fixtures/native-checksum/plugin-crlf/CapacitorApp.podspec b/cli/test/fixtures/native-checksum/plugin-crlf/CapacitorApp.podspec new file mode 100644 index 0000000000..7a06410955 --- /dev/null +++ b/cli/test/fixtures/native-checksum/plugin-crlf/CapacitorApp.podspec @@ -0,0 +1,2 @@ +Pod::Spec.new do |s| +end diff --git a/cli/test/fixtures/native-checksum/plugin-crlf/android/build.gradle b/cli/test/fixtures/native-checksum/plugin-crlf/android/build.gradle new file mode 100644 index 0000000000..4d056021fa --- /dev/null +++ b/cli/test/fixtures/native-checksum/plugin-crlf/android/build.gradle @@ -0,0 +1,2 @@ +android { +} diff --git a/cli/test/fixtures/native-checksum/plugin-crlf/android/src/main/java/com/example/Example.java b/cli/test/fixtures/native-checksum/plugin-crlf/android/src/main/java/com/example/Example.java new file mode 100644 index 0000000000..51210a4256 --- /dev/null +++ b/cli/test/fixtures/native-checksum/plugin-crlf/android/src/main/java/com/example/Example.java @@ -0,0 +1 @@ +public class Example {} diff --git a/cli/test/fixtures/native-checksum/plugin-crlf/ios/Sources/Plugin/AppPlugin.swift b/cli/test/fixtures/native-checksum/plugin-crlf/ios/Sources/Plugin/AppPlugin.swift new file mode 100644 index 0000000000..657e45e55d --- /dev/null +++ b/cli/test/fixtures/native-checksum/plugin-crlf/ios/Sources/Plugin/AppPlugin.swift @@ -0,0 +1,2 @@ +class AppPlugin { +} diff --git a/cli/test/fixtures/native-checksum/plugin-lf/CapacitorApp.podspec b/cli/test/fixtures/native-checksum/plugin-lf/CapacitorApp.podspec new file mode 100644 index 0000000000..c6b13889f8 --- /dev/null +++ b/cli/test/fixtures/native-checksum/plugin-lf/CapacitorApp.podspec @@ -0,0 +1,2 @@ +Pod::Spec.new do |s| +end diff --git a/cli/test/fixtures/native-checksum/plugin-lf/android/build.gradle b/cli/test/fixtures/native-checksum/plugin-lf/android/build.gradle new file mode 100644 index 0000000000..8b3b4fc5e1 --- /dev/null +++ b/cli/test/fixtures/native-checksum/plugin-lf/android/build.gradle @@ -0,0 +1,2 @@ +android { +} diff --git a/cli/test/fixtures/native-checksum/plugin-lf/android/src/main/java/com/example/Example.java b/cli/test/fixtures/native-checksum/plugin-lf/android/src/main/java/com/example/Example.java new file mode 100644 index 0000000000..0f5fefd304 --- /dev/null +++ b/cli/test/fixtures/native-checksum/plugin-lf/android/src/main/java/com/example/Example.java @@ -0,0 +1 @@ +public class Example {} diff --git a/cli/test/fixtures/native-checksum/plugin-lf/ios/Sources/Plugin/AppPlugin.swift b/cli/test/fixtures/native-checksum/plugin-lf/ios/Sources/Plugin/AppPlugin.swift new file mode 100644 index 0000000000..7a7a6935f9 --- /dev/null +++ b/cli/test/fixtures/native-checksum/plugin-lf/ios/Sources/Plugin/AppPlugin.swift @@ -0,0 +1,2 @@ +class AppPlugin { +} diff --git a/cli/test/helpers/onboarding-fixtures.mjs b/cli/test/helpers/onboarding-fixtures.mjs index 4a036f1fbf..8630b73bef 100644 --- a/cli/test/helpers/onboarding-fixtures.mjs +++ b/cli/test/helpers/onboarding-fixtures.mjs @@ -107,14 +107,14 @@ export function staticStepFixtures() { // NOTE: ish.ErrorStep is deliberately NOT here — it's unbounded and scrolls // (see header). Only its compact form renders inline, and that's ~20 rows. fi('ios-no-platform', h(ish.NoPlatformStep, { iosDir: 'apps/mobile/platforms/ios-native', addIosCommand: 'npx cap add ios', syncIosCommand: 'npx cap sync ios', onChange: noop, ...C }), false), - fi('ios-build-complete', h(ish.BuildCompleteStep, { buildUrl: `https://capgo.app/app/${LONG_APP_ID}/builds`, ciSecretUploadSummary: 'Uploaded 12 secrets to GitHub Actions repository secrets', buildRequestCommand: `npx @capgo/cli build --app ${LONG_APP_ID}`, ...C }), false), + fi('ios-build-complete', h(ish.BuildCompleteStep, { buildUrl: `https://console.capgo.app/app/${LONG_APP_ID}/builds`, ciSecretUploadSummary: 'Uploaded 12 secrets to GitHub Actions repository secrets', buildRequestCommand: `npx @capgo/cli build --app ${LONG_APP_ID}`, ...C }), false), fi('ios-platform-select', h(ish.PlatformSelectStep, { appId: LONG_APP_ID, onChange: noop, ...C })), fi('ios-adding-platform', h(ish.AddingPlatformStep, { addIosCommand: 'npx cap add ios', doctorCommand: 'npx @capgo/cli doctor', ...C }), false), // ── shared (rendered by the Android app) ───────────────────────────────── fi('welcome', h(ish.WelcomeStep), false), f('no-platform', h(ash.NoPlatformStep, { androidDir: 'apps/mobile/platforms/android-native', ...C }), false), f('credentials-exist-choose', h(ash.CredentialsExistStep, { appId: 'com.x.y', onChoose: noop, ...C })), - f('build-complete', h(ash.BuildCompleteStep, { uploadSummary: null, buildUrl: 'https://capgo.app/app/com.example.app/builds', ...C }), false), + f('build-complete', h(ash.BuildCompleteStep, { uploadSummary: null, buildUrl: 'https://console.capgo.app/app/com.example.app/builds', ...C }), false), f('error', h(ash.ErrorStep, { message: LONG_ERR, onChoose: noop, ...C })), // ── ci ────────────────────────────────────────────────────────────────── f('ci-secrets-setup', h(androidCi.CiSecretsSetupStep, { advice: CI_ADVICE, onChoose: noop, ...C })), diff --git a/cli/test/test-android-tail-engine.mjs b/cli/test/test-android-tail-engine.mjs index 6f12be915e..7d3b329e29 100644 --- a/cli/test/test-android-tail-engine.mjs +++ b/cli/test/test-android-tail-engine.mjs @@ -726,7 +726,7 @@ await test("androidViewForStep('build-complete') is a done view", async () => { // adding the tail markers exercises only the new Phase-6 routing. const CREDS_SAVED = { savedAt: '2026-06-03T01:00:00.000Z' } -const BUILD_REQUESTED = { buildUrl: `https://capgo.app/app/${APP_ID}/builds` } +const BUILD_REQUESTED = { buildUrl: `https://console.capgo.app/app/${APP_ID}/builds` } const CI_UPLOADED_GH = { provider: 'github', count: 3 } /** diff --git a/cli/test/test-android-tail-render.mjs b/cli/test/test-android-tail-render.mjs index 1af60c366c..1317ebe670 100644 --- a/cli/test/test-android-tail-render.mjs +++ b/cli/test/test-android-tail-render.mjs @@ -241,7 +241,7 @@ test('build-complete surfaces the upload summary, workflow path, build url and f assertContains( h(BuildCompleteStep, { uploadSummary: 'Uploaded 5 env vars to GitHub Actions', - buildUrl: 'https://capgo.app/app/com.example.app/builds', + buildUrl: 'https://console.capgo.app/app/com.example.app/builds', workflowWrittenPath: '/repo/.github/workflows/capgo-build.yml', }), [ @@ -251,7 +251,7 @@ test('build-complete surfaces the upload summary, workflow path, build url and f '/repo/.github/workflows/capgo-build.yml', 'Dispatch it from GitHub Actions to kick off an Android build.', 'Track your build:', - 'https://capgo.app/app/com.example.app/builds', + 'https://console.capgo.app/app/com.example.app/builds', 'Press Enter to finish', ], 'build-complete-full', diff --git a/cli/test/test-android-tail-routing.mjs b/cli/test/test-android-tail-routing.mjs index 969e5d2dc7..aaa519166e 100644 --- a/cli/test/test-android-tail-routing.mjs +++ b/cli/test/test-android-tail-routing.mjs @@ -81,7 +81,7 @@ const GITHUB_TARGET = { provider: 'github', label: 'GitHub Actions repository se const GITLAB_TARGET = { provider: 'gitlab', label: 'GitLab CI/CD variables', cli: 'glab' } const CREDS_SAVED = { savedAt: '2026-06-03T01:00:00.000Z' } -const BUILD_REQUESTED = { buildUrl: `https://capgo.app/app/${APP_ID}/builds` } +const BUILD_REQUESTED = { buildUrl: `https://console.capgo.app/app/${APP_ID}/builds` } const CI_UPLOADED_GH = { provider: 'github', count: 3 } // A FULLY-provisioned OAuth progress whose keystore + provisioning gates are all diff --git a/cli/test/test-frame-fit-android-shared.mjs b/cli/test/test-frame-fit-android-shared.mjs index d9efa68fe8..c1a7a9a3d2 100644 --- a/cli/test/test-frame-fit-android-shared.mjs +++ b/cli/test/test-frame-fit-android-shared.mjs @@ -94,7 +94,7 @@ test(`build-complete [dense, bare] fits ${BODY_BUDGET_ROWS}-row budget`, () => { }) test(`build-complete [dense, url only] fits ${BODY_BUDGET_ROWS}-row budget`, () => { assertFitsBudget( - h(BuildCompleteStep, { uploadSummary: null, buildUrl: 'https://capgo.app/app/com.example.app/builds', dense: true }), + h(BuildCompleteStep, { uploadSummary: null, buildUrl: 'https://console.capgo.app/app/com.example.app/builds', dense: true }), 'build-complete-dense-url', ) }) @@ -102,7 +102,7 @@ test(`build-complete [dense, summary + url] fits ${BODY_BUDGET_ROWS}-row budget` assertFitsBudget( h(BuildCompleteStep, { uploadSummary: 'Uploaded 5 env vars to GitHub Actions', - buildUrl: 'https://capgo.app/app/com.example.app/builds', + buildUrl: 'https://console.capgo.app/app/com.example.app/builds', dense: true, }), 'build-complete-dense-summary-url', @@ -112,7 +112,7 @@ test(`build-complete [dense, long summary] fits ${BODY_BUDGET_ROWS}-row budget`, assertFitsBudget( h(BuildCompleteStep, { uploadSummary: 'Uploaded 12 build environment variables to the GitHub Actions repository secrets store', - buildUrl: 'https://capgo.app/app/com.example.app/builds', + buildUrl: 'https://console.capgo.app/app/com.example.app/builds', dense: true, }), 'build-complete-dense-long-summary', diff --git a/cli/test/test-ios-tail-handoff.mjs b/cli/test/test-ios-tail-handoff.mjs index 125cb8cbd4..370c3202ea 100644 --- a/cli/test/test-ios-tail-handoff.mjs +++ b/cli/test/test-ios-tail-handoff.mjs @@ -521,7 +521,7 @@ await test('runIosEffect throws for a NON-tail, not-yet-implemented effect step // parity — existing files unaffected). const CREDS_SAVED = { savedAt: '2026-06-03T01:00:00.000Z' } -const BUILD_REQUESTED = { buildUrl: `https://capgo.app/app/${APP_ID}/builds` } +const BUILD_REQUESTED = { buildUrl: `https://console.capgo.app/app/${APP_ID}/builds` } const CI_UPLOADED_GH = { provider: 'github', count: 3 } /** A fully-provisioned create-new progress: cert + profile persisted, so without diff --git a/cli/test/test-ios-tui-render.mjs b/cli/test/test-ios-tui-render.mjs index aba6210b81..e8087b31ea 100644 --- a/cli/test/test-ios-tui-render.mjs +++ b/cli/test/test-ios-tui-render.mjs @@ -1002,7 +1002,7 @@ test('build-complete (bare) shows the all-set box, the credentials-ready line an test('build-complete surfaces the cloud-build line and the tracking url when a build was kicked off', () => { assertContains( h(BuildCompleteStep, { - buildUrl: 'https://capgo.app/app/com.example.app/builds', + buildUrl: 'https://console.capgo.app/app/com.example.app/builds', ciSecretUploadSummary: null, buildRequestCommand: 'npx @capgo/cli build request', }), @@ -1010,7 +1010,7 @@ test('build-complete surfaces the cloud-build line and the tracking url when a b 'You\'re all set!', 'Your iOS app is building in the cloud.', 'Track it at', - 'https://capgo.app/app/com.example.app/builds', + 'https://console.capgo.app/app/com.example.app/builds', 'Press Enter to finish', ], 'build-complete-build-url', diff --git a/cli/test/test-native-checksum.mjs b/cli/test/test-native-checksum.mjs new file mode 100755 index 0000000000..a305deae75 --- /dev/null +++ b/cli/test/test-native-checksum.mjs @@ -0,0 +1,140 @@ +#!/usr/bin/env node + +import assert from 'node:assert/strict' +import { Buffer } from 'node:buffer' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' + +const fixtureRoot = join(fileURLToPath(new URL('.', import.meta.url)), 'fixtures/native-checksum') + +const { + calculatePlatformChecksums, + dependencyHasNativeFiles, + getNativeScanRoots, + normalizeChecksumRelativePath, + normalizeNativeFileContentForChecksum, +} = await import('../src/native-checksum.ts') + +const { compareNativePackages, summarizeBundleCompatibility } = await import( + '../../supabase/functions/_backend/utils/bundle_compatibility.ts' +) + +let failures = 0 + +async function test(name, fn) { + try { + await fn() + console.log(`āœ“ ${name}`) + } + catch (error) { + failures += 1 + console.error(`āŒ ${name}`) + console.error(error) + } +} + +await test('normalizes CRLF and LF to the same checksum input', () => { + const lf = Buffer.from('class AppPlugin {\n}\n', 'utf8') + const crlf = Buffer.from('class AppPlugin {\r\n}\r\n', 'utf8') + const file = 'ios/Sources/Plugin/AppPlugin.swift' + + const normalizedLf = normalizeNativeFileContentForChecksum(lf, file) + const normalizedCrlf = normalizeNativeFileContentForChecksum(crlf, file) + + assert.equal(normalizedLf.toString('utf8'), normalizedCrlf.toString('utf8')) +}) + +await test('normalizes Windows-style relative paths with forward slashes', () => { + const dependencyFolderPath = '/project/node_modules/@capacitor/app' + const filePath = '/project/node_modules/@capacitor/app\\ios\\Sources\\AppPlugin.swift' + + assert.equal( + normalizeChecksumRelativePath(dependencyFolderPath, filePath), + 'ios/Sources/AppPlugin.swift', + ) +}) + +await test('produces identical checksums for LF and CRLF plugin fixtures', async () => { + const lf = await calculatePlatformChecksums(join(fixtureRoot, 'plugin-lf')) + const crlf = await calculatePlatformChecksums(join(fixtureRoot, 'plugin-crlf')) + + assert.equal(lf.ios_checksum, crlf.ios_checksum) + assert.equal(lf.android_checksum, crlf.android_checksum) + assert.ok(lf.ios_checksum) + assert.ok(lf.android_checksum) +}) + +await test('checksums @capacitor/android and @capacitor/ios platform package layouts', async () => { + const android = await calculatePlatformChecksums(join(fixtureRoot, 'capacitor-android')) + const ios = await calculatePlatformChecksums(join(fixtureRoot, 'capacitor-ios')) + + assert.ok(android.android_checksum, 'expected android checksum for @capacitor/android layout') + assert.equal(android.ios_checksum, undefined) + assert.ok(ios.ios_checksum, 'expected ios checksum for @capacitor/ios layout') + assert.equal(ios.android_checksum, undefined) +}) + +await test('detects native files in platform package layouts', () => { + assert.equal(dependencyHasNativeFiles(join(fixtureRoot, 'capacitor-android')), true) + assert.equal(dependencyHasNativeFiles(join(fixtureRoot, 'capacitor-ios')), true) +}) + +await test('detects config-only native packages without source files', async () => { + const configOnlyPath = join(fixtureRoot, 'plugin-config-only') + assert.equal(dependencyHasNativeFiles(configOnlyPath), true) + + const checksums = await calculatePlatformChecksums(configOnlyPath) + assert.ok(checksums.ios_checksum, 'expected ios checksum from podspec') + assert.ok(checksums.android_checksum, 'expected android checksum from build.gradle') +}) + +await test('discovers alternate native scan roots for platform packages', () => { + const androidRoots = getNativeScanRoots(join(fixtureRoot, 'capacitor-android'), 'android') + const iosRoots = getNativeScanRoots(join(fixtureRoot, 'capacitor-ios'), 'ios') + + assert.ok(androidRoots.some(root => root.endsWith('/capacitor'))) + assert.ok(iosRoots.some(root => root.endsWith('/Capacitor'))) +}) + +await test('does not treat lowercase capacitor/ as Capacitor/ on case-insensitive filesystems', () => { + const iosRoots = getNativeScanRoots(join(fixtureRoot, 'capacitor-android'), 'ios') + assert.equal(iosRoots.length, 0) +}) + +await test('flags same-semver checksum drift as incompatible (checksum is source of truth)', async () => { + const baseline = await calculatePlatformChecksums(join(fixtureRoot, 'plugin-lf')) + const candidate = await calculatePlatformChecksums(join(fixtureRoot, 'plugin-changed')) + + assert.notEqual(baseline.ios_checksum, candidate.ios_checksum, 'fixture sanity: ios checksums should differ') + assert.notEqual(baseline.android_checksum, candidate.android_checksum, 'fixture sanity: android checksums should differ') + + const comparisons = compareNativePackages( + [{ + name: '@capacitor/app', + version: '8.1.0', + ios_checksum: candidate.ios_checksum, + android_checksum: candidate.android_checksum, + }], + [{ + name: '@capacitor/app', + version: '8.1.0', + ios_checksum: baseline.ios_checksum, + android_checksum: baseline.android_checksum, + }], + ) + + assert.equal(comparisons[0].status, 'changed') + assert.equal(comparisons[0].compatible, false) + assert.deepEqual(comparisons[0].reasons, ['both_platforms_changed']) + + const summary = summarizeBundleCompatibility(comparisons) + assert.equal(summary.compatible, false) + assert.deepEqual(summary.offenders, ['@capacitor/app']) +}) + +if (failures > 0) { + console.error(`\nāŒ ${failures} native checksum test(s) failed`) + process.exit(1) +} + +console.log('\nāœ… Native checksum checks work') diff --git a/cli/test/test-tail-engine-shared.mjs b/cli/test/test-tail-engine-shared.mjs index c2e58963cf..6275ae7ab7 100644 --- a/cli/test/test-tail-engine-shared.mjs +++ b/cli/test/test-tail-engine-shared.mjs @@ -330,7 +330,7 @@ await test('GAP1: requesting-build emits header + blank + queued lines via onBui assertEquals(res.next, 'build-complete', 'success with no entries finishes at build-complete') assert(buildLines[0] === `Requesting build for ${APP_ID} (android)...`, 'first build-viewer line is the header (replaces, not the side-log)') assert(buildLines.includes(''), 'a blank line precedes the queued line (parity with setBuildOutput([..., \'\', queued]))') - assert(buildLines.some(l => /^āœ” Build queued — https:\/\/capgo\.app\/app\//.test(l)), 'queued line goes to the build viewer') + assert(buildLines.some(l => /^āœ” Build queued — https:\/\/console\.capgo\.app\/app\//.test(l)), 'queued line goes to the build viewer') }) await test('GAP1: requesting-build emits the no-key 2-line UX via onBuildOutput and finishes at build-complete', async () => { diff --git a/private/cli-mcp-tests b/private/cli-mcp-tests index a600cbc2df..66665b74dc 160000 --- a/private/cli-mcp-tests +++ b/private/cli-mcp-tests @@ -1 +1 @@ -Subproject commit a600cbc2dfa62337b05bebfb72e86272d7709039 +Subproject commit 66665b74dcab2024e05389ce29511363693780fd diff --git a/tests/backend-bundle-compatibility.unit.test.ts b/tests/backend-bundle-compatibility.unit.test.ts index 8c1f04f284..f8875d1e37 100644 --- a/tests/backend-bundle-compatibility.unit.test.ts +++ b/tests/backend-bundle-compatibility.unit.test.ts @@ -58,6 +58,33 @@ describe('backend bundle compatibility helpers', () => { }) }) + + it.concurrent('flags @capacitor/android checksum changes even when versions match', () => { + const comparisons = compareNativePackages( + [pkg('@capacitor/android', '8.4.0', { android_checksum: 'new-android' })], + [pkg('@capacitor/android', '8.4.0', { android_checksum: 'old-android' })], + ) + + expect(comparisons[0]).toMatchObject({ + status: 'changed', + compatible: false, + reasons: ['android_code_changed'], + }) + }) + + it.concurrent('flags @capacitor/ios checksum changes even when versions match', () => { + const comparisons = compareNativePackages( + [pkg('@capacitor/ios', '8.4.0', { ios_checksum: 'new-ios' })], + [pkg('@capacitor/ios', '8.4.0', { ios_checksum: 'old-ios' })], + ) + + expect(comparisons[0]).toMatchObject({ + status: 'changed', + compatible: false, + reasons: ['ios_code_changed'], + }) + }) + it.concurrent('flags requested version constraint changes as metadata when resolved versions match', () => { const comparisons = compareNativePackages( [pkg('@capgo/native', '1.2.0', { requested_version: '^1.2.0' })],