diff --git a/src/init/command.ts b/src/init/command.ts index 73828492..e258eabb 100644 --- a/src/init/command.ts +++ b/src/init/command.ts @@ -1,6 +1,7 @@ import type { ExecSyncOptions } from 'node:child_process' import type { Options, PendingOnboardingApp } from '../api/app' import type { Organization } from '../utils' +import type { InitCodeDiff } from './runtime' import { execSync, spawnSync } from 'node:child_process' import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs' import path, { dirname, join } from 'node:path' @@ -22,7 +23,7 @@ import { doLoginExists, loginInternal } from '../login' import { showReplicationProgress } from '../replicationProgress' 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 { 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 { setInitVersionWarning, stopInitInkSession } from './runtime' +import { setInitCodeDiff, setInitVersionWarning, stopInitInkSession } from './runtime' import { formatInitResumeMessage, initOnboardingSteps, renderInitOnboardingComplete, renderInitOnboardingFrame, renderInitOnboardingWelcome } from './ui' interface SuperOptions extends Options { @@ -53,6 +54,66 @@ let globalPlatform: 'ios' | 'android' = 'ios' let globalDelta = false let globalCurrentVersion: string | undefined let globalAppId: string | undefined +let globalCodeDiff: InitCodeDiff | undefined + +const CODE_DIFF_CONTEXT_LINES = 5 + +// Render an init-time file path with its project directory prefix so users +// can tell which nested project was modified when they run `capgo init` from +// a parent folder (e.g. `CLI/src/main.tsx` instead of `src/main.tsx`). +function formatInitFilePath(filePath: string): string { + try { + const absolute = path.isAbsolute(filePath) ? filePath : path.resolve(cwd(), filePath) + const cwdPath = cwd() + const parentOfCwd = path.dirname(cwdPath) + // If cwd has no meaningful parent (e.g. `/`), fall back to the original path. + if (parentOfCwd === cwdPath) { + return filePath + } + const rel = path.relative(parentOfCwd, absolute) + return rel.length > 0 ? rel : filePath + } + catch { + return filePath + } +} + +function buildCodeDiffLines(beforeContent: string, afterContent: string, contextSize: number) { + const beforeLines = beforeContent.length === 0 ? [] : beforeContent.split('\n') + const afterLines = afterContent.split('\n') + + // Find the first line index where the two diverge. + let start = 0 + while (start < beforeLines.length && start < afterLines.length && beforeLines[start] === afterLines[start]) { + start++ + } + + // Find the last line index (from the end) where they still diverge. + let endBefore = beforeLines.length - 1 + let endAfter = afterLines.length - 1 + while (endBefore >= start && endAfter >= start && beforeLines[endBefore] === afterLines[endAfter]) { + endBefore-- + endAfter-- + } + + // No divergence — nothing to show. + if (endAfter < start) { + return [] + } + + const contextStart = Math.max(0, start - contextSize) + const contextEnd = Math.min(afterLines.length - 1, endAfter + contextSize) + + const diffLines: { lineNumber: number, text: string, kind: 'context' | 'add' }[] = [] + for (let i = contextStart; i <= contextEnd; i++) { + diffLines.push({ + lineNumber: i + 1, + text: afterLines[i] ?? '', + kind: i >= start && i <= endAfter ? 'add' : 'context', + }) + } + return diffLines +} function readTmpObj() { tmpObject ??= readdirSync(tmp.tmpdir) @@ -408,6 +469,7 @@ function markStepDone(step: number, pathToPackageJson?: string, channelName?: st platform: globalPlatform, delta: globalDelta, currentVersion: globalCurrentVersion, + codeDiff: globalCodeDiff, })) if (pathToPackageJson) { globalPathToPackageJson = pathToPackageJson @@ -435,7 +497,7 @@ async function tryResumeOnboarding(apikey: string): Promise { - CapacitorUpdater.notifyAppReady() - }) - ` + const nuxtFileLines = [ + `import { CapacitorUpdater } from '@capgo/capacitor-updater'`, + ``, + `export default defineNuxtPlugin(() => {`, + ` CapacitorUpdater.notifyAppReady()`, + `})`, + ``, + ] + const nuxtFileContent = nuxtFileLines.join('\n') + const nuxtDisplayPath = formatInitFilePath(nuxtFilePath) if (existsSync(nuxtFilePath)) { const currentContent = readFileSync(nuxtFilePath, 'utf8') if (currentContent.includes('CapacitorUpdater.notifyAppReady()')) { - s.stop('Code already added to capacitorUpdater.client.ts file inside plugins directory ✅') - pLog.info('Plugins directory and capacitorUpdater.client.ts file already exist with required code') + s.stop() + globalCodeDiff = { + filePath: nuxtDisplayPath, + created: false, + lines: [], + note: 'Already contains CapacitorUpdater.notifyAppReady() — no change needed', + } } else { writeFileSync(nuxtFilePath, nuxtFileContent, 'utf8') - s.stop('Code added to capacitorUpdater.client.ts file inside plugins directory ✅') - pLog.info('Updated capacitorUpdater.client.ts file with required code') + s.stop() + globalCodeDiff = { + filePath: nuxtDisplayPath, + created: false, + lines: buildCodeDiffLines(currentContent, nuxtFileContent, CODE_DIFF_CONTEXT_LINES), + } } } else { writeFileSync(nuxtFilePath, nuxtFileContent, 'utf8') - s.stop('Code added to capacitorUpdater.client.ts file inside plugins directory ✅') - pLog.info('Created plugins directory and capacitorUpdater.client.ts file') + s.stop() + globalCodeDiff = { + filePath: nuxtDisplayPath, + created: true, + lines: buildCodeDiffLines('', nuxtFileContent, CODE_DIFF_CONTEXT_LINES), + } } + setInitCodeDiff(globalCodeDiff) } else { // Handle other project types let mainFilePath if (projectType === 'unknown') { - mainFilePath = await findMainFile() + mainFilePath = await findMainFile(true) } else { const isTypeScript = projectType.endsWith('-ts') @@ -1576,12 +1682,28 @@ async function addCodeStep(orgId: string, apikey: string, appId: string) { await markStep(orgId, apikey, 'add-code-manual', appId) } else if (mainFileContent.includes(codeInject)) { - s.stop(`Code already added to ${mainFilePath} ✅`) + s.stop() + globalCodeDiff = { + filePath: formatInitFilePath(mainFilePath), + created: false, + lines: [], + note: 'Already contains CapacitorUpdater.notifyAppReady() — no change needed', + } + setInitCodeDiff(globalCodeDiff) } else { - const newMainFileContent = mainFileContent.replace(last, `${last}\n${importInject};\n\n${codeInject};\n`) + // Note: no trailing `\n` — the original file already has newlines after + // `last`, so adding one here would create a spurious blank line that + // shows up as an added `+` line in the diff panel. + const newMainFileContent = mainFileContent.replace(last, `${last}\n${importInject};\n\n${codeInject};`) writeFileSync(mainFilePath, newMainFileContent, 'utf8') - s.stop(`Code added to ${mainFilePath} ✅`) + s.stop() + globalCodeDiff = { + filePath: formatInitFilePath(mainFilePath), + created: false, + lines: buildCodeDiffLines(mainFileContent, newMainFileContent, CODE_DIFF_CONTEXT_LINES), + } + setInitCodeDiff(globalCodeDiff) } } @@ -1593,6 +1715,9 @@ async function addCodeStep(orgId: string, apikey: string, appId: string) { } async function addEncryptionStep(orgId: string, apikey: string, appId: string) { + if (globalCodeDiff) { + setInitCodeDiff(globalCodeDiff) + } const dependencies = await getAllPackagesDependencies() const coreVersion = dependencies.get('@capacitor/core') const normalizedCoreVersion = normalizeConcreteVersion(coreVersion) @@ -2391,6 +2516,19 @@ export async function initApp(apikeyCommand: string, appId: string, options: Sup const resumed = await tryResumeOnboarding(options.apikey) let stepToSkip = resumed?.stepDone ?? 0 + // Whenever a resume is aborted (org no longer available, role lost, 2FA + // required, lookup failed) we restart from step 0. Drop any diff that + // `tryResumeOnboarding` restored so the freshly walked step 4 doesn't see + // stale content from an earlier run, and delete the on-disk resume file so + // a subsequent `capgo init` run won't re-offer the now-invalid resume + // before `markStepDone()` has had a chance to overwrite it. + const discardResumedState = () => { + stepToSkip = 0 + globalCodeDiff = undefined + setInitCodeDiff(undefined) + cleanupStepsDone() + } + let organization: Organization if (resumed) { // Fetch orgs to validate the saved one still exists and is accessible @@ -2399,7 +2537,7 @@ export async function initApp(apikeyCommand: string, appId: string, options: Sup pLog.error(`Cannot verify organization access: ${orgError ? JSON.stringify(orgError) : 'no data returned'}`) pLog.warn('Falling back to organization selection.') organization = await selectOrganizationForInit(supabase, ['admin', 'super_admin']) - stepToSkip = 0 + discardResumedState() } else { const savedOrg = allOrganizations.find(org => org.gid === resumed.orgId) @@ -2410,18 +2548,18 @@ export async function initApp(apikeyCommand: string, appId: string, options: Sup if (!savedOrg) { pLog.warn(`Previously used organization "${resumed.orgName}" is no longer available. Please select a new one.`) organization = await selectOrganizationForInit(supabase, ['admin', 'super_admin']) - stepToSkip = 0 + discardResumedState() } else if (!hasRequiredRole) { pLog.warn(`You no longer have admin access to "${savedOrg.name}". Please select a different organization.`) organization = await selectOrganizationForInit(supabase, ['admin', 'super_admin']) - stepToSkip = 0 + discardResumedState() } else if (blocked2fa) { pLog.warn(`Organization "${savedOrg.name}" now requires 2FA. Enable it at https://web.capgo.app/settings/account`) pLog.warn('Please select a different organization or enable 2FA and try again.') organization = await selectOrganizationForInit(supabase, ['admin', 'super_admin']) - stepToSkip = 0 + discardResumedState() } else { organization = savedOrg @@ -2506,6 +2644,13 @@ export async function initApp(apikeyCommand: string, appId: string, options: Sup markStepDone(5) } + // Keep the code diff visible throughout step 5 so users can reference it + // while answering the encryption prompt. Only clear it once we move on. + if (globalCodeDiff) { + globalCodeDiff = undefined + setInitCodeDiff(undefined) + } + if (stepToSkip < 6) { renderCurrentStep(6) platform = await selectPlatformStep(orgId, options.apikey) diff --git a/src/init/runtime.tsx b/src/init/runtime.tsx index a3bacfe1..013349ef 100644 --- a/src/init/runtime.tsx +++ b/src/init/runtime.tsx @@ -65,12 +65,26 @@ export interface InitVersionWarning { majorVersion: string } +export interface InitCodeDiffLine { + lineNumber: number + text: string + kind: 'context' | 'add' +} + +export interface InitCodeDiff { + filePath: string + created: boolean + lines: InitCodeDiffLine[] + note?: string +} + export interface InitRuntimeState { screen?: InitScreen logs: InitLogEntry[] spinner?: string prompt?: PromptRequest versionWarning?: InitVersionWarning + codeDiff?: InitCodeDiff } let state: InitRuntimeState = { @@ -130,7 +144,7 @@ export function stopInitInkSession(finalMessage?: { text: string, tone: 'green' inkApp = undefined } started = false - state = { screen: undefined, logs: [], spinner: undefined, prompt: undefined } + state = { screen: undefined, logs: [], spinner: undefined, prompt: undefined, codeDiff: undefined } if (finalMessage) stdout.write(`${finalMessage.text}\n`) } @@ -207,6 +221,11 @@ export function requestInitSelect(message: string, options: SelectPromptOption[] }) } +export function setInitCodeDiff(diff?: InitCodeDiff) { + ensureInitInkSession() + updateState(current => ({ ...current, codeDiff: diff })) +} + export function setInitVersionWarning(currentVersion: string, latestVersion: string, majorVersion: string) { updateState(current => ({ ...current, diff --git a/src/init/ui/app.tsx b/src/init/ui/app.tsx index edb6bdc9..2e555a12 100644 --- a/src/init/ui/app.tsx +++ b/src/init/ui/app.tsx @@ -1,9 +1,39 @@ -import type { InitRuntimeState } from '../runtime' +import type { InitCodeDiff, InitRuntimeState } from '../runtime' import { Alert } from '@inkjs/ui' import { Box, Text, useStdout } from 'ink' import React, { useEffect, useState } from 'react' import { CurrentStepSection, InitHeader, ProgressSection, PromptArea, ScreenIntro, SpinnerArea } from './components' +function CodeDiffPanel({ diff, width }: Readonly<{ diff: InitCodeDiff, width: number }>) { + const title = diff.created + ? `Created ${diff.filePath}` + : `Updated ${diff.filePath}` + const maxLineNumber = diff.lines.reduce((max, line) => Math.max(max, line.lineNumber), 0) + const gutterWidth = Math.max(2, String(maxLineNumber).length) + return ( + + {`📝 ${title}`} + {diff.note !== undefined && ( + {` ${diff.note}`} + )} + {diff.lines.length > 0 && ( + + {diff.lines.map((line, index) => { + const marker = line.kind === 'add' ? '+' : ' ' + const lineNum = String(line.lineNumber).padStart(gutterWidth, ' ') + const color = line.kind === 'add' ? 'green' : 'gray' + return ( + + {`${marker} ${lineNum} │ ${line.text}`} + + ) + })} + + )} + + ) +} + interface InitInkAppProps { getSnapshot: () => InitRuntimeState subscribe: (listener: () => void) => () => void @@ -16,7 +46,26 @@ export default function InitInkApp({ getSnapshot, subscribe, updatePromptError } const columns = stdout?.columns ?? 96 const rows = stdout?.rows ?? 24 const contentWidth = Math.max(0, columns - 6) - const visibleLogs = snapshot.logs.slice(-Math.max(0, rows - 14)) + // Estimate how many terminal rows the code diff panel consumes so the log + // area (and the prompt/spinner rendered after it) still fit in the viewport + // on short terminals. Overhead covers the panel's marginTop, top/bottom + // borders, title line, and the marginTop between title and line content. + // Long lines that wrap are approximated by counting each line's wrap count. + const diffPanelHeight = (() => { + const diff = snapshot.codeDiff + if (!diff) + return 0 + const innerWidth = Math.max(1, contentWidth - 4) + const wrappedLineRows = diff.lines.reduce((sum, line) => { + const rendered = ` ${String(line.lineNumber)} │ ${line.text}` + return sum + Math.max(1, Math.ceil(rendered.length / innerWidth)) + }, 0) + const noteRows = diff.note !== undefined ? 1 : 0 + const linesBlockRows = diff.lines.length > 0 ? wrappedLineRows + 1 : 0 + // 1 (panel marginTop) + 2 (borders) + 1 (title) + noteRows + linesBlockRows + return 4 + noteRows + linesBlockRows + })() + const visibleLogs = snapshot.logs.slice(-Math.max(0, rows - 14 - diffPanelHeight)) const screen = snapshot.screen useEffect(() => { @@ -46,6 +95,10 @@ export default function InitInkApp({ getSnapshot, subscribe, updatePromptError } {screen && } + {snapshot.codeDiff && ( + + )} + {visibleLogs.length > 0 && ( {visibleLogs.map((entry, index) => ( diff --git a/src/utils.ts b/src/utils.ts index 9d35559c..938ee1c0 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1111,7 +1111,7 @@ export async function findBuildCommandForProjectType(projectType: string) { return 'build' } -export async function findMainFile() { +export async function findMainFile(silent = false) { // eslint-disable-next-line regexp/no-unused-capturing-group const mainRegex = /(main|index)\.(ts|tsx|js|jsx)$/ // search for main.ts or main.js in local dir and subdirs @@ -1123,7 +1123,8 @@ export async function findMainFile() { const folders = f.split('/').length - pwdL if (folders <= 2 && mainRegex.test(f)) { mainFile = f - log.info(`Found main file here ${f}`) + if (!silent) + log.info(`Found main file here ${f}`) break } }