@raisenow/tamaro-cli 3.1.0-dev.1 → 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 +6 -3
  2. package/dist/cli.js +113 -12
  3. package/package.json +15 -14
package/README.md CHANGED
@@ -282,9 +282,12 @@ 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
286
- email templates, invalid `.env` values, or RaiseNow-generated tax receipts
287
- enabled without an uploaded organisation template.
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
288
291
 
289
292
  When `autogeneratedDonationReceiptEnabled` (or
290
293
  `autogenerated_donation_receipt_enabled`) is `true`, this command authenticates
package/dist/cli.js CHANGED
@@ -323,8 +323,7 @@ var EpmsClient = class EpmsClient {
323
323
  }
324
324
  async listOrganisationFiles(organisationUuid) {
325
325
  try {
326
- const response = await this.#epmsClient.get(`organisations/${organisationUuid}/files`).json();
327
- return Array.isArray(response) ? response : response.data ?? [];
326
+ return await this.#epmsClient.get(`organisations/${organisationUuid}/files`).json();
328
327
  } catch (error) {
329
328
  throw new Error(`Failed to list organisation files for organisation UUID: ${organisationUuid}`, { cause: error });
330
329
  }
@@ -483,7 +482,7 @@ const donationReceiptTemplateExists = (files, accountUuid) => {
483
482
  * Locate the line of a top-level `key:` assignment in the config.yml source,
484
483
  * so the error can be rendered as a code frame pointing at the value.
485
484
  */
486
- const findKeyLocation$1 = (source, key) => {
485
+ const findKeyLocation$2 = (source, key) => {
487
486
  const lines = source.split("\n");
488
487
  const assignment = new RegExp(String.raw`^\s*${key}\s*:`);
489
488
  const index = lines.findIndex((line) => assignment.test(line));
@@ -510,8 +509,8 @@ const findKeyLocation$1 = (source, key) => {
510
509
  * 51 | autogeneratedDonationReceiptEnabled: true
511
510
  * | ^^^^ No donation tax receipt PDF template found…
512
511
  */
513
- const formatError$1 = (configYmlPath, source, key, message) => {
514
- const location = findKeyLocation$1(source, key);
512
+ const formatError$2 = (configYmlPath, source, key, message) => {
513
+ const location = findKeyLocation$2(source, key);
515
514
  if (!location) return `${configYmlPath}: ${key}: ${message}`;
516
515
  const frame = codeFrameColumns(source, location, {
517
516
  forceColor: true,
@@ -544,7 +543,7 @@ const validateDonationReceiptTemplates = async (configYmlPath) => {
544
543
  if (!accountUuid) continue;
545
544
  const epmsClient = epmsAuthenticate(env);
546
545
  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}`));
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}`));
548
547
  }
549
548
  return errors;
550
549
  };
@@ -685,7 +684,7 @@ const coreVersionExists = async (version) => {
685
684
  * Locate the line of a `KEY=` assignment in the .env source, so the error can
686
685
  * be rendered as a code frame pointing at the value.
687
686
  */
688
- const findKeyLocation = (source, key) => {
687
+ const findKeyLocation$1 = (source, key) => {
689
688
  const lines = source.split("\n");
690
689
  const assignment = new RegExp(String.raw`^\s*${key}\s*=`);
691
690
  const index = lines.findIndex((line) => assignment.test(line));
@@ -712,8 +711,8 @@ const findKeyLocation = (source, key) => {
712
711
  * 2 | CORE_URL_PATTERN=adsf
713
712
  * | ^^^^ Must contain the "{{version}}" placeholder
714
713
  */
715
- const formatError = (dotEnvPath, source, key, message) => {
716
- const location = findKeyLocation(source, key);
714
+ const formatError$1 = (dotEnvPath, source, key, message) => {
715
+ const location = findKeyLocation$1(source, key);
717
716
  if (!location) return `${dotEnvPath}: ${key}: ${message}`;
718
717
  const frame = codeFrameColumns(source, location, {
719
718
  forceColor: true,
@@ -734,16 +733,117 @@ const validateEnv = async (dotEnvPath) => {
734
733
  const errors = [];
735
734
  for (const issue of result.error.issues) {
736
735
  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));
736
+ for (const key of keys) errors.push(formatError$1(dotEnvPath, source, key, issue.message));
738
737
  }
739
738
  return errors;
740
739
  }
741
740
  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`)];
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`)];
743
742
  }
744
743
  return [];
745
744
  };
746
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
747
847
  //#region src/commands/validate.ts
748
848
  /**
749
849
  * Validates the current Tamaro configuration to avoid common errors.
@@ -756,6 +856,7 @@ const validate = async () => {
756
856
  const errors = [
757
857
  ...await validateEnv(".env"),
758
858
  ...validateEmailTemplates(globSync("email-config/templates/**/*.hbs").toSorted()),
859
+ ...validateParametersYaml("email-config/parameters.yaml"),
759
860
  ...await validateDonationReceiptTemplates("config.yml")
760
861
  ];
761
862
  if (errors.length > 0) {
@@ -1434,7 +1535,7 @@ const promptBucketTamaroEmailConfig = async () => {
1434
1535
  //#endregion
1435
1536
  //#region package.json
1436
1537
  var name = "@raisenow/tamaro-cli";
1437
- var version = "3.1.0-dev.1";
1538
+ var version = "3.2.0-dev.1";
1438
1539
  //#endregion
1439
1540
  //#region src/cli.ts
1440
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-dev.1",
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
+ }