Skip to content
This repository was archived by the owner on May 1, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
195 changes: 170 additions & 25 deletions src/init/command.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -22,7 +23,7 @@
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 {
Expand Down Expand Up @@ -53,6 +54,66 @@
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)
Expand Down Expand Up @@ -408,6 +469,7 @@
platform: globalPlatform,
delta: globalDelta,
currentVersion: globalCurrentVersion,
codeDiff: globalCodeDiff,
}))
if (pathToPackageJson) {
globalPathToPackageJson = pathToPackageJson
Expand Down Expand Up @@ -435,7 +497,7 @@
if (!rawData || rawData.length === 0)
return undefined

const { step_done, orgId, orgName, appId: savedAppId, pathToPackageJson, channelName, platform, delta, currentVersion } = JSON.parse(rawData)
const { step_done, orgId, orgName, appId: savedAppId, pathToPackageJson, channelName, platform, delta, currentVersion, codeDiff } = JSON.parse(rawData)
if (!orgId || !step_done) {
pLog.warn('⚠️ Found previous onboarding progress, but it was saved in an older format.')
pLog.info(' Starting fresh. Your previous progress cannot be resumed.')
Expand Down Expand Up @@ -473,16 +535,43 @@
if (savedAppId) {
globalAppId = savedAppId
}
// Only carry the diff forward if the user is about to land on step 5 (where it's displayed).
if (step_done === 4 && codeDiff && typeof codeDiff === 'object' && typeof codeDiff.filePath === 'string' && Array.isArray(codeDiff.lines)) {
const restoredLines: { lineNumber: number, text: string, kind: 'context' | 'add' }[] = []
for (const rawLine of codeDiff.lines as unknown[]) {
if (
typeof rawLine === 'object'
&& rawLine !== null
&& typeof (rawLine as { lineNumber?: unknown }).lineNumber === 'number'
&& typeof (rawLine as { text?: unknown }).text === 'string'
&& ((rawLine as { kind?: unknown }).kind === 'context' || (rawLine as { kind?: unknown }).kind === 'add')
) {
const typed = rawLine as { lineNumber: number, text: string, kind: 'context' | 'add' }
restoredLines.push({ lineNumber: typed.lineNumber, text: typed.text, kind: typed.kind })
}
}
globalCodeDiff = {
filePath: codeDiff.filePath,
created: Boolean(codeDiff.created),
lines: restoredLines,
note: typeof codeDiff.note === 'string' ? codeDiff.note : undefined,
}
}
return { stepDone: step_done, orgId, orgName, appId: savedAppId }
}

// User chose to start over — delete the saved progress
// User chose to start over — delete the saved progress and drop any
// restored code diff so a fresh manual path doesn't re-show stale content.
cleanupStepsDone()
globalCodeDiff = undefined
setInitCodeDiff(undefined)

Check warning on line 567 in src/init/command.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove this redundant "undefined".

See more on https://sonarcloud.io/project/issues?id=Cap-go_capgo-cli&issues=AZ1yTne5s5ez1drpRLn-&open=AZ1yTne5s5ez1drpRLn-&pullRequest=580
return undefined
}
catch (err) {
pLog.error(`Cannot read which steps have been completed, error:\n${err}`)
pLog.warn('Onboarding will continue but please report it to the capgo team!')
globalCodeDiff = undefined
setInitCodeDiff(undefined)

Check warning on line 574 in src/init/command.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove this redundant "undefined".

See more on https://sonarcloud.io/project/issues?id=Cap-go_capgo-cli&issues=AZ1yTne5s5ez1drpRLn_&open=AZ1yTne5s5ez1drpRLn_&pullRequest=580
return undefined
}
}
Expand Down Expand Up @@ -1482,7 +1571,7 @@
const s = pSpinner()
s.start(`Adding @capacitor-updater to your main file`)

