@toneflix/paystack-cli 0.1.6 → 0.1.7

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 (4) hide show
  1. package/README.md +4 -3
  2. package/bin/cli.cjs +300 -91
  3. package/bin/cli.js +294 -87
  4. package/package.json +9 -6
package/README.md CHANGED
@@ -1,8 +1,9 @@
1
1
  # Paystack CLI
2
2
 
3
- [![npm version](https://img.shields.io/npm/v/@toneflix/paystack-cli.svg)](https://www.npmjs.com/package/@toneflix/paystack-cli)
4
- [![License](https://img.shields.io/npm/l/@toneflix/paystack-cli.svg)](https://github.com/toneflix/paystack-cli/blob/main/LICENSE)
5
- [![CI](https://github.com/toneflix/paystack-cli/actions/workflows/ci.yml/badge.svg)](https://github.com/toneflix/paystack-cli/actions/workflows/ci.yml)
3
+ [![npm version](https://img.shields.io/npm/v/@toneflix/paystack-cli.svg?label=npm+version&style=flat-square)](https://www.npmjs.com/package/@toneflix/paystack-cli)
4
+ [![npm downloads](https://img.shields.io/npm/dt/%40toneflix%2Fpaystack-cli?style=flat-square)](https://www.npmjs.com/package/@toneflix/paystack-cli)
5
+ [![GitHub License](https://img.shields.io/github/license/toneflix/paystack-cli?style=flat-square)](https://github.com/toneflix/paystack-cli/blob/main/LICENSE)
6
+ [![CI](https://github.com/toneflix/paystack-cli/actions/workflows/ci.yml/badge.svg?label=Tests)](https://github.com/toneflix/paystack-cli/actions/workflows/ci.yml)
6
7
  [![Deploy Docs](https://github.com/toneflix/paystack-cli/actions/workflows/deploy-docs.yml/badge.svg)](https://github.com/toneflix/paystack-cli/actions/workflows/deploy-docs.yml)
7
8
 
8
9
  The Paystack CLI helps you build, test, and manage your Paystack integration right from the terminal. Interact with the Paystack API, test webhooks locally, and manage your integration settings without leaving your command line.
package/bin/cli.cjs CHANGED
@@ -22,51 +22,80 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
22
22
  }) : target, mod));
23
23
 
24
24
  //#endregion
25
- let path = require("path");
26
- path = __toESM(path);
27
25
  let better_sqlite3 = require("better-sqlite3");
28
26
  better_sqlite3 = __toESM(better_sqlite3);
29
- let url = require("url");
30
- url = __toESM(url);
27
+ let os = require("os");
28
+ os = __toESM(os);
31
29
  let fs = require("fs");
32
30
  fs = __toESM(fs);
31
+ let path = require("path");
32
+ path = __toESM(path);
33
33
  let __h3ravel_shared = require("@h3ravel/shared");
34
34
  __h3ravel_shared = __toESM(__h3ravel_shared);
35
+ let cli_table3 = require("cli-table3");
36
+ cli_table3 = __toESM(cli_table3);
35
37
  let axios = require("axios");
36
38
  axios = __toESM(axios);
39
+ let url = require("url");
40
+ url = __toESM(url);
37
41
  let __h3ravel_musket = require("@h3ravel/musket");
38
42
  __h3ravel_musket = __toESM(__h3ravel_musket);
39
- let ora = require("ora");
40
- ora = __toESM(ora);
43
+ let h3 = require("h3");
44
+ h3 = __toESM(h3);
45
+ let detect_port = require("detect-port");
46
+ detect_port = __toESM(detect_port);
41
47
  let module$1 = require("module");
42
48
  module$1 = __toESM(module$1);
43
- let os = require("os");
44
- os = __toESM(os);
45
49
  let crypto = require("crypto");
46
50
  crypto = __toESM(crypto);
47
51
  let __ngrok_ngrok = require("@ngrok/ngrok");
48
52
  __ngrok_ngrok = __toESM(__ngrok_ngrok);
49
53
 
54
+ //#region src/utils/global.ts
55
+ String.prototype.toKebabCase = function() {
56
+ return this.replace(/([a-z])([A-Z])/g, "$1-$2").replace(/[\s_]+/g, "-").toLowerCase();
57
+ };
58
+ String.prototype.toCamelCase = function() {
59
+ return this.replace(/[-_ ]+([a-zA-Z0-9])/g, (_, c) => c.toUpperCase()).replace(/^[A-Z]/, (c) => c.toLowerCase());
60
+ };
61
+ String.prototype.toPascalCase = function() {
62
+ return this.replace(/(^\w|[-_ ]+\w)/g, (match) => match.replace(/[-_ ]+/, "").toUpperCase());
63
+ };
64
+ String.prototype.toSnakeCase = function() {
65
+ return this.replace(/([a-z])([A-Z])/g, "$1_$2").replace(/[\s-]+/g, "_").toLowerCase();
66
+ };
67
+ String.prototype.toTitleCase = function() {
68
+ return this.toLowerCase().replace(/(^|\s)\w/g, (match) => match.toUpperCase());
69
+ };
70
+ String.prototype.toCleanCase = function() {
71
+ return this.replace(/([a-z])([A-Z])/g, "$1 $2").replace(/[_-]+/g, " ").replace(/\s+/g, " ").split(" ").map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join(" ").trim().replace(/\b\w{1,3}\b/g, (match) => match.toUpperCase());
72
+ };
73
+ String.prototype.truncate = function(n, suffix = "…") {
74
+ return this.length > n ? this.slice(0, n - 1) + suffix : this.toString();
75
+ };
76
+
77
+ //#endregion
50
78
  //#region src/db.ts
51
79
  let db;
52
- const __dirname$2 = (0, path.dirname)((0, url.fileURLToPath)(require("url").pathToFileURL(__filename).href));
53
- const dirPath = path.default.normalize(path.default.join(__dirname$2, "..", "data"));
54
- (0, fs.mkdirSync)(dirPath, { recursive: true });
55
- const useDbPath = () => [dirPath];
80
+ let dbPath = path.default.join((0, os.homedir)(), ".paystackcli");
81
+ (0, fs.mkdirSync)(dbPath, { recursive: true });
82
+ const useDbPath = () => [dbPath, (path$3) => {
83
+ dbPath = path$3;
84
+ }];
56
85
  /**
57
86
  * Hook to get or set the database instance.
58
87
  *
59
88
  * @returns
60
89
  */
61
90
  const useDb = () => {
62
- return [() => db, (newDb) => {
63
- db = newDb;
91
+ return [() => db, (filename) => {
92
+ db = new better_sqlite3.default(path.default.join(dbPath, filename));
64
93
  const [{ journal_mode }] = db.pragma("journal_mode");
65
94
  if (journal_mode !== "wal") db.pragma("journal_mode = WAL");
66
95
  }];
67
96
  };
68
97
  const [getDatabase, setDatabase] = useDb();
69
- setDatabase(new better_sqlite3.default(path.default.join(dirPath, "app.db")));
98
+ setDatabase("app.db");
70
99
  /**
71
100
  * Initialize the database
72
101
  *
@@ -114,7 +143,7 @@ function remove(key) {
114
143
  * @param key
115
144
  * @returns
116
145
  */
117
- function read(key) {
146
+ function read(key, defaultValue) {
118
147
  const db$1 = getDatabase();
119
148
  try {
120
149
  const row = db$1.prepare("SELECT * FROM json_store WHERE key = ?").get(key);
@@ -124,7 +153,7 @@ function read(key) {
124
153
  return row.value;
125
154
  }
126
155
  } catch {}
127
- return null;
156
+ return defaultValue ?? null;
128
157
  }
129
158
 
130
159
  //#endregion
@@ -398,6 +427,15 @@ const findCLIPackageJson = (startDir = __dirname$1) => {
398
427
  }
399
428
  return null;
400
429
  };
430
+ const objectToTable = (obj, titleKeys = false) => {
431
+ const table = new cli_table3.default();
432
+ for (const rawKey in obj) {
433
+ if (typeof obj[rawKey] === "object") continue;
434
+ const key = logger((titleKeys ? rawKey.toCleanCase() : rawKey).truncate(30), ["bold"]);
435
+ table.push({ [key]: String(obj[rawKey]).truncate(40) });
436
+ }
437
+ return table.toString();
438
+ };
401
439
 
402
440
  //#endregion
403
441
  //#region src/paystack/apis.ts
@@ -2565,7 +2603,7 @@ const buildSignature = (param, cmd) => {
2565
2603
  };
2566
2604
 
2567
2605
  //#endregion
2568
- //#region src/utils/renderer.ts
2606
+ //#region src/utils/builders.ts
2569
2607
  /**
2570
2608
  * We will recursively map through the result data and log each key value pair
2571
2609
  * as we apply coloring based on the value type.
@@ -2580,7 +2618,7 @@ const dataRenderer = (data) => {
2580
2618
  for (const key in obj) {
2581
2619
  const value = obj[key];
2582
2620
  if (typeof value === "object" && value !== null) {
2583
- console.log(`${indentation}${stringFormatter(key)}:`);
2621
+ console.log(`${indentation}${key.toCleanCase()}:`);
2584
2622
  render(value, indent + 2);
2585
2623
  } else {
2586
2624
  let coloredValue;
@@ -2596,24 +2634,33 @@ const dataRenderer = (data) => {
2596
2634
  break;
2597
2635
  default: coloredValue = value;
2598
2636
  }
2599
- console.log(`${indentation}${stringFormatter(key)}: ${coloredValue}`);
2637
+ console.log(`${indentation}${key.toCleanCase()}: ${coloredValue}`);
2600
2638
  }
2601
2639
  }
2602
2640
  };
2603
2641
  render(data);
2604
2642
  };
2605
2643
  /**
2606
- * We will format a string by replacing underscores and hyphens with spaces,
2607
- * capitalizing the first letter of every word,
2608
- * converting camelCase to spaced words,
2609
- * and trimming any leading or trailing spaces.
2610
- * If a sentence is only two letters long we will make it uppercase.
2644
+ * Starts a mini HTTP server on the specified port to listen for incoming webhook requests.
2611
2645
  *
2612
- * @param str
2646
+ * @param port
2613
2647
  * @returns
2614
2648
  */
2615
- const stringFormatter = (str) => {
2616
- return str.replace(/([a-z])([A-Z])/g, "$1 $2").replace(/[_-]+/g, " ").replace(/\s+/g, " ").split(" ").map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join(" ").trim().replace(/^(\w{2})$/, (_, p1) => p1.toUpperCase());
2649
+ const miniServer = async (port = 3e3) => {
2650
+ const route = async (event) => {
2651
+ console.log("Incoming Webhook Request [", event.req.method, "]");
2652
+ const payload = JSON.parse(await event.req.text() || "{}");
2653
+ return Object.assign({}, { signature: event.req.headers.get("x-paystack-signature") ?? "N/A" }, payload);
2654
+ };
2655
+ const app = new h3.H3().get("/webhook", route).post("/webhook", route);
2656
+ port = await (0, detect_port.detect)(port);
2657
+ const server = (0, h3.serve)(app, {
2658
+ port,
2659
+ silent: true
2660
+ });
2661
+ const url$1 = `http://localhost:${port}/webhook`;
2662
+ __h3ravel_shared.Logger.log([["🚀 Mini server is running at:", "green"], [url$1, "cyan"]], " ");
2663
+ return Object.assign({}, server, { url: url$1 });
2617
2664
  };
2618
2665
 
2619
2666
  //#endregion
@@ -2638,14 +2685,14 @@ var Commands_default = () => {
2638
2685
  signature = `${key} \n${args}`;
2639
2686
  description = schema.description || "No description available.";
2640
2687
  handle = async () => {
2641
- const [_, setCommand] = useCommand();
2688
+ const [command$1, setCommand] = useCommand();
2642
2689
  setCommand(this);
2643
2690
  for (const param of schema.params) if (param.required && !this.argument(param.parameter)) return void this.newLine().error(`Missing required argument: ${param.parameter}`).newLine();
2644
2691
  const selected_integration = read("selected_integration")?.id;
2645
2692
  const user = read("user")?.id;
2646
2693
  if (!selected_integration || !user) return void this.error("ERROR: You're not signed in, please run the [login] command before you begin").newLine();
2647
2694
  this.newLine();
2648
- const spinner = (0, ora.default)("Loading...\n").start();
2695
+ const spinner = command$1().spinner("Loading...\n").start();
2649
2696
  const [err, result] = await promiseWrapper(executeSchema(schema, {
2650
2697
  ...this.options(),
2651
2698
  ...this.arguments()
@@ -2746,31 +2793,32 @@ var InfoCommand = class extends __h3ravel_musket.Command {
2746
2793
  version: "unknown",
2747
2794
  dependencies: {}
2748
2795
  };
2796
+ const user = read("user");
2749
2797
  const pkgPath = findCLIPackageJson();
2750
2798
  const require$1 = (0, module$1.createRequire)(require("url").pathToFileURL(__filename).href);
2751
- const [_, setCommand] = useCommand();
2752
- const [dbPath] = useDbPath();
2799
+ const [, setCommand] = useCommand();
2800
+ const [dbPath$1] = useDbPath();
2753
2801
  setCommand(this);
2754
2802
  init();
2755
- const spinner = (0, ora.default)("Gathering application information...\n").start();
2803
+ const spinner = this.spinner("Gathering application information...\n").start();
2756
2804
  if (pkgPath) try {
2757
2805
  pkg = require$1(pkgPath);
2758
2806
  } catch {}
2759
2807
  wait(500, () => {
2760
2808
  spinner.succeed("Application Information Loaded.\n");
2761
- dataRenderer({
2762
- appVersion: pkg.version,
2763
- platform: os.default.platform(),
2764
- arch: os.default.arch(),
2765
- cpus: os.default.cpus().length,
2766
- hostname: os.default.hostname(),
2767
- totalMemory: os.default.totalmem(),
2768
- freeMemory: os.default.freemem(),
2769
- uptime: os.default.uptime(),
2770
- username: os.default.userInfo().username,
2771
- database: dbPath + "/app.db",
2772
- dependencies: Object.keys(pkg.dependencies).join(", ")
2809
+ const out = objectToTable({
2810
+ "App Version": pkg.version,
2811
+ "Platform": `${os.default.platform()} ${os.default.arch()} (${os.default.release()})`,
2812
+ "CPUs": os.default.cpus().length,
2813
+ "Host": `${os.default.userInfo().username}@${os.default.hostname()}`,
2814
+ "Memory": `${(os.default.freemem() / 1024 ** 3).toFixed(2)} GB / ${(os.default.totalmem() / 1024 ** 3).toFixed(2)} GB`,
2815
+ "Database Path": dbPath$1 + "/app.db",
2816
+ "Paystack User": user ? `${user.first_name} ${user.last_name} (ID: ${user.id})` : "Not logged in",
2817
+ "Default Integration": read("selected_integration")?.business_name || "Not set"
2773
2818
  });
2819
+ console.log(out.toString());
2820
+ logger("\nDependencies:", "yellow");
2821
+ logger(Object.keys(pkg.dependencies).map((dep) => `${dep}`).join(", "), "green");
2774
2822
  this.newLine();
2775
2823
  });
2776
2824
  }
@@ -2789,6 +2837,21 @@ var InitCommand = class extends __h3ravel_musket.Command {
2789
2837
  }
2790
2838
  };
2791
2839
 
2840
+ //#endregion
2841
+ //#region src/Commands/IntegrationInfoCommand.ts
2842
+ var IntegrationInfoCommand = class extends __h3ravel_musket.Command {
2843
+ signature = "integration:info";
2844
+ description = "Get information about the currently selected integration.";
2845
+ async handle() {
2846
+ const [_, setCommand] = useCommand();
2847
+ setCommand(this);
2848
+ const selected_integration = read("selected_integration");
2849
+ if (!selected_integration) return void this.error(`ERROR: No integration selected, please run the ${logger("integration:set", ["grey", "italic"])} command to select an integration before proceeding.`);
2850
+ console.log(objectToTable(selected_integration, true));
2851
+ this.newLine();
2852
+ }
2853
+ };
2854
+
2792
2855
  //#endregion
2793
2856
  //#region src/paystack/webhooks.ts
2794
2857
  const webhook = {
@@ -2796,7 +2859,7 @@ const webhook = {
2796
2859
  event: "charge.success",
2797
2860
  data: {
2798
2861
  id: 302961,
2799
- domain: "live",
2862
+ domain: "test",
2800
2863
  status: "success",
2801
2864
  reference: "qTPrJoy9Bx",
2802
2865
  amount: 1e4,
@@ -2863,14 +2926,14 @@ const webhook = {
2863
2926
  "transfer.success": {
2864
2927
  event: "transfer.success",
2865
2928
  data: {
2866
- domain: "live",
2929
+ domain: "test",
2867
2930
  amount: 1e4,
2868
2931
  currency: "NGN",
2869
2932
  source: "balance",
2870
2933
  source_details: null,
2871
2934
  reason: "Bless you",
2872
2935
  recipient: {
2873
- domain: "live",
2936
+ domain: "test",
2874
2937
  type: "nuban",
2875
2938
  currency: "NGN",
2876
2939
  name: "Someone",
@@ -2945,7 +3008,7 @@ const webhook = {
2945
3008
  source_details: null,
2946
3009
  reason: "Test",
2947
3010
  recipient: {
2948
- domain: "live",
3011
+ domain: "test",
2949
3012
  type: "nuban",
2950
3013
  currency: "NGN",
2951
3014
  name: "Test account",
@@ -2965,6 +3028,95 @@ const webhook = {
2965
3028
  transferred_at: null,
2966
3029
  created_at: "2017-12-01T08:51:37.000Z"
2967
3030
  }
3031
+ },
3032
+ "customeridentification.failed": {
3033
+ "event": "customeridentification.failed",
3034
+ "data": {
3035
+ "customer_id": 82796315,
3036
+ "customer_code": "CUS_XXXXXXXXXXXXXXX",
3037
+ "email": "email@email.com",
3038
+ "identification": {
3039
+ "country": "NG",
3040
+ "type": "bank_account",
3041
+ "bvn": "123*****456",
3042
+ "account_number": "012****345",
3043
+ "bank_code": "999991"
3044
+ },
3045
+ "reason": "Account number or BVN is incorrect"
3046
+ }
3047
+ },
3048
+ "customeridentification.success": {
3049
+ "event": "customeridentification.success",
3050
+ "data": {
3051
+ "customer_id": "9387490384",
3052
+ "customer_code": "CUS_xnxdt6s1zg1f4nx",
3053
+ "email": "bojack@horsinaround.com",
3054
+ "identification": {
3055
+ "country": "NG",
3056
+ "type": "bvn",
3057
+ "value": "200*****677"
3058
+ }
3059
+ }
3060
+ },
3061
+ "dedicatedaccount.assign.failed": {
3062
+ "event": "dedicatedaccount.assign.failed",
3063
+ "data": {
3064
+ "customer": {
3065
+ "id": 100110,
3066
+ "first_name": "John",
3067
+ "last_name": "Doe",
3068
+ "email": "johndoe@test.com",
3069
+ "customer_code": "CUS_hcekca0j0bbg2m4",
3070
+ "phone": "+2348100000000",
3071
+ "metadata": {},
3072
+ "risk_action": "default",
3073
+ "international_format_phone": "+2348100000000"
3074
+ },
3075
+ "dedicated_account": null,
3076
+ "identification": { "status": "failed" }
3077
+ }
3078
+ },
3079
+ "dedicatedaccount.assign.success": {
3080
+ "event": "dedicatedaccount.assign.success",
3081
+ "data": {
3082
+ "customer": {
3083
+ "id": 100110,
3084
+ "first_name": "John",
3085
+ "last_name": "Doe",
3086
+ "email": "johndoe@test.com",
3087
+ "customer_code": "CUS_hp05n9khsqcesz2",
3088
+ "phone": "+2348100000000",
3089
+ "metadata": {},
3090
+ "risk_action": "default",
3091
+ "international_format_phone": "+2348100000000"
3092
+ },
3093
+ "dedicated_account": {
3094
+ "bank": {
3095
+ "name": "Test Bank",
3096
+ "id": 20,
3097
+ "slug": "test-bank"
3098
+ },
3099
+ "account_name": "PAYSTACK/John Doe",
3100
+ "account_number": "1234567890",
3101
+ "assigned": true,
3102
+ "currency": "NGN",
3103
+ "metadata": null,
3104
+ "active": true,
3105
+ "id": 987654,
3106
+ "created_at": "2022-06-21T17:12:40.000Z",
3107
+ "updated_at": "2022-08-12T14:02:51.000Z",
3108
+ "assignment": {
3109
+ "integration": 100123,
3110
+ "assignee_id": 100110,
3111
+ "assignee_type": "Customer",
3112
+ "expired": false,
3113
+ "account_type": "PAY-WITH-TRANSFER-RECURRING",
3114
+ "assigned_at": "2022-08-12T14:02:51.614Z",
3115
+ "expired_at": null
3116
+ }
3117
+ },
3118
+ "identification": { "status": "success" }
3119
+ }
2968
3120
  }
2969
3121
  };
2970
3122
  var webhooks_default = webhook;
@@ -3101,6 +3253,7 @@ function getKeys(token, type = "secret", domain = "test") {
3101
3253
  */
3102
3254
  async function pingWebhook(options, event = "charge.success") {
3103
3255
  const [command] = useCommand();
3256
+ const cmd = command();
3104
3257
  let canProceed = false;
3105
3258
  try {
3106
3259
  canProceed = await refreshIntegration();
@@ -3111,33 +3264,35 @@ async function pingWebhook(options, event = "charge.success") {
3111
3264
  if (options.domain) domain = options.domain;
3112
3265
  if (options.event) event = options.event;
3113
3266
  const key = await getKeys(read("token"), "secret", domain);
3114
- return new Promise((resolve, reject) => {
3115
- if (!canProceed) return void command().error("ERROR: Unable to ping webhook URL");
3116
- const eventObject = webhooks_default[event];
3117
- if (eventObject) {
3118
- const hash = crypto.default.createHmac("sha512", key).update(JSON.stringify(eventObject)).digest("hex");
3119
- const uri = read("selected_integration")[domain + "_webhook_endpoint"];
3120
- const spinner = (0, ora.default)(`Sending sample ${event} event payload to ${uri}`).start();
3121
- axios_default.post(uri, eventObject, { headers: { "x-paystack-signature": hash } }).then((response) => {
3122
- spinner.succeed(`Sample ${event} event payload sent to ${uri}`);
3123
- resolve({
3124
- code: response.status,
3125
- text: response.statusText,
3126
- data: response.data
3127
- });
3128
- }).catch((e) => {
3129
- spinner.fail(`Failed to send sample ${event} event payload to ${uri}`);
3130
- resolve({
3131
- code: e.response?.status ?? 0,
3132
- text: e.response?.statusText || "No response",
3133
- data: typeof e.response?.data === "string" && e.response?.data?.includes("<html") ? { response: "HTML Response" } : e.response?.data || "No response data"
3134
- });
3135
- });
3136
- } else {
3137
- command().error("ERROR: Invalid event type - " + event);
3138
- reject();
3267
+ if (!canProceed) return void cmd.error("ERROR: Unable to ping webhook URL");
3268
+ const eventObject = webhooks_default[event];
3269
+ if (eventObject) {
3270
+ if (options.mod) for (const [key$1, val] of Object.entries(eventObject.data)) if (["string", "number"].includes(typeof val)) eventObject.data[key$1] = await cmd.ask(`Enter new value for '${key$1}':`, String(val));
3271
+ else if (typeof val === "boolean") eventObject.data[key$1] = await cmd.choice(`Enter new value for '${key$1}':`, ["true", "false"], val ? 0 : 1) === "true";
3272
+ else continue;
3273
+ const hash = crypto.default.createHmac("sha512", key).update(JSON.stringify(eventObject)).digest("hex");
3274
+ const uri = read("selected_integration")[domain + "_webhook_endpoint"];
3275
+ const spinner = cmd.spinner(`Sending sample ${event} event payload to ${uri}`).start();
3276
+ try {
3277
+ const response = await axios_default.post(uri, eventObject, { headers: { "x-paystack-signature": hash } });
3278
+ spinner.succeed(`Sample ${event} event payload sent to ${uri}`);
3279
+ return {
3280
+ code: response.status,
3281
+ text: response.statusText,
3282
+ data: response.data
3283
+ };
3284
+ } catch (e) {
3285
+ spinner.fail(`Failed to send sample ${event} event payload to ${uri}`);
3286
+ return {
3287
+ code: e.response?.status ?? 0,
3288
+ text: e.response?.statusText || "No response",
3289
+ data: typeof e.response?.data === "string" && e.response?.data?.includes("<html") ? { response: "HTML Response" } : e.response?.data || "No response data"
3290
+ };
3139
3291
  }
3140
- });
3292
+ } else {
3293
+ cmd.error("ERROR: Invalid event type - " + event);
3294
+ throw new Error("Invalid event type: " + event);
3295
+ }
3141
3296
  }
3142
3297
  /**
3143
3298
  * Get integration
@@ -3148,7 +3303,7 @@ async function pingWebhook(options, event = "charge.success") {
3148
3303
  */
3149
3304
  function getIntegration(id, token) {
3150
3305
  const [command] = useCommand();
3151
- const spinner = (0, ora.default)("getting integration").start();
3306
+ const spinner = command().spinner("getting integration").start();
3152
3307
  return new Promise((resolve, reject) => {
3153
3308
  axios_default.get("/integration/" + id, { headers: {
3154
3309
  Authorization: "Bearer " + token,
@@ -3172,7 +3327,7 @@ function getIntegration(id, token) {
3172
3327
  */
3173
3328
  async function signIn(email, password) {
3174
3329
  const [command] = useCommand();
3175
- const spinner = (0, ora.default)("Logging in...").start();
3330
+ const spinner = command().spinner("Logging in...").start();
3176
3331
  try {
3177
3332
  const { data: response } = await axios_default.post("/login", {
3178
3333
  email,
@@ -3292,7 +3447,7 @@ var LogoutCommand = class extends __h3ravel_musket.Command {
3292
3447
  const [_, setCommand] = useCommand();
3293
3448
  setCommand(this);
3294
3449
  this.newLine();
3295
- const spinner = (0, ora.default)("Logging out...").start();
3450
+ const spinner = this.spinner("Logging out...").start();
3296
3451
  try {
3297
3452
  await wait(1e3, () => clearAuth());
3298
3453
  spinner.succeed("Logged out successfully");
@@ -3304,15 +3459,47 @@ var LogoutCommand = class extends __h3ravel_musket.Command {
3304
3459
  }
3305
3460
  };
3306
3461
 
3462
+ //#endregion
3463
+ //#region src/Commands/SetIntegrationCommand.ts
3464
+ var SetIntegrationCommand = class extends __h3ravel_musket.Command {
3465
+ signature = "integration:set";
3466
+ description = "Set the active integration for Paystack CLI usage.";
3467
+ async handle() {
3468
+ const [_, setCommand] = useCommand();
3469
+ setCommand(this);
3470
+ const user = read("user");
3471
+ const token = read("token");
3472
+ if (!token || !user) return void this.error(`ERROR: You're not signed in, please run the ${logger("login", ["grey", "italic"])} command before you begin`);
3473
+ const [err, integration] = await promiseWrapper(selectIntegration(user.integrations, token));
3474
+ if (err || !integration) this.error("ERROR: " + (err ?? "Integration selection failed")).newLine();
3475
+ else {
3476
+ write("selected_integration", integration);
3477
+ const user_role = read("selected_integration").logged_in_user_role;
3478
+ const [err$1, integrationData] = await promiseWrapper(getIntegration(integration.id, token));
3479
+ if (err$1 || !integrationData) return void this.error("ERROR: " + (err$1 ?? "Failed to fetch integration data")).newLine();
3480
+ integrationData.logged_in_user_role = user_role;
3481
+ write("selected_integration", integrationData);
3482
+ __h3ravel_shared.Logger.log([
3483
+ ["Switched to", "white"],
3484
+ [integration.business_name, "green"],
3485
+ ["(" + integration.id + ")", "white"]
3486
+ ], " ");
3487
+ this.newLine();
3488
+ }
3489
+ }
3490
+ };
3491
+
3307
3492
  //#endregion
3308
3493
  //#region src/Commands/WebhookCommand.ts
3309
3494
  var WebhookCommand = class extends __h3ravel_musket.Command {
3310
3495
  signature = `webhook
3311
3496
  {command=listen : The command to run to listen for webhooks locally : [listen, ping]}
3312
- {local_route? : Specify the local route to listen on for webhooks (only for listen command)}
3497
+ {url? : Specify the url to listen on for webhooks (only for listen command, should be an accessible local url)}
3313
3498
  {--D|domain=test : Specify the domain to ping the webhook : [test, live]}
3314
3499
  {--F|forward? : Specify a URL to forward the webhook to instead of the saved webhook URL}
3315
- {--E|event? : Specify the event type to simulate : [charge.success,transfer.success,transfer.failed,subscription.create]}
3500
+ {--E|event? : Specify the event type to simulate (leave empty to prompt with more options) : [charge.success,transfer.success,transfer.failed,subscription.create]}
3501
+ {--S|serve : Start a local server to receive webhooks (only for listen command, ignored if url is provided)}
3502
+ {--M|mod : Show options to modify the webhook payload before sending (only for ping command)}
3316
3503
  `;
3317
3504
  description = "Listen for webhook events locally, runs a webhook endpoint health check and listens for incoming webhooks, and ping your webhook URL from the CLI.";
3318
3505
  async handle() {
@@ -3322,12 +3509,15 @@ var WebhookCommand = class extends __h3ravel_musket.Command {
3322
3509
  this.newLine();
3323
3510
  const config = getConfig();
3324
3511
  let event = this.option("event");
3325
- let local_route = this.argument("local_route");
3512
+ let server = null;
3513
+ let localUrl = this.argument("url");
3326
3514
  const selected_integration = read("selected_integration")?.id;
3327
3515
  const user = read("user")?.id;
3328
- if (!selected_integration || !user) return void this.error("ERROR: You're not signed in, please run the `login` command before you begin");
3329
- if (this.argument("command") == "listen" && !local_route) local_route = await this.ask("Enter the local route to listen on for webhooks: ", "http://localhost:8080/webhook");
3330
- else if (this.argument("command") == "ping" && !event) event = await this.choice("Select event to simulate", [
3516
+ if (!selected_integration || !user) return void this.error(`ERROR: You're not signed in, please run the ${logger("login", ["grey", "italic"])} command before you begin`);
3517
+ if (this.argument("command") == "listen" && !localUrl) {
3518
+ if (this.option("serve")) server = await miniServer(3e3);
3519
+ localUrl = server?.url ?? await this.ask("Enter the url to listen on for webhooks: ", "http://localhost:8080/webhook");
3520
+ } else if (this.argument("command") == "ping" && !event) event = await this.choice("Select event to simulate", [
3331
3521
  {
3332
3522
  name: "Charge Success",
3333
3523
  value: "charge.success"
@@ -3343,6 +3533,22 @@ var WebhookCommand = class extends __h3ravel_musket.Command {
3343
3533
  {
3344
3534
  name: "Subscription Create",
3345
3535
  value: "subscription.create"
3536
+ },
3537
+ {
3538
+ name: "Customer Identification Failed",
3539
+ value: "customeridentification.failed"
3540
+ },
3541
+ {
3542
+ name: "Customer Identification Success",
3543
+ value: "customeridentification.success"
3544
+ },
3545
+ {
3546
+ name: "DVA Assign Failed",
3547
+ value: "dedicatedaccount.assign.failed"
3548
+ },
3549
+ {
3550
+ name: "DVA Assign Success",
3551
+ value: "dedicatedaccount.assign.success"
3346
3552
  }
3347
3553
  ], 0);
3348
3554
  const domain = this.option("domain", "test");
@@ -3353,7 +3559,7 @@ var WebhookCommand = class extends __h3ravel_musket.Command {
3353
3559
  this.error("ERROR: Your session has expired. Please run the `login` command to sign in again.");
3354
3560
  return;
3355
3561
  }
3356
- const url$1 = parseURL(local_route);
3562
+ const url$1 = parseURL(localUrl);
3357
3563
  if (!url$1.port) url$1.port = "8000";
3358
3564
  if (!url$1.search || url$1.search == "?") url$1.search = "";
3359
3565
  try {
@@ -3361,17 +3567,18 @@ var WebhookCommand = class extends __h3ravel_musket.Command {
3361
3567
  } catch {
3362
3568
  this.debug("No existing ngrok process found to kill.");
3363
3569
  }
3364
- const ngrokURL = (await __ngrok_ngrok.default.forward({
3570
+ const listener = await __ngrok_ngrok.default.forward({
3365
3571
  addr: url$1.port,
3366
3572
  authtoken: config.ngrokAuthToken || process.env.NGROK_AUTH_TOKEN,
3367
3573
  domain: process.env.NGROK_DOMAIN
3368
- })).url();
3574
+ });
3575
+ const webhookUrl = new URL(listener.url() + url$1.pathname + url$1.search);
3369
3576
  const domain$1 = this.option("domain", "test");
3370
- const spinner = (0, ora.default)("Tunelling webhook events to " + logger(local_route)).start();
3371
- const [err, result] = await promiseWrapper(setWebhook(ngrokURL, token, read("selected_integration").id));
3577
+ const spinner = this.spinner("Tunelling webhook events to " + logger(localUrl)).start();
3578
+ const [err, result] = await promiseWrapper(setWebhook(webhookUrl.toString(), token, read("selected_integration").id));
3372
3579
  if (err || !result) return void this.error("ERROR: " + (err ?? "Failed to set webhook URL")).newLine();
3373
- spinner.succeed("Listening for incoming webhook events at " + logger(local_route));
3374
- this.newLine().success(`INFO: Press ${logger("Ctrl+C", ["grey", "italic"])} to stop listening for webhook events.`).success(`INFO: Webhook URL set to ${logger(ngrokURL)} for ${domain$1} domain`).newLine();
3580
+ spinner.succeed();
3581
+ this.newLine().success(`INFO: Listening for incoming webhook events at: ${logger(localUrl)}`).success(`INFO: Webhook URL set to: ${logger(webhookUrl.toString())} for ${domain$1} domain`).success(`INFO: Press ${logger("Ctrl+C", ["grey", "italic"])} to stop listening for webhook events.`).newLine();
3375
3582
  process.stdin.resume();
3376
3583
  } else if (this.argument("command") == "ping") {
3377
3584
  await promiseWrapper(refreshIntegration());
@@ -3415,6 +3622,8 @@ __h3ravel_musket.Kernel.init(new Application(), {
3415
3622
  LogoutCommand,
3416
3623
  ConfigCommand,
3417
3624
  WebhookCommand,
3625
+ SetIntegrationCommand,
3626
+ IntegrationInfoCommand,
3418
3627
  ...Commands_default()
3419
3628
  ]
3420
3629
  });
package/bin/cli.js CHANGED
@@ -1,37 +1,64 @@
1
1
  #!/usr/bin/env node
2
- import path, { dirname } from "path";
3
2
  import Database from "better-sqlite3";
4
- import { fileURLToPath } from "url";
3
+ import os, { homedir } from "os";
5
4
  import { existsSync, mkdirSync } from "fs";
5
+ import path from "path";
6
6
  import { Logger } from "@h3ravel/shared";
7
+ import CliTable3 from "cli-table3";
7
8
  import axios from "axios";
9
+ import { fileURLToPath } from "url";
8
10
  import { Command, Kernel } from "@h3ravel/musket";
9
- import ora from "ora";
11
+ import { H3, serve } from "h3";
12
+ import { detect } from "detect-port";
10
13
  import { createRequire } from "module";
11
- import os from "os";
12
14
  import crypto from "crypto";
13
15
  import ngrok from "@ngrok/ngrok";
14
16
 
17
+ //#region src/utils/global.ts
18
+ String.prototype.toKebabCase = function() {
19
+ return this.replace(/([a-z])([A-Z])/g, "$1-$2").replace(/[\s_]+/g, "-").toLowerCase();
20
+ };
21
+ String.prototype.toCamelCase = function() {
22
+ return this.replace(/[-_ ]+([a-zA-Z0-9])/g, (_, c) => c.toUpperCase()).replace(/^[A-Z]/, (c) => c.toLowerCase());
23
+ };
24
+ String.prototype.toPascalCase = function() {
25
+ return this.replace(/(^\w|[-_ ]+\w)/g, (match) => match.replace(/[-_ ]+/, "").toUpperCase());
26
+ };
27
+ String.prototype.toSnakeCase = function() {
28
+ return this.replace(/([a-z])([A-Z])/g, "$1_$2").replace(/[\s-]+/g, "_").toLowerCase();
29
+ };
30
+ String.prototype.toTitleCase = function() {
31
+ return this.toLowerCase().replace(/(^|\s)\w/g, (match) => match.toUpperCase());
32
+ };
33
+ String.prototype.toCleanCase = function() {
34
+ return this.replace(/([a-z])([A-Z])/g, "$1 $2").replace(/[_-]+/g, " ").replace(/\s+/g, " ").split(" ").map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join(" ").trim().replace(/\b\w{1,3}\b/g, (match) => match.toUpperCase());
35
+ };
36
+ String.prototype.truncate = function(n, suffix = "…") {
37
+ return this.length > n ? this.slice(0, n - 1) + suffix : this.toString();
38
+ };
39
+
40
+ //#endregion
15
41
  //#region src/db.ts
16
42
  let db;
17
- const __dirname$1 = dirname(fileURLToPath(import.meta.url));
18
- const dirPath = path.normalize(path.join(__dirname$1, "..", "data"));
19
- mkdirSync(dirPath, { recursive: true });
20
- const useDbPath = () => [dirPath];
43
+ let dbPath = path.join(homedir(), ".paystackcli");
44
+ mkdirSync(dbPath, { recursive: true });
45
+ const useDbPath = () => [dbPath, (path$1) => {
46
+ dbPath = path$1;
47
+ }];
21
48
  /**
22
49
  * Hook to get or set the database instance.
23
50
  *
24
51
  * @returns
25
52
  */
26
53
  const useDb = () => {
27
- return [() => db, (newDb) => {
28
- db = newDb;
54
+ return [() => db, (filename) => {
55
+ db = new Database(path.join(dbPath, filename));
29
56
  const [{ journal_mode }] = db.pragma("journal_mode");
30
57
  if (journal_mode !== "wal") db.pragma("journal_mode = WAL");
31
58
  }];
32
59
  };
33
60
  const [getDatabase, setDatabase] = useDb();
34
- setDatabase(new Database(path.join(dirPath, "app.db")));
61
+ setDatabase("app.db");
35
62
  /**
36
63
  * Initialize the database
37
64
  *
@@ -79,7 +106,7 @@ function remove(key) {
79
106
  * @param key
80
107
  * @returns
81
108
  */
82
- function read(key) {
109
+ function read(key, defaultValue) {
83
110
  const db$1 = getDatabase();
84
111
  try {
85
112
  const row = db$1.prepare("SELECT * FROM json_store WHERE key = ?").get(key);
@@ -89,7 +116,7 @@ function read(key) {
89
116
  return row.value;
90
117
  }
91
118
  } catch {}
92
- return null;
119
+ return defaultValue ?? null;
93
120
  }
94
121
 
95
122
  //#endregion
@@ -363,6 +390,15 @@ const findCLIPackageJson = (startDir = __dirname) => {
363
390
  }
364
391
  return null;
365
392
  };
393
+ const objectToTable = (obj, titleKeys = false) => {
394
+ const table = new CliTable3();
395
+ for (const rawKey in obj) {
396
+ if (typeof obj[rawKey] === "object") continue;
397
+ const key = logger((titleKeys ? rawKey.toCleanCase() : rawKey).truncate(30), ["bold"]);
398
+ table.push({ [key]: String(obj[rawKey]).truncate(40) });
399
+ }
400
+ return table.toString();
401
+ };
366
402
 
367
403
  //#endregion
368
404
  //#region src/paystack/apis.ts
@@ -2530,7 +2566,7 @@ const buildSignature = (param, cmd) => {
2530
2566
  };
2531
2567
 
2532
2568
  //#endregion
2533
- //#region src/utils/renderer.ts
2569
+ //#region src/utils/builders.ts
2534
2570
  /**
2535
2571
  * We will recursively map through the result data and log each key value pair
2536
2572
  * as we apply coloring based on the value type.
@@ -2545,7 +2581,7 @@ const dataRenderer = (data) => {
2545
2581
  for (const key in obj) {
2546
2582
  const value = obj[key];
2547
2583
  if (typeof value === "object" && value !== null) {
2548
- console.log(`${indentation}${stringFormatter(key)}:`);
2584
+ console.log(`${indentation}${key.toCleanCase()}:`);
2549
2585
  render(value, indent + 2);
2550
2586
  } else {
2551
2587
  let coloredValue;
@@ -2561,24 +2597,33 @@ const dataRenderer = (data) => {
2561
2597
  break;
2562
2598
  default: coloredValue = value;
2563
2599
  }
2564
- console.log(`${indentation}${stringFormatter(key)}: ${coloredValue}`);
2600
+ console.log(`${indentation}${key.toCleanCase()}: ${coloredValue}`);
2565
2601
  }
2566
2602
  }
2567
2603
  };
2568
2604
  render(data);
2569
2605
  };
2570
2606
  /**
2571
- * We will format a string by replacing underscores and hyphens with spaces,
2572
- * capitalizing the first letter of every word,
2573
- * converting camelCase to spaced words,
2574
- * and trimming any leading or trailing spaces.
2575
- * If a sentence is only two letters long we will make it uppercase.
2607
+ * Starts a mini HTTP server on the specified port to listen for incoming webhook requests.
2576
2608
  *
2577
- * @param str
2609
+ * @param port
2578
2610
  * @returns
2579
2611
  */
2580
- const stringFormatter = (str) => {
2581
- return str.replace(/([a-z])([A-Z])/g, "$1 $2").replace(/[_-]+/g, " ").replace(/\s+/g, " ").split(" ").map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join(" ").trim().replace(/^(\w{2})$/, (_, p1) => p1.toUpperCase());
2612
+ const miniServer = async (port = 3e3) => {
2613
+ const route = async (event) => {
2614
+ console.log("Incoming Webhook Request [", event.req.method, "]");
2615
+ const payload = JSON.parse(await event.req.text() || "{}");
2616
+ return Object.assign({}, { signature: event.req.headers.get("x-paystack-signature") ?? "N/A" }, payload);
2617
+ };
2618
+ const app = new H3().get("/webhook", route).post("/webhook", route);
2619
+ port = await detect(port);
2620
+ const server = serve(app, {
2621
+ port,
2622
+ silent: true
2623
+ });
2624
+ const url = `http://localhost:${port}/webhook`;
2625
+ Logger.log([["🚀 Mini server is running at:", "green"], [url, "cyan"]], " ");
2626
+ return Object.assign({}, server, { url });
2582
2627
  };
2583
2628
 
2584
2629
  //#endregion
@@ -2603,14 +2648,14 @@ var Commands_default = () => {
2603
2648
  signature = `${key} \n${args}`;
2604
2649
  description = schema.description || "No description available.";
2605
2650
  handle = async () => {
2606
- const [_, setCommand] = useCommand();
2651
+ const [command$1, setCommand] = useCommand();
2607
2652
  setCommand(this);
2608
2653
  for (const param of schema.params) if (param.required && !this.argument(param.parameter)) return void this.newLine().error(`Missing required argument: ${param.parameter}`).newLine();
2609
2654
  const selected_integration = read("selected_integration")?.id;
2610
2655
  const user = read("user")?.id;
2611
2656
  if (!selected_integration || !user) return void this.error("ERROR: You're not signed in, please run the [login] command before you begin").newLine();
2612
2657
  this.newLine();
2613
- const spinner = ora("Loading...\n").start();
2658
+ const spinner = command$1().spinner("Loading...\n").start();
2614
2659
  const [err, result] = await promiseWrapper(executeSchema(schema, {
2615
2660
  ...this.options(),
2616
2661
  ...this.arguments()
@@ -2711,31 +2756,32 @@ var InfoCommand = class extends Command {
2711
2756
  version: "unknown",
2712
2757
  dependencies: {}
2713
2758
  };
2759
+ const user = read("user");
2714
2760
  const pkgPath = findCLIPackageJson();
2715
2761
  const require = createRequire(import.meta.url);
2716
- const [_, setCommand] = useCommand();
2717
- const [dbPath] = useDbPath();
2762
+ const [, setCommand] = useCommand();
2763
+ const [dbPath$1] = useDbPath();
2718
2764
  setCommand(this);
2719
2765
  init();
2720
- const spinner = ora("Gathering application information...\n").start();
2766
+ const spinner = this.spinner("Gathering application information...\n").start();
2721
2767
  if (pkgPath) try {
2722
2768
  pkg = require(pkgPath);
2723
2769
  } catch {}
2724
2770
  wait(500, () => {
2725
2771
  spinner.succeed("Application Information Loaded.\n");
2726
- dataRenderer({
2727
- appVersion: pkg.version,
2728
- platform: os.platform(),
2729
- arch: os.arch(),
2730
- cpus: os.cpus().length,
2731
- hostname: os.hostname(),
2732
- totalMemory: os.totalmem(),
2733
- freeMemory: os.freemem(),
2734
- uptime: os.uptime(),
2735
- username: os.userInfo().username,
2736
- database: dbPath + "/app.db",
2737
- dependencies: Object.keys(pkg.dependencies).join(", ")
2772
+ const out = objectToTable({
2773
+ "App Version": pkg.version,
2774
+ "Platform": `${os.platform()} ${os.arch()} (${os.release()})`,
2775
+ "CPUs": os.cpus().length,
2776
+ "Host": `${os.userInfo().username}@${os.hostname()}`,
2777
+ "Memory": `${(os.freemem() / 1024 ** 3).toFixed(2)} GB / ${(os.totalmem() / 1024 ** 3).toFixed(2)} GB`,
2778
+ "Database Path": dbPath$1 + "/app.db",
2779
+ "Paystack User": user ? `${user.first_name} ${user.last_name} (ID: ${user.id})` : "Not logged in",
2780
+ "Default Integration": read("selected_integration")?.business_name || "Not set"
2738
2781
  });
2782
+ console.log(out.toString());
2783
+ logger("\nDependencies:", "yellow");
2784
+ logger(Object.keys(pkg.dependencies).map((dep) => `${dep}`).join(", "), "green");
2739
2785
  this.newLine();
2740
2786
  });
2741
2787
  }
@@ -2754,6 +2800,21 @@ var InitCommand = class extends Command {
2754
2800
  }
2755
2801
  };
2756
2802
 
2803
+ //#endregion
2804
+ //#region src/Commands/IntegrationInfoCommand.ts
2805
+ var IntegrationInfoCommand = class extends Command {
2806
+ signature = "integration:info";
2807
+ description = "Get information about the currently selected integration.";
2808
+ async handle() {
2809
+ const [_, setCommand] = useCommand();
2810
+ setCommand(this);
2811
+ const selected_integration = read("selected_integration");
2812
+ if (!selected_integration) return void this.error(`ERROR: No integration selected, please run the ${logger("integration:set", ["grey", "italic"])} command to select an integration before proceeding.`);
2813
+ console.log(objectToTable(selected_integration, true));
2814
+ this.newLine();
2815
+ }
2816
+ };
2817
+
2757
2818
  //#endregion
2758
2819
  //#region src/paystack/webhooks.ts
2759
2820
  const webhook = {
@@ -2761,7 +2822,7 @@ const webhook = {
2761
2822
  event: "charge.success",
2762
2823
  data: {
2763
2824
  id: 302961,
2764
- domain: "live",
2825
+ domain: "test",
2765
2826
  status: "success",
2766
2827
  reference: "qTPrJoy9Bx",
2767
2828
  amount: 1e4,
@@ -2828,14 +2889,14 @@ const webhook = {
2828
2889
  "transfer.success": {
2829
2890
  event: "transfer.success",
2830
2891
  data: {
2831
- domain: "live",
2892
+ domain: "test",
2832
2893
  amount: 1e4,
2833
2894
  currency: "NGN",
2834
2895
  source: "balance",
2835
2896
  source_details: null,
2836
2897
  reason: "Bless you",
2837
2898
  recipient: {
2838
- domain: "live",
2899
+ domain: "test",
2839
2900
  type: "nuban",
2840
2901
  currency: "NGN",
2841
2902
  name: "Someone",
@@ -2910,7 +2971,7 @@ const webhook = {
2910
2971
  source_details: null,
2911
2972
  reason: "Test",
2912
2973
  recipient: {
2913
- domain: "live",
2974
+ domain: "test",
2914
2975
  type: "nuban",
2915
2976
  currency: "NGN",
2916
2977
  name: "Test account",
@@ -2930,6 +2991,95 @@ const webhook = {
2930
2991
  transferred_at: null,
2931
2992
  created_at: "2017-12-01T08:51:37.000Z"
2932
2993
  }
2994
+ },
2995
+ "customeridentification.failed": {
2996
+ "event": "customeridentification.failed",
2997
+ "data": {
2998
+ "customer_id": 82796315,
2999
+ "customer_code": "CUS_XXXXXXXXXXXXXXX",
3000
+ "email": "email@email.com",
3001
+ "identification": {
3002
+ "country": "NG",
3003
+ "type": "bank_account",
3004
+ "bvn": "123*****456",
3005
+ "account_number": "012****345",
3006
+ "bank_code": "999991"
3007
+ },
3008
+ "reason": "Account number or BVN is incorrect"
3009
+ }
3010
+ },
3011
+ "customeridentification.success": {
3012
+ "event": "customeridentification.success",
3013
+ "data": {
3014
+ "customer_id": "9387490384",
3015
+ "customer_code": "CUS_xnxdt6s1zg1f4nx",
3016
+ "email": "bojack@horsinaround.com",
3017
+ "identification": {
3018
+ "country": "NG",
3019
+ "type": "bvn",
3020
+ "value": "200*****677"
3021
+ }
3022
+ }
3023
+ },
3024
+ "dedicatedaccount.assign.failed": {
3025
+ "event": "dedicatedaccount.assign.failed",
3026
+ "data": {
3027
+ "customer": {
3028
+ "id": 100110,
3029
+ "first_name": "John",
3030
+ "last_name": "Doe",
3031
+ "email": "johndoe@test.com",
3032
+ "customer_code": "CUS_hcekca0j0bbg2m4",
3033
+ "phone": "+2348100000000",
3034
+ "metadata": {},
3035
+ "risk_action": "default",
3036
+ "international_format_phone": "+2348100000000"
3037
+ },
3038
+ "dedicated_account": null,
3039
+ "identification": { "status": "failed" }
3040
+ }
3041
+ },
3042
+ "dedicatedaccount.assign.success": {
3043
+ "event": "dedicatedaccount.assign.success",
3044
+ "data": {
3045
+ "customer": {
3046
+ "id": 100110,
3047
+ "first_name": "John",
3048
+ "last_name": "Doe",
3049
+ "email": "johndoe@test.com",
3050
+ "customer_code": "CUS_hp05n9khsqcesz2",
3051
+ "phone": "+2348100000000",
3052
+ "metadata": {},
3053
+ "risk_action": "default",
3054
+ "international_format_phone": "+2348100000000"
3055
+ },
3056
+ "dedicated_account": {
3057
+ "bank": {
3058
+ "name": "Test Bank",
3059
+ "id": 20,
3060
+ "slug": "test-bank"
3061
+ },
3062
+ "account_name": "PAYSTACK/John Doe",
3063
+ "account_number": "1234567890",
3064
+ "assigned": true,
3065
+ "currency": "NGN",
3066
+ "metadata": null,
3067
+ "active": true,
3068
+ "id": 987654,
3069
+ "created_at": "2022-06-21T17:12:40.000Z",
3070
+ "updated_at": "2022-08-12T14:02:51.000Z",
3071
+ "assignment": {
3072
+ "integration": 100123,
3073
+ "assignee_id": 100110,
3074
+ "assignee_type": "Customer",
3075
+ "expired": false,
3076
+ "account_type": "PAY-WITH-TRANSFER-RECURRING",
3077
+ "assigned_at": "2022-08-12T14:02:51.614Z",
3078
+ "expired_at": null
3079
+ }
3080
+ },
3081
+ "identification": { "status": "success" }
3082
+ }
2933
3083
  }
2934
3084
  };
2935
3085
  var webhooks_default = webhook;
@@ -3066,6 +3216,7 @@ function getKeys(token, type = "secret", domain = "test") {
3066
3216
  */
3067
3217
  async function pingWebhook(options, event = "charge.success") {
3068
3218
  const [command] = useCommand();
3219
+ const cmd = command();
3069
3220
  let canProceed = false;
3070
3221
  try {
3071
3222
  canProceed = await refreshIntegration();
@@ -3076,33 +3227,35 @@ async function pingWebhook(options, event = "charge.success") {
3076
3227
  if (options.domain) domain = options.domain;
3077
3228
  if (options.event) event = options.event;
3078
3229
  const key = await getKeys(read("token"), "secret", domain);
3079
- return new Promise((resolve, reject) => {
3080
- if (!canProceed) return void command().error("ERROR: Unable to ping webhook URL");
3081
- const eventObject = webhooks_default[event];
3082
- if (eventObject) {
3083
- const hash = crypto.createHmac("sha512", key).update(JSON.stringify(eventObject)).digest("hex");
3084
- const uri = read("selected_integration")[domain + "_webhook_endpoint"];
3085
- const spinner = ora(`Sending sample ${event} event payload to ${uri}`).start();
3086
- axios_default.post(uri, eventObject, { headers: { "x-paystack-signature": hash } }).then((response) => {
3087
- spinner.succeed(`Sample ${event} event payload sent to ${uri}`);
3088
- resolve({
3089
- code: response.status,
3090
- text: response.statusText,
3091
- data: response.data
3092
- });
3093
- }).catch((e) => {
3094
- spinner.fail(`Failed to send sample ${event} event payload to ${uri}`);
3095
- resolve({
3096
- code: e.response?.status ?? 0,
3097
- text: e.response?.statusText || "No response",
3098
- data: typeof e.response?.data === "string" && e.response?.data?.includes("<html") ? { response: "HTML Response" } : e.response?.data || "No response data"
3099
- });
3100
- });
3101
- } else {
3102
- command().error("ERROR: Invalid event type - " + event);
3103
- reject();
3230
+ if (!canProceed) return void cmd.error("ERROR: Unable to ping webhook URL");
3231
+ const eventObject = webhooks_default[event];
3232
+ if (eventObject) {
3233
+ if (options.mod) for (const [key$1, val] of Object.entries(eventObject.data)) if (["string", "number"].includes(typeof val)) eventObject.data[key$1] = await cmd.ask(`Enter new value for '${key$1}':`, String(val));
3234
+ else if (typeof val === "boolean") eventObject.data[key$1] = await cmd.choice(`Enter new value for '${key$1}':`, ["true", "false"], val ? 0 : 1) === "true";
3235
+ else continue;
3236
+ const hash = crypto.createHmac("sha512", key).update(JSON.stringify(eventObject)).digest("hex");
3237
+ const uri = read("selected_integration")[domain + "_webhook_endpoint"];
3238
+ const spinner = cmd.spinner(`Sending sample ${event} event payload to ${uri}`).start();
3239
+ try {
3240
+ const response = await axios_default.post(uri, eventObject, { headers: { "x-paystack-signature": hash } });
3241
+ spinner.succeed(`Sample ${event} event payload sent to ${uri}`);
3242
+ return {
3243
+ code: response.status,
3244
+ text: response.statusText,
3245
+ data: response.data
3246
+ };
3247
+ } catch (e) {
3248
+ spinner.fail(`Failed to send sample ${event} event payload to ${uri}`);
3249
+ return {
3250
+ code: e.response?.status ?? 0,
3251
+ text: e.response?.statusText || "No response",
3252
+ data: typeof e.response?.data === "string" && e.response?.data?.includes("<html") ? { response: "HTML Response" } : e.response?.data || "No response data"
3253
+ };
3104
3254
  }
3105
- });
3255
+ } else {
3256
+ cmd.error("ERROR: Invalid event type - " + event);
3257
+ throw new Error("Invalid event type: " + event);
3258
+ }
3106
3259
  }
3107
3260
  /**
3108
3261
  * Get integration
@@ -3113,7 +3266,7 @@ async function pingWebhook(options, event = "charge.success") {
3113
3266
  */
3114
3267
  function getIntegration(id, token) {
3115
3268
  const [command] = useCommand();
3116
- const spinner = ora("getting integration").start();
3269
+ const spinner = command().spinner("getting integration").start();
3117
3270
  return new Promise((resolve, reject) => {
3118
3271
  axios_default.get("/integration/" + id, { headers: {
3119
3272
  Authorization: "Bearer " + token,
@@ -3137,7 +3290,7 @@ function getIntegration(id, token) {
3137
3290
  */
3138
3291
  async function signIn(email, password) {
3139
3292
  const [command] = useCommand();
3140
- const spinner = ora("Logging in...").start();
3293
+ const spinner = command().spinner("Logging in...").start();
3141
3294
  try {
3142
3295
  const { data: response } = await axios_default.post("/login", {
3143
3296
  email,
@@ -3257,7 +3410,7 @@ var LogoutCommand = class extends Command {
3257
3410
  const [_, setCommand] = useCommand();
3258
3411
  setCommand(this);
3259
3412
  this.newLine();
3260
- const spinner = ora("Logging out...").start();
3413
+ const spinner = this.spinner("Logging out...").start();
3261
3414
  try {
3262
3415
  await wait(1e3, () => clearAuth());
3263
3416
  spinner.succeed("Logged out successfully");
@@ -3269,15 +3422,47 @@ var LogoutCommand = class extends Command {
3269
3422
  }
3270
3423
  };
3271
3424
 
3425
+ //#endregion
3426
+ //#region src/Commands/SetIntegrationCommand.ts
3427
+ var SetIntegrationCommand = class extends Command {
3428
+ signature = "integration:set";
3429
+ description = "Set the active integration for Paystack CLI usage.";
3430
+ async handle() {
3431
+ const [_, setCommand] = useCommand();
3432
+ setCommand(this);
3433
+ const user = read("user");
3434
+ const token = read("token");
3435
+ if (!token || !user) return void this.error(`ERROR: You're not signed in, please run the ${logger("login", ["grey", "italic"])} command before you begin`);
3436
+ const [err, integration] = await promiseWrapper(selectIntegration(user.integrations, token));
3437
+ if (err || !integration) this.error("ERROR: " + (err ?? "Integration selection failed")).newLine();
3438
+ else {
3439
+ write("selected_integration", integration);
3440
+ const user_role = read("selected_integration").logged_in_user_role;
3441
+ const [err$1, integrationData] = await promiseWrapper(getIntegration(integration.id, token));
3442
+ if (err$1 || !integrationData) return void this.error("ERROR: " + (err$1 ?? "Failed to fetch integration data")).newLine();
3443
+ integrationData.logged_in_user_role = user_role;
3444
+ write("selected_integration", integrationData);
3445
+ Logger.log([
3446
+ ["Switched to", "white"],
3447
+ [integration.business_name, "green"],
3448
+ ["(" + integration.id + ")", "white"]
3449
+ ], " ");
3450
+ this.newLine();
3451
+ }
3452
+ }
3453
+ };
3454
+
3272
3455
  //#endregion
3273
3456
  //#region src/Commands/WebhookCommand.ts
3274
3457
  var WebhookCommand = class extends Command {
3275
3458
  signature = `webhook
3276
3459
  {command=listen : The command to run to listen for webhooks locally : [listen, ping]}
3277
- {local_route? : Specify the local route to listen on for webhooks (only for listen command)}
3460
+ {url? : Specify the url to listen on for webhooks (only for listen command, should be an accessible local url)}
3278
3461
  {--D|domain=test : Specify the domain to ping the webhook : [test, live]}
3279
3462
  {--F|forward? : Specify a URL to forward the webhook to instead of the saved webhook URL}
3280
- {--E|event? : Specify the event type to simulate : [charge.success,transfer.success,transfer.failed,subscription.create]}
3463
+ {--E|event? : Specify the event type to simulate (leave empty to prompt with more options) : [charge.success,transfer.success,transfer.failed,subscription.create]}
3464
+ {--S|serve : Start a local server to receive webhooks (only for listen command, ignored if url is provided)}
3465
+ {--M|mod : Show options to modify the webhook payload before sending (only for ping command)}
3281
3466
  `;
3282
3467
  description = "Listen for webhook events locally, runs a webhook endpoint health check and listens for incoming webhooks, and ping your webhook URL from the CLI.";
3283
3468
  async handle() {
@@ -3287,12 +3472,15 @@ var WebhookCommand = class extends Command {
3287
3472
  this.newLine();
3288
3473
  const config = getConfig();
3289
3474
  let event = this.option("event");
3290
- let local_route = this.argument("local_route");
3475
+ let server = null;
3476
+ let localUrl = this.argument("url");
3291
3477
  const selected_integration = read("selected_integration")?.id;
3292
3478
  const user = read("user")?.id;
3293
- if (!selected_integration || !user) return void this.error("ERROR: You're not signed in, please run the `login` command before you begin");
3294
- if (this.argument("command") == "listen" && !local_route) local_route = await this.ask("Enter the local route to listen on for webhooks: ", "http://localhost:8080/webhook");
3295
- else if (this.argument("command") == "ping" && !event) event = await this.choice("Select event to simulate", [
3479
+ if (!selected_integration || !user) return void this.error(`ERROR: You're not signed in, please run the ${logger("login", ["grey", "italic"])} command before you begin`);
3480
+ if (this.argument("command") == "listen" && !localUrl) {
3481
+ if (this.option("serve")) server = await miniServer(3e3);
3482
+ localUrl = server?.url ?? await this.ask("Enter the url to listen on for webhooks: ", "http://localhost:8080/webhook");
3483
+ } else if (this.argument("command") == "ping" && !event) event = await this.choice("Select event to simulate", [
3296
3484
  {
3297
3485
  name: "Charge Success",
3298
3486
  value: "charge.success"
@@ -3308,6 +3496,22 @@ var WebhookCommand = class extends Command {
3308
3496
  {
3309
3497
  name: "Subscription Create",
3310
3498
  value: "subscription.create"
3499
+ },
3500
+ {
3501
+ name: "Customer Identification Failed",
3502
+ value: "customeridentification.failed"
3503
+ },
3504
+ {
3505
+ name: "Customer Identification Success",
3506
+ value: "customeridentification.success"
3507
+ },
3508
+ {
3509
+ name: "DVA Assign Failed",
3510
+ value: "dedicatedaccount.assign.failed"
3511
+ },
3512
+ {
3513
+ name: "DVA Assign Success",
3514
+ value: "dedicatedaccount.assign.success"
3311
3515
  }
3312
3516
  ], 0);
3313
3517
  const domain = this.option("domain", "test");
@@ -3318,7 +3522,7 @@ var WebhookCommand = class extends Command {
3318
3522
  this.error("ERROR: Your session has expired. Please run the `login` command to sign in again.");
3319
3523
  return;
3320
3524
  }
3321
- const url = parseURL(local_route);
3525
+ const url = parseURL(localUrl);
3322
3526
  if (!url.port) url.port = "8000";
3323
3527
  if (!url.search || url.search == "?") url.search = "";
3324
3528
  try {
@@ -3326,17 +3530,18 @@ var WebhookCommand = class extends Command {
3326
3530
  } catch {
3327
3531
  this.debug("No existing ngrok process found to kill.");
3328
3532
  }
3329
- const ngrokURL = (await ngrok.forward({
3533
+ const listener = await ngrok.forward({
3330
3534
  addr: url.port,
3331
3535
  authtoken: config.ngrokAuthToken || process.env.NGROK_AUTH_TOKEN,
3332
3536
  domain: process.env.NGROK_DOMAIN
3333
- })).url();
3537
+ });
3538
+ const webhookUrl = new URL(listener.url() + url.pathname + url.search);
3334
3539
  const domain$1 = this.option("domain", "test");
3335
- const spinner = ora("Tunelling webhook events to " + logger(local_route)).start();
3336
- const [err, result] = await promiseWrapper(setWebhook(ngrokURL, token, read("selected_integration").id));
3540
+ const spinner = this.spinner("Tunelling webhook events to " + logger(localUrl)).start();
3541
+ const [err, result] = await promiseWrapper(setWebhook(webhookUrl.toString(), token, read("selected_integration").id));
3337
3542
  if (err || !result) return void this.error("ERROR: " + (err ?? "Failed to set webhook URL")).newLine();
3338
- spinner.succeed("Listening for incoming webhook events at " + logger(local_route));
3339
- this.newLine().success(`INFO: Press ${logger("Ctrl+C", ["grey", "italic"])} to stop listening for webhook events.`).success(`INFO: Webhook URL set to ${logger(ngrokURL)} for ${domain$1} domain`).newLine();
3543
+ spinner.succeed();
3544
+ this.newLine().success(`INFO: Listening for incoming webhook events at: ${logger(localUrl)}`).success(`INFO: Webhook URL set to: ${logger(webhookUrl.toString())} for ${domain$1} domain`).success(`INFO: Press ${logger("Ctrl+C", ["grey", "italic"])} to stop listening for webhook events.`).newLine();
3340
3545
  process.stdin.resume();
3341
3546
  } else if (this.argument("command") == "ping") {
3342
3547
  await promiseWrapper(refreshIntegration());
@@ -3380,6 +3585,8 @@ Kernel.init(new Application(), {
3380
3585
  LogoutCommand,
3381
3586
  ConfigCommand,
3382
3587
  WebhookCommand,
3588
+ SetIntegrationCommand,
3589
+ IntegrationInfoCommand,
3383
3590
  ...Commands_default()
3384
3591
  ]
3385
3592
  });
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@toneflix/paystack-cli",
3
3
  "type": "module",
4
- "version": "0.1.6",
4
+ "version": "0.1.7",
5
5
  "description": "Interact with the Paystack API, test webhooks locally, and manage your integration settings without leaving your command line.",
6
6
  "main": "bin/cli.js",
7
7
  "private": false,
@@ -37,27 +37,30 @@
37
37
  "toneflix"
38
38
  ],
39
39
  "dependencies": {
40
- "@h3ravel/musket": "^0.6.18",
40
+ "@h3ravel/musket": "^0.10.1",
41
41
  "@h3ravel/shared": "^0.28.4",
42
42
  "@ngrok/ngrok": "^1.7.0",
43
43
  "axios": "^1.13.2",
44
44
  "better-sqlite3": "^12.6.2",
45
- "ora": "^9.0.0",
45
+ "cli-table3": "^0.6.5",
46
+ "detect-port": "^2.1.0",
47
+ "h3": "2.0.1-rc.11",
48
+ "srvx": "^0.10.1",
46
49
  "tsdown": "^0.15.4"
47
50
  },
48
51
  "devDependencies": {
52
+ "@changesets/cli": "^2.29.5",
49
53
  "@eslint/js": "^9.39.2",
50
- "typescript-eslint": "^8.53.0",
51
54
  "@eslint/markdown": "^7.5.1",
52
- "sass-embedded": "^1.90.0",
53
- "@changesets/cli": "^2.29.5",
54
55
  "@swc/core": "^1.6.1",
55
56
  "@types/better-sqlite3": "^7.6.13",
56
57
  "@types/node": "^20.14.5",
57
58
  "eslint": "^9.39.2",
59
+ "sass-embedded": "^1.90.0",
58
60
  "ts-node": "^10.9.2",
59
61
  "tsx": "^4.20.3",
60
62
  "typescript": "^5.4.5",
63
+ "typescript-eslint": "^8.53.0",
61
64
  "vite-tsconfig-paths": "^5.1.4",
62
65
  "vitepress": "^1.5.0",
63
66
  "vitest": "^3.2.4",