Skip to content
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
11 changes: 11 additions & 0 deletions .changeset/upgrade-catalyst-dependency-migration.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"@bigcommerce/catalyst": minor
---

`catalyst upgrade` now keeps your `@bigcommerce/catalyst*` dependencies up to date.

Until now these versions never moved during an upgrade, so a project stayed pinned to whatever it was created with. The upgrade now brings them to the versions that shipped with the release you're upgrading to, handled like any other change: applied automatically, or flagged as a conflict if you'd pinned one on purpose.

If your project still references these packages with `workspace:^`, the upgrade offers to swap them for published versions so your package manager can keep them current from then on. Declining, running without a terminal, or using `--dry-run` changes nothing; `--yes` accepts.

Two new reminders round it out: run an install when the upgrade touched your `package.json`, and update `@bigcommerce/catalyst` itself when a newer version is out.
69 changes: 69 additions & 0 deletions packages/catalyst/src/cli/commands/upgrade.action.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
* Downloads use the shared CLI cache so subsequent runs are offline.
*/

import { confirm } from '@inquirer/prompts';
import { execa } from 'execa';
import { cp, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
Expand All @@ -16,6 +17,7 @@ vi.setConfig({ hookTimeout: 60_000 });

import { downloadCore, parseRef, upgrade } from './upgrade';

vi.mock('@inquirer/prompts', () => ({ confirm: vi.fn() }));
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() },
Expand All @@ -34,6 +36,10 @@ const TIMEOUT = 120_000;

const createdDirs: string[] = [];
let originalCwd: string;
// vitest.setup.ts pins stdin to non-interactive; opt back in for the branches
// that are TTY-gated, and reset in afterEach.
const setTty = (value: boolean) =>
Object.defineProperty(process.stdin, 'isTTY', { value, configurable: true });

// JSON.parse returns `any`; centralise the unsafe-return suppression here.
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
Expand All @@ -53,6 +59,8 @@ beforeEach(() => {

afterEach(async () => {
process.chdir(originalCwd);
setTty(false);
vi.mocked(confirm).mockReset();
vi.restoreAllMocks();
await Promise.all(
createdDirs
Expand Down Expand Up @@ -341,3 +349,64 @@ test(
},
TIMEOUT,
);

// ── workspace-protocol dependency migration ──────────────────────────────────
// A project seeded straight from the tarball still carries `workspace:^`, which
// is exactly the monorepo-structure case: `catalyst create` never rewrote it.

const CLIENT = '@bigcommerce/catalyst-client';

const readDeps = async (projectDir: string): Promise<Record<string, string>> => {
const pkg = parseJson(await readFile(join(projectDir, 'package.json'), 'utf-8'));

// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
return pkg.dependencies as Record<string, string>;
};

// The gate is `stampedPkg && (--yes || (isTTY && confirm))`; these four rows walk it.
test.each([
['accepting the prompt migrates', [TARGET_VERSION], true, true, '^1.0.2'],
['declining the prompt keeps workspace:', [TARGET_VERSION], true, false, 'workspace:^'],
['--yes migrates without prompting', [TARGET_VERSION, '--yes'], true, undefined, '^1.0.2'],
['a non-interactive run keeps workspace:', [TARGET_VERSION], false, undefined, 'workspace:^'],
] as const)(
'%s',
async (_name, argv, tty, confirmValue, expected) => {
const root = await mkTmp();
const projectDir = await setup163Project(root);

expect((await readDeps(projectDir))[CLIENT]).toBe('workspace:^');

setTty(tty);

if (confirmValue !== undefined) vi.mocked(confirm).mockResolvedValue(confirmValue);

process.chdir(projectDir);
await upgrade.parseAsync([...argv], { from: 'user' });

expect((await readDeps(projectDir))[CLIENT]).toBe(expected);
expect(confirm).toHaveBeenCalledTimes(confirmValue === undefined ? 0 : 1);
},
TIMEOUT,
);

test(
'--dry-run reports the workspace: references without rewriting them',
async () => {
const root = await mkTmp();
const projectDir = await setup163Project(root);

setTty(true);

process.chdir(projectDir);
await upgrade.parseAsync([TARGET_VERSION, '--dry-run'], { from: 'user' });

expect(confirm).not.toHaveBeenCalled();
expect((await readDeps(projectDir))[CLIENT]).toBe('workspace:^');

const status = (await execa('git', ['status', '--porcelain'], { cwd: projectDir })).stdout;

expect(status.trim()).toBe('');
},
TIMEOUT,
);
64 changes: 64 additions & 0 deletions packages/catalyst/src/cli/commands/upgrade.integration.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,13 @@ import {
downloadCore,
mergeCorePerFile,
mergeCoreTree,
normalizeWorkspaceDeps,
resolveProject,
rewriteWorkspaceSpecifier,
} from './upgrade';

const CLIENT = '@bigcommerce/catalyst-client';

const SUPPORTS_TREE = (() => {
try {
const match = /(\d+)\.(\d+)/.exec(execSync('git --version').toString());
Expand Down Expand Up @@ -98,6 +102,66 @@ describe.each(engines)('integration (engine: %s)', (engine) => {
? mergeCoreTree(baseDir, theirsDir, oursDir)
: mergeCorePerFile(baseDir, theirsDir, oursDir, emptyFile);

test(
'bumps a Catalyst dependency across the real 1.6.3 → 1.7.0 tags',
async () => {
const root = await mkTmp();
const baseDir = join(root, 'base');
const theirsDir = join(root, 'theirs');
const [baseVersions, targetVersions] = await Promise.all([
downloadCore(REPO, BASE_REF, baseDir),
downloadCore(REPO, TARGET_REF, theirsDir),
]);

// The published client really did move between these two tags — the whole
// reason the dependency needs reconciling at all.
expect(baseVersions[CLIENT]).toBe('1.0.1');
expect(targetVersions[CLIENT]).toBe('1.0.2');

// Both tags still carry the workspace specifier in their committed tree,
// which is why an unassisted merge leaves the merchant's version frozen.
expect(await readFile(join(baseDir, 'package.json'), 'utf-8')).toContain(
`"${CLIENT}": "workspace:^"`,
);

// A flat project: `catalyst create` resolved the specifier at scaffold
// time, so the merchant holds a real range.
const oursDir = join(root, 'project');

await cp(baseDir, oursDir, { recursive: true });

const pkgPath = join(oursDir, 'package.json');

await writeFile(
pkgPath,
rewriteWorkspaceSpecifier(await readFile(pkgPath, 'utf-8'), CLIENT, '1.0.1'),
);
await initGitProject(oursDir);

const { workspace, bumped } = await normalizeWorkspaceDeps(
await readFile(pkgPath, 'utf-8'),
baseDir,
theirsDir,
baseVersions,
targetVersions,
);

expect(bumped).toContain(CLIENT);
// The project isn't on the workspace protocol, so there's nothing to migrate.
expect(workspace.map((finding) => finding.name)).not.toContain(CLIENT);

const emptyFile = join(root, '.empty');

await writeFile(emptyFile, '');

const result = await runMerge(baseDir, theirsDir, oursDir, emptyFile);

expect(result.conflicted).not.toContain('package.json');
expect(await readFile(pkgPath, 'utf-8')).toContain(`"${CLIENT}": "^1.0.2"`);
},
TIMEOUT,
);

test(
'clean project upgrades without conflicts and all changes staged',
async () => {
Expand Down
Loading
Loading