Skip to content

Commit 52126bb

Browse files
committed
feat(cli): add hub bump command for dev-time version bumping
Adds calm hub bump <resource> <file> with five subcommands (architecture, pattern, standard, control-requirement, control-configuration). The command reads the document's $id, checks whether that exact version already exists on the hub, and if so bumps the version in $id on disk by the requested change type (patch/minor/major) without pushing. If the version is not yet published the command exits cleanly with a no-op message. Pairs with --fail-if-exists on hub push: developers run hub bump before committing so CI can push the exact committed version and fail fast if it already exists.
1 parent 231032d commit 52126bb

3 files changed

Lines changed: 441 additions & 1 deletion

File tree

cli/src/cli.ts

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,12 @@ import {
3636
runListControls,
3737
runListControlConfigurations,
3838
ListOptions,
39+
BumpOptions,
40+
runBumpArchitecture,
41+
runBumpPattern,
42+
runBumpStandard,
43+
runBumpControlRequirement,
44+
runBumpControlConfiguration,
3945
} from './command-helpers/hub-commands';
4046
import type { ResourceChangeType } from '@finos/calm-shared';
4147

@@ -570,6 +576,89 @@ Example:
570576
await runPushControlConfiguration(pushOptions);
571577
});
572578

579+
// hub bump
580+
const hubBumpCmd = hubCmd.command('bump').description('Bump the version of a CALM document on disk if the current version already exists on the hub. Intended for dev-time use before committing, so CI can push with --fail-if-exists.');
581+
582+
hubBumpCmd
583+
.command('architecture <architecture-file>')
584+
.description('Bump the version of a CALM architecture on disk if that version already exists on the hub.')
585+
.option(CALMHUB_URL_OPTION, 'URL to CALMHub instance')
586+
.addOption(hubOutputOption)
587+
.addOption(hubVersionBumpOption)
588+
.action(async (architectureFile, options) => {
589+
const bumpOptions: BumpOptions = {
590+
calmHubOptions: { calmHubUrl: options.calmHubUrl },
591+
file: architectureFile,
592+
format: options.format,
593+
changeType: options.changeType.toUpperCase() as ResourceChangeType,
594+
};
595+
await runBumpArchitecture(bumpOptions);
596+
});
597+
598+
hubBumpCmd
599+
.command('pattern <pattern-file>')
600+
.description('Bump the version of a CALM pattern on disk if that version already exists on the hub.')
601+
.option(CALMHUB_URL_OPTION, 'URL to CALMHub instance')
602+
.addOption(hubOutputOption)
603+
.addOption(hubVersionBumpOption)
604+
.action(async (patternFile, options) => {
605+
const bumpOptions: BumpOptions = {
606+
calmHubOptions: { calmHubUrl: options.calmHubUrl },
607+
file: patternFile,
608+
format: options.format,
609+
changeType: options.changeType.toUpperCase() as ResourceChangeType,
610+
};
611+
await runBumpPattern(bumpOptions);
612+
});
613+
614+
hubBumpCmd
615+
.command('standard <standard-file>')
616+
.description('Bump the version of a CALM standard on disk if that version already exists on the hub.')
617+
.option(CALMHUB_URL_OPTION, 'URL to CALMHub instance')
618+
.addOption(hubOutputOption)
619+
.addOption(hubVersionBumpOption)
620+
.action(async (standardFile, options) => {
621+
const bumpOptions: BumpOptions = {
622+
calmHubOptions: { calmHubUrl: options.calmHubUrl },
623+
file: standardFile,
624+
format: options.format,
625+
changeType: options.changeType.toUpperCase() as ResourceChangeType,
626+
};
627+
await runBumpStandard(bumpOptions);
628+
});
629+
630+
hubBumpCmd
631+
.command('control-requirement <requirement-file>')
632+
.description('Bump the version of a control requirement on disk if that version already exists on the hub.')
633+
.option(CALMHUB_URL_OPTION, 'URL to CALMHub instance')
634+
.addOption(hubOutputOption)
635+
.addOption(hubVersionBumpOption)
636+
.action(async (requirementFile, options) => {
637+
const bumpOptions: BumpOptions = {
638+
calmHubOptions: { calmHubUrl: options.calmHubUrl },
639+
file: requirementFile,
640+
format: options.format,
641+
changeType: options.changeType.toUpperCase() as ResourceChangeType,
642+
};
643+
await runBumpControlRequirement(bumpOptions);
644+
});
645+
646+
hubBumpCmd
647+
.command('control-configuration <config-file>')
648+
.description('Bump the version of a control configuration on disk if that version already exists on the hub.')
649+
.option(CALMHUB_URL_OPTION, 'URL to CALMHub instance')
650+
.addOption(hubOutputOption)
651+
.addOption(hubVersionBumpOption)
652+
.action(async (configFile, options) => {
653+
const bumpOptions: BumpOptions = {
654+
calmHubOptions: { calmHubUrl: options.calmHubUrl },
655+
file: configFile,
656+
format: options.format,
657+
changeType: options.changeType.toUpperCase() as ResourceChangeType,
658+
};
659+
await runBumpControlConfiguration(bumpOptions);
660+
});
661+
573662
// hub pull
574663
const hubPullCmd = hubCmd.command('pull').description('Pull a CALM document from CALM Hub');
575664

