Skip to content

Commit 7d45299

Browse files
authored
feat(cli): add --fail-if-modified to hub push (finos#2760)
* feat(cli): add --fail-if-modified to hub push Introduces a shared, key-order-insensitive content comparison (`canonicalEqual` in @finos/calm-shared) and uses it to add a strict, non-bumping `--fail-if-modified` mode to `calm hub push` for architecture, pattern and standard documents: - brand-new mapping -> created at 1.0.0 (unchanged behaviour) - exists + unchanged -> skipped (no new version, file left as-is) - exists + modified -> push fails Default behaviour (auto-bump) is unchanged. The comparison helper is shared so it can be reused by other push paths without duplication. Claude-Session: https://claude.ai/code/session_01QiHAHdi4d4mCoy4wku5s2v * fix(cli): correct hub push --fail-if-modified comparison and extend to control docs Addresses review feedback on the strict-push comparison: - Normalise the local document the same way Hub stores it (rewriting the $id to the latest version and applying default fields via updateDocumentMetadata) before comparing, so a version-only difference (e.g. another committer auto-bumped Hub) or an absent `description` no longer reports a false "has changed". - Guard against an empty/unreadable latest-version body: surface a clear "could not read" error instead of silently reporting "has changed". - Point users at --change-type in the modified-document error message. - Extend --fail-if-modified to `control-requirement` and `control-configuration` push (same normalise-and-compare model). - Add Commander argv wiring tests so the flag plumbing is covered. Claude-Session: https://claude.ai/code/session_01QiHAHdi4d4mCoy4wku5s2v
2 parents d29c579 + 9a2805a commit 7d45299

8 files changed

Lines changed: 443 additions & 21 deletions

File tree

cli/AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ npm run copy-ai-tools # Copy AI agent files from ../calm-ai/
5959
9. **hub** - Command **group** for interacting with CALM Hub. Subcommands:
6060
- `hub push <resource> <file>` / `hub pull <resource>` / `hub list <resources>` / `hub create <resource>`
6161
- Resources span architectures, patterns, standards, control-requirements, control-configurations, namespaces, and domains (which subcommands exist varies per verb).
62+
- `hub push` auto-bumps by default (creates a new version off the latest). The `--fail-if-modified` flag (supported on `architecture`, `pattern`, `standard`, `control-requirement` and `control-configuration`) switches to a strict, non-bumping mode: a brand-new mapping is still created at `1.0.0`, an unchanged document is skipped, and a document that differs from the latest published version fails the push. The local document is normalised the same way Hub stores it (via `updateDocumentMetadata`/`updateControlDocumentMetadata`) before comparing with `canonicalEqual` from `@finos/calm-shared`, so a version-only or defaulted-field difference is not mistaken for a content change.
6263

6364
### Important Directories
6465
```

cli/src/cli.spec.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -784,6 +784,31 @@ describe('CLI Commands', () => {
784784
}));
785785
});
786786

787+
it('passes --fail-if-modified through to the architecture handler', async () => {
788+
await program.parseAsync([
789+
'node', 'cli.js', 'hub', 'push', 'architecture',
790+
'arch.json',
791+
'--calm-hub-url', 'http://hub',
792+
'--fail-if-modified',
793+
]);
794+
795+
expect(hubCommandsModule.runPushArchitecture).toHaveBeenCalledWith(expect.objectContaining({
796+
failIfModified: true,
797+
}));
798+
});
799+
800+
it('defaults --fail-if-modified to false when not provided', async () => {
801+
await program.parseAsync([
802+
'node', 'cli.js', 'hub', 'push', 'architecture',
803+
'arch.json',
804+
'--calm-hub-url', 'http://hub',
805+
]);
806+
807+
expect(hubCommandsModule.runPushArchitecture).toHaveBeenCalledWith(expect.objectContaining({
808+
failIfModified: false,
809+
}));
810+
});
811+
787812
it('passes --format pretty through to the handler', async () => {
788813
await program.parseAsync([
789814
'node', 'cli.js', 'hub', 'push', 'architecture',
@@ -1087,6 +1112,18 @@ describe('CLI Commands', () => {
10871112
changeType: 'PATCH',
10881113
}));
10891114
});
1115+
1116+
it('passes --fail-if-modified through to the handler', async () => {
1117+
await program.parseAsync([
1118+
'node', 'cli.js', 'hub', 'push', 'control-requirement', 'req.json',
1119+
'--calm-hub-url', 'http://hub',
1120+
'--fail-if-modified',
1121+
]);
1122+
1123+
expect(hubCommandsModule.runPushControlRequirement).toHaveBeenCalledWith(expect.objectContaining({
1124+
failIfModified: true,
1125+
}));
1126+
});
10901127
});
10911128

10921129
describe('pull control-requirement command', () => {

cli/src/cli.ts

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -463,6 +463,9 @@ Example:
463463
.choices(['patch', 'minor', 'major'])
464464
.default('patch');
465465

466+
const hubFailIfModifiedOption = new Option('--fail-if-modified', 'Strict mode: do not auto-bump. Skip if the document is unchanged from the latest published version; fail if it has changed. A brand-new mapping is still created at 1.0.0.')
467+
.default(false);
468+
466469
const hubCmd = new Command('hub').description('Interact with CALM Hub');
467470

468471
// hub push
@@ -476,14 +479,16 @@ Example:
476479
.option(CALMHUB_URL_OPTION, 'URL to CALMHub instance')
477480
.addOption(hubOutputOption)
478481
.addOption(hubVersionBumpOption)
482+
.addOption(hubFailIfModifiedOption)
479483
.action(async (architectureFile, options) => {
480484
const pushOptions: PushOptions = {
481485
calmHubOptions: { calmHubUrl: options.calmHubUrl },
482486
name: options.name,
483487
description: options.description,
484488
file: architectureFile,
485489
format: options.format,
486-
changeType: options.changeType.toUpperCase() as ResourceChangeType
490+
changeType: options.changeType.toUpperCase() as ResourceChangeType,
491+
failIfModified: options.failIfModified
487492
};
488493
await runPushArchitecture(pushOptions);
489494
});
@@ -496,6 +501,7 @@ Example:
496501
.option(CALMHUB_URL_OPTION, 'URL to CALMHub instance')
497502
.addOption(hubOutputOption)
498503
.addOption(hubVersionBumpOption)
504+
.addOption(hubFailIfModifiedOption)
499505
.action(async (patternFile, options) => {
500506
const pushPatternOptions: PushOptions = {
501507
calmHubOptions: { calmHubUrl: options.calmHubUrl },
@@ -504,6 +510,7 @@ Example:
504510
file: patternFile,
505511
format: options.format,
506512
changeType: options.changeType.toUpperCase() as ResourceChangeType,
513+
failIfModified: options.failIfModified
507514
};
508515
await runPushPattern(pushPatternOptions);
509516
});
@@ -516,6 +523,7 @@ Example:
516523
.option(CALMHUB_URL_OPTION, 'URL to CALMHub instance')
517524
.addOption(hubOutputOption)
518525
.addOption(hubVersionBumpOption)
526+
.addOption(hubFailIfModifiedOption)
519527
.action(async (standardFile, options) => {
520528
const pushStandardOptions: PushOptions = {
521529
calmHubOptions: { calmHubUrl: options.calmHubUrl },
@@ -524,6 +532,7 @@ Example:
524532
file: standardFile,
525533
format: options.format,
526534
changeType: options.changeType.toUpperCase() as ResourceChangeType,
535+
failIfModified: options.failIfModified
527536
};
528537
await runPushStandard(pushStandardOptions);
529538
});
@@ -534,12 +543,14 @@ Example:
534543
.option(CALMHUB_URL_OPTION, 'URL to CALMHub instance')
535544
.addOption(hubOutputOption)
536545
.addOption(hubVersionBumpOption)
546+
.addOption(hubFailIfModifiedOption)
537547
.action(async (requirementFile, options) => {
538548
const pushOptions: PushControlOptions = {
539549
calmHubOptions: { calmHubUrl: options.calmHubUrl },
540550
file: requirementFile,
541551
format: options.format,
542-
changeType: options.changeType.toUpperCase() as ResourceChangeType
552+
changeType: options.changeType.toUpperCase() as ResourceChangeType,
553+
failIfModified: options.failIfModified
543554
};
544555
await runPushControlRequirement(pushOptions);
545556
});
@@ -550,12 +561,14 @@ Example:
550561
.option(CALMHUB_URL_OPTION, 'URL to CALMHub instance')
551562
.addOption(hubOutputOption)
552563
.addOption(hubVersionBumpOption)
564+
.addOption(hubFailIfModifiedOption)
553565
.action(async (configFile, options) => {
554566
const pushOptions: PushControlOptions = {
555567
calmHubOptions: { calmHubUrl: options.calmHubUrl },
556568
file: configFile,
557569
format: options.format,
558-
changeType: options.changeType.toUpperCase() as ResourceChangeType
570+
changeType: options.changeType.toUpperCase() as ResourceChangeType,
571+
failIfModified: options.failIfModified
559572
};
560573
await runPushControlConfiguration(pushOptions);
561574
});

0 commit comments

Comments
 (0)