@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.
- package/README.md +13 -1
- package/dist/cli.js +219 -8
- 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
|
|
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 = [
|
|
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.
|
|
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.
|
|
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
|
+
}
|