const projectType = await findProjectType()
const projectType = await findProjectType({ quiet: true })
if (projectType === 'nuxtjs-js' || projectType === 'nuxtjs-ts') {
// Nuxt.js specific logic
const nuxtDir = join('plugins')
Expand All @@ -1496,36 +1585,53 @@
else {
nuxtFilePath = join(nuxtDir, 'capacitorUpdater.client.js')
}
const nuxtFileContent = `
import { CapacitorUpdater } from '@capgo/capacitor-updater'

export default defineNuxtPlugin(() => {
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),
}
Comment on lines 1598 to +1616
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Don't overwrite an existing Nuxt updater plugin wholesale.

If plugins/capacitorUpdater.client.ts/js already exists but doesn't contain notifyAppReady(), this branch replaces the entire file with the template. That can silently delete user code during onboarding. Prefer a safe merge or fall back to manual instructions instead of overwriting.

🛠️ Safer fallback
       if (existsSync(nuxtFilePath)) {
         const currentContent = readFileSync(nuxtFilePath, 'utf8')
         if (currentContent.includes('CapacitorUpdater.notifyAppReady()')) {
           s.stop()
           globalCodeDiff = {
             filePath: nuxtDisplayPath,
             created: false,
             lines: [],
             note: 'Already contains CapacitorUpdater.notifyAppReady() — no change needed',
           }
         }
         else {
-          writeFileSync(nuxtFilePath, nuxtFileContent, 'utf8')
-          s.stop()
-          globalCodeDiff = {
-            filePath: nuxtDisplayPath,
-            created: false,
-            lines: buildCodeDiffLines(currentContent, nuxtFileContent, CODE_DIFF_CONTEXT_LINES),
-          }
+          s.stop('Nuxt updater plugin already exists', 'neutral')
+          pLog.warn(`❌ ${nuxtDisplayPath} already exists and would be overwritten.`)
+          pLog.info('💡 Merge the updater plugin code manually, then continue onboarding.')
+          return
         }
       }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/init/command.ts` around lines 1598 - 1616, The current logic overwrites
an existing Nuxt plugin file when it lacks 'CapacitorUpdater.notifyAppReady()',
risking data loss; change the behavior in the branch that detects
existsSync(nuxtFilePath) and
!currentContent.includes('CapacitorUpdater.notifyAppReady()') to perform a safe,
non-destructive update: either attempt a minimal injection (search for an
appropriate client-init block or export default/ready hook and insert only the
notify call/snippet) and writeFileSync(nuxtFilePath) only if a safe insertion
point is found, or else do not call writeFileSync at all and instead set
globalCodeDiff to indicate manual action required (use
nuxtFilePath/nuxtDisplayPath, currentContent, nuxtFileContent,
buildCodeDiffLines and CODE_DIFF_CONTEXT_LINES to generate the diff/note).
Ensure readFileSync, existsSync and writeFileSync usages remain but avoid
wholesale replacement of the file content.

}
}
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')
Expand Down Expand Up @@ -1576,12 +1682,28 @@
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)
}
}

Expand All @@ -1593,6 +1715,9 @@
}

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)
Expand Down Expand Up @@ -2391,6 +2516,19 @@
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)

Check warning on line 2528 in src/init/command.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove this redundant "undefined".

See more on https://sonarcloud.io/project/issues?id=Cap-go_capgo-cli&issues=AZ1yTne5s5ez1drpRLoA&open=AZ1yTne5s5ez1drpRLoA&pullRequest=580
cleanupStepsDone()
}
Comment on lines +2519 to +2530
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Discarded resume state should also clear the temp file.

discardResumedState() only resets in-memory fields. If the user exits before the next markStepDone(), the same invalid/org-blocked resume record is still on disk and will be offered again on the next run. Calling cleanupStepsDone() here would make the discard durable.

🛠️ Proposed fix
   const discardResumedState = () => {
+    cleanupStepsDone()
     stepToSkip = 0
     globalCodeDiff = undefined
     setInitCodeDiff(undefined)
   }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// 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.
const discardResumedState = () => {
stepToSkip = 0
globalCodeDiff = undefined
setInitCodeDiff(undefined)
}
const discardResumedState = () => {
cleanupStepsDone()
stepToSkip = 0
globalCodeDiff = undefined
setInitCodeDiff(undefined)
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/init/command.ts` around lines 2519 - 2527, The discardResumedState
function only clears in-memory state (stepToSkip, globalCodeDiff,
setInitCodeDiff) but doesn't remove the on-disk resume/temp record, so make the
discard durable by invoking cleanupStepsDone() inside discardResumedState;
update discardResumedState to call cleanupStepsDone() (and keep existing resets)
so that the temp file created for resumed onboarding is cleared and the stale
resume won't be offered again before markStepDone() runs.


let organization: Organization
if (resumed) {
// Fetch orgs to validate the saved one still exists and is accessible
Expand All @@ -2399,7 +2537,7 @@
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)
Expand All @@ -2410,18 +2548,18 @@
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
Expand Down Expand Up @@ -2506,6 +2644,13 @@
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)

Check warning on line 2651 in src/init/command.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove this redundant "undefined".

See more on https://sonarcloud.io/project/issues?id=Cap-go_capgo-cli&issues=AZ1yGot9xY3f2GTzc-Ki&open=AZ1yGot9xY3f2GTzc-Ki&pullRequest=580
}

if (stepToSkip < 6) {
renderCurrentStep(6)
platform = await selectPlatformStep(orgId, options.apikey)
Expand Down
21 changes: 20 additions & 1 deletion src/init/runtime.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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`)
}
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading