diff --git a/cli/src/cli.ts b/cli/src/cli.ts index 82102bbe9..c64e24088 100644 --- a/cli/src/cli.ts +++ b/cli/src/cli.ts @@ -36,6 +36,12 @@ import { runListControls, runListControlConfigurations, ListOptions, + BumpOptions, + runBumpArchitecture, + runBumpPattern, + runBumpStandard, + runBumpControlRequirement, + runBumpControlConfiguration, } from './command-helpers/hub-commands'; import type { ResourceChangeType } from '@finos/calm-shared'; @@ -474,6 +480,7 @@ Example: .option(NAME_OPTION, 'Name for the architecture in CALM Hub; overrides `title` field if set.') .option(DESCRIPTION_OPTION, 'Description for the architecture; overrides `description` field if set.') .option(CALMHUB_URL_OPTION, 'URL to CALMHub instance') + .option('--fail-if-exists', 'Push the exact version from $id without auto-bumping; fail if that version already exists on the hub.') .addOption(hubOutputOption) .addOption(hubVersionBumpOption) .action(async (architectureFile, options) => { @@ -483,7 +490,8 @@ Example: description: options.description, file: architectureFile, format: options.format, - changeType: options.changeType.toUpperCase() as ResourceChangeType + changeType: options.changeType.toUpperCase() as ResourceChangeType, + failIfExists: options.failIfExists ?? false, }; await runPushArchitecture(pushOptions); }); @@ -494,6 +502,7 @@ Example: .option(NAME_OPTION, 'Name for the pattern in CALM Hub; overrides `title` field if set.') .option(DESCRIPTION_OPTION, 'Description for the pattern') .option(CALMHUB_URL_OPTION, 'URL to CALMHub instance') + .option('--fail-if-exists', 'Push the exact version from $id without auto-bumping; fail if that version already exists on the hub.') .addOption(hubOutputOption) .addOption(hubVersionBumpOption) .action(async (patternFile, options) => { @@ -504,6 +513,7 @@ Example: file: patternFile, format: options.format, changeType: options.changeType.toUpperCase() as ResourceChangeType, + failIfExists: options.failIfExists ?? false, }; await runPushPattern(pushPatternOptions); }); @@ -514,6 +524,7 @@ Example: .option(NAME_OPTION, 'Name for the standard in CALM Hub') .option(DESCRIPTION_OPTION, 'Description for the standard') .option(CALMHUB_URL_OPTION, 'URL to CALMHub instance') + .option('--fail-if-exists', 'Push the exact version from $id without auto-bumping; fail if that version already exists on the hub.') .addOption(hubOutputOption) .addOption(hubVersionBumpOption) .action(async (standardFile, options) => { @@ -524,6 +535,7 @@ Example: file: standardFile, format: options.format, changeType: options.changeType.toUpperCase() as ResourceChangeType, + failIfExists: options.failIfExists ?? false, }; await runPushStandard(pushStandardOptions); }); @@ -532,6 +544,7 @@ Example: .command('control-requirement ') .description('Push a control requirement version to CALM Hub. $id of document must contain a full control requirement document ID including domain, control name and version.') .option(CALMHUB_URL_OPTION, 'URL to CALMHub instance') + .option('--fail-if-exists', 'Push the exact version from $id without auto-bumping; fail if that version already exists on the hub.') .addOption(hubOutputOption) .addOption(hubVersionBumpOption) .action(async (requirementFile, options) => { @@ -539,7 +552,8 @@ Example: calmHubOptions: { calmHubUrl: options.calmHubUrl }, file: requirementFile, format: options.format, - changeType: options.changeType.toUpperCase() as ResourceChangeType + changeType: options.changeType.toUpperCase() as ResourceChangeType, + failIfExists: options.failIfExists ?? false, }; await runPushControlRequirement(pushOptions); }); @@ -548,6 +562,7 @@ Example: .command('control-configuration ') .description('Push a control configuration version to CALM Hub. $id of document must contain a full control configuration document ID including domain, control name, configuration name and version.') .option(CALMHUB_URL_OPTION, 'URL to CALMHub instance') + .option('--fail-if-exists', 'Push the exact version from $id without auto-bumping; fail if that version already exists on the hub.') .addOption(hubOutputOption) .addOption(hubVersionBumpOption) .action(async (configFile, options) => { @@ -555,11 +570,95 @@ Example: calmHubOptions: { calmHubUrl: options.calmHubUrl }, file: configFile, format: options.format, - changeType: options.changeType.toUpperCase() as ResourceChangeType + changeType: options.changeType.toUpperCase() as ResourceChangeType, + failIfExists: options.failIfExists ?? false, }; await runPushControlConfiguration(pushOptions); }); + // hub bump + 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.'); + + hubBumpCmd + .command('architecture ') + .description('Bump the version of a CALM architecture on disk if that version already exists on the hub.') + .option(CALMHUB_URL_OPTION, 'URL to CALMHub instance') + .addOption(hubOutputOption) + .addOption(hubVersionBumpOption) + .action(async (architectureFile, options) => { + const bumpOptions: BumpOptions = { + calmHubOptions: { calmHubUrl: options.calmHubUrl }, + file: architectureFile, + format: options.format, + changeType: options.changeType.toUpperCase() as ResourceChangeType, + }; + await runBumpArchitecture(bumpOptions); + }); + + hubBumpCmd + .command('pattern ') + .description('Bump the version of a CALM pattern on disk if that version already exists on the hub.') + .option(CALMHUB_URL_OPTION, 'URL to CALMHub instance') + .addOption(hubOutputOption) + .addOption(hubVersionBumpOption) + .action(async (patternFile, options) => { + const bumpOptions: BumpOptions = { + calmHubOptions: { calmHubUrl: options.calmHubUrl }, + file: patternFile, + format: options.format, + changeType: options.changeType.toUpperCase() as ResourceChangeType, + }; + await runBumpPattern(bumpOptions); + }); + + hubBumpCmd + .command('standard ') + .description('Bump the version of a CALM standard on disk if that version already exists on the hub.') + .option(CALMHUB_URL_OPTION, 'URL to CALMHub instance') + .addOption(hubOutputOption) + .addOption(hubVersionBumpOption) + .action(async (standardFile, options) => { + const bumpOptions: BumpOptions = { + calmHubOptions: { calmHubUrl: options.calmHubUrl }, + file: standardFile, + format: options.format, + changeType: options.changeType.toUpperCase() as ResourceChangeType, + }; + await runBumpStandard(bumpOptions); + }); + + hubBumpCmd + .command('control-requirement ') + .description('Bump the version of a control requirement on disk if that version already exists on the hub.') + .option(CALMHUB_URL_OPTION, 'URL to CALMHub instance') + .addOption(hubOutputOption) + .addOption(hubVersionBumpOption) + .action(async (requirementFile, options) => { + const bumpOptions: BumpOptions = { + calmHubOptions: { calmHubUrl: options.calmHubUrl }, + file: requirementFile, + format: options.format, + changeType: options.changeType.toUpperCase() as ResourceChangeType, + }; + await runBumpControlRequirement(bumpOptions); + }); + + hubBumpCmd + .command('control-configuration ') + .description('Bump the version of a control configuration on disk if that version already exists on the hub.') + .option(CALMHUB_URL_OPTION, 'URL to CALMHub instance') + .addOption(hubOutputOption) + .addOption(hubVersionBumpOption) + .action(async (configFile, options) => { + const bumpOptions: BumpOptions = { + calmHubOptions: { calmHubUrl: options.calmHubUrl }, + file: configFile, + format: options.format, + changeType: options.changeType.toUpperCase() as ResourceChangeType, + }; + await runBumpControlConfiguration(bumpOptions); + }); + // hub pull const hubPullCmd = hubCmd.command('pull').description('Pull a CALM document from CALM Hub'); diff --git a/cli/src/command-helpers/hub-commands.spec.ts b/cli/src/command-helpers/hub-commands.spec.ts index 753645ef2..b21b33fe4 100644 --- a/cli/src/command-helpers/hub-commands.spec.ts +++ b/cli/src/command-helpers/hub-commands.spec.ts @@ -8,7 +8,8 @@ import { runCreateNamespace, runListArchitectures, runListNamespaces, runCreateDomain, runListDomains, runListControls, runPushControlRequirement, runPullControlRequirement, runPushControlConfiguration, runPullControlConfiguration, printIdCreateResult, - runListControlConfigurations } from './hub-commands'; + runListControlConfigurations, + runBumpArchitecture, runBumpPattern, runBumpStandard, runBumpControlRequirement, runBumpControlConfiguration } from './hub-commands'; // We stub the @finos/calm-shared HTTP client so no real HTTP is made, but keep the // real (pure) document-id-utils helpers that orchestratePush relies on. @@ -1339,4 +1340,295 @@ describe('hub-commands', () => { }); }); + // ── --fail-if-exists on push commands ────────────────────────────────────── + + describe('runPushArchitecture with failIfExists', () => { + it('skips getMappedResourceVersions and pushes the exact version from $id', async () => { + const { mockClient } = await getSharedMocks(); + vi.mocked(mockClient.createMappedResourceVersion).mockResolvedValue( + 'http://hub/calm/namespaces/finos/architectures/my-arch/versions/1.0.0' + ); + + await runPushArchitecture({ + calmHubOptions: { calmHubUrl: 'http://hub' }, + file: 'arch.json', + failIfExists: true, + }); + + expect(mockClient.getMappedResourceVersions).not.toHaveBeenCalled(); + expect(mockClient.createMappedResourceVersion).toHaveBeenCalledWith( + expect.objectContaining({ version: '1.0.0' }), + expect.any(String) + ); + }); + + it('fails with 409 when the version already exists on the hub', async () => { + const { mockClient, shared } = await getSharedMocks(); + vi.mocked(mockClient.createMappedResourceVersion).mockRejectedValue( + new shared.HubClientError(409, 'Version already exists', 'POST /calm/namespaces/finos/architectures/my-arch/versions/1.0.0') + ); + + await expect(runPushArchitecture({ + calmHubOptions: { calmHubUrl: 'http://hub' }, + file: 'arch.json', + failIfExists: true, + })).rejects.toThrow('process.exit'); + + expect(hubOutput.printError).toHaveBeenCalledWith(409, 'Version already exists', expect.any(String), 'json'); + }); + }); + + describe('runPushControlRequirement with failIfExists', () => { + it('skips getControlRequirementVersions and pushes the exact version from $id', async () => { + const { mockClient } = await getSharedMocks(); + vi.mocked(fs.readFile).mockResolvedValue(controlReqDoc() as unknown as Uint8Array); + vi.mocked(mockClient.createControlRequirementVersion).mockResolvedValue(reqId('1.0.0')); + + await runPushControlRequirement({ calmHubOptions: { calmHubUrl: 'http://hub' }, file: 'req.json', failIfExists: true }); + + expect(mockClient.getControlRequirementVersions).not.toHaveBeenCalled(); + expect(mockClient.createControlRequirementVersion).toHaveBeenCalledWith('security', 'access-control', '1.0.0', expect.any(String)); + }); + + it('fails with 409 when the version already exists on the hub', async () => { + const { mockClient, shared } = await getSharedMocks(); + vi.mocked(fs.readFile).mockResolvedValue(controlReqDoc() as unknown as Uint8Array); + vi.mocked(mockClient.createControlRequirementVersion).mockRejectedValue( + new shared.HubClientError(409, 'Version already exists', 'POST /calm/domains/security/controls/access-control/requirement/versions/1.0.0') + ); + + await expect(runPushControlRequirement({ calmHubOptions: { calmHubUrl: 'http://hub' }, file: 'req.json', failIfExists: true })) + .rejects.toThrow('process.exit'); + + expect(hubOutput.printError).toHaveBeenCalledWith(409, 'Version already exists', expect.any(String), 'json'); + }); + }); + + describe('runPushControlConfiguration with failIfExists', () => { + it('skips getControlConfigurationVersions and pushes the exact version from $id', async () => { + const { mockClient } = await getSharedMocks(); + vi.mocked(fs.readFile).mockResolvedValue(controlConfigDoc() as unknown as Uint8Array); + vi.mocked(mockClient.createControlConfigurationVersion).mockResolvedValue(cfgId('1.0.0')); + + await runPushControlConfiguration({ calmHubOptions: { calmHubUrl: 'http://hub' }, file: 'config.json', failIfExists: true }); + + expect(mockClient.getControlConfigurationVersions).not.toHaveBeenCalled(); + expect(mockClient.createControlConfigurationVersion).toHaveBeenCalledWith('security', 'access-control', 'prod', '1.0.0', expect.any(String)); + }); + }); + + // ── runBumpArchitecture ──────────────────────────────────────────────────── + + describe('runBumpArchitecture', () => { + it('bumps the version on disk when the current version already exists on the hub', async () => { + const { mockClient } = await getSharedMocks(); + vi.mocked(mockClient.getMappedResourceVersions).mockResolvedValue(['1.0.0']); + + await runBumpArchitecture({ calmHubOptions: { calmHubUrl: 'http://hub' }, file: 'arch.json', changeType: 'PATCH' }); + + expect(mockClient.getMappedResourceVersions).toHaveBeenCalledWith('finos', 'my-arch', 'architectures'); + expect(fs.writeFile).toHaveBeenCalledWith('arch.json', expect.stringContaining('/versions/1.0.1'), 'utf-8'); + expect(hubOutput.printJsonSuccess).toHaveBeenCalledWith( + expect.objectContaining({ file: 'arch.json', previousVersion: '1.0.0', newVersion: '1.0.1' }) + ); + }); + + it('bumps by minor when changeType is MINOR', async () => { + const { mockClient } = await getSharedMocks(); + vi.mocked(mockClient.getMappedResourceVersions).mockResolvedValue(['1.0.0']); + + await runBumpArchitecture({ calmHubOptions: { calmHubUrl: 'http://hub' }, file: 'arch.json', changeType: 'MINOR' }); + + expect(fs.writeFile).toHaveBeenCalledWith('arch.json', expect.stringContaining('/versions/1.1.0'), 'utf-8'); + }); + + it('bumps by major when changeType is MAJOR', async () => { + const { mockClient } = await getSharedMocks(); + vi.mocked(mockClient.getMappedResourceVersions).mockResolvedValue(['1.0.0']); + + await runBumpArchitecture({ calmHubOptions: { calmHubUrl: 'http://hub' }, file: 'arch.json', changeType: 'MAJOR' }); + + expect(fs.writeFile).toHaveBeenCalledWith('arch.json', expect.stringContaining('/versions/2.0.0'), 'utf-8'); + }); + + it('does not write the file and prints a no-op message when the version is not yet published', async () => { + const { mockClient } = await getSharedMocks(); + vi.mocked(mockClient.getMappedResourceVersions).mockResolvedValue([]); + + await runBumpArchitecture({ calmHubOptions: { calmHubUrl: 'http://hub' }, file: 'arch.json', changeType: 'PATCH' }); + + expect(fs.writeFile).not.toHaveBeenCalled(); + expect(hubOutput.printJsonSuccess).toHaveBeenCalledWith( + expect.objectContaining({ bumped: false }) + ); + }); + + it('does not write the file when the exact on-disk version is not in the hub versions list', async () => { + const { mockClient } = await getSharedMocks(); + // Hub has 2.0.0 but file has 1.0.0 — version mismatch, no bump + vi.mocked(mockClient.getMappedResourceVersions).mockResolvedValue(['2.0.0']); + + await runBumpArchitecture({ calmHubOptions: { calmHubUrl: 'http://hub' }, file: 'arch.json', changeType: 'PATCH' }); + + expect(fs.writeFile).not.toHaveBeenCalled(); + expect(hubOutput.printJsonSuccess).toHaveBeenCalledWith(expect.objectContaining({ bumped: false })); + }); + + it('does not push to the hub', async () => { + const { mockClient } = await getSharedMocks(); + vi.mocked(mockClient.getMappedResourceVersions).mockResolvedValue(['1.0.0']); + + await runBumpArchitecture({ calmHubOptions: { calmHubUrl: 'http://hub' }, file: 'arch.json', changeType: 'PATCH' }); + + expect(mockClient.createMappedResourceVersion).not.toHaveBeenCalled(); + }); + + it('renders a pretty table when format is pretty', async () => { + const { mockClient } = await getSharedMocks(); + vi.mocked(mockClient.getMappedResourceVersions).mockResolvedValue(['1.0.0']); + + await runBumpArchitecture({ calmHubOptions: { calmHubUrl: 'http://hub' }, file: 'arch.json', changeType: 'PATCH', format: 'pretty' }); + + expect(hubOutput.printTableSuccess).toHaveBeenCalledWith( + [{ FILE: 'arch.json', FROM: '1.0.0', TO: '1.0.1' }], + expect.arrayContaining([expect.objectContaining({ key: 'FROM' }), expect.objectContaining({ key: 'TO' })]) + ); + }); + + it('exits on HubClientError', async () => { + const { mockClient, shared } = await getSharedMocks(); + vi.mocked(mockClient.getMappedResourceVersions).mockRejectedValue( + new shared.HubClientError(503, 'Service unavailable', 'GET /calm/namespaces/finos/architectures/my-arch/versions') + ); + + await expect(runBumpArchitecture({ calmHubOptions: { calmHubUrl: 'http://hub' }, file: 'arch.json', changeType: 'PATCH' })) + .rejects.toThrow('process.exit'); + expect(hubOutput.printError).toHaveBeenCalledWith(503, 'Service unavailable', expect.any(String), 'json'); + }); + }); + + describe('runBumpPattern', () => { + it('bumps the version on disk when the current version already exists on the hub', async () => { + const { mockClient } = await getSharedMocks(); + vi.mocked(fs.readFile).mockResolvedValue(pushDoc('my-pattern', 'patterns') as unknown as Uint8Array); + vi.mocked(mockClient.getMappedResourceVersions).mockResolvedValue(['1.0.0']); + + await runBumpPattern({ calmHubOptions: { calmHubUrl: 'http://hub' }, file: 'pattern.json', changeType: 'PATCH' }); + + expect(mockClient.getMappedResourceVersions).toHaveBeenCalledWith('finos', 'my-pattern', 'patterns'); + expect(fs.writeFile).toHaveBeenCalledWith('pattern.json', expect.stringContaining('/versions/1.0.1'), 'utf-8'); + }); + }); + + describe('runBumpStandard', () => { + it('bumps the version on disk when the current version already exists on the hub', async () => { + const { mockClient } = await getSharedMocks(); + vi.mocked(fs.readFile).mockResolvedValue(pushDoc('my-standard', 'standards') as unknown as Uint8Array); + vi.mocked(mockClient.getMappedResourceVersions).mockResolvedValue(['1.0.0']); + + await runBumpStandard({ calmHubOptions: { calmHubUrl: 'http://hub' }, file: 'standard.json', changeType: 'PATCH' }); + + expect(mockClient.getMappedResourceVersions).toHaveBeenCalledWith('finos', 'my-standard', 'standards'); + expect(fs.writeFile).toHaveBeenCalledWith('standard.json', expect.stringContaining('/versions/1.0.1'), 'utf-8'); + }); + }); + + // ── runBumpControlRequirement ────────────────────────────────────────────── + + describe('runBumpControlRequirement', () => { + it('bumps the version on disk when the current version already exists on the hub', async () => { + const { mockClient } = await getSharedMocks(); + vi.mocked(fs.readFile).mockResolvedValue(controlReqDoc() as unknown as Uint8Array); + vi.mocked(mockClient.getControlRequirementVersions).mockResolvedValue(['1.0.0']); + + await runBumpControlRequirement({ calmHubOptions: { calmHubUrl: 'http://hub' }, file: 'req.json', changeType: 'PATCH' }); + + expect(mockClient.getControlRequirementVersions).toHaveBeenCalledWith('security', 'access-control'); + expect(fs.writeFile).toHaveBeenCalledWith('req.json', expect.stringContaining('/requirement/versions/1.0.1'), 'utf-8'); + expect(hubOutput.printJsonSuccess).toHaveBeenCalledWith( + expect.objectContaining({ file: 'req.json', previousVersion: '1.0.0', newVersion: '1.0.1' }) + ); + }); + + it('does not write the file when the current version is not yet published', async () => { + const { mockClient } = await getSharedMocks(); + vi.mocked(fs.readFile).mockResolvedValue(controlReqDoc() as unknown as Uint8Array); + vi.mocked(mockClient.getControlRequirementVersions).mockResolvedValue([]); + + await runBumpControlRequirement({ calmHubOptions: { calmHubUrl: 'http://hub' }, file: 'req.json', changeType: 'PATCH' }); + + expect(fs.writeFile).not.toHaveBeenCalled(); + expect(hubOutput.printJsonSuccess).toHaveBeenCalledWith(expect.objectContaining({ bumped: false })); + }); + + it('does not push to the hub', async () => { + const { mockClient } = await getSharedMocks(); + vi.mocked(fs.readFile).mockResolvedValue(controlReqDoc() as unknown as Uint8Array); + vi.mocked(mockClient.getControlRequirementVersions).mockResolvedValue(['1.0.0']); + + await runBumpControlRequirement({ calmHubOptions: { calmHubUrl: 'http://hub' }, file: 'req.json', changeType: 'PATCH' }); + + expect(mockClient.createControlRequirementVersion).not.toHaveBeenCalled(); + }); + + it('exits when the document $id describes a configuration, not a requirement', async () => { + vi.mocked(fs.readFile).mockResolvedValue(controlConfigDoc() as unknown as Uint8Array); + + await expect(runBumpControlRequirement({ calmHubOptions: { calmHubUrl: 'http://hub' }, file: 'req.json', changeType: 'PATCH' })) + .rejects.toThrow('process.exit'); + expect(hubOutput.printError).toHaveBeenCalledWith( + 0, expect.stringContaining('describes a control configuration, but a control requirement was expected'), expect.any(String), 'json' + ); + }); + }); + + // ── runBumpControlConfiguration ──────────────────────────────────────────── + + describe('runBumpControlConfiguration', () => { + it('bumps the version on disk when the current version already exists on the hub', async () => { + const { mockClient } = await getSharedMocks(); + vi.mocked(fs.readFile).mockResolvedValue(controlConfigDoc() as unknown as Uint8Array); + vi.mocked(mockClient.getControlConfigurationVersions).mockResolvedValue(['1.0.0']); + + await runBumpControlConfiguration({ calmHubOptions: { calmHubUrl: 'http://hub' }, file: 'config.json', changeType: 'PATCH' }); + + expect(mockClient.getControlConfigurationVersions).toHaveBeenCalledWith('security', 'access-control', 'prod'); + expect(fs.writeFile).toHaveBeenCalledWith('config.json', expect.stringContaining('/configurations/prod/versions/1.0.1'), 'utf-8'); + expect(hubOutput.printJsonSuccess).toHaveBeenCalledWith( + expect.objectContaining({ file: 'config.json', previousVersion: '1.0.0', newVersion: '1.0.1' }) + ); + }); + + it('does not write the file when the current version is not yet published', async () => { + const { mockClient } = await getSharedMocks(); + vi.mocked(fs.readFile).mockResolvedValue(controlConfigDoc() as unknown as Uint8Array); + vi.mocked(mockClient.getControlConfigurationVersions).mockResolvedValue([]); + + await runBumpControlConfiguration({ calmHubOptions: { calmHubUrl: 'http://hub' }, file: 'config.json', changeType: 'PATCH' }); + + expect(fs.writeFile).not.toHaveBeenCalled(); + expect(hubOutput.printJsonSuccess).toHaveBeenCalledWith(expect.objectContaining({ bumped: false })); + }); + + it('does not push to the hub', async () => { + const { mockClient } = await getSharedMocks(); + vi.mocked(fs.readFile).mockResolvedValue(controlConfigDoc() as unknown as Uint8Array); + vi.mocked(mockClient.getControlConfigurationVersions).mockResolvedValue(['1.0.0']); + + await runBumpControlConfiguration({ calmHubOptions: { calmHubUrl: 'http://hub' }, file: 'config.json', changeType: 'PATCH' }); + + expect(mockClient.createControlConfigurationVersion).not.toHaveBeenCalled(); + }); + + it('exits when the document $id describes a requirement, not a configuration', async () => { + vi.mocked(fs.readFile).mockResolvedValue(controlReqDoc() as unknown as Uint8Array); + + await expect(runBumpControlConfiguration({ calmHubOptions: { calmHubUrl: 'http://hub' }, file: 'config.json', changeType: 'PATCH' })) + .rejects.toThrow('process.exit'); + expect(hubOutput.printError).toHaveBeenCalledWith( + 0, expect.stringContaining('describes a control requirement, but a control configuration was expected'), expect.any(String), 'json' + ); + }); + }); + }); diff --git a/cli/src/command-helpers/hub-commands.ts b/cli/src/command-helpers/hub-commands.ts index 334693419..9cfc6b072 100644 --- a/cli/src/command-helpers/hub-commands.ts +++ b/cli/src/command-helpers/hub-commands.ts @@ -58,6 +58,7 @@ export interface PushOptions { version?: string; format?: string; changeType?: ResourceChangeType; + failIfExists?: boolean; } export interface PushResult { @@ -183,16 +184,22 @@ export async function pushDocument( const name = options.name ?? metadata.name; const description = options.description ?? metadata.description ?? ''; - // const resourceTypeString: string = resourceType; - const mappedResourceVersions = await client.getMappedResourceVersions(namespace, mapping, resourceType); - const mappingExists = mappedResourceVersions.length > 0; - // override name/description if set const newDocumentMetadata = { ...metadata, name, description }; + + if (options.failIfExists) { + fileContent = updateDocumentMetadata(fileContent, newDocumentMetadata); + await client.createMappedResourceVersion(newDocumentMetadata, fileContent); + return newDocumentMetadata; + } + + const mappedResourceVersions = await client.getMappedResourceVersions(namespace, mapping, resourceType); + const mappingExists = mappedResourceVersions.length > 0; + if (mappingExists) { // Sort defensively so the highest version is last, regardless of the order Hub returns them in. const sortedVersions = sortSemVer(mappedResourceVersions); @@ -603,6 +610,7 @@ export interface PushControlOptions { file: string; format?: string; changeType?: ResourceChangeType; + failIfExists?: boolean; } export interface ControlPushResult { @@ -673,14 +681,19 @@ async function orchestrateControlPush(options: PushControlOptions, kind: Control const changeType = options.changeType ?? 'PATCH'; try { - const existingVersions = kind === 'configuration' - ? await client.getControlConfigurationVersions(metadata.domain, metadata.controlName, metadata.configName!) - : await client.getControlRequirementVersions(metadata.domain, metadata.controlName); - - let newVersion = '1.0.0'; - if (existingVersions.length > 0) { - const sorted = sortSemVer(existingVersions); - newVersion = computeSemVerBump(sorted[sorted.length - 1], changeType); + let newVersion: string; + if (options.failIfExists) { + newVersion = metadata.version; + } else { + const existingVersions = kind === 'configuration' + ? await client.getControlConfigurationVersions(metadata.domain, metadata.controlName, metadata.configName!) + : await client.getControlRequirementVersions(metadata.domain, metadata.controlName); + + newVersion = '1.0.0'; + if (existingVersions.length > 0) { + const sorted = sortSemVer(existingVersions); + newVersion = computeSemVerBump(sorted[sorted.length - 1], changeType); + } } const newMetadata: ControlDocumentMetadata = { ...metadata, version: newVersion }; @@ -720,6 +733,143 @@ export async function runPushControlConfiguration(options: PushControlOptions): return orchestrateControlPush(options, 'configuration'); } +// ── bump documents ───────────────────────────────────────────────────────────── + +export interface BumpOptions { + calmHubOptions: CalmHubOptions; + file: string; + changeType: ResourceChangeType; + format?: string; +} + +export interface BumpResult { + file: string; + previousVersion: string; + newVersion: string; +} + +function printBumpResult(result: BumpResult, format: OutputFormat): void { + if (format === 'pretty') { + printTableSuccess( + [{ FILE: result.file, FROM: result.previousVersion, TO: result.newVersion }], + [ + { key: 'FILE', header: 'FILE' }, + { key: 'FROM', header: 'FROM' }, + { key: 'TO', header: 'TO' } + ] + ); + } else { + printJsonSuccess(result); + } +} + +/** + * Checks whether the version in the document's $id already exists on the hub. + * If it does, bumps the version in the $id on disk without pushing. + * Intended for dev-time use before committing, so CI can push with --fail-if-exists. + */ +export async function orchestrateBump(options: BumpOptions, resourceType: ResourceType): Promise { + const format: OutputFormat = parseOutputFormat(options.format); + const calmHubOptions = await handleOptionsLoadError(options.calmHubOptions, format); + const client = new CalmHubClient(calmHubOptions); + + const requestedCommand = `bump ${resourceType} ${options.file}`; + let fileContent = await loadFileContent(options.file, requestedCommand, format); + fileContent = validateAndMinifyJSON(fileContent, options.file, requestedCommand, format); + + const metadata: DocumentMetadata = handleMetadataParsing(fileContent, requestedCommand, format); + + if (!metadata.namespace || !metadata.mapping) { + printError(0, `Document metadata must include namespace and mapping: ${options.file}`, requestedCommand, format); + process.exit(1); + } + + try { + const existingVersions = await client.getMappedResourceVersions(metadata.namespace, metadata.mapping, resourceType); + + if (!existingVersions.includes(metadata.version)) { + printJsonSuccess({ file: options.file, currentVersion: metadata.version, bumped: false, message: 'Version not yet published to hub; no bump needed.' }); + return; + } + + const newVersion = computeSemVerBump(metadata.version, options.changeType); + const newMetadata = { ...metadata, version: newVersion }; + const newContent = updateDocumentMetadata(fileContent, newMetadata); + await writeFile(options.file, newContent, 'utf-8'); + + printBumpResult({ file: options.file, previousVersion: metadata.version, newVersion }, format); + } catch (err) { + handleHubError(err, format); + } +} + +export async function runBumpArchitecture(options: BumpOptions): Promise { + return orchestrateBump(options, 'architectures'); +} + +export async function runBumpPattern(options: BumpOptions): Promise { + return orchestrateBump(options, 'patterns'); +} + +export async function runBumpStandard(options: BumpOptions): Promise { + return orchestrateBump(options, 'standards'); +} + +/** + * Checks whether the control document version in $id already exists on the hub. + * If it does, bumps the version in the $id on disk without pushing. + */ +async function orchestrateControlBump(options: BumpOptions, kind: ControlDocumentKind): Promise { + const format: OutputFormat = parseOutputFormat(options.format); + const calmHubOptions = await handleOptionsLoadError(options.calmHubOptions, format); + const client = new CalmHubClient(calmHubOptions); + + const requestedCommand = `bump control-${kind} ${options.file}`; + let fileContent = await loadFileContent(options.file, requestedCommand, format); + fileContent = validateAndMinifyJSON(fileContent, options.file, requestedCommand, format); + + let metadata: ControlDocumentMetadata; + try { + metadata = extractControlMetadata(fileContent); + } catch (error) { + printError(0, `Failed to extract control document metadata: ${error instanceof Error ? error.message : String(error)}`, requestedCommand, format); + process.exit(1); + } + + if (metadata.kind !== kind) { + printError(0, `Document $id describes a control ${metadata.kind}, but a control ${kind} was expected: ${options.file}`, requestedCommand, format); + process.exit(1); + } + + try { + const existingVersions = kind === 'configuration' + ? await client.getControlConfigurationVersions(metadata.domain, metadata.controlName, metadata.configName!) + : await client.getControlRequirementVersions(metadata.domain, metadata.controlName); + + if (!existingVersions.includes(metadata.version)) { + printJsonSuccess({ file: options.file, currentVersion: metadata.version, bumped: false, message: 'Version not yet published to hub; no bump needed.' }); + return; + } + + const newVersion = computeSemVerBump(metadata.version, options.changeType); + const newMetadata: ControlDocumentMetadata = { ...metadata, version: newVersion }; + const newContent = updateControlDocumentMetadata(fileContent, newMetadata); + await writeFile(options.file, newContent, 'utf-8'); + + printBumpResult({ file: options.file, previousVersion: metadata.version, newVersion }, format); + } catch (err) { + handleHubError(err, format); + } +} + +export async function runBumpControlRequirement(options: BumpOptions): Promise { + return orchestrateControlBump(options, 'requirement'); +} + +export async function runBumpControlConfiguration(options: BumpOptions): Promise { + return orchestrateControlBump(options, 'configuration'); +} + // ── pull control documents ────────────────────────────────────────────────────── export interface PullControlOptions {