diff --git a/skills/usage/SKILL.md b/skills/usage/SKILL.md index fbe78d23..6008443c 100644 --- a/skills/usage/SKILL.md +++ b/skills/usage/SKILL.md @@ -24,7 +24,7 @@ TanStack Intent skills should stay focused and under the validator line limit, s ### Project setup and diagnostics -- `init [apikey] [appId]`: guided first-time setup for Capgo in a Capacitor app. The interactive flow now runs as a real Ink-based fullscreen onboarding so it uses the same UI stack as `build init` (alias: `build onboarding`), with a persistent dashboard, phase roadmap, progress cards, shared log area, and resume support. When dependency auto-detection fails on macOS, the flow opens a native file picker for `package.json` before falling back to manual path entry. If the local bundle ID already exists in the selected Capgo account, onboarding offers to reuse that app, then offers to delete and recreate it, then falls back to alternate bundle ID suggestions. If the user reuses a pending app that was already created in the web onboarding flow, the CLI syncs that selected dashboard app ID back into `capacitor.config.*` before the remaining steps continue. Outside that reused pending-app path, the CLI keeps using the local Capacitor app ID. It can also offer a final `npx skills add https://github.com/Cap-go/capgo-skills -g -y` install step before the GitHub support prompt; if accepted, the support menu includes `Cap-go/capgo-skills` alongside the updater-only and all-Capgo choices. If native platforms are missing, the onboarding can offer to run `cap add` for you. If iOS sync validation fails during onboarding, the CLI can offer to run a one-line native reset command, wait for you to type `ready` after a manual fix, surface `doctor`, and save a support bundle before you leave the flow. +- `init [apikey] [appId]`: guided first-time setup for Capgo in a Capacitor app. The interactive flow now runs as a real Ink-based fullscreen onboarding so it uses the same UI stack as `build init` (alias: `build onboarding`), with a persistent dashboard, phase roadmap, progress cards, shared log area, and resume support. When dependency auto-detection fails on macOS, the flow opens a native file picker for `package.json` before falling back to manual path entry. If the local bundle ID already exists in the selected Capgo account, onboarding offers to reuse that app, then offers to delete and recreate it, then falls back to alternate bundle ID suggestions. If the user reuses a pending app that was already created in the web onboarding flow, the CLI syncs that selected dashboard app ID back into `capacitor.config.*` before the remaining steps continue. Outside that reused pending-app path, the CLI keeps using the local Capacitor app ID. It can also offer a final `npx skills add https://github.com/Cap-go/capgo-skills -g -y` install step before the GitHub support prompt; if accepted, the support menu includes `Cap-go/capgo-skills` alongside the updater-only and all-Capgo choices. If native platforms are missing, the onboarding can offer to run `cap add` for you. The updater step now verifies that `@capgo/capacitor-updater` is both declared in the selected `package.json` and resolvable from `node_modules`; if automatic install or later build/sync fails, onboarding prints the manual command, waits for the user to type `ready`, re-checks, and only then continues. If iOS sync validation fails during onboarding, the CLI can offer to run a one-line native reset command, wait for you to type `ready` after a manual fix, surface `doctor`, and save a support bundle before you leave the flow. - `login [apikey]`: store an API key locally. - `doctor`: inspect installation health and gather troubleshooting details. - `probe`: test whether the update endpoint would deliver an update. diff --git a/src/init/command.ts b/src/init/command.ts index f0366bd5..4bf84169 100644 --- a/src/init/command.ts +++ b/src/init/command.ts @@ -1,3 +1,4 @@ +import type { Buffer } from 'node:buffer' import type { ExecSyncOptions } from 'node:child_process' import type { ExistingOrganizationApp, Options, PendingOnboardingApp } from '../api/app' import type { Organization } from '../utils' @@ -26,11 +27,12 @@ import { doLoginExists, loginInternal } from '../login' import { writeOnboardingSupportBundle } from '../onboarding-support' import { showReplicationProgress } from '../replicationProgress' import { formatRunnerCommand, splitRunnerCommand } from '../runner-command' -import { createSupabaseClient, findBuildCommandForProjectType, findMainFile, findMainFileForProjectType, findProjectType, findRoot, findSavedKey, formatError, getAllPackagesDependencies, getAppId, getBundleVersion, getConfig, getInstalledVersion, getLocalConfig, getNativeProjectResetAdvice, getPackageScripts, getPMAndCommand, PACKNAME, projectIsMonorepo, updateConfigbyKey, updateConfigUpdater, validateIosUpdaterSync, verifyUser } from '../utils' +import { createSupabaseClient, findBuildCommandForProjectType, findMainFile, findMainFileForProjectType, findProjectType, findRoot, findSavedKey, formatError, getAllPackagesDependencies, getAppId, getBundleVersion, getConfig, getLocalConfig, getNativeProjectResetAdvice, getPackageScripts, getPMAndCommand, PACKNAME, projectIsMonorepo, updateConfigbyKey, updateConfigUpdater, validateIosUpdaterSync, verifyUser } 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' import { formatInitResumeMessage, initOnboardingSteps, renderInitOnboardingComplete, renderInitOnboardingFrame, renderInitOnboardingWelcome } from './ui' +import { CAPGO_UPDATER_PACKAGE, getUpdaterInstallState } from './updater' interface SuperOptions extends Options { local: boolean @@ -42,6 +44,7 @@ const regexImport = /import.*from.*/g const defaultChannel = 'production' const channelNameRegex = /^[\w.-]+$/ const appIdRegex = /^[a-z0-9]+(?:\.[\w-]+)+$/i +const whitespaceSplitPattern = /\s+/ const execOption = { stdio: 'pipe' } const capacitorConfigFiles = ['capacitor.config.ts', 'capacitor.config.js', 'capacitor.config.json'] const capacitorGettingStartedUrl = 'https://capacitorjs.com/docs/getting-started' @@ -53,6 +56,7 @@ const frameworkSetupGuides = { sveltekit: 'https://capgo.app/blog/creating-mobile-apps-with-sveltekit-and-capacitor/', } as const type CapacitorConfigSnapshot = Awaited>['config'] +type CancelablePromptValue = boolean | string | symbol let tmpObject: tmp.FileResult['name'] | undefined let globalPathToPackageJson: string | undefined @@ -339,7 +343,7 @@ function exitBeforeAuthenticatedOnboarding() { exit(1) } -function cancelBeforeAuthenticatedOnboarding(command: boolean | string | symbol) { +function cancelBeforeAuthenticatedOnboarding(command: CancelablePromptValue) { if (pIsCancel(command)) { pCancel('Operation cancelled.') exitBeforeAuthenticatedOnboarding() @@ -1023,12 +1027,18 @@ function runNativeResetCommand(platformRunner: string, nativePlatform: PlatformC } } -async function waitForReadyRetry(message: string, orgId: string, apikey: string, placeholder = 'ready'): Promise { +async function waitForReadyConfirmation( + message: string, + orgId: string, + apikey: string, + prompt = 'Type "ready" when the manual fix is done.', + placeholder = 'ready', +): Promise { pLog.info(message) while (true) { const ready = await pText({ - message: 'Type "ready" when the iOS folder is fixed.', + message: prompt, placeholder, validate: (value) => { if (!value?.trim()) @@ -1046,6 +1056,10 @@ async function waitForReadyRetry(message: string, orgId: string, apikey: string, } } +async function waitForReadyRetry(message: string, orgId: string, apikey: string, placeholder = 'ready'): Promise { + await waitForReadyConfirmation(message, orgId, apikey, 'Type "ready" when the iOS folder is fixed.', placeholder) +} + async function handleBrokenIosSync(platformRunner: string, details: string[], orgId: string, apikey: string, failureCount: number) { const resetAdvice = getNativeProjectResetAdvice(platformRunner, 'ios') const { doctor } = getInitRecoveryCommands() @@ -1662,99 +1676,108 @@ async function addChannelStep(orgId: string, apikey: string, appId: string) { return channelName } -async function getAssistedDependencies(stepsDone: number) { - // here we will assume that getAllPackagesDependencies uses 'findRoot(cwd())' for the first argument - const root = join(findRoot(cwd()), PACKNAME) - const packageJsonPath = globalPathToPackageJson ?? root - const dependencies = await getAllPackagesDependencies(undefined, packageJsonPath) - if (dependencies.size === 0 || !dependencies.has('@capacitor/core')) { - pLog.warn('No adequate dependencies found') - const doSelect = await pConfirm({ message: 'Would you like to select the package.json file manually?' }) - if (pIsCancel(doSelect)) { - pCancel('Operation cancelled.') - exit(1) +function rememberPackageJsonPath(packageJsonPath: string): void { + globalPathToPackageJson = packageJsonPath +} + +function cancelPackageJsonSelection(command: boolean | string | symbol): void { + if (pIsCancel(command)) { + pCancel('Operation cancelled.') + exit(1) + } +} + +function validatePackageJsonPath(value: string | undefined): string | undefined { + const trimmedValue = value?.trim() + if (!trimmedValue) + return 'Path is required.' + if (!existsSync(trimmedValue)) + return `Path ${trimmedValue} does not exist` + if (path.basename(trimmedValue) !== PACKNAME) + return 'Selected a file that is not a package.json file' +} + +async function selectPackageJsonFromTree(): Promise { + let currentPath = cwd() + let selectedEntry = PACKNAME as string | symbol + while (true) { + const options = readdirSync(currentPath) + .map(dir => ({ value: dir, label: dir })) + options.push({ value: '..', label: '..' }) + selectedEntry = await pSelect({ + message: 'Select package.json file:', + options, + }) + cancelPackageJsonSelection(selectedEntry) + if (typeof selectedEntry !== 'string') + continue + + if (!statSync(join(currentPath, selectedEntry)).isDirectory() && selectedEntry !== PACKNAME) { + pLog.error(`Selected a file that is not a package.json file`) + continue } - if (doSelect) { - const useNativePicker = canUseFilePicker() - if (useNativePicker) { - const selectedPath = await openPackageJsonPicker() - if (selectedPath) { - if (path.basename(selectedPath) !== PACKNAME) { - pLog.error('Selected a file that is not a package.json file') - } - else if (!existsSync(selectedPath)) { - pLog.error(`Path ${selectedPath} does not exist`) - } - else { - markStepDone(stepsDone, selectedPath) - return { dependencies: await getAllPackagesDependencies(undefined, selectedPath), path: selectedPath } - } - } + currentPath = join(currentPath, selectedEntry) + if (selectedEntry === PACKNAME) + return currentPath + } +} - pLog.info('Falling back to manual path entry.') - } +async function promptForPackageJsonPath(): Promise { + const packageJsonPath = await pText({ + message: 'Enter path to package.json file:', + validate: validatePackageJsonPath, + }) as string + cancelPackageJsonSelection(packageJsonPath) + return packageJsonPath.trim() +} - if (!useNativePicker) { - const useTreeSelect = await pConfirm({ message: 'Would you like to use a tree selector to choose the package.json file?' }) - if (pIsCancel(useTreeSelect)) { - pCancel('Operation cancelled.') - exit(1) - } +async function resolvePackageJsonPath(): Promise { + const doSelect = await pConfirm({ message: 'Would you like to select the package.json file manually?' }) + cancelPackageJsonSelection(doSelect) + if (!doSelect) + return null - if (useTreeSelect) { - let currentPath = cwd() - let selectedEntry = PACKNAME as string | symbol - while (true) { - const options = readdirSync(currentPath) - .map(dir => ({ value: dir, label: dir })) - options.push({ value: '..', label: '..' }) - selectedEntry = await pSelect({ - message: 'Select package.json file:', - options, - }) - if (pIsCancel(selectedEntry)) { - pCancel('Operation cancelled.') - exit(1) - } - if (!statSync(join(currentPath, selectedEntry)).isDirectory() && selectedEntry !== PACKNAME) { - pLog.error(`Selected a file that is not a package.json file`) - continue - } - currentPath = join(currentPath, selectedEntry) - if (selectedEntry === PACKNAME) { - break - } - } - markStepDone(stepsDone, currentPath) - return { dependencies: await getAllPackagesDependencies(undefined, currentPath), path: currentPath } - } - } + const useNativePicker = canUseFilePicker() + if (useNativePicker) { + const selectedPath = await openPackageJsonPicker() + if (selectedPath) { + const validationError = validatePackageJsonPath(selectedPath) + if (!validationError) + return selectedPath + pLog.error(validationError) + } - const packageJsonPath = await pText({ - message: 'Enter path to package.json file:', - validate: (value) => { - if (!value?.trim()) - return 'Path is required.' - if (!existsSync(value)) - return `Path ${value} does not exist` - if (path.basename(value) !== PACKNAME) - return 'Selected a file that is not a package.json file' - }, - }) as string - if (pIsCancel(packageJsonPath)) { - pCancel('Operation cancelled.') - exit(1) - } - const selectedPackageJsonPath = packageJsonPath.trim() - markStepDone(stepsDone, selectedPackageJsonPath) - return { dependencies: await getAllPackagesDependencies(undefined, selectedPackageJsonPath), path: selectedPackageJsonPath } + pLog.info('Falling back to manual path entry.') + } + + if (!useNativePicker) { + const useTreeSelect = await pConfirm({ message: 'Would you like to use a tree selector to choose the package.json file?' }) + cancelPackageJsonSelection(useTreeSelect) + if (useTreeSelect) + return selectPackageJsonFromTree() + } + + return promptForPackageJsonPath() +} + +async function getAssistedDependencies() { + // here we will assume that getAllPackagesDependencies uses 'findRoot(cwd())' for the first argument + const root = join(findRoot(cwd()), PACKNAME) + let packageJsonPath = globalPathToPackageJson ?? root + let dependencies = await getAllPackagesDependencies(undefined, packageJsonPath) + if (dependencies.size === 0 || !dependencies.has('@capacitor/core')) { + pLog.warn('No adequate dependencies found') + const selectedPackageJsonPath = await resolvePackageJsonPath() + if (selectedPackageJsonPath) { + packageJsonPath = selectedPackageJsonPath + dependencies = await getAllPackagesDependencies(undefined, packageJsonPath) } } - // even in the default case, let's mark the path to package.json + // even in the default case, remember the path to package.json // this will help with bundle upload - markStepDone(stepsDone, root) - return { dependencies: await getAllPackagesDependencies(undefined, root), path: root } + rememberPackageJsonPath(packageJsonPath) + return { dependencies, path: packageJsonPath } } const urlMigrateV5 = 'https://capacitorjs.com/docs/updating/5-0' @@ -1780,6 +1803,122 @@ function getUpdaterInstallBlocker(dependencies: Map, packageMana return undefined } +function getUpdaterVersionToInstall(coreVersion: string, logSelection = true): { versionToInstall: string, shouldOfferDirectInstall: boolean } { + if (lessThan(parse(coreVersion), parse('6.0.0'))) { + if (logSelection) { + pLog.info(`@capacitor/core version is ${coreVersion}, installing compatible capacitor-updater v5`) + pLog.warn(`Consider upgrading to Capacitor v6 or higher to support the latest mobile OS features: ${urlMigrateV6}`) + } + return { versionToInstall: '^5.0.0', shouldOfferDirectInstall: false } + } + if (lessThan(parse(coreVersion), parse('7.0.0'))) { + if (logSelection) { + pLog.info(`@capacitor/core version is ${coreVersion}, installing compatible capacitor-updater v6`) + pLog.warn(`Consider upgrading to Capacitor v7 or higher to support the latest mobile OS features: ${urlMigrateV7}`) + } + return { versionToInstall: '^6.0.0', shouldOfferDirectInstall: false } + } + if (lessThan(parse(coreVersion), parse('8.0.0'))) { + if (logSelection) { + pLog.info(`@capacitor/core version is ${coreVersion}, installing compatible capacitor-updater v7`) + pLog.warn(`Consider upgrading to Capacitor v8 to support the latest mobile OS features: ${urlMigrateV8}`) + } + return { versionToInstall: '^7.0.0', shouldOfferDirectInstall: false } + } + + if (logSelection) + pLog.info(`@capacitor/core version is ${coreVersion}, installing latest capacitor-updater`) + return { versionToInstall: 'latest', shouldOfferDirectInstall: true } +} + +function getUpdaterInstallCommand(pm: PackageManagerInfo, versionToInstall: string, force = false): string { + const forceFlag = force ? ' --force' : '' + return `${pm.installCommand}${forceFlag} ${CAPGO_UPDATER_PACKAGE}@${versionToInstall}` +} + +function formatSpawnOutput(output: string | Buffer | null | undefined): string { + if (!output) + return '' + return typeof output === 'string' ? output : output.toString('utf8') +} + +function runUpdaterInstallCommand(pm: PackageManagerInfo, packageJsonPath: string, versionToInstall: string): void { + const [command, ...args] = pm.installCommand.split(whitespaceSplitPattern).filter(Boolean) + if (!command) + throw new Error('Cannot determine package manager install command') + + const result = spawnSync(command, [...args, '--force', `${CAPGO_UPDATER_PACKAGE}@${versionToInstall}`], { + stdio: 'pipe', + cwd: dirname(packageJsonPath), + }) + if (result.error || result.status !== 0) { + const output = [formatSpawnOutput(result.stdout), formatSpawnOutput(result.stderr)] + .map(text => text.trim()) + .filter(Boolean) + .join('\n') + const outputDetails = output ? `\n${output}` : '' + const message = `Updater install failed with code ${result.status ?? 'unknown'}${outputDetails}` + throw result.error ?? new Error(message) + } +} + +function logUpdaterInstallStateDetails(packageJsonPath: string, details: string[], manualCommand: string): void { + pLog.warn(`${CAPGO_UPDATER_PACKAGE} is not ready yet.`) + for (const detail of details) { + pLog.warn(detail) + } + pLog.info(`Run this in ${dirname(packageJsonPath)}: ${manualCommand}`) +} + +async function waitForVerifiedUpdaterInstall( + orgId: string, + apikey: string, + packageJsonPath: string, + pm: PackageManagerInfo, + versionToInstall: string, + options: { allowAutoRetry?: boolean, failureText?: string } = {}, +) { + const manualCommand = getUpdaterInstallCommand(pm, versionToInstall) + + while (true) { + const state = getUpdaterInstallState(packageJsonPath) + if (state.ready) { + pLog.info(`${CAPGO_UPDATER_PACKAGE} found in package.json and node_modules ✅`) + return state + } + + logUpdaterInstallStateDetails(packageJsonPath, state.details, manualCommand) + + if (options.allowAutoRetry) { + const recoveryChoice = await selectRecoveryOption(orgId, apikey, 'Updater install is not complete yet. What do you want to do?', [ + { value: 'retry-auto', label: 'Retry automatic updater install' }, + { value: 'manual', label: 'Install it manually, then continue' }, + ], options.failureText ?? state.details.join('\n')) + + if (recoveryChoice === 'retry-auto') { + const s = pSpinner() + try { + s.start(`Running: ${getUpdaterInstallCommand(pm, versionToInstall, true)}`) + runUpdaterInstallCommand(pm, packageJsonPath, versionToInstall) + s.stop('Updater install command finished ✅') + } + catch (error) { + s.stop('Updater install failed ❌') + pLog.error(formatError(error)) + } + continue + } + } + + await waitForReadyConfirmation( + `Install ${CAPGO_UPDATER_PACKAGE} manually, then come back here.`, + orgId, + apikey, + `Type "ready" when ${CAPGO_UPDATER_PACKAGE} is installed.`, + ) + } +} + async function addUpdaterStep(orgId: string, apikey: string, appId: string) { const pm = getPMAndCommand() let pkgVersion = '1.0.0' @@ -1792,90 +1931,72 @@ async function addUpdaterStep(orgId: string, apikey: string, appId: string) { ], }) await cancelCommand(installChoice, orgId, apikey) - if (installChoice === 'yes') { - while (true) { + + while (true) { + const { dependencies, path } = await getAssistedDependencies() + const blocker = getUpdaterInstallBlocker(dependencies, pm) + if (blocker) { + pLog.warn(blocker) + await selectRecoveryOption(orgId, apikey, 'Fix the project, then choose what to do next.', [ + { value: 'retry', label: 'Retry updater checks' }, + ], blocker) + continue + } + + const coreVersion = normalizeConcreteVersion(dependencies.get('@capacitor/core'))! + const { versionToInstall, shouldOfferDirectInstall } = getUpdaterVersionToInstall(coreVersion) + pkgVersion = getBundleVersion(undefined, path) || pkgVersion + + if (installChoice === 'yes') { const s = pSpinner() - let versionToInstall = 'latest' - let shouldOfferDirectInstall = false - // 3 because this is the 4th step, ergo 3 steps have already been done - const { dependencies, path } = await getAssistedDependencies(3) - s.start(`Checking if @capgo/capacitor-updater is installed`) - - const blocker = getUpdaterInstallBlocker(dependencies, pm) - if (blocker) { - s.stop('Updater install blocked ❌') - pLog.warn(blocker) - await selectRecoveryOption(orgId, apikey, 'Fix the project, then choose what to do next.', [ - { value: 'retry', label: 'Retry updater checks' }, - ], blocker) - continue - } + s.start(`Checking if ${CAPGO_UPDATER_PACKAGE} is installed`) + const installState = getUpdaterInstallState(path) - const coreVersion = normalizeConcreteVersion(dependencies.get('@capacitor/core'))! - if (lessThan(parse(coreVersion), parse('6.0.0'))) { - pLog.info(`@capacitor/core version is ${coreVersion}, installing compatible capacitor-updater v5`) - pLog.warn(`Consider upgrading to Capacitor v6 or higher to support the latest mobile OS features: ${urlMigrateV6}`) - versionToInstall = '^5.0.0' - } - else if (lessThan(parse(coreVersion), parse('7.0.0'))) { - pLog.info(`@capacitor/core version is ${coreVersion}, installing compatible capacitor-updater v6`) - pLog.warn(`Consider upgrading to Capacitor v7 or higher to support the latest mobile OS features: ${urlMigrateV7}`) - versionToInstall = '^6.0.0' - } - else if (lessThan(parse(coreVersion), parse('8.0.0'))) { - pLog.info(`@capacitor/core version is ${coreVersion}, installing compatible capacitor-updater v7`) - pLog.warn(`Consider upgrading to Capacitor v8 to support the latest mobile OS features: ${urlMigrateV8}`) - versionToInstall = '^7.0.0' + if (installState.ready) { + s.stop(`Capgo already installed ✅`) } else { - pLog.info(`@capacitor/core version is ${coreVersion}, installing latest capacitor-updater`) - versionToInstall = 'latest' - shouldOfferDirectInstall = true - } - - try { - const installedVersion = await getInstalledVersion('@capgo/capacitor-updater', dirname(path), path) - pkgVersion = getBundleVersion(undefined, path) || pkgVersion - if (installedVersion) { - s.stop(`Capgo already installed ✅`) - } - else { - await execSync(`${pm.installCommand} --force @capgo/capacitor-updater@${versionToInstall}`, { ...execOption, cwd: dirname(path) } as ExecSyncOptions) + try { + runUpdaterInstallCommand(pm, path, versionToInstall) s.stop(`Install Done ✅`) - let doDirectInstall: boolean | symbol = false - if (shouldOfferDirectInstall) { - doDirectInstall = await pConfirm({ message: `Do you want to set instant updates in ${appId}? Read more about it here: https://capgo.app/docs/live-updates/update-behavior/#applying-updates-immediately` }) - await cancelCommand(doDirectInstall, orgId, apikey) - } - s.start(`Updating config file`) - delta = !!doDirectInstall - const directInstall = doDirectInstall - ? { - directUpdate: 'always', - autoSplashscreen: true, - } - : {} - if (doDirectInstall) { - await updateConfigbyKey('SplashScreen', { launchAutoHide: false }) - } - await updateConfigUpdater({ version: pkgVersion, appId, autoUpdate: true, ...directInstall }) - s.stop(`Config file updated ✅`) } - - break - } - catch (error) { - s.stop('Updater install failed ❌') - pLog.error(formatError(error)) - await selectRecoveryOption(orgId, apikey, 'Updater install failed. What do you want to do?', [ - { value: 'retry', label: 'Retry updater install' }, - ], formatError(error)) + catch (error) { + s.stop('Updater install failed ❌') + pLog.error(formatError(error)) + } } } + else { + pLog.info(`Install it manually with: "${getUpdaterInstallCommand(pm, versionToInstall)}"`) + } + + await waitForVerifiedUpdaterInstall(orgId, apikey, path, pm, versionToInstall, { + allowAutoRetry: installChoice === 'yes', + }) + + let doDirectInstall: boolean | symbol = false + if (shouldOfferDirectInstall) { + doDirectInstall = await pConfirm({ message: `Do you want to set instant updates in ${appId}? Read more about it here: https://capgo.app/docs/live-updates/update-behavior/#applying-updates-immediately` }) + await cancelCommand(doDirectInstall, orgId, apikey) + } + + const s = pSpinner() + s.start(`Updating config file`) + delta = !!doDirectInstall + const directInstall = doDirectInstall + ? { + directUpdate: 'always', + autoSplashscreen: true, + } + : {} + if (doDirectInstall) { + await updateConfigbyKey('SplashScreen', { launchAutoHide: false }) + } + await updateConfigUpdater({ version: pkgVersion, appId, autoUpdate: true, ...directInstall }) + s.stop(`Config file updated ✅`) + break } - else { - pLog.info(`If you change your mind, run it for yourself with: "${pm.installCommand} @capgo/capacitor-updater@latest"`) - } + await markStep(orgId, apikey, 'add-updater', appId) return { pkgVersion, delta } } @@ -2210,6 +2331,7 @@ async function addEncryptionStep(orgId: string, apikey: string, appId: string) { // every native platform that's already been `cap add`-ed, which is // exactly what we want — the key needs to end up in whichever // native projects exist. + await ensureUpdaterReadyBeforeSync(pm, orgId, apikey) const syncResult = await streamCommandInInitPanel({ title: '🔐 Syncing native project so the public key is bundled', runner: pm.runner, @@ -2448,14 +2570,94 @@ async function handleMissingBuildScript(buildCommand: string, appId: string, pla exit() } +async function getCompatibleUpdaterVersionForPackage(packageJsonPath: string, pm: PackageManagerInfo): Promise { + try { + const dependencies = await getAllPackagesDependencies(undefined, packageJsonPath) + const blocker = getUpdaterInstallBlocker(dependencies, pm) + if (blocker) + return 'latest' + + const coreVersion = normalizeConcreteVersion(dependencies.get('@capacitor/core'))! + return getUpdaterVersionToInstall(coreVersion, false).versionToInstall + } + catch { + return 'latest' + } +} + +async function handleBuildAndSyncFailure( + platform: PlatformChoice, + buildAndSyncCommand: string, + pm: PackageManagerInfo, + orgId: string, + apikey: string, + error: unknown, +): Promise<'retry' | 'completed'> { + const formattedError = formatError(error) + const packageJsonPath = globalPathToPackageJson ?? join(findRoot(cwd()), PACKNAME) + const updaterState = getUpdaterInstallState(packageJsonPath) + + pLog.error(`Build or sync failed: ${formattedError}`) + + if (!updaterState.ready) { + pLog.warn(`Capacitor sync cannot wire ${CAPGO_UPDATER_PACKAGE} until it is declared and installed.`) + const versionToInstall = await getCompatibleUpdaterVersionForPackage(packageJsonPath, pm) + await waitForVerifiedUpdaterInstall(orgId, apikey, packageJsonPath, pm, versionToInstall, { + failureText: formattedError, + }) + return 'retry' + } + + const recoveryChoice = await selectRecoveryOption(orgId, apikey, 'Build or sync failed. What do you want to do?', [ + { value: 'retry', label: 'Retry build and sync' }, + { value: 'manual', label: 'Fix it manually, then continue' }, + ], formattedError) + + if (recoveryChoice === 'retry') + return 'retry' + + await waitForReadyConfirmation( + `Run or fix this command manually, then come back here:\n${buildAndSyncCommand}`, + orgId, + apikey, + 'Type "ready" when build and sync are done.', + ) + + return 'retry' +} + +async function ensureUpdaterReadyBeforeSync(pm: PackageManagerInfo, orgId: string, apikey: string): Promise { + const packageJsonPath = globalPathToPackageJson ?? join(findRoot(cwd()), PACKNAME) + const updaterState = getUpdaterInstallState(packageJsonPath) + if (updaterState.ready) + return + + pLog.warn(`Capacitor sync needs ${CAPGO_UPDATER_PACKAGE} declared in package.json and installed first.`) + const versionToInstall = await getCompatibleUpdaterVersionForPackage(packageJsonPath, pm) + await waitForVerifiedUpdaterInstall(orgId, apikey, packageJsonPath, pm, versionToInstall, { + failureText: updaterState.details.join('\n'), + }) +} + async function runBuildAndSyncLoop(platform: PlatformChoice, buildAndSyncCommand: string, pm: PackageManagerInfo, orgId: string, apikey: string): Promise { let iosSyncFailureCount = 0 while (true) { + await ensureUpdaterReadyBeforeSync(pm, orgId, apikey) + const spinner = pSpinner() spinner.start('Checking project type') spinner.message(`Running: ${buildAndSyncCommand}`) - execSync(buildAndSyncCommand, execOption as ExecSyncOptions) + try { + execSync(buildAndSyncCommand, execOption as ExecSyncOptions) + } + catch (error) { + spinner.stop('Build or sync failed ❌') + const recovery = await handleBuildAndSyncFailure(platform, buildAndSyncCommand, pm, orgId, apikey, error) + if (recovery === 'completed') + return + continue + } if (platform === 'ios') { const syncValidation = validateIosUpdaterSync(cwd(), globalPathToPackageJson) diff --git a/src/init/updater.ts b/src/init/updater.ts new file mode 100644 index 00000000..2658733c --- /dev/null +++ b/src/init/updater.ts @@ -0,0 +1,115 @@ +import { existsSync, readFileSync } from 'node:fs' +import { createRequire } from 'node:module' +import { dirname, join } from 'node:path' + +export const CAPGO_UPDATER_PACKAGE = '@capgo/capacitor-updater' + +type DependencySection = 'dependencies' | 'devDependencies' | 'optionalDependencies' + +export interface UpdaterInstallState { + packageJsonPath: string + projectDir: string + declaredVersion: string | null + declaredIn: DependencySection | null + installedVersion: string | null + ready: boolean + details: string[] +} + +interface PackageJsonDependencies { + dependencies?: Record + devDependencies?: Record + optionalDependencies?: Record +} + +function readPackageJson(packageJsonPath: string): PackageJsonDependencies | null { + if (!existsSync(packageJsonPath)) + return null + + try { + return JSON.parse(readFileSync(packageJsonPath, 'utf-8')) as PackageJsonDependencies + } + catch { + return null + } +} + +function getDeclaredDependency(packageJsonPath: string, packageName: string) { + const packageJson = readPackageJson(packageJsonPath) + if (!packageJson) + return { version: null, section: null } + + const sections: DependencySection[] = ['dependencies', 'devDependencies', 'optionalDependencies'] + for (const section of sections) { + const dependencies = packageJson[section] + if (!dependencies || !Object.hasOwn(dependencies, packageName)) + continue + + const version = dependencies[packageName] + return { + version: typeof version === 'string' ? version : String(version), + section, + } + } + + return { version: null, section: null } +} + +function readInstalledPackageVersion(packageJsonPath: string, packageName: string): string | null { + const projectDir = dirname(packageJsonPath) + + try { + const requireFromProject = createRequire(join(projectDir, 'package.json')) + const resolvedPath = requireFromProject.resolve(`${packageName}/package.json`) + const packageJson = JSON.parse(readFileSync(resolvedPath, 'utf-8')) as { version?: unknown } + if (typeof packageJson.version === 'string') + return packageJson.version + } + catch { + // Fall through to direct node_modules lookup. + } + + let currentDir = projectDir + while (true) { + const packagePath = join(currentDir, 'node_modules', packageName, 'package.json') + if (existsSync(packagePath)) { + try { + const packageJson = JSON.parse(readFileSync(packagePath, 'utf-8')) as { version?: unknown } + if (typeof packageJson.version === 'string') + return packageJson.version + } + catch { + return null + } + } + + const parentDir = dirname(currentDir) + if (parentDir === currentDir) + break + currentDir = parentDir + } + + return null +} + +export function getUpdaterInstallState(packageJsonPath: string): UpdaterInstallState { + const projectDir = dirname(packageJsonPath) + const declaration = getDeclaredDependency(packageJsonPath, CAPGO_UPDATER_PACKAGE) + const installedVersion = readInstalledPackageVersion(packageJsonPath, CAPGO_UPDATER_PACKAGE) + const details: string[] = [] + + if (!declaration.version) + details.push(`Missing ${CAPGO_UPDATER_PACKAGE} in ${packageJsonPath}`) + if (!installedVersion) + details.push(`Cannot resolve ${CAPGO_UPDATER_PACKAGE} from ${projectDir}/node_modules`) + + return { + packageJsonPath, + projectDir, + declaredVersion: declaration.version, + declaredIn: declaration.section, + installedVersion, + ready: details.length === 0, + details, + } +} diff --git a/test/test-onboarding-recovery.mjs b/test/test-onboarding-recovery.mjs index 54c13985..1ea61a12 100644 --- a/test/test-onboarding-recovery.mjs +++ b/test/test-onboarding-recovery.mjs @@ -1,13 +1,41 @@ -import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { dirname, join } from 'node:path' import process from 'node:process' import { getBuildOnboardingRecoveryAdvice } from '../src/build/onboarding/recovery.ts' +import { CAPGO_UPDATER_PACKAGE, getUpdaterInstallState } from '../src/init/updater.ts' import { renderOnboardingSupportBundle, writeOnboardingSupportBundle } from '../src/onboarding-support.ts' import { splitRunnerCommand } from '../src/runner-command.ts' let failures = 0 +function writeFile(filePath, content) { + mkdirSync(dirname(filePath), { recursive: true }) + writeFileSync(filePath, content, 'utf8') +} + +function withTempProject(fn) { + const root = mkdtempSync(join(tmpdir(), 'capgo-updater-state-')) + try { + fn(root) + } + finally { + rmSync(root, { recursive: true, force: true }) + } +} + +function writeProjectPackage(root, dependencies) { + writeFile(join(root, 'package.json'), JSON.stringify({ dependencies })) +} + +function writeUpdaterInstall(root, version = '7.0.1') { + writeFile(join(root, 'node_modules', '@capgo', 'capacitor-updater', 'package.json'), JSON.stringify({ version })) +} + +function readUpdaterState(root) { + return getUpdaterInstallState(join(root, 'package.json')) +} + function t(name, fn) { try { fn() @@ -49,6 +77,52 @@ t('runner command helper rejects unexpected executors', () => { throw new Error('Expected unsupported runner to throw') }) +t('updater install state requires package.json declaration', () => { + withTempProject((root) => { + writeProjectPackage(root, { '@capacitor/core': '^7.0.0' }) + writeUpdaterInstall(root) + + const state = readUpdaterState(root) + if (state.ready) + throw new Error('Expected updater state to fail without declaration') + if (!state.details.some(detail => detail.includes(`Missing ${CAPGO_UPDATER_PACKAGE}`))) + throw new Error('Expected missing declaration detail') + }) +}) + +t('updater install state requires node_modules install', () => { + withTempProject((root) => { + writeProjectPackage(root, { + '@capacitor/core': '^7.0.0', + [CAPGO_UPDATER_PACKAGE]: '^7.0.0', + }) + + const state = readUpdaterState(root) + if (state.ready) + throw new Error('Expected updater state to fail without node_modules install') + if (!state.details.some(detail => detail.includes(`Cannot resolve ${CAPGO_UPDATER_PACKAGE}`))) + throw new Error('Expected missing install detail') + }) +}) + +t('updater install state passes with declaration and install', () => { + withTempProject((root) => { + writeProjectPackage(root, { + '@capacitor/core': '^7.0.0', + [CAPGO_UPDATER_PACKAGE]: '^7.0.0', + }) + writeUpdaterInstall(root) + + const state = readUpdaterState(root) + if (!state.ready) + throw new Error(`Expected updater state to pass: ${state.details.join(', ')}`) + if (state.declaredVersion !== '^7.0.0') + throw new Error('Expected declared updater version') + if (state.installedVersion !== '7.0.1') + throw new Error('Expected installed updater version') + }) +}) + t('support bundle renderer includes commands and docs', () => { const output = renderOnboardingSupportBundle({ kind: 'init',