@raisenow/tamaro-cli 3.1.0 → 3.2.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.
Files changed (3) hide show
  1. package/README.md +13 -1
  2. package/dist/cli.js +219 -8
  3. package/package.json +15 -14
package/README.md CHANGED
@@ -282,7 +282,19 @@ 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:
286
+
287
+ - Invalid email templates
288
+ - Invalid `organisation_email` / `blind_copy` values in `email-config/parameters.yaml`
289
+ - Invalid `.env` values
290
+ - RaiseNow-generated tax receipts enabled without an uploaded organisation template
291
+
292
+ When `autogeneratedDonationReceiptEnabled` (or
293
+ `autogenerated_donation_receipt_enabled`) is `true`, this command authenticates
294
+ with EPMS (browser login or client credentials) and checks that a donation
295
+ tax-receipt PDF template exists for each configured account. See the
296
+ [Confluence guide](https://raisenow.atlassian.net/wiki/spaces/PD/pages/6231687230/Tamaro+CLI+Commands#Tax-receipt-validation--how-CS-resolves-it)
297
+ for how to resolve a failed check.
286
298
 
287
299
  ### Example
288
300
 
package/dist/cli.js CHANGED
@@ -12,9 +12,9 @@ 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 { codeFrameColumns } from "@babel/code-frame";
18
18
  import { config, parse } from "dotenv";
19
19
  import { getPortPromise } from "portfinder";
20
20
  import prompts from "prompts";
@@ -280,6 +280,8 @@ var EpmsClient = class EpmsClient {
280
280
  #cacheKey;
281
281
  #epmsClient;
282
282
  #authType;
283
+ /** In-memory account UUID → organisation UUID cache for this client instance. */
284
+ #organisationIdByAccountUuid = /* @__PURE__ */ new Map();
283
285
  /**
284
286
  * Creates an instance of EpmsClient.
285
287
  *
@@ -308,14 +310,24 @@ var EpmsClient = class EpmsClient {
308
310
  return this.#epmsClient.get("users/myself").json().then((response) => myselfSchema.parse(response));
309
311
  }
310
312
  async getOrganisationIdByAccountUuid(accountUuid) {
313
+ const cached = this.#organisationIdByAccountUuid.get(accountUuid);
314
+ if (cached) return cached;
311
315
  try {
312
316
  const organisationUuid = (await this.#epmsClient.get(`accounts/${accountUuid}`).json()).organisation.uuid;
313
317
  if (!organisationUuid) throw new Error("Organisation UUID not found in response");
318
+ this.#organisationIdByAccountUuid.set(accountUuid, organisationUuid);
314
319
  return organisationUuid;
315
320
  } catch (error) {
316
321
  throw new Error(`Organisation UUID not found for account UUID: ${accountUuid}`, { cause: error });
317
322
  }
318
323
  }
324
+ async listOrganisationFiles(organisationUuid) {
325
+ try {
326
+ return await this.#epmsClient.get(`organisations/${organisationUuid}/files`).json();
327
+ } catch (error) {
328
+ throw new Error(`Failed to list organisation files for organisation UUID: ${organisationUuid}`, { cause: error });
329
+ }
330
+ }
319
331
  async updateTamaro(configName, tag, json) {
320
332
  return this.#epmsClient.put(`products/tamaro/${configName}/tags/${tag}`, { json }).json();
321
333
  }
@@ -443,6 +455,99 @@ const assertArchiveTargetDoesNotExist = (archiveTarget) => {
443
455
  if (fs.existsSync(archiveTarget)) halt(`Archive target already exists: ${archiveTarget}. Please remove it first.`);
444
456
  };
445
457
  //#endregion
458
+ //#region src/lib/validators/validateDonationReceiptTemplates.ts
459
+ /** Internal docs validation guide for CS */
460
+ const DONATION_RECEIPT_VALIDATION_GUIDE_URL = "https://raisenow.atlassian.net/wiki/spaces/PD/pages/6231687230/Tamaro+CLI+Commands#Donation-tax-receipts";
461
+ const FLAG_KEYS = ["autogeneratedDonationReceiptEnabled", "autogenerated_donation_receipt_enabled"];
462
+ /**
463
+ * Resolve which config.yml key enabled RaiseNow-generated donation tax
464
+ * receipts. Supports both camelCase and snake_case keys used in customer
465
+ * configs; returns undefined when the option is not strictly `true`.
466
+ */
467
+ const isAutogeneratedDonationReceiptEnabled = (rawConfig) => {
468
+ if (!rawConfig || typeof rawConfig !== "object") return;
469
+ const config = rawConfig;
470
+ for (const key of FLAG_KEYS) if (config[key] === true) return key;
471
+ };
472
+ /**
473
+ * Check that a donation tax receipt PDF template exists for the account in
474
+ * the organisation files list. Matches Hub storage layout:
475
+ * `{accountUuid}/donation-receipt-templates/{language}.pdf`
476
+ */
477
+ const donationReceiptTemplateExists = (files, accountUuid) => {
478
+ const prefix = `${accountUuid}/donation-receipt-templates/`;
479
+ 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("/"));
480
+ };
481
+ /**
482
+ * Locate the line of a top-level `key:` assignment in the config.yml source,
483
+ * so the error can be rendered as a code frame pointing at the value.
484
+ */
485
+ const findKeyLocation$2 = (source, key) => {
486
+ const lines = source.split("\n");
487
+ const assignment = new RegExp(String.raw`^\s*${key}\s*:`);
488
+ const index = lines.findIndex((line) => assignment.test(line));
489
+ if (index === -1) return;
490
+ const line = index + 1;
491
+ const valueStart = lines[index].indexOf(":") + 1;
492
+ return {
493
+ end: {
494
+ column: lines[index].length,
495
+ line
496
+ },
497
+ start: {
498
+ column: valueStart + 1,
499
+ line
500
+ }
501
+ };
502
+ };
503
+ /**
504
+ * Format an error for a `key:` assignment as a code frame pointing at the
505
+ * value, so it looks fancy like this:
506
+ *
507
+ * config.yml:51
508
+ * 50 | show_stored_customer_donation_receipt: true
509
+ * 51 | autogeneratedDonationReceiptEnabled: true
510
+ * | ^^^^ No donation tax receipt PDF template found…
511
+ */
512
+ const formatError$2 = (configYmlPath, source, key, message) => {
513
+ const location = findKeyLocation$2(source, key);
514
+ if (!location) return `${configYmlPath}: ${key}: ${message}`;
515
+ const frame = codeFrameColumns(source, location, {
516
+ forceColor: true,
517
+ highlightCode: true,
518
+ message
519
+ });
520
+ return `\n${configYmlPath}:${location.start.line}\n${frame}`;
521
+ };
522
+ /**
523
+ * Validate that RaiseNow-generated tax receipts are only enabled when each
524
+ * configured EPMS account has a donation tax receipt PDF template uploaded.
525
+ */
526
+ const validateDonationReceiptTemplates = async (configYmlPath) => {
527
+ if (!fs.existsSync(configYmlPath)) return [];
528
+ const source = fs.readFileSync(configYmlPath, "utf8");
529
+ const flagKey = isAutogeneratedDonationReceiptEnabled(yaml.load(source));
530
+ if (!flagKey) return [];
531
+ const { accountUuidProd, accountUuidStage } = loadTamaroMetadataFromConfig(configYmlPath);
532
+ const errors = [];
533
+ const environments = [{
534
+ accountUuid: accountUuidStage,
535
+ env: "stage",
536
+ label: "Stage"
537
+ }, {
538
+ accountUuid: accountUuidProd,
539
+ env: "prod",
540
+ label: "Prod"
541
+ }];
542
+ for (const { accountUuid, env, label } of environments) {
543
+ if (!accountUuid) continue;
544
+ const epmsClient = epmsAuthenticate(env);
545
+ const organisationUuid = await epmsClient.getOrganisationIdByAccountUuid(accountUuid);
546
+ if (!donationReceiptTemplateExists(await epmsClient.listOrganisationFiles(organisationUuid), accountUuid)) errors.push(formatError$2(configYmlPath, source, flagKey, `No donation tax receipt PDF template found for the ${label} account (${accountUuid}). See: ${DONATION_RECEIPT_VALIDATION_GUIDE_URL}`));
547
+ }
548
+ return errors;
549
+ };
550
+ //#endregion
446
551
  //#region src/lib/validators/validateEmailTemplates.ts
447
552
  /**
448
553
  * Register custom Handlebars helpers - the same, which are used in the Email Service.
@@ -579,7 +684,7 @@ const coreVersionExists = async (version) => {
579
684
  * Locate the line of a `KEY=` assignment in the .env source, so the error can
580
685
  * be rendered as a code frame pointing at the value.
581
686
  */
582
- const findKeyLocation = (source, key) => {
687
+ const findKeyLocation$1 = (source, key) => {
583
688
  const lines = source.split("\n");
584
689
  const assignment = new RegExp(String.raw`^\s*${key}\s*=`);
585
690
  const index = lines.findIndex((line) => assignment.test(line));
@@ -606,8 +711,8 @@ const findKeyLocation = (source, key) => {
606
711
  * 2 | CORE_URL_PATTERN=adsf
607
712
  * | ^^^^ Must contain the "{{version}}" placeholder
608
713
  */
609
- const formatError = (dotEnvPath, source, key, message) => {
610
- const location = findKeyLocation(source, key);
714
+ const formatError$1 = (dotEnvPath, source, key, message) => {
715
+ const location = findKeyLocation$1(source, key);
611
716
  if (!location) return `${dotEnvPath}: ${key}: ${message}`;
612
717
  const frame = codeFrameColumns(source, location, {
613
718
  forceColor: true,
@@ -628,16 +733,117 @@ const validateEnv = async (dotEnvPath) => {
628
733
  const errors = [];
629
734
  for (const issue of result.error.issues) {
630
735
  const keys = issue.code === "unrecognized_keys" ? issue.keys : [String(issue.path[0] ?? "")];
631
- for (const key of keys) errors.push(formatError(dotEnvPath, source, key, issue.message));
736
+ for (const key of keys) errors.push(formatError$1(dotEnvPath, source, key, issue.message));
632
737
  }
633
738
  return errors;
634
739
  }
635
740
  if (parsed.CORE_VERSION) {
636
- 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`)];
741
+ if (await coreVersionExists(parsed.CORE_VERSION) === false) return [formatError$1(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`)];
637
742
  }
638
743
  return [];
639
744
  };
640
745
  //#endregion
746
+ //#region src/lib/validators/validateParametersYaml.ts
747
+ /** Email-valued keys checked in `email-config/parameters.yaml` when set. */
748
+ const EMAIL_KEYS = ["organisation_email", "blind_copy"];
749
+ const emailSchema = z.email();
750
+ /**
751
+ * Bare `key:`, YAML null, or blank strings are common in customer configs and
752
+ * must not fail validation.
753
+ */
754
+ const isUnsetEmailValue = (value) => value == void 0 || typeof value === "string" && value.trim() === "";
755
+ /**
756
+ * Locate a `key: value` assignment in the YAML source so the error can be
757
+ * rendered as a code frame pointing at the value.
758
+ *
759
+ * Prefers a key+value match so repeated keys across locales (`all` / `de` /
760
+ * `en`, …) point at the right line; falls back to the first key match.
761
+ */
762
+ const findKeyLocation = (source, key, value) => {
763
+ const lines = source.split("\n");
764
+ let index = -1;
765
+ if (value !== void 0) {
766
+ const escapedValue = value.replaceAll(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`);
767
+ const assignment = new RegExp(String.raw`^\s*${key}\s*:\s*['"]?${escapedValue}['"]?\s*(?:#.*)?$`);
768
+ index = lines.findIndex((line) => assignment.test(line));
769
+ }
770
+ if (index === -1) {
771
+ const keyOnly = new RegExp(String.raw`^\s*${key}\s*:`);
772
+ index = lines.findIndex((line) => keyOnly.test(line));
773
+ }
774
+ if (index === -1) return;
775
+ const line = index + 1;
776
+ const valueStart = lines[index].indexOf(":") + 1;
777
+ return {
778
+ end: {
779
+ column: lines[index].length,
780
+ line
781
+ },
782
+ start: {
783
+ column: valueStart + 1,
784
+ line
785
+ }
786
+ };
787
+ };
788
+ /**
789
+ * Format an error for a `key:` assignment as a code frame pointing at the
790
+ * value, so it looks fancy like this:
791
+ *
792
+ * email-config/parameters.yaml:3
793
+ * 2 | organisation_name: Example
794
+ * 3 | organisation_email: not-an-email
795
+ * | ^^^^^^^^^^^^ Invalid email address
796
+ */
797
+ const formatError = (parametersPath, source, key, message, value) => {
798
+ const location = findKeyLocation(source, key, value);
799
+ if (!location) return `${parametersPath}: ${key}: ${message}`;
800
+ const frame = codeFrameColumns(source, location, {
801
+ forceColor: true,
802
+ highlightCode: true,
803
+ message
804
+ });
805
+ return `\n${parametersPath}:${location.start.line}\n${frame}`;
806
+ };
807
+ /**
808
+ * Validate `organisation_email` and `blind_copy` under each locale block
809
+ * (`all`, `de`, `en`, …) in `email-config/parameters.yaml`.
810
+ *
811
+ * Unset values are allowed; non-empty values must be valid email addresses.
812
+ */
813
+ const validateParametersYaml = (parametersPath) => {
814
+ if (!fs.existsSync(parametersPath)) return [];
815
+ const source = fs.readFileSync(parametersPath, "utf8");
816
+ let parsed;
817
+ try {
818
+ parsed = yaml.load(source);
819
+ } catch (error) {
820
+ return [`${parametersPath}: Invalid YAML: ${error instanceof Error ? error.message : String(error)}`];
821
+ }
822
+ if (parsed == void 0) return [];
823
+ if (typeof parsed !== "object" || Array.isArray(parsed)) return [`${parametersPath}: Expected a mapping of locale blocks (e.g. \`all\`), got ${Array.isArray(parsed) ? "an array" : typeof parsed}`];
824
+ const errors = [];
825
+ for (const [locale, block] of Object.entries(parsed)) {
826
+ if (block == void 0) continue;
827
+ if (typeof block !== "object" || Array.isArray(block)) {
828
+ errors.push(`${parametersPath}: ${locale}: Expected a mapping of parameters, got ${Array.isArray(block) ? "an array" : typeof block}`);
829
+ continue;
830
+ }
831
+ const parameters = block;
832
+ for (const key of EMAIL_KEYS) {
833
+ if (!(key in parameters)) continue;
834
+ const value = parameters[key];
835
+ if (isUnsetEmailValue(value)) continue;
836
+ if (typeof value !== "string") {
837
+ errors.push(formatError(parametersPath, source, key, "Must be a string email address"));
838
+ continue;
839
+ }
840
+ const result = emailSchema.safeParse(value);
841
+ if (!result.success) errors.push(formatError(parametersPath, source, key, result.error.issues[0]?.message ?? "Invalid email address", value));
842
+ }
843
+ }
844
+ return errors;
845
+ };
846
+ //#endregion
641
847
  //#region src/commands/validate.ts
642
848
  /**
643
849
  * Validates the current Tamaro configuration to avoid common errors.
@@ -647,7 +853,12 @@ const validateEnv = async (dotEnvPath) => {
647
853
  * deployed.
648
854
  */
649
855
  const validate = async () => {
650
- const errors = [...await validateEnv(".env"), ...validateEmailTemplates(globSync("email-config/templates/**/*.hbs").toSorted())];
856
+ const errors = [
857
+ ...await validateEnv(".env"),
858
+ ...validateEmailTemplates(globSync("email-config/templates/**/*.hbs").toSorted()),
859
+ ...validateParametersYaml("email-config/parameters.yaml"),
860
+ ...await validateDonationReceiptTemplates("config.yml")
861
+ ];
651
862
  if (errors.length > 0) {
652
863
  logError("❌ Validation failed with the following errors:");
653
864
  console.log(errors.map((el) => el.toString()).join("\n"));
@@ -1324,7 +1535,7 @@ const promptBucketTamaroEmailConfig = async () => {
1324
1535
  //#endregion
1325
1536
  //#region package.json
1326
1537
  var name = "@raisenow/tamaro-cli";
1327
- var version = "3.1.0";
1538
+ var version = "3.2.0-dev.1";
1328
1539
  //#endregion
1329
1540
  //#region src/cli.ts
1330
1541
  const cli = createCommand().name(name).description("CLI for Tamaro Customer Configurations development").version(version, "-v, --version");
package/package.json CHANGED
@@ -1,17 +1,30 @@
1
1
  {
2
2
  "name": "@raisenow/tamaro-cli",
3
- "version": "3.1.0",
3
+ "version": "3.2.0-dev.1",
4
4
  "author": {
5
5
  "name": "RaiseNow",
6
6
  "email": "development@raisenow.com"
7
7
  },
8
8
  "type": "module",
9
+ "bin": "dist/cli.js",
10
+ "packageManager": "pnpm@11.10.0",
9
11
  "engines": {
10
12
  "node": ">=24.18.0",
11
13
  "pnpm": ">=11.10.0",
12
14
  "npm": "please-use-pnpm",
13
15
  "yarn": "please-use-pnpm"
14
16
  },
17
+ "scripts": {
18
+ "typecheck": "tsc --build --emitDeclarationOnly",
19
+ "build": "tsdown",
20
+ "dev": "pnpm build --watch",
21
+ "readme": "pnpx http-server . --cors -o /readme.html",
22
+ "lint": "DEBUG=eslint:eslint-helpers eslint .",
23
+ "format": "prettier . --write --ignore-path ./.prettierignore",
24
+ "test": "vitest",
25
+ "prepare": "husky",
26
+ "prepublishOnly": "pnpm build"
27
+ },
15
28
  "dependencies": {
16
29
  "@aws-sdk/client-s3": "^3.1081.0",
17
30
  "@babel/code-frame": "^8.0.0",
@@ -54,17 +67,5 @@
54
67
  "eslint --fix",
55
68
  "prettier --write"
56
69
  ]
57
- },
58
- "scripts": {
59
- "typecheck": "tsc --build --emitDeclarationOnly",
60
- "build": "tsdown",
61
- "dev": "pnpm build --watch",
62
- "readme": "pnpx http-server . --cors -o /readme.html",
63
- "lint": "DEBUG=eslint:eslint-helpers eslint .",
64
- "format": "prettier . --write --ignore-path ./.prettierignore",
65
- "test": "vitest"
66
- },
67
- "bin": {
68
- "tamaro-cli": "dist/cli.js"
69
70
  }
70
- }
71
+ }