cli/src/command-helpers/hub-commands.spec.ts

Lines changed: 215 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@ import { runCreateNamespace, runListArchitectures, runListNamespaces,
88
runCreateDomain, runListDomains, runListControls,
99
runPushControlRequirement, runPullControlRequirement, runPushControlConfiguration, runPullControlConfiguration,
1010
printIdCreateResult,
11-
runListControlConfigurations } from './hub-commands';
11+
runListControlConfigurations,
12+
runBumpArchitecture, runBumpPattern, runBumpStandard, runBumpControlRequirement, runBumpControlConfiguration } from './hub-commands';
1213

1314
// We stub the @finos/calm-shared HTTP client so no real HTTP is made, but keep the
1415
// real (pure) document-id-utils helpers that orchestratePush relies on.
@@ -1416,5 +1417,218 @@ describe('hub-commands', () => {
14161417
});
14171418
});
14181419

1420+
// ── runBumpArchitecture ────────────────────────────────────────────────────
1421+
1422+
describe('runBumpArchitecture', () => {
1423+
it('bumps the version on disk when the current version already exists on the hub', async () => {
1424+
const { mockClient } = await getSharedMocks();
1425+
vi.mocked(mockClient.getMappedResourceVersions).mockResolvedValue(['1.0.0']);
1426+
1427+
await runBumpArchitecture({ calmHubOptions: { calmHubUrl: 'http://hub' }, file: 'arch.json', changeType: 'PATCH' });
1428+
1429+
expect(mockClient.getMappedResourceVersions).toHaveBeenCalledWith('finos', 'my-arch', 'architectures');
1430+
expect(fs.writeFile).toHaveBeenCalledWith('arch.json', expect.stringContaining('/versions/1.0.1'), 'utf-8');
1431+
expect(hubOutput.printJsonSuccess).toHaveBeenCalledWith(
1432+
expect.objectContaining({ file: 'arch.json', previousVersion: '1.0.0', newVersion: '1.0.1' })
1433+
);
1434+
});
1435+
1436+
it('bumps by minor when changeType is MINOR', async () => {
1437+
const { mockClient } = await getSharedMocks();
1438+
vi.mocked(mockClient.getMappedResourceVersions).mockResolvedValue(['1.0.0']);
1439+
1440+
await runBumpArchitecture({ calmHubOptions: { calmHubUrl: 'http://hub' }, file: 'arch.json', changeType: 'MINOR' });
1441+
1442+
expect(fs.writeFile).toHaveBeenCalledWith('arch.json', expect.stringContaining('/versions/1.1.0'), 'utf-8');
1443+
});
1444+
1445+
it('bumps by major when changeType is MAJOR', async () => {
1446+
const { mockClient } = await getSharedMocks();
1447+
vi.mocked(mockClient.getMappedResourceVersions).mockResolvedValue(['1.0.0']);
1448+
1449+
await runBumpArchitecture({ calmHubOptions: { calmHubUrl: 'http://hub' }, file: 'arch.json', changeType: 'MAJOR' });
1450+
1451+
expect(fs.writeFile).toHaveBeenCalledWith('arch.json', expect.stringContaining('/versions/2.0.0'), 'utf-8');
1452+
});
1453+
1454+
it('does not write the file and prints a no-op message when the version is not yet published', async () => {
1455+
const { mockClient } = await getSharedMocks();
1456+
vi.mocked(mockClient.getMappedResourceVersions).mockResolvedValue([]);
1457+
1458+
await runBumpArchitecture({ calmHubOptions: { calmHubUrl: 'http://hub' }, file: 'arch.json', changeType: 'PATCH' });
1459+
1460+
expect(fs.writeFile).not.toHaveBeenCalled();
1461+
expect(hubOutput.printJsonSuccess).toHaveBeenCalledWith(
1462+
expect.objectContaining({ bumped: false })
1463+
);
1464+
});
1465+
1466+
it('does not write the file when the exact on-disk version is not in the hub versions list', async () => {
1467+
const { mockClient } = await getSharedMocks();
1468+
// Hub has 2.0.0 but file has 1.0.0 — version mismatch, no bump
1469+
vi.mocked(mockClient.getMappedResourceVersions).mockResolvedValue(['2.0.0']);
1470+
1471+
await runBumpArchitecture({ calmHubOptions: { calmHubUrl: 'http://hub' }, file: 'arch.json', changeType: 'PATCH' });
1472+
1473+
expect(fs.writeFile).not.toHaveBeenCalled();
1474+
expect(hubOutput.printJsonSuccess).toHaveBeenCalledWith(expect.objectContaining({ bumped: false }));
1475+
});
1476+
1477+
it('does not push to the hub', async () => {
1478+
const { mockClient } = await getSharedMocks();
1479+
vi.mocked(mockClient.getMappedResourceVersions).mockResolvedValue(['1.0.0']);
1480+
1481+
await runBumpArchitecture({ calmHubOptions: { calmHubUrl: 'http://hub' }, file: 'arch.json', changeType: 'PATCH' });
1482+
1483+
expect(mockClient.createMappedResourceVersion).not.toHaveBeenCalled();
1484+
});
1485+
1486+
it('renders a pretty table when format is pretty', async () => {
1487+
const { mockClient } = await getSharedMocks();
1488+
vi.mocked(mockClient.getMappedResourceVersions).mockResolvedValue(['1.0.0']);
1489+
1490+
await runBumpArchitecture({ calmHubOptions: { calmHubUrl: 'http://hub' }, file: 'arch.json', changeType: 'PATCH', format: 'pretty' });
1491+
1492+
expect(hubOutput.printTableSuccess).toHaveBeenCalledWith(
1493+
[{ FILE: 'arch.json', FROM: '1.0.0', TO: '1.0.1' }],
1494+
expect.arrayContaining([expect.objectContaining({ key: 'FROM' }), expect.objectContaining({ key: 'TO' })])
1495+
);
1496+
});
1497+
1498+
it('exits on HubClientError', async () => {
1499+
const { mockClient, shared } = await getSharedMocks();
1500+
vi.mocked(mockClient.getMappedResourceVersions).mockRejectedValue(
1501+
new shared.HubClientError(503, 'Service unavailable', 'GET /calm/namespaces/finos/architectures/my-arch/versions')
1502+
);
1503+
1504+
await expect(runBumpArchitecture({ calmHubOptions: { calmHubUrl: 'http://hub' }, file: 'arch.json', changeType: 'PATCH' }))
1505+
.rejects.toThrow('process.exit');
1506+
expect(hubOutput.printError).toHaveBeenCalledWith(503, 'Service unavailable', expect.any(String), 'json');
1507+
});
1508+
});
1509+
1510+
describe('runBumpPattern', () => {
1511+
it('bumps the version on disk when the current version already exists on the hub', async () => {
1512+
const { mockClient } = await getSharedMocks();
1513+
vi.mocked(fs.readFile).mockResolvedValue(pushDoc('my-pattern', 'patterns') as unknown as Uint8Array);
1514+
vi.mocked(mockClient.getMappedResourceVersions).mockResolvedValue(['1.0.0']);
1515+
1516+
await runBumpPattern({ calmHubOptions: { calmHubUrl: 'http://hub' }, file: 'pattern.json', changeType: 'PATCH' });
1517+
1518+
expect(mockClient.getMappedResourceVersions).toHaveBeenCalledWith('finos', 'my-pattern', 'patterns');
1519+
expect(fs.writeFile).toHaveBeenCalledWith('pattern.json', expect.stringContaining('/versions/1.0.1'), 'utf-8');
1520+
});
1521+
});
1522+
1523+
describe('runBumpStandard', () => {
1524+
it('bumps the version on disk when the current version already exists on the hub', async () => {
1525+
const { mockClient } = await getSharedMocks();
1526+
vi.mocked(fs.readFile).mockResolvedValue(pushDoc('my-standard', 'standards') as unknown as Uint8Array);
1527+
vi.mocked(mockClient.getMappedResourceVersions).mockResolvedValue(['1.0.0']);
1528+
1529+
await runBumpStandard({ calmHubOptions: { calmHubUrl: 'http://hub' }, file: 'standard.json', changeType: 'PATCH' });
1530+
1531+
expect(mockClient.getMappedResourceVersions).toHaveBeenCalledWith('finos', 'my-standard', 'standards');
1532+
expect(fs.writeFile).toHaveBeenCalledWith('standard.json', expect.stringContaining('/versions/1.0.1'), 'utf-8');
1533+
});
1534+
});
1535+
1536+
// ── runBumpControlRequirement ──────────────────────────────────────────────
1537+
1538+
describe('runBumpControlRequirement', () => {
1539+
it('bumps the version on disk when the current version already exists on the hub', async () => {
1540+
const { mockClient } = await getSharedMocks();
1541+
vi.mocked(fs.readFile).mockResolvedValue(controlReqDoc() as unknown as Uint8Array);
1542+
vi.mocked(mockClient.getControlRequirementVersions).mockResolvedValue(['1.0.0']);
1543+
1544+
await runBumpControlRequirement({ calmHubOptions: { calmHubUrl: 'http://hub' }, file: 'req.json', changeType: 'PATCH' });
1545+
1546+
expect(mockClient.getControlRequirementVersions).toHaveBeenCalledWith('security', 'access-control');
1547+
expect(fs.writeFile).toHaveBeenCalledWith('req.json', expect.stringContaining('/requirement/versions/1.0.1'), 'utf-8');
1548+
expect(hubOutput.printJsonSuccess).toHaveBeenCalledWith(
1549+
expect.objectContaining({ file: 'req.json', previousVersion: '1.0.0', newVersion: '1.0.1' })
1550+
);
1551+
});
1552+
1553+
it('does not write the file when the current version is not yet published', async () => {
1554+
const { mockClient } = await getSharedMocks();
1555+
vi.mocked(fs.readFile).mockResolvedValue(controlReqDoc() as unknown as Uint8Array);
1556+
vi.mocked(mockClient.getControlRequirementVersions).mockResolvedValue([]);
1557+
1558+
await runBumpControlRequirement({ calmHubOptions: { calmHubUrl: 'http://hub' }, file: 'req.json', changeType: 'PATCH' });
1559+
1560+
expect(fs.writeFile).not.toHaveBeenCalled();
1561+
expect(hubOutput.printJsonSuccess).toHaveBeenCalledWith(expect.objectContaining({ bumped: false }));
1562+
});
1563+
1564+
it('does not push to the hub', async () => {
1565+
const { mockClient } = await getSharedMocks();
1566+
vi.mocked(fs.readFile).mockResolvedValue(controlReqDoc() as unknown as Uint8Array);
1567+
vi.mocked(mockClient.getControlRequirementVersions).mockResolvedValue(['1.0.0']);
1568+
1569+
await runBumpControlRequirement({ calmHubOptions: { calmHubUrl: 'http://hub' }, file: 'req.json', changeType: 'PATCH' });
1570+
1571+
expect(mockClient.createControlRequirementVersion).not.toHaveBeenCalled();
1572+
});
1573+
1574+
it('exits when the document $id describes a configuration, not a requirement', async () => {
1575+
vi.mocked(fs.readFile).mockResolvedValue(controlConfigDoc() as unknown as Uint8Array);
1576+
1577+
await expect(runBumpControlRequirement({ calmHubOptions: { calmHubUrl: 'http://hub' }, file: 'req.json', changeType: 'PATCH' }))
1578+
.rejects.toThrow('process.exit');
1579+
expect(hubOutput.printError).toHaveBeenCalledWith(
1580+
0, expect.stringContaining('describes a control configuration, but a control requirement was expected'), expect.any(String), 'json'
1581+
);
1582+
});
1583+
});
1584+
1585+
// ── runBumpControlConfiguration ────────────────────────────────────────────
1586+
1587+
describe('runBumpControlConfiguration', () => {
1588+
it('bumps the version on disk when the current version already exists on the hub', async () => {
1589+
const { mockClient } = await getSharedMocks();
1590+
vi.mocked(fs.readFile).mockResolvedValue(controlConfigDoc() as unknown as Uint8Array);
1591+
vi.mocked(mockClient.getControlConfigurationVersions).mockResolvedValue(['1.0.0']);
1592+
1593+
await runBumpControlConfiguration({ calmHubOptions: { calmHubUrl: 'http://hub' }, file: 'config.json', changeType: 'PATCH' });
1594+
1595+
expect(mockClient.getControlConfigurationVersions).toHaveBeenCalledWith('security', 'access-control', 'prod');
1596+
expect(fs.writeFile).toHaveBeenCalledWith('config.json', expect.stringContaining('/configurations/prod/versions/1.0.1'), 'utf-8');
1597+
expect(hubOutput.printJsonSuccess).toHaveBeenCalledWith(
1598+
expect.objectContaining({ file: 'config.json', previousVersion: '1.0.0', newVersion: '1.0.1' })
1599+
);
1600+
});
1601+
1602+
it('does not write the file when the current version is not yet published', async () => {
1603+
const { mockClient } = await getSharedMocks();
1604+
vi.mocked(fs.readFile).mockResolvedValue(controlConfigDoc() as unknown as Uint8Array);
1605+
vi.mocked(mockClient.getControlConfigurationVersions).mockResolvedValue([]);
1606+
1607+
await runBumpControlConfiguration({ calmHubOptions: { calmHubUrl: 'http://hub' }, file: 'config.json', changeType: 'PATCH' });
1608+
1609+
expect(fs.writeFile).not.toHaveBeenCalled();
1610+
expect(hubOutput.printJsonSuccess).toHaveBeenCalledWith(expect.objectContaining({ bumped: false }));
1611+
});
1612+
1613+
it('does not push to the hub', async () => {
1614+
const { mockClient } = await getSharedMocks();
1615+
vi.mocked(fs.readFile).mockResolvedValue(controlConfigDoc() as unknown as Uint8Array);
1616+
vi.mocked(mockClient.getControlConfigurationVersions).mockResolvedValue(['1.0.0']);
1617+
1618+
await runBumpControlConfiguration({ calmHubOptions: { calmHubUrl: 'http://hub' }, file: 'config.json', changeType: 'PATCH' });
1619+
1620+
expect(mockClient.createControlConfigurationVersion).not.toHaveBeenCalled();
1621+
});
1622+
1623+
it('exits when the document $id describes a requirement, not a configuration', async () => {
1624+
vi.mocked(fs.readFile).mockResolvedValue(controlReqDoc() as unknown as Uint8Array);
1625+
1626+
await expect(runBumpControlConfiguration({ calmHubOptions: { calmHubUrl: 'http://hub' }, file: 'config.json', changeType: 'PATCH' }))
1627+
.rejects.toThrow('process.exit');
1628+
expect(hubOutput.printError).toHaveBeenCalledWith(
1629+
0, expect.stringContaining('describes a control requirement, but a control configuration was expected'), expect.any(String), 'json'
1630+
);
1631+
});
1632+
});
14191633

14201634
});

0 commit comments

Comments
 (0)