diff --git a/cli/src/app/info.ts b/cli/src/app/info.ts index 326bd6bc49..13314533ab 100644 --- a/cli/src/app/info.ts +++ b/cli/src/app/info.ts @@ -1,11 +1,25 @@ +import { spawnSync } from 'node:child_process' +import { readFileSync } from 'node:fs' +import { dirname } from 'node:path' +import { cwd, version as nodeVersion } from 'node:process' import { platform, version } from 'node:os' -import { version as nodeVersion } from 'node:process' -import { log, spinner } from '@clack/prompts' +import { findInstallCommand, findPackageManagerRunner, findPackageManagerType } from '@capgo/find-package-manager' +import { confirm, isCancel, log, select, spinner } from '@clack/prompts' import pack from '../../package.json' import { trackEvent } from '../analytics/track' -import { getAllPackagesDependencies, getAppId, getBundleVersion, getConfig } from '../utils' +import { canPromptInteractively, findRoot, getAllPackagesDependencies, getAppId, getBundleVersion, getConfig, getPMAndCommand } from '../utils' import { getLatestVersion } from '../utils/latest-version' +export const OUTDATED_DEPENDENCIES_ERROR = 'Some dependencies are not up to date' + +export interface OutdatedDependency { + name: string + installed: string + latest: string +} + +export type DoctorUpdateChoice = 'capgo-only' | 'all' | 'skip' + async function getLatestDependencies(installedDependencies: Record) { const latestDependencies: Record = {} const keys = Object.keys(installedDependencies) @@ -19,8 +33,9 @@ async function getLatestDependencies(installedDependencies: Record = { '@capgo/cli': pack.version, } @@ -37,25 +52,321 @@ interface DoctorInfoOptions { packageJson?: string } -export function computeDoctorAnalyticsTags( +export function parseDoctorPackageJsonPaths(packageJson?: string): string[] | undefined { + if (!packageJson) + return undefined + + const paths = packageJson.split(',').map(path => path.trim()).filter(Boolean) + return paths.length > 0 ? paths : undefined +} + +export function resolveDoctorProjectRoot(packageJson?: string): string { + const paths = parseDoctorPackageJsonPaths(packageJson) + if (!paths) + return findRoot(cwd()) + + return dirname(paths[0]) +} + +function readDeclaredDependencyNames(packageJsonPath: string): Set { + const pkg = JSON.parse(readFileSync(packageJsonPath, 'utf-8')) as { + dependencies?: Record + devDependencies?: Record + } + return new Set([ + ...Object.keys(pkg.dependencies ?? {}), + ...Object.keys(pkg.devDependencies ?? {}), + ]) +} + +export function groupOutdatedPackagesByPackageJson( + packageJsonPaths: string[], + packages: OutdatedDependency[], + readDeclaredNames: (packageJsonPath: string) => Set = readDeclaredDependencyNames, +): { packageJsonPath: string, packages: OutdatedDependency[] }[] { + const groups: { packageJsonPath: string, packages: OutdatedDependency[] }[] = [] + const declaredInAnyManifest = new Set() + + for (const packageJsonPath of packageJsonPaths) { + const declared = readDeclaredNames(packageJsonPath) + const packagesForManifest = packages.filter(dep => declared.has(dep.name)) + if (packagesForManifest.length === 0) + continue + + groups.push({ packageJsonPath, packages: packagesForManifest }) + for (const dep of packagesForManifest) + declaredInAnyManifest.add(dep.name) + } + + const unassigned = packages.filter(dep => !declaredInAnyManifest.has(dep.name)) + if (unassigned.length > 0 && packageJsonPaths[0]) + groups.push({ packageJsonPath: packageJsonPaths[0], packages: unassigned }) + + return groups +} + +export function shellQuotePath(path: string, shellPlatform: NodeJS.Platform = platform()): string { + if (shellPlatform === 'win32') + return `"${path.replace(/"/g, '""')}"` + + return `'${path.replaceAll('\'', '\'\\\'\'')}'` +} + +export function formatDoctorInstallHint( + projectRoot: string, + installCommand: string, + shellPlatform: NodeJS.Platform = platform(), +): string { + if (shellPlatform === 'win32') + return `cd /d ${shellQuotePath(projectRoot, shellPlatform)} && ${installCommand}` + + return `(cd ${shellQuotePath(projectRoot, shellPlatform)} && ${installCommand})` +} + +export function buildOutdatedInstallCommandsForDoctor( + packageJson: string | undefined, + packages: OutdatedDependency[], + readDeclaredNames?: (packageJsonPath: string) => Set, + shellPlatform: NodeJS.Platform = platform(), +): string { + const packageJsonPaths = parseDoctorPackageJsonPaths(packageJson) + if (!packageJsonPaths || packageJsonPaths.length <= 1) { + const projectRoot = resolveDoctorProjectRoot(packageJson) + return buildOutdatedInstallCommand(getPMAndCommandForDir(projectRoot), packages) + } + + return groupOutdatedPackagesByPackageJson(packageJsonPaths, packages, readDeclaredNames) + .map(({ packageJsonPath, packages: groupPackages }) => { + const projectRoot = dirname(packageJsonPath) + const pm = getPMAndCommandForDir(projectRoot) + return formatDoctorInstallHint(projectRoot, buildOutdatedInstallCommand(pm, groupPackages), shellPlatform) + }) + .join('\n') +} + +export function runOutdatedDependencyUpdatesForDoctor( + packageJson: string | undefined, + packages: OutdatedDependency[], + readDeclaredNames?: (packageJsonPath: string) => Set, +): void { + const packageJsonPaths = parseDoctorPackageJsonPaths(packageJson) + if (!packageJsonPaths || packageJsonPaths.length <= 1) { + const projectRoot = resolveDoctorProjectRoot(packageJson) + runOutdatedDependencyUpdates(getPMAndCommandForDir(projectRoot), packages, projectRoot) + return + } + + for (const { packageJsonPath, packages: groupPackages } of groupOutdatedPackagesByPackageJson(packageJsonPaths, packages, readDeclaredNames)) { + const projectRoot = dirname(packageJsonPath) + runOutdatedDependencyUpdates(getPMAndCommandForDir(projectRoot), groupPackages, projectRoot) + } +} + +export function getPMAndCommandForDir(projectRoot: string) { + const pm = findPackageManagerType(projectRoot, 'npm') + const command = findInstallCommand(pm) + const runner = findPackageManagerRunner(projectRoot) + return { pm, command, installCommand: `${pm} ${command}`, runner } +} + +export interface DoctorRecoveryResult { + recovered: boolean + remainingOutdated: OutdatedDependency[] +} + +export function listOutdatedDependencies( installed: Record, latest: Record, -): { is_outdated: boolean, dependency_count: number, outdated_count: number } { - const keys = Object.keys(installed) - let outdatedCount = 0 - for (const key of keys) { - const have = installed[key] - const want = latest[key] +): OutdatedDependency[] { + const outdated: OutdatedDependency[] = [] + for (const name of Object.keys(installed)) { + const have = installed[name] + const want = latest[name] if (have && want && have !== want) - outdatedCount += 1 + outdated.push({ name, installed: have, latest: want }) + } + return outdated +} + +export function partitionOutdatedDependencies(outdated: OutdatedDependency[]): { + capgo: OutdatedDependency[] + other: OutdatedDependency[] +} { + const capgo: OutdatedDependency[] = [] + const other: OutdatedDependency[] = [] + for (const dep of outdated) { + if (dep.name.startsWith('@capgo/')) + capgo.push(dep) + else + other.push(dep) + } + return { capgo, other } +} + +export function packagesForDoctorUpdateChoice( + choice: DoctorUpdateChoice, + capgo: OutdatedDependency[], + other: OutdatedDependency[], +): OutdatedDependency[] { + if (choice === 'skip') + return [] + if (choice === 'all') + return [...capgo, ...other] + return capgo +} + +export function buildOutdatedInstallCommand( + pm: ReturnType, + packages: OutdatedDependency[], +): string { + const specs = packages.map(dep => `${dep.name}@latest`).join(' ') + return `${pm.installCommand} ${specs}`.trim() +} + +function formatSpawnOutput(output: string | Buffer | null | undefined): string { + if (!output) + return '' + return typeof output === 'string' ? output : output.toString('utf8') +} + +export function runOutdatedDependencyUpdates( + pm: ReturnType, + packages: OutdatedDependency[], + projectRoot: string, +): void { + if (packages.length === 0) + return + + const [command, ...baseArgs] = pm.installCommand.split(/\s+/).filter(Boolean) + if (!command) + throw new Error('Cannot determine package manager install command') + + const specs = packages.map(dep => `${dep.name}@latest`) + const result = spawnSync(command, [...baseArgs, ...specs], { + stdio: 'pipe', + cwd: projectRoot, + }) + + 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 = `Dependency update failed with code ${result.status ?? 'unknown'}${outputDetails}` + throw result.error ?? new Error(message) } +} + +export function computeDoctorAnalyticsTags( + installed: Record, + latest: Record, +): { is_outdated: boolean, dependency_count: number, outdated_count: number } { + const outdated = listOutdatedDependencies(installed, latest) return { - is_outdated: outdatedCount > 0, - dependency_count: keys.length, - outdated_count: outdatedCount, + is_outdated: outdated.length > 0, + dependency_count: Object.keys(installed).length, + outdated_count: outdated.length, } } +function logOutdatedDependencyTable(outdated: OutdatedDependency[]) { + log.warn('\x1B[31m๐Ÿšจ Some dependencies are not up to date\x1B[0m') + for (const dep of outdated) + log.warn(` ${dep.name}: ${dep.installed} โ†’ ${dep.latest}`) +} + +function throwOutdatedDependenciesError(packageJson: string | undefined, outdated: OutdatedDependency[], silent: boolean) { + if (!silent && outdated.length > 0) + log.info(`Run:\n${buildOutdatedInstallCommandsForDoctor(packageJson, outdated)}`) + throw new Error(OUTDATED_DEPENDENCIES_ERROR) +} + +async function promptDoctorUpdateChoice(capgo: OutdatedDependency[], other: OutdatedDependency[]): Promise { + if (capgo.length > 0 && other.length === 0) { + const shouldUpdate = await confirm({ + message: 'Update outdated @capgo/* packages now?', + initialValue: true, + }) + if (isCancel(shouldUpdate)) + return 'skip' + return shouldUpdate ? 'capgo-only' : 'skip' + } + + if (capgo.length === 0 && other.length > 0) { + const choice = await select({ + message: 'Outdated Capacitor-related packages detected. How do you want to proceed?', + options: [ + { value: 'all', label: 'Update all listed packages now' }, + { value: 'skip', label: 'Skip (doctor will fail)' }, + ], + }) + if (isCancel(choice)) + return 'skip' + return choice as DoctorUpdateChoice + } + + const choice = await select({ + message: 'Outdated dependencies detected. How do you want to proceed?', + options: [ + { value: 'capgo-only', label: 'Update @capgo/* packages only (recommended)' }, + { value: 'all', label: 'Update all listed packages' }, + { value: 'skip', label: 'Skip (doctor will fail)' }, + ], + }) + if (isCancel(choice)) + return 'skip' + return choice as DoctorUpdateChoice +} + +async function maybeRecoverOutdatedDependencies( + outdated: OutdatedDependency[], + options: DoctorInfoOptions, + silent: boolean, +): Promise { + if (!canPromptInteractively({ silent })) + return { recovered: false, remainingOutdated: outdated } + + const { capgo, other } = partitionOutdatedDependencies(outdated) + const choice = await promptDoctorUpdateChoice(capgo, other) + const packagesToUpdate = packagesForDoctorUpdateChoice(choice, capgo, other) + + if (packagesToUpdate.length === 0) + return { recovered: false, remainingOutdated: outdated } + + const installCommand = buildOutdatedInstallCommandsForDoctor(options.packageJson, packagesToUpdate) + const s = spinner() + s.start(`Running: ${installCommand.split('\n')[0]}`) + + try { + runOutdatedDependencyUpdatesForDoctor(options.packageJson, packagesToUpdate) + s.stop('Dependencies updated') + } + catch (error) { + s.stop('Dependency update failed') + log.error(error instanceof Error ? error.message : String(error)) + log.info(`Run manually: ${installCommand}`) + return { recovered: false, remainingOutdated: outdated } + } + + const installedAfterUpdate = await getInstalledDependencies(options.packageJson) + const latestAfterUpdate = await getLatestDependencies(installedAfterUpdate) + const stillOutdated = listOutdatedDependencies(installedAfterUpdate, latestAfterUpdate) + + if (stillOutdated.length === 0) { + void trackEvent({ + channel: 'cli-usage', + event: 'CLI Recovered Outdated Dependencies', + tags: { recovery: 'update', outdated_count: outdated.length }, + }) + log.success('\x1B[32mโœ… All dependencies are up to date after update\x1B[0m') + return { recovered: true, remainingOutdated: [] } + } + + logOutdatedDependencyTable(stillOutdated) + return { recovered: false, remainingOutdated: stillOutdated } +} + export async function getInfoInternal(options: DoctorInfoOptions, silent = false) { if (!silent) log.warn(' ๐Ÿ’Š Capgo Doctor ๐Ÿ’Š') @@ -77,7 +388,7 @@ export async function getInfoInternal(options: DoctorInfoOptions, silent = false log.info(' Installed Dependencies:') } - const installedDependencies = await getInstalledDependencies() + let installedDependencies = await getInstalledDependencies(options.packageJson) if (Object.keys(installedDependencies).length === 0) { if (!silent) @@ -111,10 +422,18 @@ export async function getInfoInternal(options: DoctorInfoOptions, silent = false tags: computeDoctorAnalyticsTags(installedDependencies, latestDependencies), }) - if (JSON.stringify(installedDependencies) !== JSON.stringify(latestDependencies)) { + const outdated = listOutdatedDependencies(installedDependencies, latestDependencies) + + if (outdated.length > 0) { if (!silent) - log.warn('\x1B[31m๐Ÿšจ Some dependencies are not up to date\x1B[0m') - throw new Error('Some dependencies are not up to date') + logOutdatedDependencyTable(outdated) + + const recovery = await maybeRecoverOutdatedDependencies(outdated, options, silent) + if (!recovery.recovered) + throwOutdatedDependenciesError(options.packageJson, recovery.remainingOutdated, silent) + + installedDependencies = await getInstalledDependencies(options.packageJson) + latestDependencies = await getLatestDependencies(installedDependencies) } if (!silent) diff --git a/cli/test/test-doctor-analytics.mjs b/cli/test/test-doctor-analytics.mjs index 1b54102ef4..552e045d92 100644 --- a/cli/test/test-doctor-analytics.mjs +++ b/cli/test/test-doctor-analytics.mjs @@ -1,6 +1,19 @@ #!/usr/bin/env node import assert from 'node:assert/strict' -import { computeDoctorAnalyticsTags } from '../src/app/info.ts' +import { + buildOutdatedInstallCommand, + buildOutdatedInstallCommandsForDoctor, + computeDoctorAnalyticsTags, + formatDoctorInstallHint, + getPMAndCommandForDir, + groupOutdatedPackagesByPackageJson, + listOutdatedDependencies, + packagesForDoctorUpdateChoice, + parseDoctorPackageJsonPaths, + partitionOutdatedDependencies, + resolveDoctorProjectRoot, + shellQuotePath, +} from '../src/app/info.ts' console.log('๐Ÿงช Testing doctor analytics tags...\n') @@ -31,4 +44,166 @@ assert.equal(allOutdated.is_outdated, true) assert.equal(allOutdated.dependency_count, 2) assert.equal(allOutdated.outdated_count, 2) +// stringify mismatch / missing latest key is NOT outdated +const missingLatest = listOutdatedDependencies( + { '@capgo/cli': '1.0.0', '@capgo/capacitor-updater': '6.0.0' }, + { '@capgo/capacitor-updater': '6.2.0' }, +) +assert.deepEqual(missingLatest, [ + { name: '@capgo/capacitor-updater', installed: '6.0.0', latest: '6.2.0' }, +]) +assert.equal(computeDoctorAnalyticsTags( + { '@capgo/cli': '1.0.0', '@capgo/capacitor-updater': '6.0.0' }, + { '@capgo/capacitor-updater': '6.2.0' }, +).is_outdated, true) +assert.equal(computeDoctorAnalyticsTags( + { '@capgo/cli': '1.0.0', '@capgo/capacitor-updater': '6.0.0' }, + { '@capgo/capacitor-updater': '6.2.0' }, +).outdated_count, 1) + +const extraLatestKeys = listOutdatedDependencies( + { '@capgo/cli': '1.0.0' }, + { '@capgo/cli': '1.0.0', '@capgo/capacitor-updater': '6.2.0' }, +) +assert.deepEqual(extraLatestKeys, []) +assert.equal(computeDoctorAnalyticsTags( + { '@capgo/cli': '1.0.0' }, + { '@capgo/cli': '1.0.0', '@capgo/capacitor-updater': '6.2.0' }, +).is_outdated, false) + +// mixed outdated vs up-to-date counts +const mixed = listOutdatedDependencies( + { + '@capgo/capacitor-updater': '6.0.0', + '@capacitor/core': '6.1.0', + '@capawesome/capacitor-app': '6.0.0', + }, + { + '@capgo/capacitor-updater': '6.2.0', + '@capacitor/core': '6.1.0', + '@capawesome/capacitor-app': '6.1.0', + }, +) +assert.deepEqual(mixed, [ + { name: '@capgo/capacitor-updater', installed: '6.0.0', latest: '6.2.0' }, + { name: '@capawesome/capacitor-app', installed: '6.0.0', latest: '6.1.0' }, +]) +assert.equal(computeDoctorAnalyticsTags( + { + '@capgo/capacitor-updater': '6.0.0', + '@capacitor/core': '6.1.0', + '@capawesome/capacitor-app': '6.0.0', + }, + { + '@capgo/capacitor-updater': '6.2.0', + '@capacitor/core': '6.1.0', + '@capawesome/capacitor-app': '6.1.0', + }, +).outdated_count, 2) + +// recovery partitioning and install command helpers +const outdatedSample = [ + { name: '@capgo/capacitor-updater', installed: '6.0.0', latest: '6.2.0' }, + { name: '@capacitor/core', installed: '6.0.0', latest: '6.1.0' }, +] +const partitioned = partitionOutdatedDependencies(outdatedSample) +assert.equal(partitioned.capgo.length, 1) +assert.equal(partitioned.other.length, 1) + +assert.equal( + packagesForDoctorUpdateChoice('capgo-only', partitioned.capgo, partitioned.other).length, + 1, +) +assert.equal( + packagesForDoctorUpdateChoice('all', partitioned.capgo, partitioned.other).length, + 2, +) +assert.equal( + packagesForDoctorUpdateChoice('skip', partitioned.capgo, partitioned.other).length, + 0, +) + +const installCommand = buildOutdatedInstallCommand( + { pm: 'npm', command: 'install', installCommand: 'npm install', runner: 'npx' }, + outdatedSample, +) +assert.equal( + installCommand, + 'npm install @capgo/capacitor-updater@latest @capacitor/core@latest', +) + +assert.equal(resolveDoctorProjectRoot('/apps/mobile/package.json'), '/apps/mobile') +assert.equal( + resolveDoctorProjectRoot('/apps/mobile/package.json,/apps/shared/package.json'), + '/apps/mobile', +) +assert.equal(getPMAndCommandForDir('/tmp/project').installCommand.includes('install'), true) + +const packageJsonPaths = [ + '/apps/mobile/package.json', + '/apps/shared/package.json', +] +const declaredByPath = new Map([ + [packageJsonPaths[0], new Set(['@capgo/capacitor-updater'])], + [packageJsonPaths[1], new Set(['@capacitor/core'])], +]) +const grouped = groupOutdatedPackagesByPackageJson(packageJsonPaths, outdatedSample, path => declaredByPath.get(path)) +assert.equal(grouped.length, 2) +assert.equal(grouped[0].packageJsonPath, packageJsonPaths[0]) +assert.equal(grouped[0].packages.length, 1) +assert.equal(grouped[0].packages[0].name, '@capgo/capacitor-updater') +assert.equal(grouped[1].packageJsonPath, packageJsonPaths[1]) +assert.equal(grouped[1].packages[0].name, '@capacitor/core') + +const multiInstallCommands = buildOutdatedInstallCommandsForDoctor( + '/apps/mobile/package.json,/apps/shared/package.json', + outdatedSample, + path => declaredByPath.get(path), +) +assert.ok(multiInstallCommands.includes('cd \'/apps/mobile\'')) +assert.ok(multiInstallCommands.includes('cd \'/apps/shared\'')) +assert.ok(multiInstallCommands.includes('@capgo/capacitor-updater@latest')) +assert.ok(multiInstallCommands.includes('@capacitor/core@latest')) + +assert.deepEqual(parseDoctorPackageJsonPaths('/apps/mobile/package.json,/apps/shared/package.json'), packageJsonPaths) + +const sharedDependency = [{ name: '@capacitor/core', installed: '6.0.0', latest: '6.1.0' }] +const sharedDeclaredByPath = new Map([ + [packageJsonPaths[0], new Set(['@capacitor/core'])], + [packageJsonPaths[1], new Set(['@capacitor/core'])], +]) +const sharedGrouped = groupOutdatedPackagesByPackageJson(packageJsonPaths, sharedDependency, path => sharedDeclaredByPath.get(path)) +assert.equal(sharedGrouped.length, 2) +assert.equal(sharedGrouped[0].packages[0].name, '@capacitor/core') +assert.equal(sharedGrouped[1].packages[0].name, '@capacitor/core') + +const spacedPaths = ['/apps/my mobile/package.json', '/apps/shared/package.json'] +const spacedDeclaredByPath = new Map([ + [spacedPaths[0], new Set(['@capgo/capacitor-updater'])], + [spacedPaths[1], new Set(['@capacitor/core'])], +]) +const spacedInstallCommands = buildOutdatedInstallCommandsForDoctor( + '/apps/my mobile/package.json,/apps/shared/package.json', + outdatedSample, + path => spacedDeclaredByPath.get(path), +) +assert.ok(spacedInstallCommands.includes('cd \'/apps/my mobile\'')) + +assert.equal(shellQuotePath('/apps/mobile'), '\'/apps/mobile\'') +assert.equal(shellQuotePath('/apps/my mobile'), '\'/apps/my mobile\'') +assert.equal(shellQuotePath('/apps/foo$(rm -rf /)'), '\'/apps/foo$(rm -rf /)\'') +assert.equal(shellQuotePath('/apps/o\'brien'), '\'/apps/o\'\\\'\'brien\'') +assert.equal(shellQuotePath('C:\\apps\\mobile', 'win32'), '"C:\\apps\\mobile"') +assert.equal(shellQuotePath('C:\\apps\\my mobile', 'win32'), '"C:\\apps\\my mobile"') +assert.equal(shellQuotePath('C:\\apps\\foo"bar', 'win32'), '"C:\\apps\\foo""bar"') + +assert.equal( + formatDoctorInstallHint('/apps/mobile', 'npm install @capgo/cli@latest'), + '(cd \'/apps/mobile\' && npm install @capgo/cli@latest)', +) +assert.equal( + formatDoctorInstallHint('C:\\apps\\mobile', 'npm install @capgo/cli@latest', 'win32'), + 'cd /d "C:\\apps\\mobile" && npm install @capgo/cli@latest', +) + console.log('โœ… doctor analytics tags tests passed')