@raisenow/tamaro-cli 3.0.0 → 3.1.0-dev.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -282,7 +282,16 @@ pnpx @raisenow/tamaro-cli update-epms --tag WEB-123
282
282
 
283
283
  ## `validate`
284
284
 
285
- Validate the current configuration and fail on known issues such as invalid email templates.
285
+ Validate the current configuration and fail on known issues such as invalid
286
+ email templates, invalid `.env` values, or RaiseNow-generated tax receipts
287
+ enabled without an uploaded organisation template.
288
+
289
+ When `autogeneratedDonationReceiptEnabled` (or
290
+ `autogenerated_donation_receipt_enabled`) is `true`, this command authenticates
291
+ with EPMS (browser login or client credentials) and checks that a donation
292
+ tax-receipt PDF template exists for each configured account. See the
293
+ [Confluence guide](https://raisenow.atlassian.net/wiki/spaces/PD/pages/6231687230/Tamaro+CLI+Commands#Tax-receipt-validation--how-CS-resolves-it)
294
+ for how to resolve a failed check.
286
295
 
287
296
  ### Example
288
297
 
package/dist/cli.js CHANGED
@@ -12,9 +12,10 @@ import ky from "ky";
12
12
  import { createServer } from "node:http";
13
13
  import open from "open";
14
14
  import { globSync } from "glob";
15
+ import { codeFrameColumns } from "@babel/code-frame";
15
16
  import Handlebars from "handlebars";
16
17
  import helpers from "handlebars-helpers";
17
- import { config } from "dotenv";
18
+ import { config, parse } from "dotenv";
18
19
  import { getPortPromise } from "portfinder";
19
20
  import prompts from "prompts";
20
21
  import columnify from "columnify";
@@ -279,6 +280,8 @@ var EpmsClient = class EpmsClient {
279
280
  #cacheKey;
280
281
  #epmsClient;
281
282
  #authType;
283
+ /** In-memory account UUID → organisation UUID cache for this client instance. */
284
+ #organisationIdByAccountUuid = /* @__PURE__ */ new Map();
282
285
  /**
283
286
  * Creates an instance of EpmsClient.
284
287
  *
@@ -307,14 +310,25 @@ var EpmsClient = class EpmsClient {
307
310
  return this.#epmsClient.get("users/myself").json().then((response) => myselfSchema.parse(response));
308
311
  }
309
312
  async getOrganisationIdByAccountUuid(accountUuid) {
313
+ const cached = this.#organisationIdByAccountUuid.get(accountUuid);
314
+ if (cached) return cached;
310
315
  try {
311
316
  const organisationUuid = (await this.#epmsClient.get(`accounts/${accountUuid}`).json()).organisation.uuid;
312
317
  if (!organisationUuid) throw new Error("Organisation UUID not found in response");
318
+ this.#organisationIdByAccountUuid.set(accountUuid, organisationUuid);
313
319
  return organisationUuid;
314
320
  } catch (error) {
315
321
  throw new Error(`Organisation UUID not found for account UUID: ${accountUuid}`, { cause: error });
316
322
  }
317
323
  }
324
+ async listOrganisationFiles(organisationUuid) {
325
+ try {
326
+ const response = await this.#epmsClient.get(`organisations/${organisationUuid}/files`).json();
327
+ return Array.isArray(response) ? response : response.data ?? [];
328
+ } catch (error) {
329
+ throw new Error(`Failed to list organisation files for organisation UUID: ${organisationUuid}`, { cause: error });
330
+ }
331
+ }
318
332
  async updateTamaro(configName, tag, json) {
319
333
  return this.#epmsClient.put(`products/tamaro/${configName}/tags/${tag}`, { json }).json();
320
334
  }
@@ -442,6 +456,99 @@ const assertArchiveTargetDoesNotExist = (archiveTarget) => {
442
456
  if (fs.existsSync(archiveTarget)) halt(`Archive target already exists: ${archiveTarget}. Please remove it first.`);
443
457
  };
444
458
  //#endregion
459
+ //#region src/lib/validators/validateDonationReceiptTemplates.ts
460
+ /** Internal docs validation guide for CS */
461
+ const DONATION_RECEIPT_VALIDATION_GUIDE_URL = "https://raisenow.atlassian.net/wiki/spaces/PD/pages/6231687230/Tamaro+CLI+Commands#Donation-tax-receipts";
462
+ const FLAG_KEYS = ["autogeneratedDonationReceiptEnabled", "autogenerated_donation_receipt_enabled"];
463
+ /**
464
+ * Resolve which config.yml key enabled RaiseNow-generated donation tax
465
+ * receipts. Supports both camelCase and snake_case keys used in customer
466
+ * configs; returns undefined when the option is not strictly `true`.
467
+ */
468
+ const isAutogeneratedDonationReceiptEnabled = (rawConfig) => {
469
+ if (!rawConfig || typeof rawConfig !== "object") return;
470
+ const config = rawConfig;
471
+ for (const key of FLAG_KEYS) if (config[key] === true) return key;
472
+ };
473
+ /**
474
+ * Check that a donation tax receipt PDF template exists for the account in
475
+ * the organisation files list. Matches Hub storage layout:
476
+ * `{accountUuid}/donation-receipt-templates/{language}.pdf`
477
+ */
478
+ const donationReceiptTemplateExists = (files, accountUuid) => {
479
+ const prefix = `${accountUuid}/donation-receipt-templates/`;
480
+ return files.some((file) => file.name.startsWith(prefix) && file.name.endsWith(".pdf") && file.name.slice(prefix.length).length > 0 && !file.name.slice(prefix.length).includes("/"));
481
+ };
482
+ /**
483
+ * Locate the line of a top-level `key:` assignment in the config.yml source,
484
+ * so the error can be rendered as a code frame pointing at the value.
485
+ */
486
+ const findKeyLocation$1 = (source, key) => {
487
+ const lines = source.split("\n");
488
+ const assignment = new RegExp(String.raw`^\s*${key}\s*:`);
489
+ const index = lines.findIndex((line) => assignment.test(line));
490
+ if (index === -1) return;
491
+ const line = index + 1;
492
+ const valueStart = lines[index].indexOf(":") + 1;
493
+ return {
494
+ end: {
495
+ column: lines[index].length,
496
+ line
497
+ },
498
+ start: {
499
+ column: valueStart + 1,
500
+ line
501
+ }
502
+ };
503
+ };
504
+ /**
505
+ * Format an error for a `key:` assignment as a code frame pointing at the
506
+ * value, so it looks fancy like this:
507
+ *
508
+ * config.yml:51
509
+ * 50 | show_stored_customer_donation_receipt: true
510
+ * 51 | autogeneratedDonationReceiptEnabled: true
511
+ * | ^^^^ No donation tax receipt PDF template found…
512
+ */
513
+ const formatError$1 = (configYmlPath, source, key, message) => {
514
+ const location = findKeyLocation$1(source, key);
515
+ if (!location) return `${configYmlPath}: ${key}: ${message}`;
516
+ const frame = codeFrameColumns(source, location, {
517
+ forceColor: true,
518
+ highlightCode: true,
519
+ message
520
+ });
521
+ return `\n${configYmlPath}:${location.start.line}\n${frame}`;
522
+ };
523
+ /**
524
+ * Validate that RaiseNow-generated tax receipts are only enabled when each
525
+ * configured EPMS account has a donation tax receipt PDF template uploaded.
526
+ */
527
+ const validateDonationReceiptTemplates = async (configYmlPath) => {
528
+ if (!fs.existsSync(configYmlPath)) return [];
529
+ const source = fs.readFileSync(configYmlPath, "utf8");
530
+ const flagKey = isAutogeneratedDonationReceiptEnabled(yaml.load(source));
531
+ if (!flagKey) return [];
532
+ const { accountUuidProd, accountUuidStage } = loadTamaroMetadataFromConfig(configYmlPath);
533
+ const errors = [];
534
+ const environments = [{
535
+ accountUuid: accountUuidStage,
536
+ env: "stage",
537
+ label: "Stage"
538
+ }, {
539
+ accountUuid: accountUuidProd,
540
+ env: "prod",
541
+ label: "Prod"
542
+ }];
543
+ for (const { accountUuid, env, label } of environments) {
544
+ if (!accountUuid) continue;
545
+ const epmsClient = epmsAuthenticate(env);
546
+ const organisationUuid = await epmsClient.getOrganisationIdByAccountUuid(accountUuid);
547
+ if (!donationReceiptTemplateExists(await epmsClient.listOrganisationFiles(organisationUuid), accountUuid)) errors.push(formatError$1(configYmlPath, source, flagKey, `No donation tax receipt PDF template found for the ${label} account (${accountUuid}). See: ${DONATION_RECEIPT_VALIDATION_GUIDE_URL}`));
548
+ }
549
+ return errors;
550
+ };
551
+ //#endregion
445
552
  //#region src/lib/validators/validateEmailTemplates.ts
446
553
  /**
447
554
  * Register custom Handlebars helpers - the same, which are used in the Email Service.
@@ -545,6 +652,98 @@ const compileEmailTemplate = (template) => {
545
652
  Handlebars.compile(template)({});
546
653
  };
547
654
  //#endregion
655
+ //#region src/lib/validators/validateEnv.ts
656
+ const envSchema = z.strictObject({
657
+ CORE_URL: z.url().optional(),
658
+ CORE_URL_PATTERN: z.string().includes("{{version}}", { message: "Must contain the \"{{version}}\" placeholder" }).optional(),
659
+ CORE_VERSION: z.string().refine((version) => version === version.trim(), { message: "There must be no leading or trailing whitespace in the version string" }).refine((version) => version === version.toLowerCase(), { message: "The version string must be lowercase" }).optional(),
660
+ DISABLE_URL_VERSION_OVERRIDE: z.enum([
661
+ "true",
662
+ "false",
663
+ "1",
664
+ "0"
665
+ ]).optional(),
666
+ LOCAL_CORE: z.enum(["true", "false"]).optional()
667
+ });
668
+ const TAMARO_CORE_REGISTRY_URL = "https://registry.npmjs.org/@raisenow/tamaro-core";
669
+ /**
670
+ * Check that the configured version exists for "@raisenow/tamaro-core" on npm:
671
+ * as a dist-tag (e.g. "latest", "lts_2026"), an exact version, or a partial
672
+ * version ("2", "2.15") matching at least one published version.
673
+ */
674
+ const coreVersionExists = async (version) => {
675
+ const metadata = await ky.get(TAMARO_CORE_REGISTRY_URL, {
676
+ headers: { accept: "application/vnd.npm.install-v1+json" },
677
+ retry: 0,
678
+ timeout: DEFAULT_HTTP_TIMEOUT
679
+ }).json();
680
+ const distTags = Object.keys(metadata["dist-tags"] ?? {});
681
+ const versions = Object.keys(metadata.versions ?? {});
682
+ return distTags.includes(version) || versions.includes(version) || versions.some((existing) => existing.startsWith(`${version}.`));
683
+ };
684
+ /**
685
+ * Locate the line of a `KEY=` assignment in the .env source, so the error can
686
+ * be rendered as a code frame pointing at the value.
687
+ */
688
+ const findKeyLocation = (source, key) => {
689
+ const lines = source.split("\n");
690
+ const assignment = new RegExp(String.raw`^\s*${key}\s*=`);
691
+ const index = lines.findIndex((line) => assignment.test(line));
692
+ if (index === -1) return;
693
+ const line = index + 1;
694
+ const valueStart = lines[index].indexOf("=") + 1;
695
+ return {
696
+ end: {
697
+ column: lines[index].length,
698
+ line
699
+ },
700
+ start: {
701
+ column: valueStart,
702
+ line
703
+ }
704
+ };
705
+ };
706
+ /**
707
+ * Format an error for a `KEY=` assignment as a code frame pointing at the
708
+ * value, so it looks fancy like this:
709
+ *
710
+ * .env:2
711
+ * 1 | CORE_VERSION=LTS_2026
712
+ * 2 | CORE_URL_PATTERN=adsf
713
+ * | ^^^^ Must contain the "{{version}}" placeholder
714
+ */
715
+ const formatError = (dotEnvPath, source, key, message) => {
716
+ const location = findKeyLocation(source, key);
717
+ if (!location) return `${dotEnvPath}: ${key}: ${message}`;
718
+ const frame = codeFrameColumns(source, location, {
719
+ forceColor: true,
720
+ highlightCode: true,
721
+ message
722
+ });
723
+ return `\n${dotEnvPath}:${location.start.line}\n${frame}`;
724
+ };
725
+ /**
726
+ * Validate the environment variables in the config's .env file.
727
+ */
728
+ const validateEnv = async (dotEnvPath) => {
729
+ if (!fs.existsSync(dotEnvPath)) return [];
730
+ const source = fs.readFileSync(dotEnvPath, "utf8");
731
+ const parsed = parse(source);
732
+ const result = envSchema.safeParse(parsed);
733
+ if (!result.success) {
734
+ const errors = [];
735
+ for (const issue of result.error.issues) {
736
+ const keys = issue.code === "unrecognized_keys" ? issue.keys : [String(issue.path[0] ?? "")];
737
+ for (const key of keys) errors.push(formatError(dotEnvPath, source, key, issue.message));
738
+ }
739
+ return errors;
740
+ }
741
+ if (parsed.CORE_VERSION) {
742
+ if (await coreVersionExists(parsed.CORE_VERSION) === false) return [formatError(dotEnvPath, source, "CORE_VERSION", `Version "${parsed.CORE_VERSION}" does not exist. See all versions at https://www.npmjs.com/package/@raisenow/tamaro-core?activeTab=versions`)];
743
+ }
744
+ return [];
745
+ };
746
+ //#endregion
548
747
  //#region src/commands/validate.ts
549
748
  /**
550
749
  * Validates the current Tamaro configuration to avoid common errors.
@@ -554,8 +753,16 @@ const compileEmailTemplate = (template) => {
554
753
  * deployed.
555
754
  */
556
755
  const validate = async () => {
557
- const errors = validateEmailTemplates(globSync("email-config/templates/**/*.hbs").toSorted());
558
- if (errors.length > 0) halt(errors.map((el) => el.toString()).join("\n"));
756
+ const errors = [
757
+ ...await validateEnv(".env"),
758
+ ...validateEmailTemplates(globSync("email-config/templates/**/*.hbs").toSorted()),
759
+ ...await validateDonationReceiptTemplates("config.yml")
760
+ ];
761
+ if (errors.length > 0) {
762
+ logError("❌ Validation failed with the following errors:");
763
+ console.log(errors.map((el) => el.toString()).join("\n"));
764
+ halt();
765
+ }
559
766
  logTitle("Validated successfully");
560
767
  };
561
768
  //#endregion
@@ -631,6 +838,7 @@ const getOutDir = (configName) => {
631
838
  //#region src/commands/update-epms.ts
632
839
  const updateEpms = async (options) => {
633
840
  const configName = getConfigName();
841
+ await validate();
634
842
  if (!options.profile && !options.ci) {
635
843
  assertAwsProfilesPresent();
636
844
  options.profile = await promptAwsProfile();
@@ -912,6 +1120,7 @@ const dev = async (options) => {
912
1120
  const configName = getConfigName();
913
1121
  const configsPackageRoot = getConfigsPackageRoot();
914
1122
  assertCrtExists(configsPackageRoot);
1123
+ await validate();
915
1124
  const port = await getPortPromise({ port: Number(DEFAULT_PORT) });
916
1125
  logTitle(`\nStarting Vite dev server for "${configName}" configuration...`);
917
1126
  runCommandSync(`pnpm vite dev --port ${port}`, {
@@ -1225,7 +1434,7 @@ const promptBucketTamaroEmailConfig = async () => {
1225
1434
  //#endregion
1226
1435
  //#region package.json
1227
1436
  var name = "@raisenow/tamaro-cli";
1228
- var version = "3.0.0";
1437
+ var version = "3.1.0-dev.1";
1229
1438
  //#endregion
1230
1439
  //#region src/cli.ts
1231
1440
  const cli = createCommand().name(name).description("CLI for Tamaro Customer Configurations development").version(version, "-v, --version");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@raisenow/tamaro-cli",
3
- "version": "3.0.0",
3
+ "version": "3.1.0-dev.1",
4
4
  "author": {
5
5
  "name": "RaiseNow",
6
6
  "email": "development@raisenow.com"
@@ -13,7 +13,8 @@
13
13
  "yarn": "please-use-pnpm"
14
14
  },
15
15
  "dependencies": {
16
- "@aws-sdk/client-s3": "^3.1080.0",
16
+ "@aws-sdk/client-s3": "^3.1081.0",
17
+ "@babel/code-frame": "^8.0.0",
17
18
  "@rnw-npm/cli-helpers": "^2.0.1",
18
19
  "columnify": "^1.6.0",
19
20
  "commander": "^15.0.0",
@@ -1,6 +0,0 @@
1
- <component name="InspectionProjectProfileManager">
2
- <profile version="1.0">
3
- <option name="myName" value="Project Default" />
4
- <inspection_tool class="Eslint" enabled="true" level="WARNING" enabled_by_default="true" />
5
- </profile>
6
- </component>
package/.idea/modules.xml DELETED
@@ -1,8 +0,0 @@
1
- <?xml version="1.0" encoding="UTF-8"?>
2
- <project version="4">
3
- <component name="ProjectModuleManager">
4
- <modules>
5
- <module fileurl="file://$PROJECT_DIR$/.idea/tamaro-cli.iml" filepath="$PROJECT_DIR$/.idea/tamaro-cli.iml" />
6
- </modules>
7
- </component>
8
- </project>
@@ -1,12 +0,0 @@
1
- <?xml version="1.0" encoding="UTF-8"?>
2
- <module type="WEB_MODULE" version="4">
3
- <component name="NewModuleRootManager">
4
- <content url="file://$MODULE_DIR$">
5
- <excludeFolder url="file://$MODULE_DIR$/temp" />
6
- <excludeFolder url="file://$MODULE_DIR$/.tmp" />
7
- <excludeFolder url="file://$MODULE_DIR$/tmp" />
8
- </content>
9
- <orderEntry type="inheritedJdk" />
10
- <orderEntry type="sourceFolder" forTests="false" />
11
- </component>
12
- </module>
package/.idea/vcs.xml DELETED
@@ -1,6 +0,0 @@
1
- <?xml version="1.0" encoding="UTF-8"?>
2
- <project version="4">
3
- <component name="VcsDirectoryMappings">
4
- <mapping directory="$PROJECT_DIR$" vcs="Git" />
5
- </component>
6
- </project>
@@ -1,62 +0,0 @@
1
- <?xml version="1.0" encoding="UTF-8"?>
2
- <project version="4">
3
- <component name="ChangeListManager">
4
- <list default="true" id="03cc3f97-55ca-4737-9fe2-779ac8457ab2" name="Changes" comment="" />
5
- <option name="SHOW_DIALOG" value="false" />
6
- <option name="HIGHLIGHT_CONFLICTS" value="true" />
7
- <option name="HIGHLIGHT_NON_ACTIVE_CHANGELIST" value="false" />
8
- <option name="LAST_RESOLUTION" value="IGNORE" />
9
- </component>
10
- <component name="Git.Settings">
11
- <option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" />
12
- </component>
13
- <component name="MarkdownSettingsMigration">
14
- <option name="stateVersion" value="1" />
15
- </component>
16
- <component name="ProjectId" id="3BwvHZOW2N4A0f0JXo7q8qDzYwY" />
17
- <component name="ProjectViewState">
18
- <option name="autoscrollFromSource" value="true" />
19
- <option name="autoscrollToSource" value="true" />
20
- <option name="hideEmptyMiddlePackages" value="true" />
21
- <option name="showLibraryContents" value="true" />
22
- <option name="showMembers" value="true" />
23
- </component>
24
- <component name="PropertiesComponent">{
25
- &quot;keyToString&quot;: {
26
- &quot;RunOnceActivity.OpenProjectViewOnStart&quot;: &quot;true&quot;,
27
- &quot;RunOnceActivity.ShowReadmeOnStart&quot;: &quot;true&quot;,
28
- &quot;WebServerToolWindowFactoryState&quot;: &quot;false&quot;,
29
- &quot;node.js.detected.package.eslint&quot;: &quot;true&quot;,
30
- &quot;node.js.selected.package.eslint&quot;: &quot;(autodetect)&quot;,
31
- &quot;nodejs_package_manager_path&quot;: &quot;pnpm&quot;,
32
- &quot;prettierjs.PrettierConfiguration.Package&quot;: &quot;/Users/sigerello/dev/_raisenow/tools/tamaro-cli/node_modules/prettier&quot;,
33
- &quot;ts.external.directory.path&quot;: &quot;/Users/sigerello/dev/_raisenow/tools/tamaro-cli/node_modules/typescript/lib&quot;,
34
- &quot;vue.rearranger.settings.migration&quot;: &quot;true&quot;
35
- }
36
- }</component>
37
- <component name="SpellCheckerSettings" RuntimeDictionaries="0" Folders="0" CustomDictionaries="0" DefaultDictionary="application-level" UseSingleDictionary="true" transferred="true" />
38
- <component name="TaskManager">
39
- <task active="true" id="Default" summary="Default task">
40
- <changelist id="03cc3f97-55ca-4737-9fe2-779ac8457ab2" name="Changes" comment="" />
41
- <created>1775412859839</created>
42
- <option name="number" value="Default" />
43
- <option name="presentableId" value="Default" />
44
- <updated>1775412859839</updated>
45
- <workItem from="1775412861312" duration="2060000" />
46
- <workItem from="1776174639969" duration="1525000" />
47
- <workItem from="1776736351878" duration="598000" />
48
- <workItem from="1777934504965" duration="82000" />
49
- <workItem from="1777936008103" duration="88000" />
50
- <workItem from="1778477038620" duration="2530000" />
51
- <workItem from="1780398242165" duration="1962000" />
52
- <workItem from="1781661280180" duration="2828000" />
53
- <workItem from="1782699840335" duration="599000" />
54
- <workItem from="1782869357470" duration="598000" />
55
- <workItem from="1783270034892" duration="1197000" />
56
- </task>
57
- <servers />
58
- </component>
59
- <component name="TypeScriptGeneratedFilesManager">
60
- <option name="version" value="3" />
61
- </component>
62
- </project>