diff --git a/.changeset/cli-upgrade-command.md b/.changeset/cli-upgrade-command.md new file mode 100644 index 0000000000..bfda79fa0d --- /dev/null +++ b/.changeset/cli-upgrade-command.md @@ -0,0 +1,5 @@ +--- +"@bigcommerce/catalyst": minor +--- + +Add `catalyst upgrade` command for upgrading a Catalyst project to a newer version via a 3-way merge. The command downloads the base and target version tarballs, runs a whole-tree `git merge-tree` merge (falling back to per-file `git merge-file` on older git), and applies the result directly to the project — never aborting, always producing resolvable `<<>>` markers for conflicts. Clean changes are pre-staged; conflicts are registered as real unmerged index entries so editors surface the merge UI. Supports flat and nested repo layouts, integration tag families (`--ref`), dry-run preview, explicit base override (`--from`), and alternate source repos (`--repository`). Projects without a `catalyst.ref` tracking field are detected automatically and backfilled. diff --git a/packages/catalyst/src/cli/commands/upgrade.action.spec.ts b/packages/catalyst/src/cli/commands/upgrade.action.spec.ts new file mode 100644 index 0000000000..1b7c9e513d --- /dev/null +++ b/packages/catalyst/src/cli/commands/upgrade.action.spec.ts @@ -0,0 +1,343 @@ +/** + * Action-level tests for `catalyst upgrade`. Invokes upgrade.parseAsync() the + * same way the real CLI does, using process.chdir() to control the working + * directory. Telemetry, logger output, and spinner animations are mocked. + * Downloads use the shared CLI cache so subsequent runs are offline. + */ + +import { execa } from 'execa'; +import { cp, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, expect, test, vi } from 'vitest'; + +// Windows holds git file locks longer than the default 10 s hook timeout. +vi.setConfig({ hookTimeout: 60_000 }); + +import { downloadCore, parseRef, upgrade } from './upgrade'; + +vi.mock('../lib/telemetry', () => ({ getTelemetry: () => ({ track: vi.fn() }) })); +vi.mock('../lib/logger', () => ({ + consola: { log: vi.fn(), success: vi.fn(), error: vi.fn(), warn: vi.fn(), info: vi.fn() }, +})); +vi.mock('yocto-spinner', () => ({ + default: () => ({ start: () => ({ success: vi.fn(), error: vi.fn(), warning: vi.fn() }) }), +})); + +const REPO = 'bigcommerce/catalyst'; +const BASE_REF = '@bigcommerce/catalyst-core@1.6.3'; +const TARGET_VERSION = '1.7.0'; +const TARGET_REF = `@bigcommerce/catalyst-core@${TARGET_VERSION}`; +const MAKESWIFT_BASE_REF = '@bigcommerce/catalyst-makeswift@1.2.0'; +const MAKESWIFT_TARGET_REF = '@bigcommerce/catalyst-makeswift@1.3.0'; +const TIMEOUT = 120_000; + +const createdDirs: string[] = []; +let originalCwd: string; + +// JSON.parse returns `any`; centralise the unsafe-return suppression here. +// eslint-disable-next-line @typescript-eslint/no-unsafe-return +const parseJson = (raw: string): Record => JSON.parse(raw); + +async function mkTmp(): Promise { + const dir = await mkdtemp(join(tmpdir(), 'upgrade-action-')); + + createdDirs.push(dir); + + return dir; +} + +beforeEach(() => { + originalCwd = process.cwd(); +}); + +afterEach(async () => { + process.chdir(originalCwd); + vi.restoreAllMocks(); + await Promise.all( + createdDirs + .splice(0) + .map((d) => rm(d, { recursive: true, force: true, maxRetries: 10, retryDelay: 500 })), + ); +}); + +async function initGitProject(dir: string): Promise { + await execa('git', ['init', '-q'], { cwd: dir }); + await execa('git', ['config', 'user.email', 't@t.com'], { cwd: dir }); + await execa('git', ['config', 'user.name', 't'], { cwd: dir }); + await execa('git', ['config', 'commit.gpgsign', 'false'], { cwd: dir }); + await execa('git', ['config', 'gc.auto', '0'], { cwd: dir }); + await execa('git', ['add', '-A'], { cwd: dir }); + await execa('git', ['commit', '-qm', 'base'], { cwd: dir }); +} + +// Creates and returns a committed project directory seeded from the 1.6.3 tarball. +// Versions <= 1.7.0 predate LTRAC-466 and have no catalyst.ref field in the +// tarball, so we inject it here when withCatalystRef is true so that action +// tests which don't specifically test the missing-ref path don't hit the +// interactive confirm prompt. +async function setup163Project( + root: string, + opts: { withCatalystRef?: boolean } = {}, +): Promise { + const { withCatalystRef = true } = opts; + const baseDir = join(root, 'base'); + + await downloadCore(REPO, BASE_REF, baseDir); + + const projectDir = join(root, 'project'); + + await cp(baseDir, projectDir, { recursive: true }); + + const pkgPath = join(projectDir, 'package.json'); + const pkg = parseJson(await readFile(pkgPath, 'utf-8')); + + if (withCatalystRef) { + const { version } = parseRef(BASE_REF); + + pkg.catalyst = { version, ref: BASE_REF }; + } else { + delete pkg.catalyst; + } + + await writeFile(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`); + await initGitProject(projectDir); + + return projectDir; +} + +// ── Fast tests (no tarball downloads) ──────────────────────────────────────── + +test('already up to date short-circuits before downloading', async () => { + const root = await mkTmp(); + const projectDir = join(root, 'project'); + + await mkdir(projectDir, { recursive: true }); + await writeFile( + join(projectDir, 'package.json'), + `${JSON.stringify( + { + name: '@bigcommerce/catalyst-core', + version: TARGET_VERSION, + catalyst: { version: TARGET_VERSION, ref: TARGET_REF }, + }, + null, + 2, + )}\n`, + ); + await initGitProject(projectDir); + process.chdir(projectDir); + + // Resolves cleanly — no download, no exit, no file changes. + await expect(upgrade.parseAsync([TARGET_VERSION], { from: 'user' })).resolves.toBeDefined(); + + const status = (await execa('git', ['status', '--porcelain'], { cwd: projectDir })).stdout; + + expect(status.trim()).toBe(''); + // git init + commit on Windows CI takes longer than the 5s default +}, 15_000); + +test('dirty worktree: uncommitted changes cause exit(1) before any download', async () => { + const root = await mkTmp(); + const projectDir = join(root, 'project'); + + await mkdir(projectDir, { recursive: true }); + await writeFile( + join(projectDir, 'package.json'), + `${JSON.stringify( + { + name: '@bigcommerce/catalyst-core', + version: '1.6.3', + catalyst: { version: '1.6.3', ref: BASE_REF }, + }, + null, + 2, + )}\n`, + ); + await initGitProject(projectDir); + + // Untracked file → dirty tree. + await writeFile(join(projectDir, 'dirty.ts'), 'export const x = 1;\n'); + process.chdir(projectDir); + + const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('process.exit'); + }); + + await expect(upgrade.parseAsync([TARGET_VERSION], { from: 'user' })).rejects.toThrow(); + expect(exitSpy).toHaveBeenCalledWith(1); +}); + +test('invalid tag: a nonexistent version surfaces a 404 error', async () => { + const root = await mkTmp(); + + // Test downloadCore directly — the action re-throws this error unchanged. + await expect( + downloadCore(REPO, '@bigcommerce/catalyst-core@0.0.1-nonexistent', join(root, 'dest')), + ).rejects.toThrow(/404.*not found/i); +}, 30_000); + +// ── Tests that use real tarballs (cached after first run) ───────────────────── + +test( + '--dry-run prints the diff but leaves the project completely unchanged', + async () => { + const root = await mkTmp(); + const projectDir = await setup163Project(root); + const pkgBefore = await readFile(join(projectDir, 'package.json'), 'utf-8'); + + process.chdir(projectDir); + + await upgrade.parseAsync([TARGET_VERSION, '--dry-run'], { from: 'user' }); + + // Nothing staged or modified. + const status = (await execa('git', ['status', '--porcelain'], { cwd: projectDir })).stdout; + + expect(status.trim()).toBe(''); + expect(await readFile(join(projectDir, 'package.json'), 'utf-8')).toBe(pkgBefore); + }, + TIMEOUT, +); + +test( + 'missing catalyst.ref with --yes infers the base and stamps the target ref', + async () => { + const root = await mkTmp(); + const projectDir = await setup163Project(root, { withCatalystRef: false }); + + process.chdir(projectDir); + + await upgrade.parseAsync([TARGET_VERSION, '--yes'], { from: 'user' }); + + const pkg = parseJson(await readFile(join(projectDir, 'package.json'), 'utf-8')); + + expect(pkg.catalyst).toMatchObject({ ref: TARGET_REF }); + }, + TIMEOUT, +); + +test( + 'running upgrade from inside core/ resolves to the git root and applies correctly', + async () => { + const root = await mkTmp(); + const baseDir = join(root, 'base'); + + await downloadCore(REPO, BASE_REF, baseDir); + + // Nested layout: Catalyst package lives under /core/. + const projectRoot = join(root, 'project'); + + await mkdir(join(projectRoot, 'core'), { recursive: true }); + await cp(baseDir, join(projectRoot, 'core'), { recursive: true }); + + // 1.6.3 predates LTRAC-466 — inject catalyst.ref so the action doesn't + // hit the interactive confirm prompt. + const corePkgPath = join(projectRoot, 'core', 'package.json'); + const corePkg = parseJson(await readFile(corePkgPath, 'utf-8')); + const { version: baseVersion } = parseRef(BASE_REF); + + corePkg.catalyst = { version: baseVersion, ref: BASE_REF }; + await writeFile(corePkgPath, `${JSON.stringify(corePkg, null, 2)}\n`); + await initGitProject(projectRoot); + + // Simulate running `catalyst upgrade` from inside core/. + process.chdir(join(projectRoot, 'core')); + + await upgrade.parseAsync([TARGET_VERSION], { from: 'user' }); + + const pkg = parseJson(await readFile(join(projectRoot, 'core', 'package.json'), 'utf-8')); + + expect(pkg.catalyst).toMatchObject({ ref: TARGET_REF }); + }, + TIMEOUT, +); + +test( + 'dirty worktree with --dry-run proceeds without error', + async () => { + const root = await mkTmp(); + const projectDir = await setup163Project(root); + + // Staged change — normally blocks the upgrade. + await writeFile(join(projectDir, 'dirty.ts'), 'export const x = 1;\n'); + process.chdir(projectDir); + + // Should resolve without throwing and leave the tree as-is. + await expect( + upgrade.parseAsync([TARGET_VERSION, '--dry-run'], { from: 'user' }), + ).resolves.toBeDefined(); + }, + TIMEOUT, +); + +test( + 'staged-but-uncommitted changes are treated as a dirty worktree and cause exit(1)', + async () => { + const root = await mkTmp(); + const projectDir = await setup163Project(root); + + // Stage a new file without committing — this is dirty too. + await writeFile(join(projectDir, 'staged.ts'), 'export const x = 1;\n'); + await execa('git', ['add', 'staged.ts'], { cwd: projectDir }); + process.chdir(projectDir); + + const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('process.exit'); + }); + + await expect(upgrade.parseAsync([TARGET_VERSION], { from: 'user' })).rejects.toThrow(); + expect(exitSpy).toHaveBeenCalledWith(1); + }, + TIMEOUT, +); + +test( + '--from overrides base detection when catalyst.ref is missing', + async () => { + const root = await mkTmp(); + // Start with a project that has no catalyst.ref — normally triggers the + // interactive confirm prompt. Passing --from bypasses it entirely. + const projectDir = await setup163Project(root, { withCatalystRef: false }); + + process.chdir(projectDir); + + await upgrade.parseAsync([TARGET_VERSION, '--from', BASE_REF], { from: 'user' }); + + const pkg = parseJson(await readFile(join(projectDir, 'package.json'), 'utf-8')); + + expect(pkg.catalyst).toMatchObject({ ref: TARGET_REF }); + }, + TIMEOUT, +); + +test( + '--ref flag upgrades a makeswift integration family project to the target tag', + async () => { + const root = await mkTmp(); + const baseDir = join(root, 'base'); + + await downloadCore(REPO, MAKESWIFT_BASE_REF, baseDir); + + const projectDir = join(root, 'project'); + + await cp(baseDir, projectDir, { recursive: true }); + + // makeswift 1.2.0 also predates LTRAC-466 — inject catalyst.ref. + const pkgPath = join(projectDir, 'package.json'); + const pkg = parseJson(await readFile(pkgPath, 'utf-8')); + const { version: baseVersion } = parseRef(MAKESWIFT_BASE_REF); + + pkg.catalyst = { version: baseVersion, ref: MAKESWIFT_BASE_REF }; + await writeFile(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`); + await initGitProject(projectDir); + + process.chdir(projectDir); + + await upgrade.parseAsync(['--ref', MAKESWIFT_TARGET_REF], { from: 'user' }); + + const updated = parseJson(await readFile(pkgPath, 'utf-8')); + + expect(updated.catalyst).toMatchObject({ ref: MAKESWIFT_TARGET_REF }); + }, + TIMEOUT, +); diff --git a/packages/catalyst/src/cli/commands/upgrade.integration.spec.ts b/packages/catalyst/src/cli/commands/upgrade.integration.spec.ts new file mode 100644 index 0000000000..9bc7ebbd08 --- /dev/null +++ b/packages/catalyst/src/cli/commands/upgrade.integration.spec.ts @@ -0,0 +1,406 @@ +/** + * Integration tests for `catalyst upgrade`. These tests download real Catalyst + * tarballs (using the same cache as the CLI at ~/.cache/catalyst-cli/cores) and + * run the full upgrade pipeline against them. On first run, tarballs are fetched + * from GitHub (~3MB each). Subsequent runs use the on-disk cache and run offline. + */ +import { execa } from 'execa'; +import { execSync } from 'node:child_process'; +import { access, cp, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, expect, test, vi } from 'vitest'; + +vi.setConfig({ hookTimeout: 60_000 }); + +import { + applyIndexState, + computeBaseSimilarity, + downloadCore, + mergeCorePerFile, + mergeCoreTree, + resolveProject, +} from './upgrade'; + +const SUPPORTS_TREE = (() => { + try { + const match = /(\d+)\.(\d+)/.exec(execSync('git --version').toString()); + + return !!match && (Number(match[1]) > 2 || (Number(match[1]) === 2 && Number(match[2]) >= 38)); + } catch { + return false; + } +})(); + +const engines: Array<'per-file' | 'tree'> = SUPPORTS_TREE ? ['per-file', 'tree'] : ['per-file']; + +const exists = (p: string) => + access(p) + .then(() => true) + .catch(() => false); + +const createdDirs: string[] = []; + +async function mkTmp(): Promise { + const dir = await mkdtemp(join(tmpdir(), 'upgrade-integ-')); + + createdDirs.push(dir); + + return dir; +} + +afterEach(async () => { + await Promise.all( + createdDirs + .splice(0) + .map((d) => rm(d, { recursive: true, force: true, maxRetries: 10, retryDelay: 500 })), + ); +}); + +const REPO = 'bigcommerce/catalyst'; +// Versions used in the manual spike testing — known to have a meaningful diff. +const BASE_REF = '@bigcommerce/catalyst-core@1.6.3'; +const TARGET_REF = '@bigcommerce/catalyst-core@1.7.0'; + +const MAKESWIFT_BASE_REF = '@bigcommerce/catalyst-makeswift@1.2.0'; +const MAKESWIFT_TARGET_REF = '@bigcommerce/catalyst-makeswift@1.3.0'; + +async function fetchTarballs(root: string): Promise<{ baseDir: string; theirsDir: string }> { + const baseDir = join(root, 'base'); + const theirsDir = join(root, 'theirs'); + + await Promise.all([ + downloadCore(REPO, BASE_REF, baseDir), + downloadCore(REPO, TARGET_REF, theirsDir), + ]); + + return { baseDir, theirsDir }; +} + +async function initGitProject(dir: string): Promise { + await execa('git', ['init', '-q'], { cwd: dir }); + await execa('git', ['config', 'user.email', 't@t.com'], { cwd: dir }); + await execa('git', ['config', 'user.name', 't'], { cwd: dir }); + await execa('git', ['config', 'commit.gpgsign', 'false'], { cwd: dir }); + // Disable auto-gc so git never spawns background pack processes that would + // still be running when afterEach tries to remove the temp directory. + await execa('git', ['config', 'gc.auto', '0'], { cwd: dir }); + await execa('git', ['add', '-A'], { cwd: dir }); + await execa('git', ['commit', '-qm', 'base'], { cwd: dir }); +} + +// 120s per test to allow cold-cache downloads on first run. +const TIMEOUT = 120_000; + +describe.each(engines)('integration (engine: %s)', (engine) => { + const runMerge = (baseDir: string, theirsDir: string, oursDir: string, emptyFile: string) => + engine === 'tree' + ? mergeCoreTree(baseDir, theirsDir, oursDir) + : mergeCorePerFile(baseDir, theirsDir, oursDir, emptyFile); + + test( + 'clean project upgrades without conflicts and all changes staged', + async () => { + const root = await mkTmp(); + const { baseDir, theirsDir } = await fetchTarballs(root); + + // Project = fresh copy of 1.6.3 with no merchant modifications. + const oursDir = join(root, 'project'); + + await cp(baseDir, oursDir, { recursive: true }); + await initGitProject(oursDir); + + const emptyFile = join(root, '.empty'); + + await writeFile(emptyFile, ''); + + const result = await runMerge(baseDir, theirsDir, oursDir, emptyFile); + + // No merchant customizations → no conflicts. + expect(result.conflicted).toHaveLength(0); + // Versions aren't identical, so there must be at least one change. + expect(result.applied.length + result.added.length + result.deleted.length).toBeGreaterThan( + 0, + ); + + await applyIndexState(oursDir, '.', baseDir, theirsDir, result, false); + + const status = (await execa('git', ['status', '--porcelain'], { cwd: oursDir })).stdout; + const lines = status.trim().split('\n').filter(Boolean); + + // Every change should be staged (leading column non-space, non-?). + expect(lines.length).toBeGreaterThan(0); + expect(lines.every((l) => !l.startsWith(' ') && !l.startsWith('?'))).toBe(true); + }, + TIMEOUT, + ); + + test( + 'merchant-deleted file that upstream modifies is restored as a conflict', + async () => { + const root = await mkTmp(); + const { baseDir, theirsDir } = await fetchTarballs(root); + + // Start from the base version, then delete package.json — a file upstream always + // modifies (version bump at minimum between 1.6.3 and 1.7.0). + const oursDir = join(root, 'project'); + + await cp(baseDir, oursDir, { recursive: true }); + await rm(join(oursDir, 'package.json'), { force: true }); + await initGitProject(oursDir); + + const emptyFile = join(root, '.empty'); + + await writeFile(emptyFile, ''); + + const result = await runMerge(baseDir, theirsDir, oursDir, emptyFile); + + // modify/delete: upstream's version should be restored and flagged as conflicted. + expect(result.conflicted).toContain('package.json'); + expect(await exists(join(oursDir, 'package.json'))).toBe(true); + }, + TIMEOUT, + ); + + test( + 'merchant dep addition in a non-overlapping region is preserved after upgrade', + async () => { + const root = await mkTmp(); + const { baseDir, theirsDir } = await fetchTarballs(root); + + const oursDir = join(root, 'project'); + + await cp(baseDir, oursDir, { recursive: true }); + + const pkgPath = join(oursDir, 'package.json'); + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const pkg: Record = JSON.parse(await readFile(pkgPath, 'utf-8')); + const deps: Record = {}; + + if (typeof pkg.dependencies === 'object' && pkg.dependencies !== null) { + Object.assign(deps, pkg.dependencies); + } + + deps['some-merchant-package'] = '^1.0.0'; + pkg.dependencies = deps; + await writeFile(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`); + await initGitProject(oursDir); + + const emptyFile = join(root, '.empty'); + + await writeFile(emptyFile, ''); + + await runMerge(baseDir, theirsDir, oursDir, emptyFile); + + // The merchant dep must be present in the merged output — either cleanly + // applied or in the ours side of any conflict markers. + const merged = await readFile(pkgPath, 'utf-8'); + + expect(merged).toContain('"some-merchant-package"'); + }, + TIMEOUT, + ); + + test( + 're-merging after upgrade with identical base and target produces no changes', + async () => { + const root = await mkTmp(); + const { baseDir, theirsDir } = await fetchTarballs(root); + + const oursDir = join(root, 'project'); + + await cp(baseDir, oursDir, { recursive: true }); + await initGitProject(oursDir); + + const emptyFile = join(root, '.empty'); + + await writeFile(emptyFile, ''); + + // First upgrade: base → target (ours now at target state) + await runMerge(baseDir, theirsDir, oursDir, emptyFile); + + // Second "upgrade": target → target (no upstream diff) → nothing to do + const idempotentResult = await runMerge(theirsDir, theirsDir, oursDir, emptyFile); + + expect(idempotentResult.applied).toHaveLength(0); + expect(idempotentResult.added).toHaveLength(0); + expect(idempotentResult.deleted).toHaveLength(0); + expect(idempotentResult.conflicted).toHaveLength(0); + }, + TIMEOUT, + ); + + test( + 'flat repo layout — changes land at root and resolveProject detects relDir "."', + async () => { + const root = await mkTmp(); + const { baseDir, theirsDir } = await fetchTarballs(root); + + // Flat layout: extract base tarball contents directly to the repo root (no core/ subdir). + const oursDir = join(root, 'flat-project'); + + await cp(baseDir, oursDir, { recursive: true }); + await initGitProject(oursDir); + + // resolveProject must identify this as a flat layout. + const project = await resolveProject(oursDir); + + expect(project).not.toBeNull(); + expect(project?.relDir).toBe('.'); + + const emptyFile = join(root, '.empty'); + + await writeFile(emptyFile, ''); + + const result = await runMerge(baseDir, theirsDir, oursDir, emptyFile); + + // All changed paths should be at the root — no "core/" prefix. + const allPaths = [ + ...result.applied, + ...result.added, + ...result.deleted, + ...result.conflicted, + ]; + + expect(allPaths.some((p) => p.startsWith('core/'))).toBe(false); + + // No nested core/ directory should have been created inside the flat project. + expect(await exists(join(oursDir, 'core'))).toBe(false); + }, + TIMEOUT, + ); + + test( + 'file modified by merchant AND upstream produces conflict markers', + async () => { + const root = await mkTmp(); + const { baseDir, theirsDir } = await fetchTarballs(root); + + // Merchant project: start from 1.6.3, then change package.json's version + // field — the same field that the 1.6.3 → 1.7.0 diff also touches. + const oursDir = join(root, 'project'); + + await cp(baseDir, oursDir, { recursive: true }); + + const pkgPath = join(oursDir, 'package.json'); + const raw = await readFile(pkgPath, 'utf-8'); + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const pkg: Record = JSON.parse(raw); + + pkg.version = 'custom-merchant-version'; // overlaps with upstream's version bump + await writeFile(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`); + await initGitProject(oursDir); + + const emptyFile = join(root, '.empty'); + + await writeFile(emptyFile, ''); + + const result = await runMerge(baseDir, theirsDir, oursDir, emptyFile); + + // package.json was modified by both sides → must be conflicted. + expect(result.conflicted).toContain('package.json'); + + // Confirm the file contains conflict markers. + const merged = await readFile(join(oursDir, 'package.json'), 'utf-8'); + + expect(merged).toContain('<<<<<<< ours'); + expect(merged).toContain('>>>>>>> theirs'); + }, + TIMEOUT, + ); +}); + +describe.each(engines)('integration makeswift family (engine: %s)', (engine) => { + const runMerge = (baseDir: string, theirsDir: string, oursDir: string, emptyFile: string) => + engine === 'tree' + ? mergeCoreTree(baseDir, theirsDir, oursDir) + : mergeCorePerFile(baseDir, theirsDir, oursDir, emptyFile); + + test( + 'clean makeswift project (1.2.0 → 1.3.0) upgrades without conflicts', + async () => { + const root = await mkTmp(); + const baseDir = join(root, 'base'); + const theirsDir = join(root, 'theirs'); + + await Promise.all([ + downloadCore(REPO, MAKESWIFT_BASE_REF, baseDir), + downloadCore(REPO, MAKESWIFT_TARGET_REF, theirsDir), + ]); + + const oursDir = join(root, 'project'); + + await cp(baseDir, oursDir, { recursive: true }); + await execa('git', ['init', '-q'], { cwd: oursDir }); + await execa('git', ['config', 'user.email', 't@t.com'], { cwd: oursDir }); + await execa('git', ['config', 'user.name', 't'], { cwd: oursDir }); + await execa('git', ['config', 'commit.gpgsign', 'false'], { cwd: oursDir }); + await execa('git', ['add', '-A'], { cwd: oursDir }); + await execa('git', ['commit', '-qm', 'base'], { cwd: oursDir }); + + const emptyFile = join(root, '.empty'); + + await writeFile(emptyFile, ''); + + const result = await runMerge(baseDir, theirsDir, oursDir, emptyFile); + + // A clean makeswift project should merge without conflicts. + expect(result.conflicted).toHaveLength(0); + expect(result.applied.length + result.added.length + result.deleted.length).toBeGreaterThan( + 0, + ); + }, + TIMEOUT, + ); + + test( + 'computeBaseSimilarity scores higher for the correct makeswift base than a wrong base', + async () => { + const root = await mkTmp(); + const baseDir = join(root, 'base'); + const theirsDir = join(root, 'theirs'); + + await Promise.all([ + downloadCore(REPO, MAKESWIFT_BASE_REF, baseDir), + downloadCore(REPO, MAKESWIFT_TARGET_REF, theirsDir), + ]); + + // "Project" = clean makeswift 1.2.0 (no modifications). + const projectDir = join(root, 'project'); + + await cp(baseDir, projectDir, { recursive: true }); + + // Correct base scores near-perfect (project is an unmodified 1.2.0 copy). + const correctScore = await computeBaseSimilarity(baseDir, projectDir); + // Wrong base (1.3.0) scores lower because files changed between versions. + const wrongScore = await computeBaseSimilarity(theirsDir, projectDir); + + expect(correctScore).toBeGreaterThan(0.9); + expect(wrongScore).toBeLessThan(correctScore); + }, + TIMEOUT, + ); +}); + +test( + 'computeBaseSimilarity: correct base scores higher than a wrong base', + async () => { + const root = await mkTmp(); + const { baseDir, theirsDir } = await fetchTarballs(root); + + // "Project" = clean 1.6.3 (no merchant modifications). + const projectDir = join(root, 'project'); + + await cp(baseDir, projectDir, { recursive: true }); + + // Correct base (1.6.3 vs a fresh 1.6.3 project) → near-perfect similarity. + const correctScore = await computeBaseSimilarity(baseDir, projectDir); + + // Wrong base (1.7.0 vs the same 1.6.3 project) → lower similarity. + const wrongScore = await computeBaseSimilarity(theirsDir, projectDir); + + expect(correctScore).toBeGreaterThan(0.9); + expect(wrongScore).toBeLessThan(correctScore); + }, + TIMEOUT, +); diff --git a/packages/catalyst/src/cli/commands/upgrade.spec.ts b/packages/catalyst/src/cli/commands/upgrade.spec.ts index 81c3a9fcdc..f2b81f347f 100644 --- a/packages/catalyst/src/cli/commands/upgrade.spec.ts +++ b/packages/catalyst/src/cli/commands/upgrade.spec.ts @@ -1,9 +1,14 @@ +import { Command } from '@commander-js/extra-typings'; import { execa } from 'execa'; import { execSync } from 'node:child_process'; import { access, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; -import { afterEach, describe, expect, test } from 'vitest'; +import { afterEach, describe, expect, test, vi } from 'vitest'; + +// The tree engine runs many git subprocesses sequentially on Windows CI; give +// every test in this file enough headroom (the fast ones finish in < 1 s). +vi.setConfig({ testTimeout: 30_000 }); import { applyIndexState, @@ -11,7 +16,10 @@ import { mergeCorePerFile, mergeCoreTree, parseRef, + resolveBaseRef, + resolveProject, resolveStrategy, + upgrade, } from './upgrade'; const createdDirs: string[] = []; @@ -35,7 +43,16 @@ const exists = (p: string) => .catch(() => false); afterEach(async () => { - await Promise.all(createdDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); + await Promise.all( + createdDirs + .splice(0) + .map((dir) => rm(dir, { recursive: true, force: true, maxRetries: 10, retryDelay: 500 })), + ); +}); + +test('properly configured Command instance', () => { + expect(upgrade).toBeInstanceOf(Command); + expect(upgrade.name()).toBe('upgrade'); }); describe('parseRef', () => { @@ -58,6 +75,122 @@ describe('parseRef', () => { }); }); +describe('resolveProject', () => { + async function initRepo(): Promise { + const repo = await mkTmp(); + + await execa('git', ['init', '-q'], { cwd: repo }); + await execa('git', ['config', 'user.email', 't@t.com'], { cwd: repo }); + await execa('git', ['config', 'user.name', 't'], { cwd: repo }); + + return repo; + } + + const catalystPkg = (version: string) => + `${JSON.stringify( + { + name: '@bigcommerce/catalyst-core', + version, + catalyst: { version, ref: `@bigcommerce/catalyst-core@${version}` }, + }, + null, + 2, + )}\n`; + + test('detects a nested (monorepo) layout → relDir "core"', async () => { + const repo = await initRepo(); + + await write(join(repo, 'core', 'package.json'), catalystPkg('1.6.3')); + + const project = await resolveProject(repo); + + expect(project).not.toBeNull(); + expect(project?.relDir).toBe('core'); + expect(project?.catalystRoot.endsWith('core')).toBe(true); + expect(project?.pkg.catalyst?.ref).toBe('@bigcommerce/catalyst-core@1.6.3'); + }); + + test('detects a flat layout → relDir "."', async () => { + const repo = await initRepo(); + + await write(join(repo, 'package.json'), catalystPkg('1.7.0')); + + const project = await resolveProject(repo); + + expect(project).not.toBeNull(); + expect(project?.relDir).toBe('.'); + expect(project?.pkg.catalyst?.ref).toBe('@bigcommerce/catalyst-core@1.7.0'); + }); + + test('returns null outside a git repo', async () => { + const notARepo = await mkTmp(); + + expect(await resolveProject(notARepo)).toBeNull(); + }); +}); + +describe('resolveBaseRef', () => { + // Minimal Project stub — resolveBaseRef only reads `.pkg` (plus its --from / + // --yes args). Conditional spreads keep optional fields truly absent so the + // stub satisfies CorePackageJson under exactOptionalPropertyTypes. + const projectWith = (pkg: { name?: string; version: string; ref?: string }) => ({ + gitRoot: '/repo', + catalystRoot: '/repo/core', + relDir: 'core', + pkgPath: '/repo/core/package.json', + rawContent: '', + pkg: { + ...(pkg.name === undefined ? {} : { name: pkg.name }), + version: pkg.version, + ...(pkg.ref === undefined ? {} : { catalyst: { version: pkg.version, ref: pkg.ref } }), + }, + }); + + test('uses catalyst.ref verbatim when present (ignores --from)', async () => { + expect( + await resolveBaseRef( + projectWith({ + name: '@bigcommerce/catalyst-core', + version: '1.6.3', + ref: '@bigcommerce/catalyst-core@1.6.3', + }), + '@bigcommerce/catalyst-core@1.0.0', + false, + ), + ).toBe('@bigcommerce/catalyst-core@1.6.3'); + }); + + test('missing catalyst.ref: --from wins when provided', async () => { + expect( + await resolveBaseRef( + projectWith({ name: '@bigcommerce/catalyst-core', version: '1.6.3' }), + '@bigcommerce/catalyst-makeswift@1.5.0', + false, + ), + ).toBe('@bigcommerce/catalyst-makeswift@1.5.0'); + }); + + test('missing catalyst.ref (pre-LTRAC-466): infers @ under --yes', async () => { + expect( + await resolveBaseRef( + projectWith({ name: '@bigcommerce/catalyst-makeswift', version: '1.6.3' }), + undefined, + true, + ), + ).toBe('@bigcommerce/catalyst-makeswift@1.6.3'); + }); + + test('missing catalyst.ref + unknown package name: defaults to catalyst-core family', async () => { + expect( + await resolveBaseRef( + projectWith({ name: 'acme-storefront', version: '1.6.3' }), + undefined, + true, + ), + ).toBe('@bigcommerce/catalyst-core@1.6.3'); + }); +}); + describe('computeBaseSimilarity', () => { test('returns 1.0 when all base files match exactly', async () => { const root = await mkTmp(); diff --git a/packages/catalyst/src/cli/commands/upgrade.ts b/packages/catalyst/src/cli/commands/upgrade.ts index 479f484e81..8f0b8ea94c 100644 --- a/packages/catalyst/src/cli/commands/upgrade.ts +++ b/packages/catalyst/src/cli/commands/upgrade.ts @@ -1,19 +1,25 @@ +import { Command, Option } from '@commander-js/extra-typings'; +import { confirm } from '@inquirer/prompts'; import { execa } from 'execa'; import { access, copyFile, + cp, mkdir, mkdtemp, readdir, readFile, + rename, rm, writeFile, } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { dirname, join } from 'node:path'; +import { homedir, tmpdir } from 'node:os'; +import { dirname, join, relative } from 'node:path'; +import yoctoSpinner from 'yocto-spinner'; import { z } from 'zod'; import { consola } from '../lib/logger'; +import { getTelemetry } from '../lib/telemetry'; const CorePackageJson = z.object({ name: z.string().optional(), @@ -21,6 +27,27 @@ const CorePackageJson = z.object({ catalyst: z.object({ version: z.string(), ref: z.string() }).optional(), }); +// Catalyst versions are git tags on the upstream monorepo (e.g. +// "@bigcommerce/catalyst-core@1.7.0"), NOT npm packages. GitHub serves a +// tarball for any tag via the repos///tarball/ endpoint. +const DEFAULT_REPOSITORY = 'bigcommerce/catalyst'; + +// Moving tags that don't pin a concrete version. When the target is one of +// these we resolve the real version from the downloaded core/package.json so +// catalyst.ref records a stable pin rather than the alias. +const MOVING_TAGS = new Set(['latest', 'canary', 'alpha']); + +// Known Catalyst package families, used to reconstruct a base ref from a +// project's package.json `name` when `catalyst.ref` is missing. +const KNOWN_FAMILIES = new Set([ + '@bigcommerce/catalyst-core', + '@bigcommerce/catalyst-makeswift', + '@bigcommerce/catalyst-b2b-makeswift', + '@bigcommerce/catalyst-b2b', +]); + +const CACHE_DIR = join(homedir(), '.cache', 'catalyst-cli', 'cores'); + // ── small fs helpers ────────────────────────────────────────────────────────── const pathExists = (p: string) => access(p) @@ -332,11 +359,11 @@ export async function mergeCoreTree( env, }).then((r) => r.stdout.trim()); - const [baseTree, oursTree, theirsTree] = await Promise.all([ - writeTree(baseDir, 'base'), - writeTree(catalystRoot, 'ours'), - writeTree(theirsDir, 'theirs'), - ]); + // Run sequentially to avoid concurrent writes to the shared object store + // on Windows, where simultaneous git processes cause EPERM on object files. + const baseTree = await writeTree(baseDir, 'base'); + const oursTree = await writeTree(catalystRoot, 'ours'); + const theirsTree = await writeTree(theirsDir, 'theirs'); const baseCommit = await commitTree(baseTree); const [oursCommit, theirsCommit] = await Promise.all([ @@ -422,7 +449,7 @@ export async function mergeCoreTree( conflicted, }; } finally { - await rm(scratch, { recursive: true, force: true }); + await rm(scratch, { recursive: true, force: true, maxRetries: 10, retryDelay: 500 }); } } @@ -521,5 +548,520 @@ export async function applyIndexState( }); } -// Re-export readResolvedVersion so later PRs can use it without re-importing. -export { readResolvedVersion }; +// ── project (destination) layout resolution ─────────────────────────────────── +// The command is merchant-facing and must support two repo shapes: +// * flat (future) — core/ contents at the repo root (package.json at root) +// * nested (deprecated monorepo clone) — core/package.json under /core +// We locate the package.json carrying the `catalyst` field; if none has it yet +// we fall back to the most likely Catalyst package.json so the auto-detect path +// can still run. +interface Project { + gitRoot: string; + catalystRoot: string; + relDir: string; // "." (flat) or "core" (nested), relative to gitRoot + pkgPath: string; + rawContent: string; + pkg: z.infer; +} + +export async function resolveProject(cwd: string): Promise { + let gitRoot: string; + + try { + gitRoot = (await execa('git', ['rev-parse', '--show-toplevel'], { cwd })).stdout.trim(); + } catch { + return null; + } + + // Probe order favours the nested core/ first (a monorepo clone also has a + // root package.json we don't want), then flat, then the cwd variants. + const candidateDirs = [...new Set([join(gitRoot, 'core'), gitRoot, join(cwd, 'core'), cwd])]; + + const parsed = ( + await Promise.all( + candidateDirs.map(async (dir) => { + const pkgPath = join(dir, 'package.json'); + const raw = await readFile(pkgPath, 'utf-8').catch(() => null); + + if (!raw) return null; + + let pkgJson: unknown; + + try { + pkgJson = JSON.parse(raw); + } catch { + return null; + } + + const result = CorePackageJson.safeParse(pkgJson); + + return result.success ? { dir, pkgPath, raw, pkg: result.data } : null; + }), + ) + ).filter((entry) => entry !== null); + + if (parsed.length === 0) return null; + + // Prefer the package.json that already declares `catalyst`; otherwise fall + // back to one that looks like a Catalyst project (known family name). + const chosen = + parsed.find((p) => p.pkg.catalyst?.ref) ?? + parsed.find((p) => p.pkg.name !== undefined && KNOWN_FAMILIES.has(p.pkg.name)) ?? + parsed[0]; + + return { + gitRoot, + catalystRoot: chosen.dir, + relDir: relative(gitRoot, chosen.dir) || '.', + pkgPath: chosen.pkgPath, + rawContent: chosen.raw, + pkg: chosen.pkg, + }; +} + +// ── tarball download (source side stays the monorepo) ───────────────────────── +export async function downloadCore( + repository: string, + ref: string, + destDir: string, + token?: string, +): Promise { + // Never cache moving tags (latest/canary/alpha) — they'd go stale silently. + const cacheable = !MOVING_TAGS.has(parseRef(ref).version); + // Use a separator before the ref so that repository strings differing only + // by characters that sanitize to '_' (e.g. '/' vs '_') produce distinct keys. + const cacheKey = `${repository.replace(/[^a-zA-Z0-9._-]/g, '_')}__${ref.replace(/[^a-zA-Z0-9._-]/g, '_')}`; + const cachePath = join(CACHE_DIR, cacheKey); + + // Guard against a partially-written cache entry: two concurrent downloadCore + // calls for the same ref can race — the first creates cachePath mid-copy, and + // the second sees it as present but gets an incomplete directory. Validate with + // a package.json sentinel (every extracted core/ tree must have one). + if ( + cacheable && + (await pathExists(cachePath)) && + (await pathExists(join(cachePath, 'package.json'))) + ) { + await cp(cachePath, destDir, { recursive: true }); + + return; + } + + // encodeURIComponent turns "@bigcommerce/catalyst-core@1.7.0" into + // "%40bigcommerce%2Fcatalyst-core%401.7.0", which the API accepts. + const url = `https://api.github.com/repos/${repository}/tarball/${encodeURIComponent(ref)}`; + const headers: Record = { + 'User-Agent': 'catalyst-cli', + Accept: 'application/vnd.github+json', + }; + + if (token) headers.Authorization = `Bearer ${token}`; + + const res = await fetch(url, { headers }); + + if (!res.ok) { + let hint = ''; + + if (res.status === 404) hint = ` — tag "${ref}" not found in ${repository}`; + if (res.status === 403) hint = ' — GitHub rate limit hit; set GITHUB_TOKEN to raise it'; + + throw new Error(`GitHub returned ${res.status} for ${ref}${hint}`); + } + + const tarballPath = `${destDir}.tgz`; + const rawDir = `${destDir}.raw`; + + await writeFile(tarballPath, Buffer.from(await res.arrayBuffer())); + await mkdir(rawDir, { recursive: true }); + // --strip-components=1 drops the "bigcommerce-catalyst-/" top dir. + await execa('tar', ['-xzf', tarballPath, '-C', rawDir, '--strip-components=1']); + + // Source is expected to be the monorepo (core/ inside); fall back to the + // extracted root for a hypothetical flat-source tag (insurance, not a target). + const coreDir = join(rawDir, 'core'); + const sourceDir = (await pathExists(coreDir)) ? coreDir : rawDir; + + await rename(sourceDir, destDir); + await rm(tarballPath, { force: true }); + await rm(rawDir, { recursive: true, force: true }); + + if (cacheable) { + await mkdir(CACHE_DIR, { recursive: true }); + await cp(destDir, cachePath, { recursive: true }).catch(() => { + /* best-effort cache; ignore failures */ + }); + } +} + +// ── base-ref resolution / auto-detect ───────────────────────────────────────── +// Returns the base ref to diff from, auto-detecting when catalyst.ref is absent. +export async function resolveBaseRef( + project: Project, + fromOption: string | undefined, + assumeYes: boolean, +): Promise { + if (project.pkg.catalyst?.ref) return project.pkg.catalyst.ref; + if (fromOption) return fromOption; + + // Older projects mirror the Catalyst version in `version`; the package `name` + // tells us the family. Propose the inferred ref and let the merchant confirm. + const family = + project.pkg.name && KNOWN_FAMILIES.has(project.pkg.name) + ? project.pkg.name + : '@bigcommerce/catalyst-core'; + const guess = `${family}@${project.pkg.version}`; + + consola.warn(`No \`catalyst.ref\` found in package.json. Inferred base: ${guess}`); + + if (assumeYes) return guess; + + const ok = await confirm({ message: `Upgrade from ${guess}?`, default: true }).catch(() => false); + + if (!ok) { + consola.info('Re-run with `--from ` to set the base version explicitly.'); + + return null; + } + + return guess; +} + +// ── summary output ──────────────────────────────────────────────────────────── +function printSummary(result: MergeResult, relDir: string, stampedPkg: boolean): void { + // package.json is excluded from the conflict list when the stamp resolved it. + const unresolved = result.conflicted.filter((f) => !(stampedPkg && f === 'package.json')); + + const parts = [ + `${result.applied.length} updated`, + `${result.added.length} added`, + `${result.deleted.length} removed`, + ]; + + if (unresolved.length) parts.push(`${unresolved.length} with conflicts`); + + consola.log(`\n${parts.join(', ')}`); + + if (unresolved.length) { + const files = unresolved.map((f) => ` ${f}`).join('\n'); + + consola.warn( + `\nStaged the clean changes. ${unresolved.length} file(s) need conflict resolution (the <<>> markers):\n${files}\n\nResolve them, then: git add ${relDir} && git commit`, + ); + } else { + consola.success('Staged all changes — review with `git diff --cached`, then commit.'); + } +} + +export const upgrade = new Command('upgrade') + .configureHelp({ showGlobalOptions: true }) + .aliases([ + 'up', + // These are hidden aliases + 'upgrayedd', + 'ugrad', + ]) + .description('Upgrade your Catalyst project to a newer version by applying a 3-way merge.') + .argument('[version]', 'Target version to upgrade to (default: latest)') + .addOption( + new Option( + '--ref ', + 'Full git tag to upgrade to, e.g. an integration family like @bigcommerce/catalyst-makeswift@1.7.0. Overrides [version].', + ), + ) + .addOption(new Option('--from ', 'Base ref to upgrade from (when catalyst.ref is missing)')) + .option('--dry-run', 'Generate and display the diff without applying it') + .option('--yes', 'Skip confirmation prompts (e.g. inferred base ref)') + .addOption( + new Option( + '--strategy ', + 'Merge engine: tree (git merge-tree, full fidelity), per-file (git merge-file, no-history fallback), or auto (tree when git >= 2.38, else per-file)', + ) + .choices(['auto', 'tree', 'per-file'] as const) + .default('auto' as const) + .hideHelp(), + ) + .addOption( + new Option('--repository ', 'GitHub repository to pull versions from').default( + DEFAULT_REPOSITORY, + ), + ) + .addHelpText( + 'after', + ` +Examples: + # Upgrade to the latest Catalyst version + $ catalyst upgrade + + # Upgrade to a specific version + $ catalyst upgrade 1.8.0 + + # Preview what would change without applying + $ catalyst upgrade --dry-run + + # Upgrade to an integration family (makeswift, b2b-makeswift, ...) + $ catalyst upgrade --ref @bigcommerce/catalyst-makeswift@1.7.0 + +Conflicts are written as standard <<>> markers — the upgrade +never aborts. Versions are git tags on the repository (not npm). Set GITHUB_TOKEN +to raise the GitHub API rate limit.`, + ) + // eslint-disable-next-line complexity + .action(async (version, options) => { + // ── 1. Resolve the merchant project (flat or nested layout) ─────────── + const project = await resolveProject(process.cwd()); + + if (!project) { + consola.error( + 'Run `catalyst upgrade` from inside a Catalyst git repository (flat core/ repo or a project containing core/).', + ); + process.exit(1); + } + + const { gitRoot, catalystRoot, relDir, pkgPath } = project; + + // ── 2. Resolve base + target refs ───────────────────────────────────── + const baseRef = await resolveBaseRef(project, options.from, options.yes ?? false); + + if (!baseRef) process.exit(1); + + let basePackage: string; + let upstreamRef: string; + let upstreamTagVersion: string; + + try { + ({ packageName: basePackage } = parseRef(baseRef)); + upstreamRef = options.ref ?? `${basePackage}@${version ?? 'latest'}`; + ({ version: upstreamTagVersion } = parseRef(upstreamRef)); + } catch { + consola.error( + `Invalid ref format — expected @ (e.g. @bigcommerce/catalyst-core@1.7.0). Got: "${baseRef}"`, + ); + process.exit(1); + } + + if (upstreamRef === baseRef) { + consola.success('Already up to date.'); + + return; + } + + // ── 3. Require a clean working tree at the catalyst root (preview exempt) ─ + // The merge writes in place, so pre-existing uncommitted edits would be + // indistinguishable from the upgrade. Committing/stashing first gives the + // merchant a clean point to diff and roll back from. + if (!options.dryRun) { + const statusArgs = relDir === '.' ? [] : ['--', relDir]; + const status = await execa('git', ['status', '--porcelain', ...statusArgs], { + cwd: gitRoot, + reject: false, + }); + + if (status.stdout.trim()) { + const where = relDir === '.' ? 'The repo' : `${relDir}/`; + + consola.error( + `${where} has uncommitted changes. Commit or stash them before upgrading, so the upgrade is isolated and reversible:\n git add ${relDir} && git commit -m "snapshot before upgrade"\n # or: git stash`, + ); + process.exit(1); + } + } + + // ── 3b. Backfill catalyst.ref when it was inferred ──────────────────── + // If catalyst.ref was absent and the user confirmed an inferred base (not + // an explicit --from), write the field and stage it so it travels with the + // upgrade commit. Runs after the clean-tree check so we only add to an + // already-clean index. --from is excluded: the user already knows the base + // and shouldn't get an extra staged change they didn't ask for. + if (!project.pkg.catalyst?.ref && !options.from && !options.dryRun) { + const { version: baseVersion } = parseRef(baseRef); + const rawPkg = await readFile(pkgPath, 'utf-8'); + const parsedPkg = z.record(z.string(), z.unknown()).parse(JSON.parse(rawPkg)); + + parsedPkg.catalyst = { version: baseVersion, ref: baseRef }; + await writeFile(pkgPath, `${JSON.stringify(parsedPkg, null, 2)}\n`); + await execa('git', ['add', '--', pkgPath], { cwd: gitRoot }); + consola.success(`Added catalyst.ref → ${baseRef}`); + } + + const token = process.env.GITHUB_TOKEN; + const tmpDir = await mkdtemp(join(tmpdir(), 'catalyst-upgrade-')); + + try { + const baseDir = join(tmpDir, 'base'); + const theirsDir = join(tmpDir, 'theirs'); + const emptyFile = join(tmpDir, '.empty'); + + await writeFile(emptyFile, ''); + + const downloadSpinner = yoctoSpinner().start(`Downloading ${baseRef} and ${upstreamRef}...`); + + try { + await Promise.all([ + downloadCore(options.repository, baseRef, baseDir, token), + downloadCore(options.repository, upstreamRef, theirsDir, token), + ]); + downloadSpinner.success('Downloaded both versions.'); + } catch (err) { + downloadSpinner.error('Download failed'); + throw err; + } + + // When the base was auto-inferred (no catalyst.ref, no --from), validate + // the guess by checking how many base files are unmodified in the project. + // A correct base scores ~70-80%+; a wrong guess (e.g. merchant manually + // bumped their version field without actually upgrading) scores much lower. + if (!project.pkg.catalyst?.ref && !options.from) { + const similarity = await computeBaseSimilarity(baseDir, catalystRoot); + const pct = Math.round(similarity * 100); + + if (similarity < 0.5) { + consola.warn( + `Low confidence in inferred base ${baseRef} (${pct}% of base files match your project). If the merge result looks off, re-run with \`--from \` to set the base explicitly.`, + ); + } + } + + // For moving tags (latest/canary/alpha) we don't know the real version + // until the tarball lands, so read it from the downloaded package.json. + // For concrete tags the version is already in the tag itself. + const resolvedVersion = MOVING_TAGS.has(upstreamTagVersion) + ? await readResolvedVersion(theirsDir) + : upstreamTagVersion; + const newRef = MOVING_TAGS.has(upstreamTagVersion) + ? `${basePackage}@${resolvedVersion}` + : upstreamRef; + + // ── 4. Dry run: show the unified diff and stop ────────────────────── + if (options.dryRun) { + const diffSpinner = yoctoSpinner().start('Generating diff...'); + const diff = await execa( + 'git', + ['diff', '--no-index', '--binary', '--diff-algorithm=histogram', 'base', 'theirs'], + { cwd: tmpDir, reject: false, stripFinalNewline: false }, + ); + + if ((diff.exitCode ?? 0) > 1) { + diffSpinner.error('Failed to generate diff'); + throw new Error(diff.stderr); + } + + if (!diff.stdout.trim()) { + diffSpinner.success('No differences between versions — already up to date.'); + + return; + } + + const fileCount = (diff.stdout.match(/^diff --git /gm) ?? []).length; + + diffSpinner.success(`Diff ready — ${fileCount} file(s) affected.`); + consola.log('\nDiff preview (--dry-run, not applied):\n'); + consola.log(diff.stdout); + + return; + } + + // ── 5. Apply via 3-way merge (whole-tree by default, per-file fallback) ─ + const strategy = await resolveStrategy(options.strategy); + const mergeSpinner = yoctoSpinner().start(`Merging changes (${strategy})...`); + const result = + strategy === 'tree' + ? await mergeCoreTree(baseDir, theirsDir, catalystRoot) + : await mergeCorePerFile(baseDir, theirsDir, catalystRoot, emptyFile); + const total = + result.applied.length + + result.added.length + + result.deleted.length + + result.conflicted.length; + + if (total === 0) { + mergeSpinner.success('No differences between versions — already up to date.'); + + return; + } + + if (result.conflicted.length) { + mergeSpinner.warning('Merged with conflicts — resolve the markers, then commit.'); + } else { + mergeSpinner.success('Merged cleanly.'); + } + + // ── 6. Stamp catalyst.ref (skip if package.json itself conflicted) ── + const patchedRaw = await readFile(pkgPath, 'utf-8'); + + let stampedPkg = false; + + try { + const patchedPkg = z.record(z.string(), z.unknown()).parse(JSON.parse(patchedRaw)); + + patchedPkg.catalyst = { version: resolvedVersion, ref: newRef }; + await writeFile(pkgPath, `${JSON.stringify(patchedPkg, null, 2)}\n`); + stampedPkg = true; + consola.success(`catalyst.ref updated → ${newRef}`); + } catch { + // package.json has conflict markers (scripts, deps, etc.). The catalyst + // field was added cleanly by ours and should sit outside the conflict + // blocks — so replace just that field in-place, leaving all other conflict + // markers intact for the merchant to resolve normally. + const catalystReplacement = JSON.stringify( + { version: resolvedVersion, ref: newRef }, + null, + 2, + ) + .split('\n') + .map((line, i) => (i === 0 ? line : ` ${line}`)) + .join('\n'); + + // Use a function replacer to prevent $-interpolation in the replacement string + // (e.g. $& or $1 in a ref/version value would silently corrupt the output). + const updated = patchedRaw.replace( + /"catalyst":\s*\{[^}]*\}/, + () => `"catalyst": ${catalystReplacement}`, + ); + + if (updated !== patchedRaw) { + await writeFile(pkgPath, updated); + // stampedPkg stays false — the file still has conflict markers in other + // sections, so it must stay as a UU unmerged entry so the editor shows + // the merge UI. Only the catalyst field was resolved in place. + consola.success(`catalyst.ref updated → ${newRef}`); + consola.info( + 'package.json still has conflicts in other sections — resolve them, then: git add package.json', + ); + } else { + consola.warn( + `package.json has conflicts — after resolving, add:\n "catalyst": { "version": "${resolvedVersion}", "ref": "${newRef}" }`, + ); + } + } + + // ── 7. Stage the clean changes; mark conflicts as real unmerged entries ─ + // Staging is a convenience; the merge already landed on disk, so never let + // a git quirk here fail the whole upgrade. + try { + await applyIndexState(gitRoot, relDir, baseDir, theirsDir, result, stampedPkg); + } catch (err) { + consola.warn( + `Couldn't auto-stage the changes (${err instanceof Error ? err.message : String(err)}). Your files are merged on disk — run \`git add ${relDir}\` yourself.`, + ); + } + + const gitVersion = await execa('git', ['--version']) + .then((r) => r.stdout.trim()) + .catch(() => 'unknown'); + + await getTelemetry().track('upgrade', { + strategy, + gitVersion, + dryRun: Boolean(options.dryRun), + applied: result.applied.length, + added: result.added.length, + deleted: result.deleted.length, + conflicts: result.conflicted.length, + hasConflicts: result.conflicted.length > 0, + }); + + printSummary(result, relDir, stampedPkg); + } finally { + await rm(tmpDir, { recursive: true, force: true, maxRetries: 10, retryDelay: 500 }); + } + }); diff --git a/packages/catalyst/src/cli/program.ts b/packages/catalyst/src/cli/program.ts index 0103375bc6..c43dbec6b5 100644 --- a/packages/catalyst/src/cli/program.ts +++ b/packages/catalyst/src/cli/program.ts @@ -17,6 +17,7 @@ import { logs } from './commands/logs'; import { project } from './commands/project'; import { start } from './commands/start'; import { telemetry } from './commands/telemetry'; +import { upgrade } from './commands/upgrade'; import { version } from './commands/version'; import { telemetryPostHook, telemetryPreHook } from './hooks/telemetry'; import { consola } from './lib/logger'; @@ -91,6 +92,7 @@ program .addCommand(env) .addCommand(channel) .addCommand(auth) + .addCommand(upgrade) .addCommand(telemetry) .hook('preAction', telemetryPreHook) .hook('postAction', telemetryPostHook);