hookwright 1.0.0
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/LICENSE +21 -0
- package/README.md +116 -0
- package/dist/cli.js +2890 -0
- package/package.json +44 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,2890 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
4
|
+
var __esm = (fn, res, err) => function __init() {
|
|
5
|
+
if (err) throw err[0];
|
|
6
|
+
try {
|
|
7
|
+
return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
|
|
8
|
+
} catch (e) {
|
|
9
|
+
throw err = [e], e;
|
|
10
|
+
}
|
|
11
|
+
};
|
|
12
|
+
var __export = (target, all) => {
|
|
13
|
+
for (var name in all)
|
|
14
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
// package.json
|
|
18
|
+
var package_exports = {};
|
|
19
|
+
__export(package_exports, {
|
|
20
|
+
default: () => package_default
|
|
21
|
+
});
|
|
22
|
+
var package_default;
|
|
23
|
+
var init_package = __esm({
|
|
24
|
+
"package.json"() {
|
|
25
|
+
package_default = {
|
|
26
|
+
name: "hookwright",
|
|
27
|
+
version: "1.0.0",
|
|
28
|
+
description: "Build real, signed e-commerce webhooks from a live Shopify catalogue \u2014 interactive terminal UI, no backend",
|
|
29
|
+
keywords: [
|
|
30
|
+
"webhook",
|
|
31
|
+
"shopify",
|
|
32
|
+
"cashfree",
|
|
33
|
+
"testing",
|
|
34
|
+
"hmac",
|
|
35
|
+
"cli",
|
|
36
|
+
"ink",
|
|
37
|
+
"abandoned-cart",
|
|
38
|
+
"payload-generator"
|
|
39
|
+
],
|
|
40
|
+
license: "MIT",
|
|
41
|
+
type: "module",
|
|
42
|
+
bin: {
|
|
43
|
+
hookwright: "dist/cli.js"
|
|
44
|
+
},
|
|
45
|
+
files: [
|
|
46
|
+
"dist",
|
|
47
|
+
"README.md",
|
|
48
|
+
"LICENSE"
|
|
49
|
+
],
|
|
50
|
+
scripts: {
|
|
51
|
+
build: "node scripts/build.mjs",
|
|
52
|
+
dev: "node scripts/build.mjs --watch",
|
|
53
|
+
start: "npm run build --silent && node dist/cli.js",
|
|
54
|
+
test: "node --test test/*.test.mjs",
|
|
55
|
+
prepublishOnly: "npm run build && npm test"
|
|
56
|
+
},
|
|
57
|
+
engines: {
|
|
58
|
+
node: ">=20"
|
|
59
|
+
},
|
|
60
|
+
dependencies: {
|
|
61
|
+
"@inkjs/ui": "^2.0.0",
|
|
62
|
+
ink: "^7.1.1",
|
|
63
|
+
react: "^19.2.8"
|
|
64
|
+
},
|
|
65
|
+
devDependencies: {
|
|
66
|
+
esbuild: "^0.28.2"
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// src/cli.jsx
|
|
73
|
+
import React15 from "react";
|
|
74
|
+
import { render } from "ink";
|
|
75
|
+
|
|
76
|
+
// src/ui/App.jsx
|
|
77
|
+
import React14, { useState as useState8 } from "react";
|
|
78
|
+
import { useApp as useApp2 } from "ink";
|
|
79
|
+
|
|
80
|
+
// src/ui/screens/Home.jsx
|
|
81
|
+
import React2 from "react";
|
|
82
|
+
import { Box as Box2, Text as Text2 } from "ink";
|
|
83
|
+
import { Select } from "@inkjs/ui";
|
|
84
|
+
|
|
85
|
+
// src/ui/components/Header.jsx
|
|
86
|
+
import React from "react";
|
|
87
|
+
import { Box, Text } from "ink";
|
|
88
|
+
|
|
89
|
+
// src/ui/theme.js
|
|
90
|
+
var palette = {
|
|
91
|
+
accent: "cyan",
|
|
92
|
+
ok: "green",
|
|
93
|
+
warn: "yellow",
|
|
94
|
+
bad: "red",
|
|
95
|
+
dim: "gray",
|
|
96
|
+
heading: "magenta"
|
|
97
|
+
};
|
|
98
|
+
var glyph = {
|
|
99
|
+
tick: "\u2714",
|
|
100
|
+
cross: "\u2716",
|
|
101
|
+
warn: "\u25B2",
|
|
102
|
+
dot: "\u2022",
|
|
103
|
+
arrow: "\u203A",
|
|
104
|
+
pointer: "\u276F"
|
|
105
|
+
};
|
|
106
|
+
function mask(value) {
|
|
107
|
+
const s = String(value ?? "");
|
|
108
|
+
if (!s) return "";
|
|
109
|
+
if (s.length <= 8) return "*".repeat(s.length);
|
|
110
|
+
return s.slice(0, 4) + "*".repeat(Math.max(4, s.length - 8)) + s.slice(-4);
|
|
111
|
+
}
|
|
112
|
+
function truncate(value, max = 52) {
|
|
113
|
+
const s = String(value ?? "");
|
|
114
|
+
return s.length > max ? s.slice(0, max - 1) + "\u2026" : s;
|
|
115
|
+
}
|
|
116
|
+
function money(value, currency) {
|
|
117
|
+
if (value == null) return "\u2014";
|
|
118
|
+
return `${Number(value).toFixed(2)} ${currency ?? ""}`.trim();
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// src/ui/components/Header.jsx
|
|
122
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
123
|
+
function Header({ title, subtitle, right }) {
|
|
124
|
+
return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [
|
|
125
|
+
/* @__PURE__ */ jsxs(Box, { borderStyle: "round", borderColor: palette.accent, paddingX: 1, justifyContent: "space-between", children: [
|
|
126
|
+
/* @__PURE__ */ jsx(Text, { bold: true, color: palette.accent, children: title }),
|
|
127
|
+
right ? /* @__PURE__ */ jsx(Text, { color: palette.dim, children: truncate(right, 44) }) : null
|
|
128
|
+
] }),
|
|
129
|
+
subtitle ? /* @__PURE__ */ jsx(Box, { paddingX: 1, children: /* @__PURE__ */ jsx(Text, { color: palette.dim, children: subtitle }) }) : null
|
|
130
|
+
] });
|
|
131
|
+
}
|
|
132
|
+
function Section({ title, children }) {
|
|
133
|
+
return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", marginTop: 1, children: [
|
|
134
|
+
/* @__PURE__ */ jsx(Text, { bold: true, color: palette.heading, children: title }),
|
|
135
|
+
/* @__PURE__ */ jsx(Box, { flexDirection: "column", marginTop: 0, children })
|
|
136
|
+
] });
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// src/config.mjs
|
|
140
|
+
import fs from "node:fs";
|
|
141
|
+
import os from "node:os";
|
|
142
|
+
import path from "node:path";
|
|
143
|
+
var HOME = process.env.HOOKWRIGHT_HOME || process.env.HOOKFORGE_HOME || process.env.WEBHOOK_FORGE_HOME;
|
|
144
|
+
var xdgConfig = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config");
|
|
145
|
+
var xdgState = process.env.XDG_STATE_HOME || path.join(os.homedir(), ".local", "state");
|
|
146
|
+
var LEGACY_NAMES = ["hookforge", "webhook-forge"];
|
|
147
|
+
function pick(base, name) {
|
|
148
|
+
const preferred = path.join(base, name);
|
|
149
|
+
if (fs.existsSync(preferred)) return preferred;
|
|
150
|
+
for (const legacy of LEGACY_NAMES) {
|
|
151
|
+
const candidate = path.join(base, legacy);
|
|
152
|
+
if (fs.existsSync(candidate)) return candidate;
|
|
153
|
+
}
|
|
154
|
+
return preferred;
|
|
155
|
+
}
|
|
156
|
+
var CONFIG_HOME = HOME || pick(xdgConfig, "hookwright");
|
|
157
|
+
var STATE_HOME = HOME || pick(xdgState, "hookwright");
|
|
158
|
+
var CONFIG_PATH = process.env.HOOKWRIGHT_CONFIG || process.env.HOOKFORGE_CONFIG || process.env.WEBHOOK_FORGE_CONFIG || path.join(CONFIG_HOME, "config.json");
|
|
159
|
+
var STATE_DIR = STATE_HOME;
|
|
160
|
+
var OUT_DIR = path.join(STATE_HOME, "payloads");
|
|
161
|
+
var DEFAULT_CONFIG = {
|
|
162
|
+
shopify: {
|
|
163
|
+
domain: "",
|
|
164
|
+
accessToken: "",
|
|
165
|
+
apiVersion: "2024-10",
|
|
166
|
+
// cached from the last successful connection, for display only
|
|
167
|
+
shop: null
|
|
168
|
+
},
|
|
169
|
+
// One destination per provider — set globally in Setup, reused on every build.
|
|
170
|
+
targets: {
|
|
171
|
+
"cashfree-occ": { webhookUrl: "", webhookSecret: "" },
|
|
172
|
+
"razorpay-magic": { webhookUrl: "", webhookSecret: "" },
|
|
173
|
+
nitro: { webhookUrl: "", webhookSecret: "" }
|
|
174
|
+
},
|
|
175
|
+
customer: {
|
|
176
|
+
firstName: "",
|
|
177
|
+
lastName: "",
|
|
178
|
+
email: "",
|
|
179
|
+
phone: "",
|
|
180
|
+
address1: "",
|
|
181
|
+
address2: "",
|
|
182
|
+
city: "",
|
|
183
|
+
province: "",
|
|
184
|
+
provinceCode: "",
|
|
185
|
+
country: "",
|
|
186
|
+
countryCode: "IN",
|
|
187
|
+
zip: ""
|
|
188
|
+
},
|
|
189
|
+
allowedPhones: [],
|
|
190
|
+
defaults: {
|
|
191
|
+
platform: "shopify",
|
|
192
|
+
currency: "INR",
|
|
193
|
+
copyToClipboard: true,
|
|
194
|
+
utm: {
|
|
195
|
+
utm_source: "facebook",
|
|
196
|
+
utm_medium: "cpc",
|
|
197
|
+
utm_campaign: "abandoned-cart-test",
|
|
198
|
+
utm_content: "",
|
|
199
|
+
fbclid: ""
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
};
|
|
203
|
+
function deepMerge(base, extra) {
|
|
204
|
+
const out = Array.isArray(base) ? [...base] : { ...base };
|
|
205
|
+
for (const [k, v] of Object.entries(extra ?? {})) {
|
|
206
|
+
if (v && typeof v === "object" && !Array.isArray(v) && base?.[k] && typeof base[k] === "object" && !Array.isArray(base[k])) {
|
|
207
|
+
out[k] = deepMerge(base[k], v);
|
|
208
|
+
} else if (v !== void 0) {
|
|
209
|
+
out[k] = v;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
return out;
|
|
213
|
+
}
|
|
214
|
+
function resolveConfigPath(explicit) {
|
|
215
|
+
return explicit ? path.resolve(String(explicit)) : CONFIG_PATH;
|
|
216
|
+
}
|
|
217
|
+
function migrate(cfg) {
|
|
218
|
+
const legacy = cfg.target;
|
|
219
|
+
if (legacy && (legacy.webhookUrl || legacy.webhookSecret)) {
|
|
220
|
+
const existing = cfg.targets["cashfree-occ"];
|
|
221
|
+
if (!existing.webhookUrl && !existing.webhookSecret) {
|
|
222
|
+
cfg.targets["cashfree-occ"] = {
|
|
223
|
+
webhookUrl: legacy.webhookUrl ?? "",
|
|
224
|
+
webhookSecret: legacy.webhookSecret ?? ""
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
delete cfg.target;
|
|
229
|
+
return cfg;
|
|
230
|
+
}
|
|
231
|
+
function loadConfig(explicitPath) {
|
|
232
|
+
const file = resolveConfigPath(explicitPath);
|
|
233
|
+
if (!fs.existsSync(file)) return { ...structuredClone(DEFAULT_CONFIG), _new: true, _path: file };
|
|
234
|
+
try {
|
|
235
|
+
const raw = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
236
|
+
return { ...migrate(deepMerge(DEFAULT_CONFIG, raw)), _path: file };
|
|
237
|
+
} catch (err) {
|
|
238
|
+
throw new Error(`${file} is not valid JSON: ${err.message}`);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
function targetFor(cfg, providerId) {
|
|
242
|
+
return cfg.targets?.[providerId] ?? { webhookUrl: "", webhookSecret: "" };
|
|
243
|
+
}
|
|
244
|
+
function setTarget(cfg, providerId, patch) {
|
|
245
|
+
cfg.targets = cfg.targets ?? {};
|
|
246
|
+
cfg.targets[providerId] = { ...targetFor(cfg, providerId), ...patch };
|
|
247
|
+
return cfg;
|
|
248
|
+
}
|
|
249
|
+
function configuredTargets(cfg) {
|
|
250
|
+
return Object.entries(cfg.targets ?? {}).filter(([, t]) => t.webhookUrl);
|
|
251
|
+
}
|
|
252
|
+
function saveConfig(cfg, explicitPath) {
|
|
253
|
+
const file = resolveConfigPath(explicitPath ?? cfg._path);
|
|
254
|
+
const clean = { ...cfg };
|
|
255
|
+
delete clean._new;
|
|
256
|
+
delete clean._path;
|
|
257
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
258
|
+
fs.writeFileSync(file, JSON.stringify(clean, null, 2) + "\n", { mode: 384 });
|
|
259
|
+
try {
|
|
260
|
+
fs.chmodSync(file, 384);
|
|
261
|
+
} catch {
|
|
262
|
+
}
|
|
263
|
+
return file;
|
|
264
|
+
}
|
|
265
|
+
function applyShopDefaults(cfg, shop, productCount) {
|
|
266
|
+
const applied = [];
|
|
267
|
+
if (!shop) return applied;
|
|
268
|
+
cfg.shopify.shop = {
|
|
269
|
+
name: shop.name,
|
|
270
|
+
domain: shop.domain,
|
|
271
|
+
myshopifyDomain: shop.myshopify_domain,
|
|
272
|
+
currency: shop.currency,
|
|
273
|
+
countryCode: shop.country_code,
|
|
274
|
+
countryName: shop.country_name,
|
|
275
|
+
timezone: shop.iana_timezone,
|
|
276
|
+
plan: shop.plan_display_name,
|
|
277
|
+
email: shop.email,
|
|
278
|
+
productCount: productCount ?? null,
|
|
279
|
+
connectedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
280
|
+
};
|
|
281
|
+
if (shop.currency && cfg.defaults.currency !== shop.currency) {
|
|
282
|
+
cfg.defaults.currency = shop.currency;
|
|
283
|
+
applied.push(`currency set to ${shop.currency}`);
|
|
284
|
+
}
|
|
285
|
+
const fill = (path6, value, label) => {
|
|
286
|
+
if (!value) return;
|
|
287
|
+
const keys = path6.split(".");
|
|
288
|
+
const last = keys.pop();
|
|
289
|
+
const target = keys.reduce((a, k) => a[k], cfg);
|
|
290
|
+
if (!target[last]) {
|
|
291
|
+
target[last] = value;
|
|
292
|
+
applied.push(label);
|
|
293
|
+
}
|
|
294
|
+
};
|
|
295
|
+
fill("customer.countryCode", shop.country_code, `country code set to ${shop.country_code}`);
|
|
296
|
+
fill("customer.country", shop.country_name, `country set to ${shop.country_name}`);
|
|
297
|
+
fill("customer.province", shop.province, `province set to ${shop.province}`);
|
|
298
|
+
fill("customer.provinceCode", shop.province_code, `province code set to ${shop.province_code}`);
|
|
299
|
+
fill("customer.city", shop.city, `city set to ${shop.city}`);
|
|
300
|
+
fill("customer.zip", shop.zip, `zip set to ${shop.zip}`);
|
|
301
|
+
fill("customer.email", shop.email, `contact email set to ${shop.email}`);
|
|
302
|
+
fill("customer.phone", shop.phone, `test phone set to the store contact number`);
|
|
303
|
+
return applied;
|
|
304
|
+
}
|
|
305
|
+
function isShopifyConnected(cfg) {
|
|
306
|
+
return Boolean(cfg.shopify.domain && cfg.shopify.accessToken);
|
|
307
|
+
}
|
|
308
|
+
function canSend(cfg, providerId = "cashfree-occ") {
|
|
309
|
+
const target = targetFor(cfg, providerId);
|
|
310
|
+
return Boolean(target.webhookUrl && cfg.customer.phone);
|
|
311
|
+
}
|
|
312
|
+
function configStatus(cfg, providerId = "cashfree-occ") {
|
|
313
|
+
const target = targetFor(cfg, providerId);
|
|
314
|
+
return [
|
|
315
|
+
{ key: "Shopify domain", value: cfg.shopify.domain, ok: Boolean(cfg.shopify.domain) },
|
|
316
|
+
{ key: "Shopify token", value: cfg.shopify.accessToken, ok: Boolean(cfg.shopify.accessToken), secret: true },
|
|
317
|
+
{ key: "Webhook URL", value: target.webhookUrl, ok: Boolean(target.webhookUrl) },
|
|
318
|
+
{ key: "Webhook secret", value: target.webhookSecret, ok: Boolean(target.webhookSecret), secret: true },
|
|
319
|
+
{ key: "Test phone", value: cfg.customer.phone, ok: Boolean(cfg.customer.phone) },
|
|
320
|
+
{ key: "Ship-to country", value: cfg.customer.countryCode, ok: Boolean(cfg.customer.countryCode) }
|
|
321
|
+
];
|
|
322
|
+
}
|
|
323
|
+
function isConfigured(cfg, providerId = "cashfree-occ") {
|
|
324
|
+
return configStatus(cfg, providerId).every((r) => r.ok);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// src/ui/screens/Home.jsx
|
|
328
|
+
import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
|
|
329
|
+
function Home({ config, onPick, onQuit }) {
|
|
330
|
+
const ready = canSend(config);
|
|
331
|
+
const shop = config.shopify.shop;
|
|
332
|
+
const options = [
|
|
333
|
+
{ label: "Setup".padEnd(18) + (ready ? "Shopify and webhook destination" : "connect Shopify and set the webhook URL"), value: "setup" },
|
|
334
|
+
{ label: "Integrations".padEnd(18) + "build and send a provider webhook", value: "integrations" },
|
|
335
|
+
{ label: "History".padEnd(18) + "review or re-send a saved payload", value: "history" },
|
|
336
|
+
{ label: "Doctor".padEnd(18) + "check credentials and connectivity", value: "doctor" },
|
|
337
|
+
{ label: "Clear".padEnd(18) + "remove cached data", value: "clear" },
|
|
338
|
+
{ label: "Quit".padEnd(18), value: "quit" }
|
|
339
|
+
];
|
|
340
|
+
return /* @__PURE__ */ jsxs2(Box2, { flexDirection: "column", children: [
|
|
341
|
+
/* @__PURE__ */ jsx2(
|
|
342
|
+
Header,
|
|
343
|
+
{
|
|
344
|
+
title: "hookwright",
|
|
345
|
+
subtitle: "real catalogue \u2192 signed provider webhooks",
|
|
346
|
+
right: shop?.name ?? config.shopify.domain ?? "not connected"
|
|
347
|
+
}
|
|
348
|
+
),
|
|
349
|
+
/* @__PURE__ */ jsx2(
|
|
350
|
+
Select,
|
|
351
|
+
{
|
|
352
|
+
visibleOptionCount: options.length,
|
|
353
|
+
options,
|
|
354
|
+
onChange: (value) => value === "quit" ? onQuit() : onPick(value)
|
|
355
|
+
}
|
|
356
|
+
),
|
|
357
|
+
/* @__PURE__ */ jsx2(Box2, { marginTop: 1, children: /* @__PURE__ */ jsx2(Text2, { color: palette.dim, children: "\u2191\u2193 move \xB7 enter select \xB7 q quit" }) })
|
|
358
|
+
] });
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// src/ui/screens/Setup.jsx
|
|
362
|
+
import React3 from "react";
|
|
363
|
+
import { Box as Box3, Text as Text3, useInput } from "ink";
|
|
364
|
+
import { Select as Select2 } from "@inkjs/ui";
|
|
365
|
+
|
|
366
|
+
// src/providers/cashfree-occ.mjs
|
|
367
|
+
import crypto from "node:crypto";
|
|
368
|
+
|
|
369
|
+
// src/shopify/products.mjs
|
|
370
|
+
function normalizeProduct(product) {
|
|
371
|
+
const variants = (product.variants ?? []).map((v) => ({
|
|
372
|
+
id: String(v.id),
|
|
373
|
+
title: v.title,
|
|
374
|
+
sku: v.sku || "",
|
|
375
|
+
price: v.price != null ? Number(v.price) : null,
|
|
376
|
+
available: v.inventory_quantity == null ? true : v.inventory_quantity > 0,
|
|
377
|
+
imageId: v.image_id ? String(v.image_id) : null
|
|
378
|
+
}));
|
|
379
|
+
const images = (product.images ?? []).map((img) => ({ id: String(img.id), src: img.src }));
|
|
380
|
+
const defaultImage = product.image?.src ?? images[0]?.src ?? "";
|
|
381
|
+
return {
|
|
382
|
+
id: String(product.id),
|
|
383
|
+
title: product.title,
|
|
384
|
+
handle: product.handle,
|
|
385
|
+
status: product.status,
|
|
386
|
+
vendor: product.vendor,
|
|
387
|
+
productType: product.product_type,
|
|
388
|
+
images,
|
|
389
|
+
image: defaultImage,
|
|
390
|
+
variants
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
function imageForVariant(product, variant) {
|
|
394
|
+
if (variant?.imageId) {
|
|
395
|
+
const hit = product.images.find((i) => i.id === variant.imageId);
|
|
396
|
+
if (hit) return hit.src;
|
|
397
|
+
}
|
|
398
|
+
return product.image;
|
|
399
|
+
}
|
|
400
|
+
function cartPermalink(domain, items) {
|
|
401
|
+
const parts = items.filter((i) => i.variantId).map((i) => `${i.variantId}:${i.quantity}`).join(",");
|
|
402
|
+
const clean = String(domain).replace(/^https?:\/\//i, "");
|
|
403
|
+
const scheme = /^(127\.0\.0\.1|localhost)(:\d+)?$/i.test(clean) ? "http" : "https";
|
|
404
|
+
return `${scheme}://${clean}/cart/${parts}`;
|
|
405
|
+
}
|
|
406
|
+
function money2(value) {
|
|
407
|
+
return Math.round(Number(value) * 100) / 100;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// src/providers/cashfree-occ.mjs
|
|
411
|
+
var GATE_PATH = "type";
|
|
412
|
+
var GATE_VALUE = "ABANDONED_CHECKOUT";
|
|
413
|
+
var fieldMap = [
|
|
414
|
+
{ source: "type", target: "\xABgate\xBB", required: true, note: `must equal ${GATE_VALUE}` },
|
|
415
|
+
{ source: "event_time", target: "eventTime" },
|
|
416
|
+
{ source: "data.cart_id", target: "cartId" },
|
|
417
|
+
{ source: "data.cart_token", target: "cartToken" },
|
|
418
|
+
{ source: "data.store_url", target: "storeUrl", required: true },
|
|
419
|
+
{ source: "data.platform", target: "platform" },
|
|
420
|
+
{ source: "data.email", target: "email" },
|
|
421
|
+
{ source: "data.phone", target: "phone", required: true, note: "or shipping_address.phone; must parse to E.164" },
|
|
422
|
+
{ source: "data.abandoned_checkout_url", target: "abandonedCheckoutUrl / checkoutUrl", required: true },
|
|
423
|
+
{ source: "data.original_total_price", target: "pricing.originalTotalPrice" },
|
|
424
|
+
{ source: "data.total_price", target: "pricing.totalPrice", required: true },
|
|
425
|
+
{ source: "data.total_discount", target: "pricing.totalDiscount" },
|
|
426
|
+
{ source: "data.customer.first_name", target: "customer.firstName" },
|
|
427
|
+
{ source: "data.customer.last_name", target: "customer.lastName" },
|
|
428
|
+
{ source: "data.customer.email", target: "customer.email" },
|
|
429
|
+
{ source: "data.customer.shipping_address.customer_name", target: "customer.customerName" },
|
|
430
|
+
{ source: "data.customer.shipping_address.phone", target: "phone fallback" },
|
|
431
|
+
{ source: "data.customer.shipping_address.address1", target: "customer.address1" },
|
|
432
|
+
{ source: "data.customer.shipping_address.address2", target: "customer.address2" },
|
|
433
|
+
{ source: "data.customer.shipping_address.city", target: "customer.city" },
|
|
434
|
+
{ source: "data.customer.shipping_address.province", target: "customer.province" },
|
|
435
|
+
{ source: "data.customer.shipping_address.province_code", target: "customer.provinceCode" },
|
|
436
|
+
{ source: "data.customer.shipping_address.country", target: "customer.country" },
|
|
437
|
+
{ source: "data.customer.shipping_address.country_code", target: "customer.countryCode", required: true, note: "region code used to parse the phone" },
|
|
438
|
+
{ source: "data.customer.shipping_address.zip", target: "customer.zip" },
|
|
439
|
+
{ source: "data.line_items[0].name", target: "cart.productName", required: true },
|
|
440
|
+
{ source: "data.line_items[0].currency", target: "pricing.currency", required: true, note: "currency is read off the FIRST line item, not the root" },
|
|
441
|
+
{ source: "data.line_items[0].image_url", target: "cart.image", required: true, note: "first line item with an image wins" },
|
|
442
|
+
{ source: "data.line_items[0].quantity", target: "cart.quantity" },
|
|
443
|
+
{ source: "data.utm_parameters.utm_source", target: "utm.source" },
|
|
444
|
+
{ source: "data.utm_parameters.utm_medium", target: "utm.medium" },
|
|
445
|
+
{ source: "data.utm_parameters.utm_campaign", target: "utm.campaign" },
|
|
446
|
+
{ source: "data.utm_parameters.utm_content", target: "utm.content" },
|
|
447
|
+
{ source: "data.utm_parameters.fbclid", target: "utm.fbclid" }
|
|
448
|
+
];
|
|
449
|
+
function buildPayload(ctx) {
|
|
450
|
+
const { config, items, phone } = ctx;
|
|
451
|
+
const now = ctx.now ?? /* @__PURE__ */ new Date();
|
|
452
|
+
const currency = ctx.currency || config.defaults.currency;
|
|
453
|
+
const domain = config.shopify.domain;
|
|
454
|
+
const cust = config.customer;
|
|
455
|
+
const lineItems = items.map(({ product, variant, quantity }) => ({
|
|
456
|
+
name: product.title,
|
|
457
|
+
variant_id: variant ? Number(variant.id) : null,
|
|
458
|
+
variant_title: variant?.title ?? null,
|
|
459
|
+
reference: Number(product.id),
|
|
460
|
+
sku: variant?.sku || null,
|
|
461
|
+
quantity,
|
|
462
|
+
price: money2(variant?.price ?? 0),
|
|
463
|
+
currency,
|
|
464
|
+
image_url: imageForVariant(product, variant)
|
|
465
|
+
}));
|
|
466
|
+
const originalTotal = money2(lineItems.reduce((sum, li) => sum + li.price * li.quantity, 0));
|
|
467
|
+
const discount = money2(Math.min(ctx.discount ?? 0, originalTotal));
|
|
468
|
+
const total = money2(originalTotal - discount);
|
|
469
|
+
const checkoutUrl = cartPermalink(domain, items.map(({ variant, quantity }) => ({ variantId: variant?.id, quantity })));
|
|
470
|
+
return {
|
|
471
|
+
type: GATE_VALUE,
|
|
472
|
+
event_time: now.toISOString(),
|
|
473
|
+
data: {
|
|
474
|
+
cart_id: crypto.randomUUID(),
|
|
475
|
+
cart_token: crypto.randomBytes(16).toString("hex"),
|
|
476
|
+
store_url: domain,
|
|
477
|
+
platform: config.defaults.platform,
|
|
478
|
+
email: cust.email,
|
|
479
|
+
phone,
|
|
480
|
+
abandoned_checkout_url: checkoutUrl,
|
|
481
|
+
original_total_price: originalTotal,
|
|
482
|
+
total_price: total,
|
|
483
|
+
total_discount: discount,
|
|
484
|
+
customer: {
|
|
485
|
+
first_name: cust.firstName,
|
|
486
|
+
last_name: cust.lastName,
|
|
487
|
+
email: cust.email,
|
|
488
|
+
shipping_address: {
|
|
489
|
+
customer_name: `${cust.firstName} ${cust.lastName}`.trim(),
|
|
490
|
+
phone,
|
|
491
|
+
address1: cust.address1,
|
|
492
|
+
address2: cust.address2,
|
|
493
|
+
city: cust.city,
|
|
494
|
+
province: cust.province,
|
|
495
|
+
province_code: cust.provinceCode,
|
|
496
|
+
country: cust.country,
|
|
497
|
+
country_code: cust.countryCode,
|
|
498
|
+
zip: cust.zip
|
|
499
|
+
}
|
|
500
|
+
},
|
|
501
|
+
line_items: lineItems,
|
|
502
|
+
utm_parameters: { ...config.defaults.utm }
|
|
503
|
+
}
|
|
504
|
+
};
|
|
505
|
+
}
|
|
506
|
+
function sign({ body, secret, timestamp }) {
|
|
507
|
+
const ts = timestamp ?? String(Math.floor(Date.now() / 1e3));
|
|
508
|
+
const signature = crypto.createHmac("sha256", String(secret)).update(`${ts}${body}`).digest("base64");
|
|
509
|
+
return {
|
|
510
|
+
timestamp: ts,
|
|
511
|
+
signature,
|
|
512
|
+
headers: {
|
|
513
|
+
"content-type": "application/json",
|
|
514
|
+
"x-webhook-signature": signature,
|
|
515
|
+
"x-webhook-timestamp": ts
|
|
516
|
+
}
|
|
517
|
+
};
|
|
518
|
+
}
|
|
519
|
+
function webhookUrl(config) {
|
|
520
|
+
return String(targetFor(config, "cashfree-occ").webhookUrl ?? "").trim();
|
|
521
|
+
}
|
|
522
|
+
function webhookSecret(config) {
|
|
523
|
+
return String(targetFor(config, "cashfree-occ").webhookSecret ?? "");
|
|
524
|
+
}
|
|
525
|
+
var editableFields = [
|
|
526
|
+
{ group: "Event", path: "event_time", label: "Event time", hint: "ISO timestamp of the abandonment" },
|
|
527
|
+
{ group: "Cart", path: "data.cart_id", label: "Cart id" },
|
|
528
|
+
{ group: "Cart", path: "data.cart_token", label: "Cart token" },
|
|
529
|
+
{ group: "Cart", path: "data.store_url", label: "Store URL" },
|
|
530
|
+
{ group: "Cart", path: "data.platform", label: "Platform" },
|
|
531
|
+
{ group: "Cart", path: "data.abandoned_checkout_url", label: "Abandoned checkout URL", hint: "the link the shopper is sent back to" },
|
|
532
|
+
{ group: "Pricing", path: "data.original_total_price", label: "Original total", type: "number" },
|
|
533
|
+
{ group: "Pricing", path: "data.total_discount", label: "Discount", type: "number" },
|
|
534
|
+
{ group: "Pricing", path: "data.total_price", label: "Total price", type: "number" },
|
|
535
|
+
{ group: "Customer", path: "data.customer.first_name", label: "First name" },
|
|
536
|
+
{ group: "Customer", path: "data.customer.last_name", label: "Last name" },
|
|
537
|
+
{ group: "Customer", path: "data.email", label: "Email" },
|
|
538
|
+
{ group: "Customer", path: "data.phone", label: "Phone", hint: "must parse to E.164 against the country code below" },
|
|
539
|
+
{ group: "Shipping", path: "data.customer.shipping_address.customer_name", label: "Ship-to name" },
|
|
540
|
+
{ group: "Shipping", path: "data.customer.shipping_address.address1", label: "Address line 1" },
|
|
541
|
+
{ group: "Shipping", path: "data.customer.shipping_address.address2", label: "Address line 2", optional: true },
|
|
542
|
+
{ group: "Shipping", path: "data.customer.shipping_address.city", label: "City" },
|
|
543
|
+
{ group: "Shipping", path: "data.customer.shipping_address.province", label: "Province / state" },
|
|
544
|
+
{ group: "Shipping", path: "data.customer.shipping_address.province_code", label: "Province code", optional: true },
|
|
545
|
+
{ group: "Shipping", path: "data.customer.shipping_address.country", label: "Country" },
|
|
546
|
+
{ group: "Shipping", path: "data.customer.shipping_address.country_code", label: "Country code", hint: "region used to parse the phone" },
|
|
547
|
+
{ group: "Shipping", path: "data.customer.shipping_address.zip", label: "Zip / postcode" },
|
|
548
|
+
{ group: "UTM", path: "data.utm_parameters.utm_source", label: "UTM source", optional: true },
|
|
549
|
+
{ group: "UTM", path: "data.utm_parameters.utm_medium", label: "UTM medium", optional: true },
|
|
550
|
+
{ group: "UTM", path: "data.utm_parameters.utm_campaign", label: "UTM campaign", optional: true },
|
|
551
|
+
{ group: "UTM", path: "data.utm_parameters.utm_content", label: "UTM content", optional: true },
|
|
552
|
+
{ group: "UTM", path: "data.utm_parameters.fbclid", label: "Facebook click id", optional: true }
|
|
553
|
+
];
|
|
554
|
+
var cashfree_occ_default = {
|
|
555
|
+
id: "cashfree-occ",
|
|
556
|
+
urlKey: "cashfree_occ",
|
|
557
|
+
label: "Cashfree One Click Checkout",
|
|
558
|
+
eventType: "cashfree_occ_abandoned_checkout",
|
|
559
|
+
gate: { path: GATE_PATH, value: GATE_VALUE },
|
|
560
|
+
signatureScheme: "base64 HMAC-SHA256 over `timestamp + body`",
|
|
561
|
+
signatureHeader: "x-webhook-signature",
|
|
562
|
+
fieldMap,
|
|
563
|
+
editableFields,
|
|
564
|
+
buildPayload,
|
|
565
|
+
sign,
|
|
566
|
+
webhookUrl,
|
|
567
|
+
webhookSecret
|
|
568
|
+
};
|
|
569
|
+
|
|
570
|
+
// src/providers/index.mjs
|
|
571
|
+
var PROVIDER_META = [
|
|
572
|
+
{
|
|
573
|
+
id: "cashfree-occ",
|
|
574
|
+
label: "Cashfree One Click Checkout",
|
|
575
|
+
signature: "base64 HMAC-SHA256 over `timestamp + body`",
|
|
576
|
+
header: "x-webhook-signature",
|
|
577
|
+
available: true
|
|
578
|
+
},
|
|
579
|
+
{
|
|
580
|
+
id: "razorpay-magic",
|
|
581
|
+
label: "Razorpay Magic",
|
|
582
|
+
signature: "hex HMAC-SHA256 over the body",
|
|
583
|
+
header: "x-razorpay-signature",
|
|
584
|
+
available: false
|
|
585
|
+
},
|
|
586
|
+
{
|
|
587
|
+
id: "nitro",
|
|
588
|
+
label: "Nitro",
|
|
589
|
+
signature: "static bearer token",
|
|
590
|
+
header: "authorization",
|
|
591
|
+
available: false
|
|
592
|
+
}
|
|
593
|
+
];
|
|
594
|
+
var providers = {
|
|
595
|
+
[cashfree_occ_default.id]: cashfree_occ_default
|
|
596
|
+
};
|
|
597
|
+
function getProvider(id) {
|
|
598
|
+
const provider = providers[id];
|
|
599
|
+
if (!provider) {
|
|
600
|
+
throw new Error(`unknown provider "${id}" \u2014 available: ${Object.keys(providers).join(", ")}`);
|
|
601
|
+
}
|
|
602
|
+
return provider;
|
|
603
|
+
}
|
|
604
|
+
function listProviders() {
|
|
605
|
+
return Object.values(providers);
|
|
606
|
+
}
|
|
607
|
+
function listProviderMeta() {
|
|
608
|
+
return PROVIDER_META;
|
|
609
|
+
}
|
|
610
|
+
function metaFor(id) {
|
|
611
|
+
return PROVIDER_META.find((p) => p.id === id) ?? { id, label: id, available: Boolean(providers[id]) };
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
// src/ui/screens/Setup.jsx
|
|
615
|
+
import { jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
|
|
616
|
+
function Setup({ config, onPick, onBack }) {
|
|
617
|
+
const shopifyOk = isShopifyConnected(config);
|
|
618
|
+
const configured = configuredTargets(config).length;
|
|
619
|
+
const total = listProviderMeta().length;
|
|
620
|
+
const phoneOk = Boolean(config.customer.phone);
|
|
621
|
+
const shop = config.shopify.shop;
|
|
622
|
+
useInput((input, key) => {
|
|
623
|
+
if (key.escape) onBack();
|
|
624
|
+
});
|
|
625
|
+
const options = [
|
|
626
|
+
{ label: "Shopify".padEnd(20) + (shopifyOk ? `connected \xB7 ${shop?.name ?? config.shopify.domain}` : "connect a store \u2014 domain + Admin API token"), value: "connect" },
|
|
627
|
+
{ label: "Webhook".padEnd(20) + `destinations \xB7 ${configured} of ${total} set`, value: "webhook" },
|
|
628
|
+
{ label: "Customer".padEnd(20) + (phoneOk ? `${config.customer.firstName} ${config.customer.lastName} \xB7 ${config.customer.phone}` : "shopper name, phone, shipping address"), value: "customer" },
|
|
629
|
+
{ label: "Defaults".padEnd(20) + `currency ${config.defaults.currency} \xB7 platform ${config.defaults.platform}`, value: "defaults" },
|
|
630
|
+
{ label: "Back".padEnd(20), value: "back" }
|
|
631
|
+
];
|
|
632
|
+
return /* @__PURE__ */ jsxs3(Box3, { flexDirection: "column", children: [
|
|
633
|
+
/* @__PURE__ */ jsx3(Header, { title: "Setup", subtitle: "the product source and the webhook destination", right: "esc to go back" }),
|
|
634
|
+
/* @__PURE__ */ jsx3(Select2, { visibleOptionCount: options.length, options, onChange: (v) => v === "back" ? onBack() : onPick(v) }),
|
|
635
|
+
/* @__PURE__ */ jsx3(Box3, { marginTop: 1, children: /* @__PURE__ */ jsx3(Text3, { color: palette.dim, children: `${glyph.dot} shared by every integration \u2014 set it once` }) })
|
|
636
|
+
] });
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
// src/ui/screens/Integrations.jsx
|
|
640
|
+
import React4 from "react";
|
|
641
|
+
import { Box as Box4, Text as Text4, useInput as useInput2 } from "ink";
|
|
642
|
+
import { Select as Select3 } from "@inkjs/ui";
|
|
643
|
+
import { jsx as jsx4, jsxs as jsxs4 } from "react/jsx-runtime";
|
|
644
|
+
var PLANNED = listProviderMeta().filter((p) => !p.available);
|
|
645
|
+
function Integrations({ config, onPick, onBack, onSetup }) {
|
|
646
|
+
const available = listProviders();
|
|
647
|
+
const ready = available.every((p) => canSend(config, p.id));
|
|
648
|
+
useInput2((input, key) => {
|
|
649
|
+
if (key.escape) onBack();
|
|
650
|
+
});
|
|
651
|
+
const options = [
|
|
652
|
+
...available.map((p) => ({
|
|
653
|
+
label: p.label.padEnd(34) + (canSend(config, p.id) ? "ready" : "setup incomplete"),
|
|
654
|
+
value: p.id
|
|
655
|
+
})),
|
|
656
|
+
...ready ? [] : [{ label: "Go to Setup".padEnd(34) + "connect Shopify and set the webhook URL", value: "__setup" }],
|
|
657
|
+
{ label: "Back".padEnd(34), value: "__back" }
|
|
658
|
+
];
|
|
659
|
+
return /* @__PURE__ */ jsxs4(Box4, { flexDirection: "column", children: [
|
|
660
|
+
/* @__PURE__ */ jsx4(Header, { title: "Integrations", subtitle: "pick the provider whose webhook you want to generate", right: "esc to go back" }),
|
|
661
|
+
/* @__PURE__ */ jsx4(
|
|
662
|
+
Select3,
|
|
663
|
+
{
|
|
664
|
+
visibleOptionCount: options.length,
|
|
665
|
+
options,
|
|
666
|
+
onChange: (v) => v === "__back" ? onBack() : v === "__setup" ? onSetup() : onPick(v)
|
|
667
|
+
}
|
|
668
|
+
),
|
|
669
|
+
/* @__PURE__ */ jsx4(Box4, { flexDirection: "column", marginTop: 1, children: PLANNED.map((p) => /* @__PURE__ */ jsx4(Text4, { color: palette.dim, children: ` ${glyph.dot} ${p.label.padEnd(32)} not yet available` }, p.id)) })
|
|
670
|
+
] });
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
// src/ui/screens/Build.jsx
|
|
674
|
+
import React7, { useEffect, useState } from "react";
|
|
675
|
+
import { Box as Box7, Text as Text7, useApp, useInput as useInput3 } from "ink";
|
|
676
|
+
import { Spinner, Select as Select4, MultiSelect, TextInput, ConfirmInput, Alert, Badge } from "@inkjs/ui";
|
|
677
|
+
|
|
678
|
+
// src/ui/components/CheckList.jsx
|
|
679
|
+
import React5 from "react";
|
|
680
|
+
import { Box as Box5, Text as Text5 } from "ink";
|
|
681
|
+
import { jsx as jsx5, jsxs as jsxs5 } from "react/jsx-runtime";
|
|
682
|
+
function CheckList({ checks }) {
|
|
683
|
+
return /* @__PURE__ */ jsx5(Box5, { flexDirection: "column", children: checks.map((check, i) => {
|
|
684
|
+
const colour = check.ok ? palette.ok : check.warn ? palette.warn : palette.bad;
|
|
685
|
+
const mark = check.ok ? glyph.tick : check.warn ? glyph.warn : glyph.cross;
|
|
686
|
+
return /* @__PURE__ */ jsxs5(Box5, { children: [
|
|
687
|
+
/* @__PURE__ */ jsx5(Text5, { color: colour, children: ` ${mark} ` }),
|
|
688
|
+
/* @__PURE__ */ jsx5(Text5, { bold: true, children: check.name.padEnd(24) }),
|
|
689
|
+
/* @__PURE__ */ jsx5(Text5, { color: palette.dim, children: check.detail })
|
|
690
|
+
] }, i);
|
|
691
|
+
}) });
|
|
692
|
+
}
|
|
693
|
+
function KeyValue({ rows, keyWidth = 14 }) {
|
|
694
|
+
return /* @__PURE__ */ jsx5(Box5, { flexDirection: "column", children: rows.map(([k, v, colour], i) => /* @__PURE__ */ jsxs5(Box5, { children: [
|
|
695
|
+
/* @__PURE__ */ jsx5(Text5, { color: palette.dim, children: ` ${String(k).padEnd(keyWidth)}` }),
|
|
696
|
+
/* @__PURE__ */ jsx5(Text5, { color: colour, children: String(v) })
|
|
697
|
+
] }, i)) });
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
// src/ui/components/FieldTable.jsx
|
|
701
|
+
import React6 from "react";
|
|
702
|
+
import { Box as Box6, Text as Text6 } from "ink";
|
|
703
|
+
import { jsx as jsx6, jsxs as jsxs6 } from "react/jsx-runtime";
|
|
704
|
+
function FieldTable({ coverage: coverage2, limit }) {
|
|
705
|
+
const rows = limit ? coverage2.slice(0, limit) : coverage2;
|
|
706
|
+
const fieldWidth = Math.min(46, Math.max(...rows.map((r) => r.field.length)) + 1);
|
|
707
|
+
const mapWidth = Math.min(30, Math.max(...rows.map((r) => String(r.maps_to).length)) + 1);
|
|
708
|
+
return /* @__PURE__ */ jsxs6(Box6, { flexDirection: "column", children: [
|
|
709
|
+
/* @__PURE__ */ jsxs6(Box6, { children: [
|
|
710
|
+
/* @__PURE__ */ jsx6(Text6, { bold: true, color: palette.dim, children: " " + "payload field".padEnd(fieldWidth) }),
|
|
711
|
+
/* @__PURE__ */ jsx6(Text6, { bold: true, color: palette.dim, children: "maps to".padEnd(mapWidth) }),
|
|
712
|
+
/* @__PURE__ */ jsx6(Text6, { bold: true, color: palette.dim, children: "value" })
|
|
713
|
+
] }),
|
|
714
|
+
rows.map((row, i) => {
|
|
715
|
+
const colour = row.status === "MISSING" ? palette.bad : row.status === "empty" ? palette.dim : void 0;
|
|
716
|
+
return /* @__PURE__ */ jsxs6(Box6, { children: [
|
|
717
|
+
/* @__PURE__ */ jsx6(Text6, { color: colour, children: " " + truncate(row.field, fieldWidth - 1).padEnd(fieldWidth) }),
|
|
718
|
+
/* @__PURE__ */ jsx6(Text6, { color: palette.dim, children: truncate(String(row.maps_to), mapWidth - 1).padEnd(mapWidth) }),
|
|
719
|
+
/* @__PURE__ */ jsx6(Text6, { color: colour, children: truncate(row.value, 34) })
|
|
720
|
+
] }, i);
|
|
721
|
+
}),
|
|
722
|
+
limit && coverage2.length > limit ? /* @__PURE__ */ jsx6(Text6, { color: palette.dim, children: ` \u2026 ${coverage2.length - limit} more fields` }) : null
|
|
723
|
+
] });
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
// src/errors.mjs
|
|
727
|
+
var ForgeError = class extends Error {
|
|
728
|
+
constructor(message, { hint, cause } = {}) {
|
|
729
|
+
super(message);
|
|
730
|
+
this.name = "ForgeError";
|
|
731
|
+
this.hint = hint;
|
|
732
|
+
if (cause) this.cause = cause;
|
|
733
|
+
}
|
|
734
|
+
};
|
|
735
|
+
var ConfigError = class extends ForgeError {
|
|
736
|
+
constructor(message, hint) {
|
|
737
|
+
super(message, { hint });
|
|
738
|
+
this.name = "ConfigError";
|
|
739
|
+
}
|
|
740
|
+
};
|
|
741
|
+
var ShopifyError = class extends ForgeError {
|
|
742
|
+
constructor(message, { status, body, hint } = {}) {
|
|
743
|
+
super(message, { hint });
|
|
744
|
+
this.name = "ShopifyError";
|
|
745
|
+
this.status = status;
|
|
746
|
+
this.body = body;
|
|
747
|
+
}
|
|
748
|
+
};
|
|
749
|
+
|
|
750
|
+
// src/logger.mjs
|
|
751
|
+
import fs2 from "node:fs";
|
|
752
|
+
import path2 from "node:path";
|
|
753
|
+
var LOG_PATH = path2.join(STATE_DIR, "forge.log");
|
|
754
|
+
var verbose = false;
|
|
755
|
+
function setVerbose(v) {
|
|
756
|
+
verbose = Boolean(v);
|
|
757
|
+
}
|
|
758
|
+
function isVerbose() {
|
|
759
|
+
return verbose;
|
|
760
|
+
}
|
|
761
|
+
function audit(event, detail = {}) {
|
|
762
|
+
try {
|
|
763
|
+
fs2.mkdirSync(STATE_DIR, { recursive: true });
|
|
764
|
+
const line = JSON.stringify({ at: (/* @__PURE__ */ new Date()).toISOString(), event, ...redact(detail) });
|
|
765
|
+
fs2.appendFileSync(LOG_PATH, line + "\n");
|
|
766
|
+
} catch {
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
function redact(obj) {
|
|
770
|
+
const SECRET_KEYS = /token|secret|password|signature|authorization/i;
|
|
771
|
+
const walk = (v) => {
|
|
772
|
+
if (Array.isArray(v)) return v.map(walk);
|
|
773
|
+
if (v && typeof v === "object") {
|
|
774
|
+
return Object.fromEntries(Object.entries(v).map(([k, val]) => [k, SECRET_KEYS.test(k) ? "\xABredacted\xBB" : walk(val)]));
|
|
775
|
+
}
|
|
776
|
+
return v;
|
|
777
|
+
};
|
|
778
|
+
return walk(obj);
|
|
779
|
+
}
|
|
780
|
+
var logPath = LOG_PATH;
|
|
781
|
+
|
|
782
|
+
// src/shopify/client.mjs
|
|
783
|
+
var DEFAULT_API_VERSION = "2024-10";
|
|
784
|
+
var ShopifyClient = class {
|
|
785
|
+
constructor({ domain, accessToken, apiVersion = DEFAULT_API_VERSION }) {
|
|
786
|
+
if (!domain) throw new ShopifyError("Shopify domain missing", { hint: "run `hookwright configure`" });
|
|
787
|
+
if (!accessToken) throw new ShopifyError("Shopify access token missing", { hint: "run `hookwright configure`" });
|
|
788
|
+
this.domain = normalizeDomain(domain);
|
|
789
|
+
this.protocol = /^http:\/\//i.test(String(domain).trim()) || /^(127\.0\.0\.1|localhost)(:\d+)?$/i.test(this.domain) ? "http" : "https";
|
|
790
|
+
this.accessToken = accessToken;
|
|
791
|
+
this.apiVersion = apiVersion || DEFAULT_API_VERSION;
|
|
792
|
+
}
|
|
793
|
+
get base() {
|
|
794
|
+
return `${this.protocol}://${this.domain}/admin/api/${this.apiVersion}`;
|
|
795
|
+
}
|
|
796
|
+
async request(pathOrUrl, { method = "GET", query, body, timeoutMs = 2e4 } = {}) {
|
|
797
|
+
const url = pathOrUrl.startsWith("http") ? new URL(pathOrUrl) : new URL(this.base + pathOrUrl);
|
|
798
|
+
for (const [k, v] of Object.entries(query ?? {})) {
|
|
799
|
+
if (v !== void 0 && v !== null && v !== "") url.searchParams.set(k, String(v));
|
|
800
|
+
}
|
|
801
|
+
const controller = new AbortController();
|
|
802
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
803
|
+
let res;
|
|
804
|
+
try {
|
|
805
|
+
res = await fetch(url, {
|
|
806
|
+
method,
|
|
807
|
+
signal: controller.signal,
|
|
808
|
+
headers: {
|
|
809
|
+
"X-Shopify-Access-Token": this.accessToken,
|
|
810
|
+
"Content-Type": "application/json",
|
|
811
|
+
Accept: "application/json"
|
|
812
|
+
},
|
|
813
|
+
body: body ? JSON.stringify(body) : void 0
|
|
814
|
+
});
|
|
815
|
+
} catch (err) {
|
|
816
|
+
clearTimeout(timer);
|
|
817
|
+
if (err.name === "AbortError") throw new ShopifyError(`Shopify request timed out after ${timeoutMs}ms`, { hint: "check the store domain and your network" });
|
|
818
|
+
throw new ShopifyError(`Shopify request failed: ${err.message}`, { hint: "check the store domain" });
|
|
819
|
+
}
|
|
820
|
+
clearTimeout(timer);
|
|
821
|
+
const text = await res.text();
|
|
822
|
+
let json = null;
|
|
823
|
+
try {
|
|
824
|
+
json = text ? JSON.parse(text) : null;
|
|
825
|
+
} catch {
|
|
826
|
+
}
|
|
827
|
+
if (isVerbose()) audit("shopify.request", { url: url.pathname, status: res.status });
|
|
828
|
+
if (res.status === 401 || res.status === 403) {
|
|
829
|
+
throw new ShopifyError(`Shopify rejected the credentials (${res.status})`, {
|
|
830
|
+
status: res.status,
|
|
831
|
+
body: json ?? text,
|
|
832
|
+
hint: "the Admin API access token needs read_products scope, and must belong to this store"
|
|
833
|
+
});
|
|
834
|
+
}
|
|
835
|
+
if (res.status === 404) {
|
|
836
|
+
throw new ShopifyError("Shopify returned 404 \u2014 wrong store domain or API version", {
|
|
837
|
+
status: 404,
|
|
838
|
+
hint: `tried ${url.host}${url.pathname}`
|
|
839
|
+
});
|
|
840
|
+
}
|
|
841
|
+
if (res.status === 429) {
|
|
842
|
+
const retry = Number(res.headers.get("retry-after") ?? 2);
|
|
843
|
+
await sleep(retry * 1e3);
|
|
844
|
+
return this.request(pathOrUrl, { method, query, body, timeoutMs });
|
|
845
|
+
}
|
|
846
|
+
if (!res.ok) {
|
|
847
|
+
throw new ShopifyError(`Shopify error ${res.status}`, { status: res.status, body: json ?? text });
|
|
848
|
+
}
|
|
849
|
+
return { json, headers: res.headers };
|
|
850
|
+
}
|
|
851
|
+
/** Verifies the credentials and returns the real shop record. */
|
|
852
|
+
async shop() {
|
|
853
|
+
const { json } = await this.request("/shop.json");
|
|
854
|
+
return json?.shop ?? null;
|
|
855
|
+
}
|
|
856
|
+
/**
|
|
857
|
+
* Products, following Link-header pagination.
|
|
858
|
+
* @param {{limit?: number, title?: string, pages?: number, status?: string}} opts
|
|
859
|
+
*/
|
|
860
|
+
async products({ limit = 50, title, pages = 1, status = "active" } = {}) {
|
|
861
|
+
const out = [];
|
|
862
|
+
let next = null;
|
|
863
|
+
for (let page = 0; page < pages; page += 1) {
|
|
864
|
+
const { json, headers } = next ? await this.request(next) : await this.request("/products.json", { query: { limit, title, status } });
|
|
865
|
+
out.push(...json?.products ?? []);
|
|
866
|
+
next = parseNextLink(headers.get("link"));
|
|
867
|
+
if (!next) break;
|
|
868
|
+
}
|
|
869
|
+
return out;
|
|
870
|
+
}
|
|
871
|
+
async productCount() {
|
|
872
|
+
const { json } = await this.request("/products/count.json");
|
|
873
|
+
return json?.count ?? 0;
|
|
874
|
+
}
|
|
875
|
+
};
|
|
876
|
+
function normalizeDomain(domain) {
|
|
877
|
+
return String(domain).trim().replace(/^https?:\/\//, "").replace(/\/.*$/, "").toLowerCase();
|
|
878
|
+
}
|
|
879
|
+
function parseNextLink(linkHeader) {
|
|
880
|
+
if (!linkHeader) return null;
|
|
881
|
+
const match = linkHeader.split(",").find((part) => part.includes('rel="next"'));
|
|
882
|
+
if (!match) return null;
|
|
883
|
+
const url = match.match(/<([^>]+)>/);
|
|
884
|
+
return url ? url[1] : null;
|
|
885
|
+
}
|
|
886
|
+
var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
887
|
+
|
|
888
|
+
// src/core/catalogue.mjs
|
|
889
|
+
async function verifyStore(cfg) {
|
|
890
|
+
const client = new ShopifyClient(cfg.shopify);
|
|
891
|
+
const shop = await client.shop();
|
|
892
|
+
const count = await client.productCount().catch(() => null);
|
|
893
|
+
return { shop, count };
|
|
894
|
+
}
|
|
895
|
+
async function fetchCatalogue(cfg, { search, pages = 1, limit = 50 } = {}) {
|
|
896
|
+
const client = new ShopifyClient(cfg.shopify);
|
|
897
|
+
const raw = await client.products({ title: search, pages, limit });
|
|
898
|
+
return raw.map(normalizeProduct).filter((p) => p.variants.length);
|
|
899
|
+
}
|
|
900
|
+
function autoSelect(products, count = 1) {
|
|
901
|
+
return products.slice(0, count).map((product) => ({
|
|
902
|
+
product,
|
|
903
|
+
variant: product.variants[0],
|
|
904
|
+
quantity: 1
|
|
905
|
+
}));
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
// src/phone.mjs
|
|
909
|
+
var CALLING_CODES = {
|
|
910
|
+
IN: "91",
|
|
911
|
+
US: "1",
|
|
912
|
+
CA: "1",
|
|
913
|
+
GB: "44",
|
|
914
|
+
AE: "971",
|
|
915
|
+
SG: "65",
|
|
916
|
+
AU: "61",
|
|
917
|
+
NZ: "64",
|
|
918
|
+
DE: "49",
|
|
919
|
+
FR: "33",
|
|
920
|
+
IT: "39",
|
|
921
|
+
ES: "34",
|
|
922
|
+
NL: "31",
|
|
923
|
+
SE: "46",
|
|
924
|
+
RO: "40",
|
|
925
|
+
PL: "48",
|
|
926
|
+
ZA: "27",
|
|
927
|
+
NG: "234",
|
|
928
|
+
KE: "254",
|
|
929
|
+
BD: "880",
|
|
930
|
+
PK: "92",
|
|
931
|
+
LK: "94",
|
|
932
|
+
NP: "977",
|
|
933
|
+
MY: "60",
|
|
934
|
+
ID: "62",
|
|
935
|
+
PH: "63",
|
|
936
|
+
TH: "66",
|
|
937
|
+
VN: "84",
|
|
938
|
+
JP: "81",
|
|
939
|
+
KR: "82",
|
|
940
|
+
CN: "86",
|
|
941
|
+
SA: "966",
|
|
942
|
+
QA: "974",
|
|
943
|
+
KW: "965",
|
|
944
|
+
OM: "968",
|
|
945
|
+
BH: "973",
|
|
946
|
+
BR: "55",
|
|
947
|
+
MX: "52"
|
|
948
|
+
};
|
|
949
|
+
function callingCode(regionCode) {
|
|
950
|
+
return CALLING_CODES[String(regionCode || "").toUpperCase()] || null;
|
|
951
|
+
}
|
|
952
|
+
function toE164(raw, regionCode) {
|
|
953
|
+
const cleaned = String(raw ?? "").replace(/\s+/g, "");
|
|
954
|
+
if (!cleaned) return { ok: false, reason: "phone is empty" };
|
|
955
|
+
if (cleaned.startsWith("+")) {
|
|
956
|
+
const digits2 = cleaned.slice(1).replace(/\D/g, "");
|
|
957
|
+
if (digits2.length < 8 || digits2.length > 15) return { ok: false, reason: `+${digits2} is not a plausible E.164 length` };
|
|
958
|
+
return { ok: true, value: `+${digits2}` };
|
|
959
|
+
}
|
|
960
|
+
const cc = callingCode(regionCode);
|
|
961
|
+
if (!cc) {
|
|
962
|
+
return {
|
|
963
|
+
ok: false,
|
|
964
|
+
reason: `no country calling code known for region "${regionCode}" \u2014 give the phone in +E.164 form instead`
|
|
965
|
+
};
|
|
966
|
+
}
|
|
967
|
+
let national = cleaned.replace(/\D/g, "").replace(/^0+/, "");
|
|
968
|
+
if (national.startsWith(cc) && national.length > 10) national = national.slice(cc.length);
|
|
969
|
+
const value = `+${cc}${national}`;
|
|
970
|
+
const digits = value.slice(1);
|
|
971
|
+
if (digits.length < 8 || digits.length > 15) return { ok: false, reason: `${value} is not a plausible E.164 length` };
|
|
972
|
+
return { ok: true, value };
|
|
973
|
+
}
|
|
974
|
+
var knownRegions = Object.keys(CALLING_CODES);
|
|
975
|
+
|
|
976
|
+
// src/core/paths.mjs
|
|
977
|
+
function getPath(obj, path6) {
|
|
978
|
+
return String(path6).replace(/\[(\d+)\]/g, ".$1").split(".").reduce((acc, key) => acc == null ? void 0 : acc[key], obj);
|
|
979
|
+
}
|
|
980
|
+
function setPath(obj, path6, value) {
|
|
981
|
+
const keys = String(path6).replace(/\[(\d+)\]/g, ".$1").split(".");
|
|
982
|
+
const last = keys.pop();
|
|
983
|
+
const target = keys.reduce((acc, key) => {
|
|
984
|
+
if (acc[key] == null || typeof acc[key] !== "object") acc[key] = {};
|
|
985
|
+
return acc[key];
|
|
986
|
+
}, obj);
|
|
987
|
+
target[last] = value;
|
|
988
|
+
return obj;
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
// src/core/validate.mjs
|
|
992
|
+
function checkRoundTrip(body) {
|
|
993
|
+
let reparsed;
|
|
994
|
+
try {
|
|
995
|
+
reparsed = JSON.stringify(JSON.parse(body));
|
|
996
|
+
} catch (err) {
|
|
997
|
+
return { name: "JSON round-trip", ok: false, detail: `body is not valid JSON: ${err.message}` };
|
|
998
|
+
}
|
|
999
|
+
if (reparsed === body) {
|
|
1000
|
+
return { name: "JSON round-trip", ok: true, detail: "signed bytes survive parse \u2192 stringify" };
|
|
1001
|
+
}
|
|
1002
|
+
const at = firstDifference(body, reparsed);
|
|
1003
|
+
return {
|
|
1004
|
+
name: "JSON round-trip",
|
|
1005
|
+
ok: false,
|
|
1006
|
+
detail: `re-serialized body differs at offset ${at.index}: sent ${JSON.stringify(at.a)} vs recomputed ${JSON.stringify(at.b)}`
|
|
1007
|
+
};
|
|
1008
|
+
}
|
|
1009
|
+
function checkGate(payload, provider) {
|
|
1010
|
+
const actual = getPath(payload, provider.gate.path);
|
|
1011
|
+
const ok = actual === provider.gate.value;
|
|
1012
|
+
return {
|
|
1013
|
+
name: "Schema gate",
|
|
1014
|
+
ok,
|
|
1015
|
+
detail: ok ? `${provider.gate.path} = "${actual}"` : `${provider.gate.path} is ${JSON.stringify(actual)} but the schema filter requires "${provider.gate.value}" \u2014 the event would be silently ignored`
|
|
1016
|
+
};
|
|
1017
|
+
}
|
|
1018
|
+
function checkPhone(payload) {
|
|
1019
|
+
const phone = getPath(payload, "data.phone") ?? getPath(payload, "data.customer.shipping_address.phone");
|
|
1020
|
+
const region = getPath(payload, "data.customer.shipping_address.country_code");
|
|
1021
|
+
if (!phone) {
|
|
1022
|
+
return { name: "Phone", ok: false, detail: "no phone in data.phone or shipping_address.phone \u2014 consumer throws BadPayloadError" };
|
|
1023
|
+
}
|
|
1024
|
+
const parsed = toE164(phone, region);
|
|
1025
|
+
return {
|
|
1026
|
+
name: "Phone",
|
|
1027
|
+
ok: parsed.ok,
|
|
1028
|
+
detail: parsed.ok ? `${phone} \u2192 ${parsed.value} (region ${region || "n/a"})` : parsed.reason
|
|
1029
|
+
};
|
|
1030
|
+
}
|
|
1031
|
+
function checkAllowedPhone(payload, config) {
|
|
1032
|
+
const allowed = config.allowedPhones ?? [];
|
|
1033
|
+
if (!allowed.length) {
|
|
1034
|
+
return { name: "Phone allow-list", ok: true, detail: "no allow-list configured (skipped)", warn: true };
|
|
1035
|
+
}
|
|
1036
|
+
const phone = getPath(payload, "data.phone");
|
|
1037
|
+
const parsed = toE164(phone, getPath(payload, "data.customer.shipping_address.country_code"));
|
|
1038
|
+
const normalized = parsed.ok ? parsed.value : phone;
|
|
1039
|
+
const ok = allowed.some((p) => toE164(p, "IN").value === normalized || p === normalized);
|
|
1040
|
+
return {
|
|
1041
|
+
name: "Phone allow-list",
|
|
1042
|
+
ok,
|
|
1043
|
+
detail: ok ? `${normalized} is an approved test handset` : `${normalized} is NOT in allowedPhones \u2014 refusing to risk messaging a real shopper`
|
|
1044
|
+
};
|
|
1045
|
+
}
|
|
1046
|
+
function coverage(payload, fieldMap2) {
|
|
1047
|
+
return fieldMap2.map((f) => {
|
|
1048
|
+
const value = getPath(payload, f.source);
|
|
1049
|
+
const present = value !== void 0 && value !== null && value !== "";
|
|
1050
|
+
return {
|
|
1051
|
+
field: f.source,
|
|
1052
|
+
maps_to: f.target,
|
|
1053
|
+
value: present ? preview(value) : "\u2014",
|
|
1054
|
+
status: present ? "ok" : f.required ? "MISSING" : "empty",
|
|
1055
|
+
required: Boolean(f.required),
|
|
1056
|
+
note: f.note ?? ""
|
|
1057
|
+
};
|
|
1058
|
+
});
|
|
1059
|
+
}
|
|
1060
|
+
async function checkImages(payload, { timeoutMs = 1e4 } = {}) {
|
|
1061
|
+
const items = getPath(payload, "data.line_items") ?? [];
|
|
1062
|
+
const urls = [...new Set(items.map((i) => i?.image_url).filter(Boolean))];
|
|
1063
|
+
if (!urls.length) {
|
|
1064
|
+
return [{ name: "Product images", ok: false, detail: "no line item carries an image_url \u2014 cart.image resolves to undefined" }];
|
|
1065
|
+
}
|
|
1066
|
+
const results = await Promise.all(
|
|
1067
|
+
urls.map(async (url) => {
|
|
1068
|
+
const controller = new AbortController();
|
|
1069
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
1070
|
+
try {
|
|
1071
|
+
const res = await fetch(url, { method: "HEAD", signal: controller.signal });
|
|
1072
|
+
clearTimeout(timer);
|
|
1073
|
+
return { url, status: res.status, ok: res.ok };
|
|
1074
|
+
} catch (err) {
|
|
1075
|
+
clearTimeout(timer);
|
|
1076
|
+
return { url, status: 0, ok: false, error: err.name === "AbortError" ? "timeout" : err.message };
|
|
1077
|
+
}
|
|
1078
|
+
})
|
|
1079
|
+
);
|
|
1080
|
+
return results.map((r) => ({
|
|
1081
|
+
name: "Product image",
|
|
1082
|
+
ok: r.ok,
|
|
1083
|
+
detail: `${r.ok ? "HTTP " + r.status : "unreachable (" + (r.error ?? r.status) + ")"} \u2014 ${shorten(r.url)}`
|
|
1084
|
+
}));
|
|
1085
|
+
}
|
|
1086
|
+
async function runAll({ payload, body, provider, config, checkImageUrls = true }) {
|
|
1087
|
+
const checks = [checkGate(payload, provider), checkRoundTrip(body), checkPhone(payload), checkAllowedPhone(payload, config)];
|
|
1088
|
+
if (checkImageUrls) checks.push(...await checkImages(payload));
|
|
1089
|
+
const cov = coverage(payload, provider.fieldMap);
|
|
1090
|
+
const missing = cov.filter((c2) => c2.status === "MISSING");
|
|
1091
|
+
checks.push({
|
|
1092
|
+
name: "Required field coverage",
|
|
1093
|
+
ok: missing.length === 0,
|
|
1094
|
+
detail: missing.length === 0 ? `all ${cov.length} mapped fields resolved` : `missing: ${missing.map((m) => m.field).join(", ")}`
|
|
1095
|
+
});
|
|
1096
|
+
return {
|
|
1097
|
+
checks,
|
|
1098
|
+
coverage: cov,
|
|
1099
|
+
ok: checks.filter((c2) => !c2.warn).every((c2) => c2.ok),
|
|
1100
|
+
blocking: checks.filter((c2) => !c2.ok && !c2.warn)
|
|
1101
|
+
};
|
|
1102
|
+
}
|
|
1103
|
+
function preview(value) {
|
|
1104
|
+
if (typeof value === "object") return JSON.stringify(value).slice(0, 48);
|
|
1105
|
+
const s = String(value);
|
|
1106
|
+
return s.length > 48 ? s.slice(0, 45) + "\u2026" : s;
|
|
1107
|
+
}
|
|
1108
|
+
function shorten(url) {
|
|
1109
|
+
const s = String(url);
|
|
1110
|
+
return s.length > 62 ? s.slice(0, 40) + "\u2026" + s.slice(-18) : s;
|
|
1111
|
+
}
|
|
1112
|
+
function firstDifference(a, b) {
|
|
1113
|
+
const len = Math.min(a.length, b.length);
|
|
1114
|
+
for (let i = 0; i < len; i += 1) {
|
|
1115
|
+
if (a[i] !== b[i]) return { index: i, a: a.slice(Math.max(0, i - 12), i + 12), b: b.slice(Math.max(0, i - 12), i + 12) };
|
|
1116
|
+
}
|
|
1117
|
+
return { index: len, a: a.slice(len), b: b.slice(len) };
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
// src/store.mjs
|
|
1121
|
+
import fs3 from "node:fs";
|
|
1122
|
+
import path3 from "node:path";
|
|
1123
|
+
var stamp = () => (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
|
|
1124
|
+
function savePayload({ provider, payload, body, meta }) {
|
|
1125
|
+
fs3.mkdirSync(OUT_DIR, { recursive: true });
|
|
1126
|
+
const name = `${stamp()}__${provider}.json`;
|
|
1127
|
+
const file = path3.join(OUT_DIR, name);
|
|
1128
|
+
const record = {
|
|
1129
|
+
savedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1130
|
+
provider,
|
|
1131
|
+
meta,
|
|
1132
|
+
payload,
|
|
1133
|
+
// the exact string that must be signed and sent — never re-serialize this
|
|
1134
|
+
body
|
|
1135
|
+
};
|
|
1136
|
+
fs3.writeFileSync(file, JSON.stringify(record, null, 2) + "\n");
|
|
1137
|
+
fs3.writeFileSync(path3.join(OUT_DIR, "last.json"), JSON.stringify(record, null, 2) + "\n");
|
|
1138
|
+
return file;
|
|
1139
|
+
}
|
|
1140
|
+
function loadLast() {
|
|
1141
|
+
const file = path3.join(OUT_DIR, "last.json");
|
|
1142
|
+
if (!fs3.existsSync(file)) return null;
|
|
1143
|
+
return JSON.parse(fs3.readFileSync(file, "utf8"));
|
|
1144
|
+
}
|
|
1145
|
+
function readRecord(dir, file) {
|
|
1146
|
+
const full = path3.join(dir, file);
|
|
1147
|
+
try {
|
|
1148
|
+
const rec = JSON.parse(fs3.readFileSync(full, "utf8"));
|
|
1149
|
+
if (!rec || typeof rec !== "object" || !rec.savedAt || !rec.provider || !rec.body) return null;
|
|
1150
|
+
return {
|
|
1151
|
+
file,
|
|
1152
|
+
path: full,
|
|
1153
|
+
provider: String(rec.provider),
|
|
1154
|
+
savedAt: String(rec.savedAt),
|
|
1155
|
+
meta: rec.meta ?? {}
|
|
1156
|
+
};
|
|
1157
|
+
} catch {
|
|
1158
|
+
return null;
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
function listPayloads(limit = 20) {
|
|
1162
|
+
if (!fs3.existsSync(OUT_DIR)) return [];
|
|
1163
|
+
return fs3.readdirSync(OUT_DIR).filter((f) => f.endsWith(".json") && f !== "last.json").sort().reverse().map((f) => readRecord(OUT_DIR, f)).filter(Boolean).slice(0, limit);
|
|
1164
|
+
}
|
|
1165
|
+
function loadPayload(file) {
|
|
1166
|
+
const full = path3.isAbsolute(file) ? file : path3.join(OUT_DIR, file);
|
|
1167
|
+
return JSON.parse(fs3.readFileSync(full, "utf8"));
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
// src/core/build.mjs
|
|
1171
|
+
function draftPayload({ cfg, providerId = "cashfree-occ", items, discount = 0, phone }) {
|
|
1172
|
+
const provider = getProvider(providerId);
|
|
1173
|
+
const resolvedPhone = phone ?? cfg.customer.phone;
|
|
1174
|
+
const parsed = toE164(resolvedPhone, cfg.customer.countryCode);
|
|
1175
|
+
const payload = provider.buildPayload({
|
|
1176
|
+
config: cfg,
|
|
1177
|
+
items,
|
|
1178
|
+
discount,
|
|
1179
|
+
phone: parsed.ok ? parsed.value : String(resolvedPhone ?? "")
|
|
1180
|
+
});
|
|
1181
|
+
const fields = (provider.editableFields ?? []).map((f) => ({ ...f, value: getPath(payload, f.path) }));
|
|
1182
|
+
return { provider, payload, fields };
|
|
1183
|
+
}
|
|
1184
|
+
function applyEdits(payload, fields, edits) {
|
|
1185
|
+
for (const field of fields) {
|
|
1186
|
+
if (!(field.path in edits)) continue;
|
|
1187
|
+
const raw = edits[field.path];
|
|
1188
|
+
const value = field.type === "number" ? Number.parseFloat(raw) || 0 : raw;
|
|
1189
|
+
setPath(payload, field.path, value);
|
|
1190
|
+
}
|
|
1191
|
+
return payload;
|
|
1192
|
+
}
|
|
1193
|
+
async function buildPayload2({ cfg, providerId = "cashfree-occ", items, discount = 0, phone, checkImageUrls = true, payload: prebuilt }) {
|
|
1194
|
+
if (!items?.length) throw new ConfigError("no products selected");
|
|
1195
|
+
const provider = getProvider(providerId);
|
|
1196
|
+
const resolvedPhone = phone ?? cfg.customer.phone;
|
|
1197
|
+
const parsed = toE164(resolvedPhone, cfg.customer.countryCode);
|
|
1198
|
+
if (!parsed.ok) throw new ConfigError(`phone is unusable: ${parsed.reason}`);
|
|
1199
|
+
const payload = prebuilt ?? provider.buildPayload({ config: cfg, items, discount, phone: parsed.value });
|
|
1200
|
+
const body = JSON.stringify(payload);
|
|
1201
|
+
const report = await runAll({ payload, body, provider, config: cfg, checkImageUrls });
|
|
1202
|
+
const endpoint = provider.webhookUrl(cfg);
|
|
1203
|
+
let host = "";
|
|
1204
|
+
try {
|
|
1205
|
+
host = endpoint ? new URL(endpoint).host : "";
|
|
1206
|
+
} catch {
|
|
1207
|
+
host = "";
|
|
1208
|
+
}
|
|
1209
|
+
const meta = {
|
|
1210
|
+
provider: provider.id,
|
|
1211
|
+
endpoint,
|
|
1212
|
+
environment: host,
|
|
1213
|
+
store: cfg.shopify.domain,
|
|
1214
|
+
items: items.map((i) => ({ title: i.product.title, variant: i.variant?.title, qty: i.quantity, price: i.variant?.price })),
|
|
1215
|
+
totalPrice: payload.data.total_price,
|
|
1216
|
+
currency: payload.data.line_items[0]?.currency,
|
|
1217
|
+
valid: report.ok
|
|
1218
|
+
};
|
|
1219
|
+
const file = savePayload({ provider: provider.id, payload, body, meta });
|
|
1220
|
+
audit("payload.built", { provider: provider.id, valid: report.ok, file });
|
|
1221
|
+
return { provider, payload, body, report, file, meta };
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
// src/core/send.mjs
|
|
1225
|
+
import fs4 from "node:fs";
|
|
1226
|
+
import path4 from "node:path";
|
|
1227
|
+
var PROD_PATTERNS = [/\bprod(uction)?\b/i, /\blive\b/i];
|
|
1228
|
+
function looksLikeProduction(origin) {
|
|
1229
|
+
return PROD_PATTERNS.some((re) => re.test(String(origin)));
|
|
1230
|
+
}
|
|
1231
|
+
function buildRequest({ provider, config, body, timestamp }) {
|
|
1232
|
+
const url = provider.webhookUrl(config);
|
|
1233
|
+
const secret = provider.webhookSecret ? provider.webhookSecret(config) : "";
|
|
1234
|
+
const signed = provider.sign({ body, secret, timestamp });
|
|
1235
|
+
return { url, headers: signed.headers, signature: signed.signature, timestamp: signed.timestamp, body };
|
|
1236
|
+
}
|
|
1237
|
+
function toCurl({ url, headers, bodyFile }) {
|
|
1238
|
+
const lines = [`curl -X POST '${url}' \\`];
|
|
1239
|
+
for (const [k, v] of Object.entries(headers)) {
|
|
1240
|
+
lines.push(` -H '${k}: ${v}' \\`);
|
|
1241
|
+
}
|
|
1242
|
+
lines.push(` --data-binary @${bodyFile}`);
|
|
1243
|
+
return lines.join("\n");
|
|
1244
|
+
}
|
|
1245
|
+
function writeBodyFile(body, name = "last-body.json") {
|
|
1246
|
+
fs4.mkdirSync(STATE_DIR, { recursive: true });
|
|
1247
|
+
const file = path4.join(STATE_DIR, name);
|
|
1248
|
+
fs4.writeFileSync(file, body);
|
|
1249
|
+
return file;
|
|
1250
|
+
}
|
|
1251
|
+
async function send({ url, headers, body, timeoutMs = 2e4 }) {
|
|
1252
|
+
const controller = new AbortController();
|
|
1253
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
1254
|
+
const startedAt = Date.now();
|
|
1255
|
+
try {
|
|
1256
|
+
const res = await fetch(url, { method: "POST", headers, body, signal: controller.signal });
|
|
1257
|
+
clearTimeout(timer);
|
|
1258
|
+
const text = await res.text();
|
|
1259
|
+
const result = {
|
|
1260
|
+
ok: res.ok,
|
|
1261
|
+
status: res.status,
|
|
1262
|
+
durationMs: Date.now() - startedAt,
|
|
1263
|
+
body: text,
|
|
1264
|
+
headers: Object.fromEntries(res.headers.entries())
|
|
1265
|
+
};
|
|
1266
|
+
audit("webhook.sent", { url, status: res.status, durationMs: result.durationMs });
|
|
1267
|
+
return result;
|
|
1268
|
+
} catch (err) {
|
|
1269
|
+
clearTimeout(timer);
|
|
1270
|
+
const failure = {
|
|
1271
|
+
ok: false,
|
|
1272
|
+
status: 0,
|
|
1273
|
+
durationMs: Date.now() - startedAt,
|
|
1274
|
+
error: err.name === "AbortError" ? `timed out after ${timeoutMs}ms` : err.message
|
|
1275
|
+
};
|
|
1276
|
+
audit("webhook.failed", { url, error: failure.error });
|
|
1277
|
+
return failure;
|
|
1278
|
+
}
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
// src/core/dispatch.mjs
|
|
1282
|
+
async function dispatch({ cfg, record, force = false, dryRun = false, revalidate = true }) {
|
|
1283
|
+
const rec = record ?? loadLast();
|
|
1284
|
+
if (!rec) throw new ForgeError("nothing to send", { hint: "build a payload first" });
|
|
1285
|
+
const provider = getProvider(rec.provider);
|
|
1286
|
+
let report = null;
|
|
1287
|
+
if (revalidate) {
|
|
1288
|
+
report = await runAll({ payload: rec.payload, body: rec.body, provider, config: cfg, checkImageUrls: false });
|
|
1289
|
+
if (!report.ok && !force) {
|
|
1290
|
+
throw new ForgeError("refusing to send a payload that fails pre-flight", {
|
|
1291
|
+
hint: `blocking: ${report.blocking.map((b) => b.name).join(", ")} \u2014 pass --force to override`
|
|
1292
|
+
});
|
|
1293
|
+
}
|
|
1294
|
+
}
|
|
1295
|
+
const request = buildRequest({ provider, config: cfg, body: rec.body });
|
|
1296
|
+
if (looksLikeProduction(request.url) && !force) {
|
|
1297
|
+
throw new ForgeError(`target looks like production: ${request.url}`, { hint: "pass --force if you really mean it" });
|
|
1298
|
+
}
|
|
1299
|
+
const bodyFile = writeBodyFile(rec.body);
|
|
1300
|
+
const curl = toCurl({ url: request.url, headers: request.headers, bodyFile });
|
|
1301
|
+
if (dryRun) return { dryRun: true, request, bodyFile, curl, report, provider };
|
|
1302
|
+
const result = await send({ url: request.url, headers: request.headers, body: rec.body });
|
|
1303
|
+
return { ...result, dryRun: false, request, bodyFile, curl, report, provider };
|
|
1304
|
+
}
|
|
1305
|
+
function explainStatus(status) {
|
|
1306
|
+
if (status >= 200 && status < 300) return "accepted \u2014 a 2xx only means it was received; check eventData to confirm the event landed";
|
|
1307
|
+
if (status === 401 || status === 403) return "signature rejected \u2014 the stored webhook_secret does not match the one used to sign";
|
|
1308
|
+
if (status === 404) return "not found \u2014 wrong company id or urlKey in the webhook URL";
|
|
1309
|
+
if (status === 400) return "bad request \u2014 the consumer could not parse the payload";
|
|
1310
|
+
if (status >= 500) return "receiver error \u2014 check the endpoint logs";
|
|
1311
|
+
return "unexpected status";
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
// src/core/clipboard.mjs
|
|
1315
|
+
import { spawn } from "node:child_process";
|
|
1316
|
+
var CANDIDATES = [
|
|
1317
|
+
{ cmd: "wl-copy", args: [], platforms: ["linux"] },
|
|
1318
|
+
{ cmd: "xclip", args: ["-selection", "clipboard"], platforms: ["linux"] },
|
|
1319
|
+
{ cmd: "xsel", args: ["--clipboard", "--input"], platforms: ["linux"] },
|
|
1320
|
+
{ cmd: "pbcopy", args: [], platforms: ["darwin"] },
|
|
1321
|
+
{ cmd: "clip.exe", args: [], platforms: ["linux", "win32"] },
|
|
1322
|
+
{ cmd: "clip", args: [], platforms: ["win32"] }
|
|
1323
|
+
];
|
|
1324
|
+
function pipeTo(cmd, args, text) {
|
|
1325
|
+
return new Promise((resolve) => {
|
|
1326
|
+
let child;
|
|
1327
|
+
try {
|
|
1328
|
+
child = spawn(cmd, args, { stdio: ["pipe", "ignore", "ignore"] });
|
|
1329
|
+
} catch {
|
|
1330
|
+
return resolve(false);
|
|
1331
|
+
}
|
|
1332
|
+
child.on("error", () => resolve(false));
|
|
1333
|
+
child.on("close", (code) => resolve(code === 0));
|
|
1334
|
+
try {
|
|
1335
|
+
child.stdin.end(text);
|
|
1336
|
+
} catch {
|
|
1337
|
+
resolve(false);
|
|
1338
|
+
}
|
|
1339
|
+
});
|
|
1340
|
+
}
|
|
1341
|
+
function osc52(text, stream) {
|
|
1342
|
+
const out = stream ?? (process.stderr.isTTY ? process.stderr : process.stdout);
|
|
1343
|
+
try {
|
|
1344
|
+
const encoded = Buffer.from(text, "utf8").toString("base64");
|
|
1345
|
+
out.write(`\x1B]52;c;${encoded}\x07`);
|
|
1346
|
+
return true;
|
|
1347
|
+
} catch {
|
|
1348
|
+
return false;
|
|
1349
|
+
}
|
|
1350
|
+
}
|
|
1351
|
+
async function copy(text, { allowOsc52 = true } = {}) {
|
|
1352
|
+
const platform = process.platform;
|
|
1353
|
+
for (const candidate of CANDIDATES) {
|
|
1354
|
+
if (!candidate.platforms.includes(platform)) continue;
|
|
1355
|
+
const ok = await pipeTo(candidate.cmd, candidate.args, text);
|
|
1356
|
+
if (ok) return { ok: true, via: candidate.cmd };
|
|
1357
|
+
}
|
|
1358
|
+
if (allowOsc52 && (process.stderr.isTTY || process.stdout.isTTY) && osc52(text)) {
|
|
1359
|
+
return { ok: true, via: "terminal (OSC 52)" };
|
|
1360
|
+
}
|
|
1361
|
+
return { ok: false, via: "none" };
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1364
|
+
// src/ui/screens/Build.jsx
|
|
1365
|
+
import { jsx as jsx7, jsxs as jsxs7 } from "react/jsx-runtime";
|
|
1366
|
+
var STEP = {
|
|
1367
|
+
LOADING: "loading",
|
|
1368
|
+
PICK: "pick",
|
|
1369
|
+
VARIANT: "variant",
|
|
1370
|
+
QUANTITY: "quantity",
|
|
1371
|
+
DISCOUNT: "discount",
|
|
1372
|
+
MODE: "mode",
|
|
1373
|
+
FIELDS: "fields",
|
|
1374
|
+
BUILDING: "building",
|
|
1375
|
+
REPORT: "report",
|
|
1376
|
+
CONFIRM: "confirm",
|
|
1377
|
+
SENDING: "sending",
|
|
1378
|
+
RESULT: "result",
|
|
1379
|
+
ERROR: "error"
|
|
1380
|
+
};
|
|
1381
|
+
function Build({ config, providerId = "cashfree-occ", onDone }) {
|
|
1382
|
+
const { exit } = useApp();
|
|
1383
|
+
const [step, setStep] = useState(STEP.LOADING);
|
|
1384
|
+
const [products, setProducts] = useState([]);
|
|
1385
|
+
const [chosen, setChosen] = useState([]);
|
|
1386
|
+
const [cursor, setCursor] = useState(0);
|
|
1387
|
+
const [items, setItems] = useState([]);
|
|
1388
|
+
const [pendingVariant, setPendingVariant] = useState(null);
|
|
1389
|
+
const [draft, setDraft] = useState(null);
|
|
1390
|
+
const [fieldIndex, setFieldIndex] = useState(0);
|
|
1391
|
+
const [edits, setEdits] = useState({});
|
|
1392
|
+
const [built, setBuilt] = useState(null);
|
|
1393
|
+
const [copied, setCopied] = useState(null);
|
|
1394
|
+
const [showPayload, setShowPayload] = useState(true);
|
|
1395
|
+
const [sendResult, setSendResult] = useState(null);
|
|
1396
|
+
const [error, setError] = useState(null);
|
|
1397
|
+
useInput3((input, key) => {
|
|
1398
|
+
if (key.escape) onDone();
|
|
1399
|
+
if ((step === STEP.RESULT || step === STEP.ERROR) && (key.return || input === "q")) onDone();
|
|
1400
|
+
if (step === STEP.REPORT && input === "p") setShowPayload((v) => !v);
|
|
1401
|
+
if (step === STEP.REPORT && input === "c" && built) {
|
|
1402
|
+
copy(built.body).then(setCopied);
|
|
1403
|
+
}
|
|
1404
|
+
});
|
|
1405
|
+
useEffect(() => {
|
|
1406
|
+
let alive = true;
|
|
1407
|
+
fetchCatalogue(config).then((list) => {
|
|
1408
|
+
if (!alive) return;
|
|
1409
|
+
if (!list.length) {
|
|
1410
|
+
setError({ message: "Shopify returned no products", hint: "check that the token has read_products and the store has active products" });
|
|
1411
|
+
setStep(STEP.ERROR);
|
|
1412
|
+
return;
|
|
1413
|
+
}
|
|
1414
|
+
setProducts(list);
|
|
1415
|
+
setStep(STEP.PICK);
|
|
1416
|
+
}).catch((err) => {
|
|
1417
|
+
if (!alive) return;
|
|
1418
|
+
setError({ message: err.message, hint: err.hint });
|
|
1419
|
+
setStep(STEP.ERROR);
|
|
1420
|
+
});
|
|
1421
|
+
return () => {
|
|
1422
|
+
alive = false;
|
|
1423
|
+
};
|
|
1424
|
+
}, []);
|
|
1425
|
+
const advance = (nextItems, nextCursor) => {
|
|
1426
|
+
if (nextCursor >= chosen.length) {
|
|
1427
|
+
setItems(nextItems);
|
|
1428
|
+
setStep(STEP.DISCOUNT);
|
|
1429
|
+
return;
|
|
1430
|
+
}
|
|
1431
|
+
setItems(nextItems);
|
|
1432
|
+
setCursor(nextCursor);
|
|
1433
|
+
const product = chosen[nextCursor];
|
|
1434
|
+
if (product.variants.length > 1) {
|
|
1435
|
+
setStep(STEP.VARIANT);
|
|
1436
|
+
} else {
|
|
1437
|
+
setPendingVariant(product.variants[0]);
|
|
1438
|
+
setStep(STEP.QUANTITY);
|
|
1439
|
+
}
|
|
1440
|
+
};
|
|
1441
|
+
const onProductsPicked = (ids) => {
|
|
1442
|
+
const picked = ids.map((id) => products.find((p) => p.id === id)).filter(Boolean);
|
|
1443
|
+
if (!picked.length) return;
|
|
1444
|
+
setChosen(picked);
|
|
1445
|
+
const first = picked[0];
|
|
1446
|
+
setCursor(0);
|
|
1447
|
+
setItems([]);
|
|
1448
|
+
if (first.variants.length > 1) setStep(STEP.VARIANT);
|
|
1449
|
+
else {
|
|
1450
|
+
setPendingVariant(first.variants[0]);
|
|
1451
|
+
setStep(STEP.QUANTITY);
|
|
1452
|
+
}
|
|
1453
|
+
};
|
|
1454
|
+
const onVariantPicked = (variantId) => {
|
|
1455
|
+
const product = chosen[cursor];
|
|
1456
|
+
setPendingVariant(product.variants.find((v) => v.id === variantId) ?? product.variants[0]);
|
|
1457
|
+
setStep(STEP.QUANTITY);
|
|
1458
|
+
};
|
|
1459
|
+
const onQuantity = (raw) => {
|
|
1460
|
+
const qty = Math.max(1, Number.parseInt(raw, 10) || 1);
|
|
1461
|
+
const product = chosen[cursor];
|
|
1462
|
+
const nextItems = [...items, { product, variant: pendingVariant, quantity: qty }];
|
|
1463
|
+
advance(nextItems, cursor + 1);
|
|
1464
|
+
};
|
|
1465
|
+
const onDiscount = (raw) => {
|
|
1466
|
+
const discount = Math.max(0, Number.parseFloat(raw) || 0);
|
|
1467
|
+
try {
|
|
1468
|
+
const next = draftPayload({ cfg: config, providerId, items, discount });
|
|
1469
|
+
setDraft({ ...next, discount });
|
|
1470
|
+
setStep(STEP.MODE);
|
|
1471
|
+
} catch (err) {
|
|
1472
|
+
setError({ message: err.message, hint: err.hint });
|
|
1473
|
+
setStep(STEP.ERROR);
|
|
1474
|
+
}
|
|
1475
|
+
};
|
|
1476
|
+
const finalise = (payload) => {
|
|
1477
|
+
setStep(STEP.BUILDING);
|
|
1478
|
+
buildPayload2({ cfg: config, providerId, items, discount: draft.discount, payload }).then(async (result) => {
|
|
1479
|
+
setBuilt(result);
|
|
1480
|
+
if (config.defaults.copyToClipboard !== false) {
|
|
1481
|
+
setCopied(await copy(result.body));
|
|
1482
|
+
}
|
|
1483
|
+
setStep(STEP.REPORT);
|
|
1484
|
+
}).catch((err) => {
|
|
1485
|
+
setError({ message: err.message, hint: err.hint });
|
|
1486
|
+
setStep(STEP.ERROR);
|
|
1487
|
+
});
|
|
1488
|
+
};
|
|
1489
|
+
const onMode = (mode) => {
|
|
1490
|
+
if (mode === "accept") return finalise(draft.payload);
|
|
1491
|
+
setFieldIndex(0);
|
|
1492
|
+
setEdits({});
|
|
1493
|
+
setStep(STEP.FIELDS);
|
|
1494
|
+
};
|
|
1495
|
+
const onFieldSubmit = (raw) => {
|
|
1496
|
+
const field = draft.fields[fieldIndex];
|
|
1497
|
+
const nextEdits = { ...edits, [field.path]: raw };
|
|
1498
|
+
setEdits(nextEdits);
|
|
1499
|
+
if (fieldIndex + 1 < draft.fields.length) {
|
|
1500
|
+
setFieldIndex(fieldIndex + 1);
|
|
1501
|
+
return;
|
|
1502
|
+
}
|
|
1503
|
+
finalise(applyEdits(draft.payload, draft.fields, nextEdits));
|
|
1504
|
+
};
|
|
1505
|
+
const doSend = () => {
|
|
1506
|
+
setStep(STEP.SENDING);
|
|
1507
|
+
dispatch({ cfg: config, record: { provider: built.provider.id, payload: built.payload, body: built.body } }).then((result) => {
|
|
1508
|
+
setSendResult(result);
|
|
1509
|
+
setStep(STEP.RESULT);
|
|
1510
|
+
}).catch((err) => {
|
|
1511
|
+
setError({ message: err.message, hint: err.hint });
|
|
1512
|
+
setStep(STEP.ERROR);
|
|
1513
|
+
});
|
|
1514
|
+
};
|
|
1515
|
+
const store = config.shopify.domain;
|
|
1516
|
+
const currency = config.defaults.currency;
|
|
1517
|
+
return /* @__PURE__ */ jsxs7(Box7, { flexDirection: "column", children: [
|
|
1518
|
+
/* @__PURE__ */ jsx7(
|
|
1519
|
+
Header,
|
|
1520
|
+
{
|
|
1521
|
+
title: "Build payload",
|
|
1522
|
+
subtitle: `${metaFor(providerId).label} \xB7 abandoned checkout`,
|
|
1523
|
+
right: store
|
|
1524
|
+
}
|
|
1525
|
+
),
|
|
1526
|
+
step === STEP.LOADING && /* @__PURE__ */ jsx7(Spinner, { label: `fetching live products from ${store}\u2026` }),
|
|
1527
|
+
step === STEP.PICK && /* @__PURE__ */ jsxs7(Box7, { flexDirection: "column", children: [
|
|
1528
|
+
/* @__PURE__ */ jsxs7(Text7, { children: [
|
|
1529
|
+
`Select the products in the abandoned cart `,
|
|
1530
|
+
/* @__PURE__ */ jsx7(Text7, { color: palette.dim, children: "(space toggles \xB7 enter confirms)" })
|
|
1531
|
+
] }),
|
|
1532
|
+
/* @__PURE__ */ jsx7(Box7, { marginTop: 1, children: /* @__PURE__ */ jsx7(
|
|
1533
|
+
MultiSelect,
|
|
1534
|
+
{
|
|
1535
|
+
visibleOptionCount: 10,
|
|
1536
|
+
options: products.map((p) => ({
|
|
1537
|
+
label: `${truncate(p.title, 44).padEnd(45)} ${money(p.variants[0]?.price, currency)}`,
|
|
1538
|
+
value: p.id
|
|
1539
|
+
})),
|
|
1540
|
+
onSubmit: onProductsPicked
|
|
1541
|
+
}
|
|
1542
|
+
) })
|
|
1543
|
+
] }),
|
|
1544
|
+
step === STEP.VARIANT && /* @__PURE__ */ jsxs7(Box7, { flexDirection: "column", children: [
|
|
1545
|
+
/* @__PURE__ */ jsxs7(Text7, { children: [
|
|
1546
|
+
`Variant for `,
|
|
1547
|
+
/* @__PURE__ */ jsx7(Text7, { bold: true, color: palette.accent, children: chosen[cursor]?.title })
|
|
1548
|
+
] }),
|
|
1549
|
+
/* @__PURE__ */ jsx7(Box7, { marginTop: 1, children: /* @__PURE__ */ jsx7(
|
|
1550
|
+
Select4,
|
|
1551
|
+
{
|
|
1552
|
+
visibleOptionCount: 8,
|
|
1553
|
+
options: chosen[cursor].variants.map((v) => ({
|
|
1554
|
+
label: `${truncate(v.title, 34).padEnd(35)} ${money(v.price, currency)}${v.sku ? " " + v.sku : ""}`,
|
|
1555
|
+
value: v.id
|
|
1556
|
+
})),
|
|
1557
|
+
onChange: onVariantPicked
|
|
1558
|
+
},
|
|
1559
|
+
`variant-${cursor}`
|
|
1560
|
+
) })
|
|
1561
|
+
] }),
|
|
1562
|
+
step === STEP.QUANTITY && /* @__PURE__ */ jsxs7(Box7, { flexDirection: "column", children: [
|
|
1563
|
+
/* @__PURE__ */ jsxs7(Text7, { children: [
|
|
1564
|
+
`Quantity for `,
|
|
1565
|
+
/* @__PURE__ */ jsx7(Text7, { bold: true, color: palette.accent, children: chosen[cursor]?.title }),
|
|
1566
|
+
/* @__PURE__ */ jsx7(Text7, { color: palette.dim, children: pendingVariant?.title ? ` \xB7 ${pendingVariant.title}` : "" })
|
|
1567
|
+
] }),
|
|
1568
|
+
/* @__PURE__ */ jsxs7(Box7, { marginTop: 1, children: [
|
|
1569
|
+
/* @__PURE__ */ jsx7(Text7, { color: palette.accent, children: "\u276F " }),
|
|
1570
|
+
/* @__PURE__ */ jsx7(TextInput, { defaultValue: "1", placeholder: "1", onSubmit: onQuantity }, `qty-${cursor}`)
|
|
1571
|
+
] })
|
|
1572
|
+
] }),
|
|
1573
|
+
step === STEP.DISCOUNT && /* @__PURE__ */ jsxs7(Box7, { flexDirection: "column", children: [
|
|
1574
|
+
/* @__PURE__ */ jsxs7(Text7, { children: [
|
|
1575
|
+
`Cart discount in ${currency} `,
|
|
1576
|
+
/* @__PURE__ */ jsx7(Text7, { color: palette.dim, children: "(0 for none)" })
|
|
1577
|
+
] }),
|
|
1578
|
+
/* @__PURE__ */ jsxs7(Box7, { marginTop: 1, children: [
|
|
1579
|
+
/* @__PURE__ */ jsx7(Text7, { color: palette.accent, children: "\u276F " }),
|
|
1580
|
+
/* @__PURE__ */ jsx7(TextInput, { defaultValue: "0", placeholder: "0", onSubmit: onDiscount })
|
|
1581
|
+
] })
|
|
1582
|
+
] }),
|
|
1583
|
+
step === STEP.MODE && draft && /* @__PURE__ */ jsxs7(Box7, { flexDirection: "column", children: [
|
|
1584
|
+
/* @__PURE__ */ jsxs7(Box7, { flexDirection: "column", borderStyle: "round", borderColor: palette.accent, paddingX: 1, marginBottom: 1, children: [
|
|
1585
|
+
/* @__PURE__ */ jsx7(Text7, { color: palette.dim, children: `${draft.fields.length} fields have been filled in from Shopify and your profile.` }),
|
|
1586
|
+
/* @__PURE__ */ jsx7(Text7, { color: palette.dim, children: "Review them one by one, or accept the suggestions as they are." })
|
|
1587
|
+
] }),
|
|
1588
|
+
/* @__PURE__ */ jsx7(
|
|
1589
|
+
Select4,
|
|
1590
|
+
{
|
|
1591
|
+
options: [
|
|
1592
|
+
{ label: "Use the suggested values".padEnd(30) + "fastest", value: "accept" },
|
|
1593
|
+
{ label: "Review every field".padEnd(30) + "enter accepts \xB7 type to change", value: "review" }
|
|
1594
|
+
],
|
|
1595
|
+
onChange: onMode
|
|
1596
|
+
}
|
|
1597
|
+
)
|
|
1598
|
+
] }),
|
|
1599
|
+
step === STEP.FIELDS && draft && /* @__PURE__ */ jsx7(
|
|
1600
|
+
FieldPrompt,
|
|
1601
|
+
{
|
|
1602
|
+
field: draft.fields[fieldIndex],
|
|
1603
|
+
index: fieldIndex,
|
|
1604
|
+
total: draft.fields.length,
|
|
1605
|
+
value: edits[draft.fields[fieldIndex].path] ?? draft.fields[fieldIndex].value,
|
|
1606
|
+
onSubmit: onFieldSubmit
|
|
1607
|
+
}
|
|
1608
|
+
),
|
|
1609
|
+
step === STEP.BUILDING && /* @__PURE__ */ jsx7(Spinner, { label: "assembling payload, verifying images and signature fidelity\u2026" }),
|
|
1610
|
+
(step === STEP.REPORT || step === STEP.CONFIRM) && built && /* @__PURE__ */ jsxs7(Box7, { flexDirection: "column", children: [
|
|
1611
|
+
/* @__PURE__ */ jsx7(Summary, { built, currency }),
|
|
1612
|
+
/* @__PURE__ */ jsx7(Section, { title: "Pre-flight", children: /* @__PURE__ */ jsx7(CheckList, { checks: built.report.checks }) }),
|
|
1613
|
+
/* @__PURE__ */ jsx7(Section, { title: "Field coverage", children: /* @__PURE__ */ jsx7(FieldTable, { coverage: built.report.coverage }) }),
|
|
1614
|
+
/* @__PURE__ */ jsx7(Section, { title: "Request", children: /* @__PURE__ */ jsx7(RequestPreview, { config, built }) }),
|
|
1615
|
+
showPayload ? /* @__PURE__ */ jsx7(Section, { title: "Payload", children: /* @__PURE__ */ jsx7(Box7, { borderStyle: "round", borderColor: palette.dim, paddingX: 1, flexDirection: "column", children: /* @__PURE__ */ jsx7(Text7, { color: palette.dim, children: JSON.stringify(built.payload, null, 2) }) }) }) : null,
|
|
1616
|
+
/* @__PURE__ */ jsx7(Box7, { marginTop: 1, children: copied?.ok ? /* @__PURE__ */ jsx7(Text7, { color: palette.ok, children: `\u2714 payload copied to clipboard via ${copied.via}` }) : /* @__PURE__ */ jsx7(Text7, { color: palette.dim, children: `payload saved to ${built.file} \xB7 press c to copy` }) }),
|
|
1617
|
+
/* @__PURE__ */ jsx7(Box7, { children: /* @__PURE__ */ jsx7(Text7, { color: palette.dim, children: "p toggles the full payload \xB7 c copies it" }) }),
|
|
1618
|
+
/* @__PURE__ */ jsx7(Box7, { marginTop: 1, children: built.report.ok ? /* @__PURE__ */ jsx7(Alert, { variant: "success", children: "payload is valid \u2014 the event will pass the schema gate and signature check" }) : /* @__PURE__ */ jsx7(Alert, { variant: "error", children: `blocking: ${built.report.blocking.map((b) => b.name).join(", ")}` }) }),
|
|
1619
|
+
/* @__PURE__ */ jsxs7(Box7, { marginTop: 1, children: [
|
|
1620
|
+
/* @__PURE__ */ jsx7(Text7, { children: "Send it to the endpoint now? " }),
|
|
1621
|
+
/* @__PURE__ */ jsx7(
|
|
1622
|
+
ConfirmInput,
|
|
1623
|
+
{
|
|
1624
|
+
isDisabled: !built.report.ok,
|
|
1625
|
+
onConfirm: doSend,
|
|
1626
|
+
onCancel: () => onDone()
|
|
1627
|
+
}
|
|
1628
|
+
)
|
|
1629
|
+
] }),
|
|
1630
|
+
!built.report.ok && /* @__PURE__ */ jsx7(Text7, { color: palette.dim, children: " sending is disabled while pre-flight fails \u2014 fix the payload, or use the CLI with --force" })
|
|
1631
|
+
] }),
|
|
1632
|
+
step === STEP.SENDING && /* @__PURE__ */ jsx7(Spinner, { label: "signing and POSTing the webhook\u2026" }),
|
|
1633
|
+
step === STEP.RESULT && sendResult && /* @__PURE__ */ jsxs7(Box7, { flexDirection: "column", children: [
|
|
1634
|
+
/* @__PURE__ */ jsxs7(Box7, { marginBottom: 1, children: [
|
|
1635
|
+
sendResult.ok ? /* @__PURE__ */ jsx7(Badge, { color: "green", children: `HTTP ${sendResult.status}` }) : /* @__PURE__ */ jsx7(Badge, { color: "red", children: sendResult.error ? "FAILED" : `HTTP ${sendResult.status}` }),
|
|
1636
|
+
/* @__PURE__ */ jsx7(Text7, { children: " " }),
|
|
1637
|
+
/* @__PURE__ */ jsx7(Text7, { color: palette.dim, children: sendResult.error ?? explainStatus(sendResult.status) })
|
|
1638
|
+
] }),
|
|
1639
|
+
/* @__PURE__ */ jsx7(
|
|
1640
|
+
KeyValue,
|
|
1641
|
+
{
|
|
1642
|
+
rows: [
|
|
1643
|
+
["url", sendResult.request.url],
|
|
1644
|
+
["timestamp", sendResult.request.timestamp],
|
|
1645
|
+
["signature", truncate(sendResult.request.signature, 44)],
|
|
1646
|
+
["took", `${sendResult.durationMs}ms`],
|
|
1647
|
+
["response", truncate(String(sendResult.body ?? "\u2014"), 60)],
|
|
1648
|
+
["payload", built.file]
|
|
1649
|
+
]
|
|
1650
|
+
}
|
|
1651
|
+
),
|
|
1652
|
+
/* @__PURE__ */ jsxs7(Box7, { marginTop: 1, flexDirection: "column", children: [
|
|
1653
|
+
/* @__PURE__ */ jsx7(Text7, { color: palette.dim, children: "reproduce:" }),
|
|
1654
|
+
/* @__PURE__ */ jsx7(Text7, { color: palette.dim, children: sendResult.curl })
|
|
1655
|
+
] }),
|
|
1656
|
+
/* @__PURE__ */ jsx7(Box7, { marginTop: 1, children: /* @__PURE__ */ jsx7(Text7, { color: palette.dim, children: "press enter to return to the menu" }) })
|
|
1657
|
+
] }),
|
|
1658
|
+
step === STEP.ERROR && error && /* @__PURE__ */ jsxs7(Box7, { flexDirection: "column", children: [
|
|
1659
|
+
/* @__PURE__ */ jsx7(Alert, { variant: "error", children: error.message }),
|
|
1660
|
+
error.hint ? /* @__PURE__ */ jsx7(Text7, { color: palette.warn, children: ` ${error.hint}` }) : null,
|
|
1661
|
+
/* @__PURE__ */ jsx7(Box7, { marginTop: 1, children: /* @__PURE__ */ jsx7(Text7, { color: palette.dim, children: "press enter to return to the menu" }) })
|
|
1662
|
+
] })
|
|
1663
|
+
] });
|
|
1664
|
+
}
|
|
1665
|
+
function RequestPreview({ config, built }) {
|
|
1666
|
+
let request = null;
|
|
1667
|
+
try {
|
|
1668
|
+
request = buildRequest({ provider: built.provider, config, body: built.body });
|
|
1669
|
+
} catch {
|
|
1670
|
+
request = null;
|
|
1671
|
+
}
|
|
1672
|
+
if (!request) return /* @__PURE__ */ jsx7(Text7, { color: palette.dim, children: " destination not configured" });
|
|
1673
|
+
return /* @__PURE__ */ jsx7(
|
|
1674
|
+
KeyValue,
|
|
1675
|
+
{
|
|
1676
|
+
keyWidth: 22,
|
|
1677
|
+
rows: [
|
|
1678
|
+
["POST", truncate(request.url, 56)],
|
|
1679
|
+
["x-webhook-timestamp", request.timestamp],
|
|
1680
|
+
["x-webhook-signature", truncate(request.signature, 46)],
|
|
1681
|
+
["body", `${built.body.length} bytes`]
|
|
1682
|
+
]
|
|
1683
|
+
}
|
|
1684
|
+
);
|
|
1685
|
+
}
|
|
1686
|
+
function FieldPrompt({ field, index, total, value, onSubmit }) {
|
|
1687
|
+
const filled = Math.round(index / total * 30);
|
|
1688
|
+
return /* @__PURE__ */ jsxs7(Box7, { flexDirection: "column", children: [
|
|
1689
|
+
/* @__PURE__ */ jsxs7(Box7, { children: [
|
|
1690
|
+
/* @__PURE__ */ jsx7(Text7, { color: palette.accent, children: "\u2588".repeat(filled) }),
|
|
1691
|
+
/* @__PURE__ */ jsx7(Text7, { color: palette.dim, children: "\u2591".repeat(30 - filled) }),
|
|
1692
|
+
/* @__PURE__ */ jsx7(Text7, { color: palette.dim, children: ` ${index + 1}/${total} ${field.group}` })
|
|
1693
|
+
] }),
|
|
1694
|
+
/* @__PURE__ */ jsxs7(Box7, { marginTop: 1, flexDirection: "column", children: [
|
|
1695
|
+
/* @__PURE__ */ jsx7(Text7, { bold: true, color: palette.heading, children: field.label }),
|
|
1696
|
+
field.hint ? /* @__PURE__ */ jsx7(Text7, { color: palette.dim, children: ` ${field.hint}` }) : null,
|
|
1697
|
+
field.optional ? /* @__PURE__ */ jsx7(Text7, { color: palette.dim, children: " optional" }) : null
|
|
1698
|
+
] }),
|
|
1699
|
+
/* @__PURE__ */ jsxs7(Box7, { marginTop: 1, children: [
|
|
1700
|
+
/* @__PURE__ */ jsx7(Text7, { color: palette.accent, children: "\u276F " }),
|
|
1701
|
+
/* @__PURE__ */ jsx7(
|
|
1702
|
+
TextInput,
|
|
1703
|
+
{
|
|
1704
|
+
defaultValue: value == null ? "" : String(value),
|
|
1705
|
+
placeholder: field.optional ? "(leave blank)" : "",
|
|
1706
|
+
onSubmit
|
|
1707
|
+
},
|
|
1708
|
+
field.path
|
|
1709
|
+
)
|
|
1710
|
+
] }),
|
|
1711
|
+
/* @__PURE__ */ jsx7(Box7, { marginTop: 1, children: /* @__PURE__ */ jsx7(Text7, { color: palette.dim, children: "enter accepts the suggestion \xB7 edit the text to change it" }) })
|
|
1712
|
+
] });
|
|
1713
|
+
}
|
|
1714
|
+
function Summary({ built, currency }) {
|
|
1715
|
+
const d = built.payload.data;
|
|
1716
|
+
return /* @__PURE__ */ jsx7(Box7, { flexDirection: "column", borderStyle: "round", borderColor: palette.ok, paddingX: 1, children: /* @__PURE__ */ jsx7(
|
|
1717
|
+
KeyValue,
|
|
1718
|
+
{
|
|
1719
|
+
rows: [
|
|
1720
|
+
["type", built.payload.type],
|
|
1721
|
+
["store", d.store_url],
|
|
1722
|
+
["customer", `${d.customer.shipping_address.customer_name} \xB7 ${d.phone}`],
|
|
1723
|
+
["items", d.line_items.map((i) => `${truncate(i.name, 28)} \xD7${i.quantity}`).join(", ")],
|
|
1724
|
+
["total", `${money(d.total_price, d.line_items[0]?.currency ?? currency)} (was ${money(d.original_total_price)} \xB7 discount ${money(d.total_discount)})`],
|
|
1725
|
+
["checkout", truncate(d.abandoned_checkout_url, 58)],
|
|
1726
|
+
["saved", built.file]
|
|
1727
|
+
]
|
|
1728
|
+
}
|
|
1729
|
+
) });
|
|
1730
|
+
}
|
|
1731
|
+
|
|
1732
|
+
// src/ui/screens/Configure.jsx
|
|
1733
|
+
import React8, { useState as useState2 } from "react";
|
|
1734
|
+
import { Box as Box8, Text as Text8, useInput as useInput4 } from "ink";
|
|
1735
|
+
import { Spinner as Spinner2, TextInput as TextInput2, PasswordInput, Select as Select5, Alert as Alert2 } from "@inkjs/ui";
|
|
1736
|
+
import { jsx as jsx8, jsxs as jsxs8 } from "react/jsx-runtime";
|
|
1737
|
+
var FIELDS = [
|
|
1738
|
+
{ group: "Customer", key: "customer.firstName", label: "First name", placeholder: "Test" },
|
|
1739
|
+
{ group: "Customer", key: "customer.lastName", label: "Last name", placeholder: "Shopper" },
|
|
1740
|
+
{ group: "Customer", key: "customer.email", label: "Email" },
|
|
1741
|
+
{ group: "Customer", key: "customer.countryCode", label: "Country code", placeholder: "IN", transform: (v) => v.toUpperCase() },
|
|
1742
|
+
{ group: "Customer", key: "customer.phone", label: "Phone", hint: "the handset that will receive the message" },
|
|
1743
|
+
{ group: "Customer", key: "customer.address1", label: "Address line 1" },
|
|
1744
|
+
{ group: "Customer", key: "customer.address2", label: "Address line 2", optional: true },
|
|
1745
|
+
{ group: "Customer", key: "customer.city", label: "City" },
|
|
1746
|
+
{ group: "Customer", key: "customer.province", label: "Province / state" },
|
|
1747
|
+
{ group: "Customer", key: "customer.provinceCode", label: "Province code", optional: true },
|
|
1748
|
+
{ group: "Customer", key: "customer.country", label: "Country name", placeholder: "India" },
|
|
1749
|
+
{ group: "Customer", key: "customer.zip", label: "Zip / postcode" },
|
|
1750
|
+
{ group: "Defaults", key: "defaults.currency", label: "Currency", placeholder: "INR", transform: (v) => v.toUpperCase() },
|
|
1751
|
+
{ group: "Defaults", key: "defaults.platform", label: "Platform", placeholder: "shopify" }
|
|
1752
|
+
];
|
|
1753
|
+
var SECTION_HINTS = {
|
|
1754
|
+
Customer: "the shopper the abandoned cart belongs to",
|
|
1755
|
+
Defaults: "currency, platform"
|
|
1756
|
+
};
|
|
1757
|
+
var get = (obj, path6) => path6.split(".").reduce((a, k) => a == null ? void 0 : a[k], obj);
|
|
1758
|
+
var set = (obj, path6, value) => {
|
|
1759
|
+
const keys = path6.split(".");
|
|
1760
|
+
const last = keys.pop();
|
|
1761
|
+
const target = keys.reduce((a, k) => a[k] ??= {}, obj);
|
|
1762
|
+
target[last] = value;
|
|
1763
|
+
return obj;
|
|
1764
|
+
};
|
|
1765
|
+
function Configure({ config, section: initialSection, onDone }) {
|
|
1766
|
+
const [draft] = useState2(() => structuredClone(config));
|
|
1767
|
+
const [section, setSection] = useState2(initialSection ?? null);
|
|
1768
|
+
const [index, setIndex] = useState2(0);
|
|
1769
|
+
const [phase, setPhase] = useState2("form");
|
|
1770
|
+
const [verifyMsg, setVerifyMsg] = useState2(null);
|
|
1771
|
+
const fields = section ? FIELDS.filter((f) => f.group.toLowerCase() === section.toLowerCase()) : [];
|
|
1772
|
+
useInput4((input, key) => {
|
|
1773
|
+
if (key.escape) onDone();
|
|
1774
|
+
if (phase === "done" && (key.return || input === "q")) onDone(draft);
|
|
1775
|
+
});
|
|
1776
|
+
const field = fields[index];
|
|
1777
|
+
const commit = (raw) => {
|
|
1778
|
+
const value = field.transform ? field.transform(String(raw ?? "").trim()) : String(raw ?? "").trim();
|
|
1779
|
+
set(draft, field.key, value);
|
|
1780
|
+
if (field.key === "customer.phone") {
|
|
1781
|
+
const parsed = toE164(value, get(draft, "customer.countryCode"));
|
|
1782
|
+
if (parsed.ok) {
|
|
1783
|
+
set(draft, "customer.phone", parsed.value);
|
|
1784
|
+
if (!draft.allowedPhones.includes(parsed.value)) draft.allowedPhones.push(parsed.value);
|
|
1785
|
+
}
|
|
1786
|
+
}
|
|
1787
|
+
if (index + 1 < fields.length) {
|
|
1788
|
+
setIndex(index + 1);
|
|
1789
|
+
return;
|
|
1790
|
+
}
|
|
1791
|
+
finish();
|
|
1792
|
+
};
|
|
1793
|
+
const finish = () => {
|
|
1794
|
+
{
|
|
1795
|
+
const file = saveConfig(draft);
|
|
1796
|
+
setVerifyMsg({ ok: true, text: `${section} settings saved`, file });
|
|
1797
|
+
setPhase("done");
|
|
1798
|
+
return;
|
|
1799
|
+
}
|
|
1800
|
+
setPhase("verifying");
|
|
1801
|
+
verifyStore(draft).then(({ shop, count }) => {
|
|
1802
|
+
if (shop?.currency) draft.defaults.currency = shop.currency;
|
|
1803
|
+
setVerifyMsg({ ok: true, text: `connected to ${shop?.name} \xB7 ${shop?.currency} \xB7 ${count ?? "?"} products` });
|
|
1804
|
+
}).catch((err) => setVerifyMsg({ ok: false, text: err.message, hint: err.hint })).finally(() => {
|
|
1805
|
+
const file = saveConfig(draft);
|
|
1806
|
+
setVerifyMsg((m) => ({ ...m, file }));
|
|
1807
|
+
setPhase("done");
|
|
1808
|
+
});
|
|
1809
|
+
};
|
|
1810
|
+
if (!section) {
|
|
1811
|
+
return /* @__PURE__ */ jsxs8(Box8, { flexDirection: "column", children: [
|
|
1812
|
+
/* @__PURE__ */ jsx8(Header, { title: "Configure", subtitle: "pick what you want to change", right: "esc to go back" }),
|
|
1813
|
+
/* @__PURE__ */ jsx8(
|
|
1814
|
+
Select5,
|
|
1815
|
+
{
|
|
1816
|
+
visibleOptionCount: 8,
|
|
1817
|
+
options: Object.entries(SECTION_HINTS).map(([key, hint]) => ({
|
|
1818
|
+
label: `${key.padEnd(10)} ${hint}`,
|
|
1819
|
+
value: key
|
|
1820
|
+
})),
|
|
1821
|
+
onChange: (value) => {
|
|
1822
|
+
setSection(value);
|
|
1823
|
+
setIndex(0);
|
|
1824
|
+
}
|
|
1825
|
+
}
|
|
1826
|
+
)
|
|
1827
|
+
] });
|
|
1828
|
+
}
|
|
1829
|
+
if (phase === "verifying") {
|
|
1830
|
+
return /* @__PURE__ */ jsxs8(Box8, { flexDirection: "column", children: [
|
|
1831
|
+
/* @__PURE__ */ jsx8(Header, { title: "Configure", subtitle: "verifying credentials" }),
|
|
1832
|
+
/* @__PURE__ */ jsx8(Spinner2, { label: "contacting Shopify\u2026" })
|
|
1833
|
+
] });
|
|
1834
|
+
}
|
|
1835
|
+
if (phase === "done") {
|
|
1836
|
+
return /* @__PURE__ */ jsxs8(Box8, { flexDirection: "column", children: [
|
|
1837
|
+
/* @__PURE__ */ jsx8(Header, { title: "Configure", subtitle: "saved" }),
|
|
1838
|
+
/* @__PURE__ */ jsx8(Alert2, { variant: verifyMsg?.ok ? "success" : "warning", children: verifyMsg?.text ?? "saved" }),
|
|
1839
|
+
verifyMsg?.hint ? /* @__PURE__ */ jsx8(Text8, { color: palette.warn, children: ` ${verifyMsg.hint}` }) : null,
|
|
1840
|
+
/* @__PURE__ */ jsx8(Box8, { marginTop: 1, children: /* @__PURE__ */ jsx8(Text8, { color: palette.dim, children: `config written to ${verifyMsg?.file} (chmod 600)` }) }),
|
|
1841
|
+
/* @__PURE__ */ jsx8(Box8, { marginTop: 1, children: /* @__PURE__ */ jsx8(Text8, { color: palette.dim, children: "press enter to return to the menu" }) })
|
|
1842
|
+
] });
|
|
1843
|
+
}
|
|
1844
|
+
const current = get(draft, field.key) ?? "";
|
|
1845
|
+
return /* @__PURE__ */ jsxs8(Box8, { flexDirection: "column", children: [
|
|
1846
|
+
/* @__PURE__ */ jsx8(Header, { title: "Configure", subtitle: `${field.group} \xB7 step ${index + 1} of ${fields.length}`, right: "esc to cancel" }),
|
|
1847
|
+
/* @__PURE__ */ jsx8(Progress, { index, total: fields.length }),
|
|
1848
|
+
/* @__PURE__ */ jsxs8(Section, { title: field.label, children: [
|
|
1849
|
+
field.hint ? /* @__PURE__ */ jsx8(Text8, { color: palette.dim, children: ` ${field.hint}` }) : null,
|
|
1850
|
+
/* @__PURE__ */ jsxs8(Box8, { marginTop: 1, children: [
|
|
1851
|
+
/* @__PURE__ */ jsx8(Text8, { color: palette.accent, children: `${glyph.pointer} ` }),
|
|
1852
|
+
field.type === "select" ? /* @__PURE__ */ jsx8(
|
|
1853
|
+
Select5,
|
|
1854
|
+
{
|
|
1855
|
+
options: field.options,
|
|
1856
|
+
defaultValue: current || void 0,
|
|
1857
|
+
onChange: commit
|
|
1858
|
+
},
|
|
1859
|
+
field.key
|
|
1860
|
+
) : field.secret ? /* @__PURE__ */ jsx8(PasswordInput, { placeholder: current ? mask(current) : "\u2022\u2022\u2022\u2022\u2022\u2022", onSubmit: (v) => commit(v || current) }, field.key) : /* @__PURE__ */ jsx8(
|
|
1861
|
+
TextInput2,
|
|
1862
|
+
{
|
|
1863
|
+
defaultValue: current,
|
|
1864
|
+
placeholder: field.placeholder ?? (field.optional ? "(optional)" : ""),
|
|
1865
|
+
onSubmit: (v) => commit(v || current)
|
|
1866
|
+
},
|
|
1867
|
+
field.key
|
|
1868
|
+
)
|
|
1869
|
+
] })
|
|
1870
|
+
] })
|
|
1871
|
+
] });
|
|
1872
|
+
}
|
|
1873
|
+
function Progress({ index, total }) {
|
|
1874
|
+
const filled = Math.round(index / total * 30);
|
|
1875
|
+
return /* @__PURE__ */ jsxs8(Box8, { children: [
|
|
1876
|
+
/* @__PURE__ */ jsx8(Text8, { color: palette.accent, children: "\u2588".repeat(filled) }),
|
|
1877
|
+
/* @__PURE__ */ jsx8(Text8, { color: palette.dim, children: "\u2591".repeat(30 - filled) }),
|
|
1878
|
+
/* @__PURE__ */ jsx8(Text8, { color: palette.dim, children: ` ${index}/${total}` })
|
|
1879
|
+
] });
|
|
1880
|
+
}
|
|
1881
|
+
|
|
1882
|
+
// src/ui/screens/ConnectShopify.jsx
|
|
1883
|
+
import React9, { useState as useState3 } from "react";
|
|
1884
|
+
import { Box as Box9, Text as Text9, useInput as useInput5 } from "ink";
|
|
1885
|
+
import { Spinner as Spinner3, TextInput as TextInput3, PasswordInput as PasswordInput2, Alert as Alert3, Badge as Badge2 } from "@inkjs/ui";
|
|
1886
|
+
import { jsx as jsx9, jsxs as jsxs9 } from "react/jsx-runtime";
|
|
1887
|
+
var STEP2 = { DOMAIN: "domain", TOKEN: "token", VERIFY: "verify", DONE: "done", FAILED: "failed" };
|
|
1888
|
+
function ConnectShopify({ config, onDone }) {
|
|
1889
|
+
const [draft] = useState3(() => structuredClone(config));
|
|
1890
|
+
const [step, setStep] = useState3(STEP2.DOMAIN);
|
|
1891
|
+
const [result, setResult] = useState3(null);
|
|
1892
|
+
const [error, setError] = useState3(null);
|
|
1893
|
+
useInput5((input, key) => {
|
|
1894
|
+
if (key.escape) onDone();
|
|
1895
|
+
if (step === STEP2.DONE && (key.return || input === "q")) onDone(draft);
|
|
1896
|
+
if (step === STEP2.FAILED && (key.return || input === "q")) setStep(STEP2.DOMAIN);
|
|
1897
|
+
});
|
|
1898
|
+
const onDomain = (value) => {
|
|
1899
|
+
draft.shopify.domain = normalizeDomain(value || draft.shopify.domain);
|
|
1900
|
+
if (!draft.shopify.domain) return;
|
|
1901
|
+
setStep(STEP2.TOKEN);
|
|
1902
|
+
};
|
|
1903
|
+
const onToken = (value) => {
|
|
1904
|
+
draft.shopify.accessToken = value || draft.shopify.accessToken;
|
|
1905
|
+
if (!draft.shopify.accessToken) return;
|
|
1906
|
+
setStep(STEP2.VERIFY);
|
|
1907
|
+
verifyStore(draft).then(({ shop, count }) => {
|
|
1908
|
+
const applied = applyShopDefaults(draft, shop, count);
|
|
1909
|
+
setResult({ shop, count, applied });
|
|
1910
|
+
saveConfig(draft);
|
|
1911
|
+
setStep(STEP2.DONE);
|
|
1912
|
+
}).catch((err) => {
|
|
1913
|
+
setError({ message: err.message, hint: err.hint });
|
|
1914
|
+
setStep(STEP2.FAILED);
|
|
1915
|
+
});
|
|
1916
|
+
};
|
|
1917
|
+
return /* @__PURE__ */ jsxs9(Box9, { flexDirection: "column", children: [
|
|
1918
|
+
/* @__PURE__ */ jsx9(Header, { title: "Connect Shopify", subtitle: "the catalogue every payload is built from", right: "esc to cancel" }),
|
|
1919
|
+
step === STEP2.DOMAIN && /* @__PURE__ */ jsxs9(Section, { title: "Store domain", children: [
|
|
1920
|
+
/* @__PURE__ */ jsx9(Text9, { color: palette.dim, children: " the myshopify.com domain, not your custom domain" }),
|
|
1921
|
+
/* @__PURE__ */ jsxs9(Box9, { marginTop: 1, children: [
|
|
1922
|
+
/* @__PURE__ */ jsx9(Text9, { color: palette.accent, children: `${glyph.pointer} ` }),
|
|
1923
|
+
/* @__PURE__ */ jsx9(
|
|
1924
|
+
TextInput3,
|
|
1925
|
+
{
|
|
1926
|
+
defaultValue: draft.shopify.domain,
|
|
1927
|
+
placeholder: "your-store.myshopify.com",
|
|
1928
|
+
onSubmit: onDomain
|
|
1929
|
+
}
|
|
1930
|
+
)
|
|
1931
|
+
] })
|
|
1932
|
+
] }),
|
|
1933
|
+
step === STEP2.TOKEN && /* @__PURE__ */ jsxs9(Section, { title: "Admin API access token", children: [
|
|
1934
|
+
/* @__PURE__ */ jsx9(Text9, { color: palette.dim, children: " Shopify admin \u2192 Settings \u2192 Apps and sales channels \u2192 Develop apps" }),
|
|
1935
|
+
/* @__PURE__ */ jsx9(Text9, { color: palette.dim, children: " the token needs the read_products scope" }),
|
|
1936
|
+
/* @__PURE__ */ jsxs9(Box9, { marginTop: 1, children: [
|
|
1937
|
+
/* @__PURE__ */ jsx9(Text9, { color: palette.accent, children: `${glyph.pointer} ` }),
|
|
1938
|
+
/* @__PURE__ */ jsx9(
|
|
1939
|
+
PasswordInput2,
|
|
1940
|
+
{
|
|
1941
|
+
placeholder: draft.shopify.accessToken ? mask(draft.shopify.accessToken) : "shpat_\u2026",
|
|
1942
|
+
onSubmit: onToken
|
|
1943
|
+
}
|
|
1944
|
+
)
|
|
1945
|
+
] })
|
|
1946
|
+
] }),
|
|
1947
|
+
step === STEP2.VERIFY && /* @__PURE__ */ jsx9(Spinner3, { label: `connecting to ${draft.shopify.domain}\u2026` }),
|
|
1948
|
+
step === STEP2.DONE && result && /* @__PURE__ */ jsxs9(Box9, { flexDirection: "column", children: [
|
|
1949
|
+
/* @__PURE__ */ jsxs9(Box9, { marginBottom: 1, children: [
|
|
1950
|
+
/* @__PURE__ */ jsx9(Badge2, { color: "green", children: "CONNECTED" }),
|
|
1951
|
+
/* @__PURE__ */ jsx9(Text9, { children: " " }),
|
|
1952
|
+
/* @__PURE__ */ jsx9(Text9, { bold: true, children: result.shop?.name })
|
|
1953
|
+
] }),
|
|
1954
|
+
/* @__PURE__ */ jsx9(Box9, { flexDirection: "column", borderStyle: "round", borderColor: palette.ok, paddingX: 1, children: /* @__PURE__ */ jsx9(
|
|
1955
|
+
KeyValue,
|
|
1956
|
+
{
|
|
1957
|
+
keyWidth: 16,
|
|
1958
|
+
rows: [
|
|
1959
|
+
["domain", result.shop?.myshopify_domain ?? draft.shopify.domain],
|
|
1960
|
+
["storefront", result.shop?.domain ?? "\u2014"],
|
|
1961
|
+
["products", String(result.count ?? "?")],
|
|
1962
|
+
["currency", result.shop?.currency ?? "\u2014"],
|
|
1963
|
+
["country", `${result.shop?.country_name ?? "\u2014"} (${result.shop?.country_code ?? "\u2014"})`],
|
|
1964
|
+
["timezone", result.shop?.iana_timezone ?? "\u2014"],
|
|
1965
|
+
["plan", result.shop?.plan_display_name ?? "\u2014"],
|
|
1966
|
+
["contact", result.shop?.email ?? "\u2014"]
|
|
1967
|
+
]
|
|
1968
|
+
}
|
|
1969
|
+
) }),
|
|
1970
|
+
result.applied.length ? /* @__PURE__ */ jsx9(Section, { title: "Picked up from the store", children: result.applied.map((line, i) => /* @__PURE__ */ jsx9(Text9, { color: palette.dim, children: ` ${glyph.tick} ${line}` }, i)) }) : null,
|
|
1971
|
+
/* @__PURE__ */ jsx9(Box9, { marginTop: 1, children: /* @__PURE__ */ jsx9(Alert3, { variant: "success", children: "Shopify connected \u2014 you can browse the real catalogue now" }) }),
|
|
1972
|
+
/* @__PURE__ */ jsx9(Box9, { marginTop: 1, children: /* @__PURE__ */ jsx9(Text9, { color: palette.dim, children: "press enter to continue" }) })
|
|
1973
|
+
] }),
|
|
1974
|
+
step === STEP2.FAILED && error && /* @__PURE__ */ jsxs9(Box9, { flexDirection: "column", children: [
|
|
1975
|
+
/* @__PURE__ */ jsx9(Alert3, { variant: "error", children: error.message }),
|
|
1976
|
+
error.hint ? /* @__PURE__ */ jsx9(Text9, { color: palette.warn, children: ` ${error.hint}` }) : null,
|
|
1977
|
+
/* @__PURE__ */ jsxs9(Box9, { marginTop: 1, flexDirection: "column", children: [
|
|
1978
|
+
/* @__PURE__ */ jsx9(Text9, { color: palette.dim, children: " common causes:" }),
|
|
1979
|
+
/* @__PURE__ */ jsx9(Text9, { color: palette.dim, children: " \xB7 the token is missing the read_products scope" }),
|
|
1980
|
+
/* @__PURE__ */ jsx9(Text9, { color: palette.dim, children: " \xB7 the token belongs to a different store" }),
|
|
1981
|
+
/* @__PURE__ */ jsx9(Text9, { color: palette.dim, children: " \xB7 the domain is the custom domain, not the .myshopify.com one" })
|
|
1982
|
+
] }),
|
|
1983
|
+
/* @__PURE__ */ jsx9(Box9, { marginTop: 1, children: /* @__PURE__ */ jsx9(Text9, { color: palette.dim, children: "press enter to try again" }) })
|
|
1984
|
+
] })
|
|
1985
|
+
] });
|
|
1986
|
+
}
|
|
1987
|
+
|
|
1988
|
+
// src/ui/screens/Webhook.jsx
|
|
1989
|
+
import React10, { useState as useState4 } from "react";
|
|
1990
|
+
import { Box as Box10, Text as Text10, useInput as useInput6 } from "ink";
|
|
1991
|
+
import { Select as Select6, TextInput as TextInput4, PasswordInput as PasswordInput3, Alert as Alert4, Badge as Badge3, Spinner as Spinner4 } from "@inkjs/ui";
|
|
1992
|
+
import { jsx as jsx10, jsxs as jsxs10 } from "react/jsx-runtime";
|
|
1993
|
+
var STEP3 = { PICK: "pick", URL: "url", SECRET: "secret", PROBE: "probe", DONE: "done" };
|
|
1994
|
+
function Webhook({ config, onDone }) {
|
|
1995
|
+
const [draft] = useState4(() => structuredClone(config));
|
|
1996
|
+
const [providerId, setProviderId] = useState4(null);
|
|
1997
|
+
const [step, setStep] = useState4(STEP3.PICK);
|
|
1998
|
+
const [probe, setProbe] = useState4(null);
|
|
1999
|
+
const [urlError, setUrlError] = useState4(null);
|
|
2000
|
+
useInput6((input, key) => {
|
|
2001
|
+
if (key.escape) {
|
|
2002
|
+
if (step === STEP3.PICK) onDone(draft);
|
|
2003
|
+
else {
|
|
2004
|
+
setStep(STEP3.PICK);
|
|
2005
|
+
setProviderId(null);
|
|
2006
|
+
}
|
|
2007
|
+
}
|
|
2008
|
+
if (step === STEP3.DONE && (key.return || input === "q")) {
|
|
2009
|
+
setStep(STEP3.PICK);
|
|
2010
|
+
setProviderId(null);
|
|
2011
|
+
}
|
|
2012
|
+
});
|
|
2013
|
+
const meta = providerId ? metaFor(providerId) : null;
|
|
2014
|
+
const target = providerId ? targetFor(draft, providerId) : null;
|
|
2015
|
+
const onUrl = (value) => {
|
|
2016
|
+
const url = (value || target.webhookUrl || "").trim();
|
|
2017
|
+
if (!/^https?:\/\/.+/i.test(url)) {
|
|
2018
|
+
setUrlError("must be a full URL starting with http:// or https://");
|
|
2019
|
+
return;
|
|
2020
|
+
}
|
|
2021
|
+
setUrlError(null);
|
|
2022
|
+
setTarget(draft, providerId, { webhookUrl: url });
|
|
2023
|
+
setStep(STEP3.SECRET);
|
|
2024
|
+
};
|
|
2025
|
+
const onSecret = (value) => {
|
|
2026
|
+
setTarget(draft, providerId, { webhookSecret: value || target.webhookSecret });
|
|
2027
|
+
saveConfig(draft);
|
|
2028
|
+
setStep(STEP3.PROBE);
|
|
2029
|
+
probeEndpoint(targetFor(draft, providerId).webhookUrl).then(setProbe).finally(() => setStep(STEP3.DONE));
|
|
2030
|
+
};
|
|
2031
|
+
if (step === STEP3.PICK) {
|
|
2032
|
+
const options = [
|
|
2033
|
+
...listProviderMeta().map((p) => {
|
|
2034
|
+
const t = targetFor(draft, p.id);
|
|
2035
|
+
return {
|
|
2036
|
+
label: p.label.padEnd(30) + (t.webhookUrl ? truncate(t.webhookUrl, 34) : "not set"),
|
|
2037
|
+
value: p.id
|
|
2038
|
+
};
|
|
2039
|
+
}),
|
|
2040
|
+
{ label: "Back".padEnd(30), value: "__back" }
|
|
2041
|
+
];
|
|
2042
|
+
return /* @__PURE__ */ jsxs10(Box10, { flexDirection: "column", children: [
|
|
2043
|
+
/* @__PURE__ */ jsx10(Header, { title: "Webhook destinations", subtitle: "one endpoint and secret per integration", right: "esc to go back" }),
|
|
2044
|
+
/* @__PURE__ */ jsx10(
|
|
2045
|
+
Select6,
|
|
2046
|
+
{
|
|
2047
|
+
visibleOptionCount: options.length,
|
|
2048
|
+
options,
|
|
2049
|
+
onChange: (v) => {
|
|
2050
|
+
if (v === "__back") return onDone(draft);
|
|
2051
|
+
setProviderId(v);
|
|
2052
|
+
setUrlError(null);
|
|
2053
|
+
setStep(STEP3.URL);
|
|
2054
|
+
}
|
|
2055
|
+
}
|
|
2056
|
+
),
|
|
2057
|
+
/* @__PURE__ */ jsx10(Box10, { marginTop: 1, children: /* @__PURE__ */ jsx10(Text10, { color: palette.dim, children: `${glyph.dot} set globally \u2014 every build for that provider uses it` }) })
|
|
2058
|
+
] });
|
|
2059
|
+
}
|
|
2060
|
+
return /* @__PURE__ */ jsxs10(Box10, { flexDirection: "column", children: [
|
|
2061
|
+
/* @__PURE__ */ jsx10(Header, { title: meta.label, subtitle: `signature: ${meta.signature}`, right: "esc to cancel" }),
|
|
2062
|
+
step === STEP3.URL && /* @__PURE__ */ jsxs10(Section, { title: "Webhook URL", children: [
|
|
2063
|
+
/* @__PURE__ */ jsx10(Text10, { color: palette.dim, children: " the full endpoint the provider would call \u2014 paste it as-is" }),
|
|
2064
|
+
urlError ? /* @__PURE__ */ jsx10(Text10, { color: palette.bad, children: ` ${glyph.cross} ${urlError}` }) : null,
|
|
2065
|
+
/* @__PURE__ */ jsxs10(Box10, { marginTop: 1, children: [
|
|
2066
|
+
/* @__PURE__ */ jsx10(Text10, { color: palette.accent, children: `${glyph.pointer} ` }),
|
|
2067
|
+
/* @__PURE__ */ jsx10(
|
|
2068
|
+
TextInput4,
|
|
2069
|
+
{
|
|
2070
|
+
defaultValue: target.webhookUrl,
|
|
2071
|
+
placeholder: "https://your-endpoint.example.com/webhook/\u2026",
|
|
2072
|
+
onSubmit: onUrl
|
|
2073
|
+
}
|
|
2074
|
+
)
|
|
2075
|
+
] })
|
|
2076
|
+
] }),
|
|
2077
|
+
step === STEP3.SECRET && /* @__PURE__ */ jsxs10(Section, { title: meta.id === "nitro" ? "Bearer token" : "Signing secret", children: [
|
|
2078
|
+
/* @__PURE__ */ jsx10(Text10, { color: palette.dim, children: ` sent as ${meta.header}` }),
|
|
2079
|
+
/* @__PURE__ */ jsx10(Text10, { color: palette.dim, children: " must match what the receiver has stored" }),
|
|
2080
|
+
/* @__PURE__ */ jsxs10(Box10, { marginTop: 1, children: [
|
|
2081
|
+
/* @__PURE__ */ jsx10(Text10, { color: palette.accent, children: `${glyph.pointer} ` }),
|
|
2082
|
+
/* @__PURE__ */ jsx10(
|
|
2083
|
+
PasswordInput3,
|
|
2084
|
+
{
|
|
2085
|
+
placeholder: target.webhookSecret ? mask(target.webhookSecret) : "the signing secret\u2026",
|
|
2086
|
+
onSubmit: onSecret
|
|
2087
|
+
}
|
|
2088
|
+
)
|
|
2089
|
+
] })
|
|
2090
|
+
] }),
|
|
2091
|
+
step === STEP3.PROBE && /* @__PURE__ */ jsx10(Spinner4, { label: "checking the endpoint is reachable\u2026" }),
|
|
2092
|
+
step === STEP3.DONE && /* @__PURE__ */ jsxs10(Box10, { flexDirection: "column", children: [
|
|
2093
|
+
/* @__PURE__ */ jsxs10(Box10, { marginBottom: 1, children: [
|
|
2094
|
+
/* @__PURE__ */ jsx10(Badge3, { color: probe?.reachable ? "green" : "yellow", children: probe?.reachable ? "REACHABLE" : "UNVERIFIED" }),
|
|
2095
|
+
/* @__PURE__ */ jsx10(Text10, { children: " " }),
|
|
2096
|
+
/* @__PURE__ */ jsx10(Text10, { color: palette.dim, children: probe?.reachable ? `responded HTTP ${probe.status} to an unsigned probe` : probe?.error ?? "could not reach it \u2014 saved anyway" })
|
|
2097
|
+
] }),
|
|
2098
|
+
/* @__PURE__ */ jsx10(Box10, { flexDirection: "column", borderStyle: "round", borderColor: palette.ok, paddingX: 1, children: /* @__PURE__ */ jsx10(
|
|
2099
|
+
KeyValue,
|
|
2100
|
+
{
|
|
2101
|
+
keyWidth: 10,
|
|
2102
|
+
rows: [
|
|
2103
|
+
["provider", meta.label],
|
|
2104
|
+
["url", truncate(targetFor(draft, providerId).webhookUrl, 58)],
|
|
2105
|
+
["secret", targetFor(draft, providerId).webhookSecret ? mask(targetFor(draft, providerId).webhookSecret) : "none \u2014 signature will be skipped"]
|
|
2106
|
+
]
|
|
2107
|
+
}
|
|
2108
|
+
) }),
|
|
2109
|
+
!meta.available ? /* @__PURE__ */ jsx10(Box10, { marginTop: 1, children: /* @__PURE__ */ jsx10(Alert4, { variant: "info", children: `saved \u2014 ${meta.label} payload generation is not implemented yet` }) }) : null,
|
|
2110
|
+
/* @__PURE__ */ jsx10(Box10, { marginTop: 1, children: /* @__PURE__ */ jsx10(Text10, { color: palette.dim, children: "press enter to configure another" }) })
|
|
2111
|
+
] })
|
|
2112
|
+
] });
|
|
2113
|
+
}
|
|
2114
|
+
async function probeEndpoint(url) {
|
|
2115
|
+
const controller = new AbortController();
|
|
2116
|
+
const timer = setTimeout(() => controller.abort(), 1e4);
|
|
2117
|
+
try {
|
|
2118
|
+
const res = await fetch(url, {
|
|
2119
|
+
method: "POST",
|
|
2120
|
+
headers: { "content-type": "application/json" },
|
|
2121
|
+
body: JSON.stringify({ type: "FORGE_PROBE" }),
|
|
2122
|
+
signal: controller.signal
|
|
2123
|
+
});
|
|
2124
|
+
clearTimeout(timer);
|
|
2125
|
+
return { reachable: true, status: res.status };
|
|
2126
|
+
} catch (err) {
|
|
2127
|
+
clearTimeout(timer);
|
|
2128
|
+
return { reachable: false, error: err.name === "AbortError" ? "timed out" : err.message };
|
|
2129
|
+
}
|
|
2130
|
+
}
|
|
2131
|
+
|
|
2132
|
+
// src/ui/screens/Doctor.jsx
|
|
2133
|
+
import React11, { useEffect as useEffect2, useState as useState5 } from "react";
|
|
2134
|
+
import { Box as Box11, Text as Text11, useInput as useInput7 } from "ink";
|
|
2135
|
+
import { Spinner as Spinner5 } from "@inkjs/ui";
|
|
2136
|
+
import { jsx as jsx11, jsxs as jsxs11 } from "react/jsx-runtime";
|
|
2137
|
+
function Doctor({ config, onDone }) {
|
|
2138
|
+
const [checks, setChecks] = useState5(null);
|
|
2139
|
+
useInput7((input, key) => {
|
|
2140
|
+
if (key.escape || key.return || input === "q") onDone();
|
|
2141
|
+
});
|
|
2142
|
+
useEffect2(() => {
|
|
2143
|
+
let alive = true;
|
|
2144
|
+
run(config).then((result) => alive && setChecks(result));
|
|
2145
|
+
return () => {
|
|
2146
|
+
alive = false;
|
|
2147
|
+
};
|
|
2148
|
+
}, []);
|
|
2149
|
+
return /* @__PURE__ */ jsxs11(Box11, { flexDirection: "column", children: [
|
|
2150
|
+
/* @__PURE__ */ jsx11(Header, { title: "Doctor", subtitle: "everything the generator depends on" }),
|
|
2151
|
+
!checks ? /* @__PURE__ */ jsx11(Spinner5, { label: "running checks\u2026" }) : /* @__PURE__ */ jsxs11(Box11, { flexDirection: "column", children: [
|
|
2152
|
+
/* @__PURE__ */ jsx11(Section, { title: "Configuration", children: /* @__PURE__ */ jsx11(CheckList, { checks: checks.config }) }),
|
|
2153
|
+
/* @__PURE__ */ jsx11(Section, { title: "Connectivity", children: /* @__PURE__ */ jsx11(CheckList, { checks: checks.connectivity }) }),
|
|
2154
|
+
/* @__PURE__ */ jsx11(Section, { title: "Runtime", children: /* @__PURE__ */ jsx11(CheckList, { checks: checks.runtime }) }),
|
|
2155
|
+
/* @__PURE__ */ jsx11(Box11, { marginTop: 1, children: /* @__PURE__ */ jsx11(Text11, { color: palette.dim, children: "press enter to return to the menu" }) })
|
|
2156
|
+
] })
|
|
2157
|
+
] });
|
|
2158
|
+
}
|
|
2159
|
+
async function run(config) {
|
|
2160
|
+
const cfgChecks = configStatus(config).map((row) => ({
|
|
2161
|
+
name: row.key,
|
|
2162
|
+
ok: row.ok,
|
|
2163
|
+
detail: row.value ? row.secret ? mask(row.value) : row.value : "not set"
|
|
2164
|
+
}));
|
|
2165
|
+
const connectivity = [];
|
|
2166
|
+
if (config.shopify.domain && config.shopify.accessToken) {
|
|
2167
|
+
try {
|
|
2168
|
+
const { shop, count } = await verifyStore(config);
|
|
2169
|
+
connectivity.push({ name: "Shopify", ok: true, detail: `${shop?.name} \xB7 ${shop?.currency} \xB7 ${count ?? "?"} products` });
|
|
2170
|
+
} catch (err) {
|
|
2171
|
+
connectivity.push({ name: "Shopify", ok: false, detail: err.message });
|
|
2172
|
+
}
|
|
2173
|
+
} else {
|
|
2174
|
+
connectivity.push({ name: "Shopify", ok: false, detail: "not configured" });
|
|
2175
|
+
}
|
|
2176
|
+
const url = getProvider("cashfree-occ").webhookUrl(config);
|
|
2177
|
+
if (url) {
|
|
2178
|
+
try {
|
|
2179
|
+
const controller = new AbortController();
|
|
2180
|
+
const timer = setTimeout(() => controller.abort(), 12e3);
|
|
2181
|
+
const res = await fetch(url, {
|
|
2182
|
+
method: "POST",
|
|
2183
|
+
headers: { "content-type": "application/json" },
|
|
2184
|
+
body: JSON.stringify({ type: "FORGE_PROBE" }),
|
|
2185
|
+
signal: controller.signal
|
|
2186
|
+
});
|
|
2187
|
+
clearTimeout(timer);
|
|
2188
|
+
connectivity.push({ name: "Webhook endpoint", ok: true, detail: `reachable \u2014 HTTP ${res.status} (unsigned probe, rejection is expected)` });
|
|
2189
|
+
} catch (err) {
|
|
2190
|
+
connectivity.push({ name: "Webhook endpoint", ok: false, detail: err.name === "AbortError" ? "timeout" : err.message });
|
|
2191
|
+
}
|
|
2192
|
+
} else {
|
|
2193
|
+
connectivity.push({ name: "Webhook endpoint", ok: false, detail: "not configured" });
|
|
2194
|
+
}
|
|
2195
|
+
const runtime = [
|
|
2196
|
+
{ name: "node", ok: Number(process.versions.node.split(".")[0]) >= 20, detail: process.version },
|
|
2197
|
+
{ name: "global fetch", ok: typeof fetch === "function", detail: typeof fetch === "function" ? "available" : "missing \u2014 needs node 18+" }
|
|
2198
|
+
];
|
|
2199
|
+
return { config: cfgChecks, connectivity, runtime };
|
|
2200
|
+
}
|
|
2201
|
+
|
|
2202
|
+
// src/ui/screens/History.jsx
|
|
2203
|
+
import React12, { useState as useState6 } from "react";
|
|
2204
|
+
import { Box as Box12, Text as Text12, useInput as useInput8 } from "ink";
|
|
2205
|
+
import { Select as Select7, Spinner as Spinner6, Badge as Badge4, Alert as Alert5, ConfirmInput as ConfirmInput2 } from "@inkjs/ui";
|
|
2206
|
+
import { jsx as jsx12, jsxs as jsxs12 } from "react/jsx-runtime";
|
|
2207
|
+
function History({ config, onDone }) {
|
|
2208
|
+
const [items] = useState6(() => listPayloads(20));
|
|
2209
|
+
const [selected, setSelected] = useState6(null);
|
|
2210
|
+
const [phase, setPhase] = useState6("list");
|
|
2211
|
+
const [result, setResult] = useState6(null);
|
|
2212
|
+
const [error, setError] = useState6(null);
|
|
2213
|
+
useInput8((input, key) => {
|
|
2214
|
+
if (key.escape) onDone();
|
|
2215
|
+
if ((phase === "result" || phase === "error") && (key.return || input === "q")) onDone();
|
|
2216
|
+
});
|
|
2217
|
+
const onPick = (file) => {
|
|
2218
|
+
setSelected(loadPayload(file));
|
|
2219
|
+
setPhase("detail");
|
|
2220
|
+
};
|
|
2221
|
+
const resend = () => {
|
|
2222
|
+
setPhase("sending");
|
|
2223
|
+
dispatch({ cfg: config, record: selected }).then((r) => {
|
|
2224
|
+
setResult(r);
|
|
2225
|
+
setPhase("result");
|
|
2226
|
+
}).catch((err) => {
|
|
2227
|
+
setError(err);
|
|
2228
|
+
setPhase("error");
|
|
2229
|
+
});
|
|
2230
|
+
};
|
|
2231
|
+
if (!items.length) {
|
|
2232
|
+
return /* @__PURE__ */ jsxs12(Box12, { flexDirection: "column", children: [
|
|
2233
|
+
/* @__PURE__ */ jsx12(Header, { title: "History", subtitle: "saved payloads" }),
|
|
2234
|
+
/* @__PURE__ */ jsx12(Text12, { color: palette.dim, children: "nothing built yet \u2014 press esc to go back" })
|
|
2235
|
+
] });
|
|
2236
|
+
}
|
|
2237
|
+
return /* @__PURE__ */ jsxs12(Box12, { flexDirection: "column", children: [
|
|
2238
|
+
/* @__PURE__ */ jsx12(Header, { title: "History", subtitle: `${items.length} saved payload${items.length === 1 ? "" : "s"}`, right: "esc to go back" }),
|
|
2239
|
+
phase === "list" && /* @__PURE__ */ jsx12(
|
|
2240
|
+
Select7,
|
|
2241
|
+
{
|
|
2242
|
+
visibleOptionCount: 10,
|
|
2243
|
+
options: items.map((i) => ({
|
|
2244
|
+
label: `${i.savedAt.replace("T", " ").slice(0, 19)} ${i.provider} ${money(i.meta.totalPrice, i.meta.currency)} ${i.meta.valid ? "\u2714" : "\u2716"}`,
|
|
2245
|
+
value: i.path
|
|
2246
|
+
})),
|
|
2247
|
+
onChange: onPick
|
|
2248
|
+
}
|
|
2249
|
+
),
|
|
2250
|
+
phase === "detail" && selected && /* @__PURE__ */ jsxs12(Box12, { flexDirection: "column", children: [
|
|
2251
|
+
/* @__PURE__ */ jsx12(Section, { title: "Payload", children: /* @__PURE__ */ jsx12(
|
|
2252
|
+
KeyValue,
|
|
2253
|
+
{
|
|
2254
|
+
rows: [
|
|
2255
|
+
["saved", selected.savedAt],
|
|
2256
|
+
["provider", selected.provider],
|
|
2257
|
+
["environment", selected.meta?.environment ?? "\u2014"],
|
|
2258
|
+
["store", selected.meta?.store ?? "\u2014"],
|
|
2259
|
+
["items", (selected.meta?.items ?? []).map((i) => `${truncate(i.title, 24)} \xD7${i.qty}`).join(", ")],
|
|
2260
|
+
["total", money(selected.meta?.totalPrice, selected.meta?.currency)],
|
|
2261
|
+
["bytes", String(selected.body.length)]
|
|
2262
|
+
]
|
|
2263
|
+
}
|
|
2264
|
+
) }),
|
|
2265
|
+
/* @__PURE__ */ jsxs12(Box12, { marginTop: 1, children: [
|
|
2266
|
+
/* @__PURE__ */ jsx12(Text12, { children: "Re-send this payload (it will be re-signed with a fresh timestamp)? " }),
|
|
2267
|
+
/* @__PURE__ */ jsx12(ConfirmInput2, { onConfirm: resend, onCancel: () => setPhase("list") })
|
|
2268
|
+
] })
|
|
2269
|
+
] }),
|
|
2270
|
+
phase === "sending" && /* @__PURE__ */ jsx12(Spinner6, { label: "re-signing and sending\u2026" }),
|
|
2271
|
+
phase === "result" && result && /* @__PURE__ */ jsxs12(Box12, { flexDirection: "column", children: [
|
|
2272
|
+
/* @__PURE__ */ jsxs12(Box12, { marginBottom: 1, children: [
|
|
2273
|
+
/* @__PURE__ */ jsx12(Badge4, { color: result.ok ? "green" : "red", children: result.error ? "FAILED" : `HTTP ${result.status}` }),
|
|
2274
|
+
/* @__PURE__ */ jsx12(Text12, { children: " " }),
|
|
2275
|
+
/* @__PURE__ */ jsx12(Text12, { color: palette.dim, children: result.error ?? explainStatus(result.status) })
|
|
2276
|
+
] }),
|
|
2277
|
+
/* @__PURE__ */ jsx12(Text12, { color: palette.dim, children: "press enter to return" })
|
|
2278
|
+
] }),
|
|
2279
|
+
phase === "error" && error && /* @__PURE__ */ jsxs12(Box12, { flexDirection: "column", children: [
|
|
2280
|
+
/* @__PURE__ */ jsx12(Alert5, { variant: "error", children: error.message }),
|
|
2281
|
+
error.hint ? /* @__PURE__ */ jsx12(Text12, { color: palette.warn, children: ` ${error.hint}` }) : null,
|
|
2282
|
+
/* @__PURE__ */ jsx12(Text12, { color: palette.dim, children: "press enter to return" })
|
|
2283
|
+
] })
|
|
2284
|
+
] });
|
|
2285
|
+
}
|
|
2286
|
+
|
|
2287
|
+
// src/ui/screens/Clear.jsx
|
|
2288
|
+
import React13, { useState as useState7 } from "react";
|
|
2289
|
+
import { Box as Box13, Text as Text13, useInput as useInput9 } from "ink";
|
|
2290
|
+
import { Select as Select8, ConfirmInput as ConfirmInput3, Alert as Alert6, Badge as Badge5 } from "@inkjs/ui";
|
|
2291
|
+
|
|
2292
|
+
// src/core/cleanup.mjs
|
|
2293
|
+
import fs5 from "node:fs";
|
|
2294
|
+
import path5 from "node:path";
|
|
2295
|
+
var bytes = (n) => n < 1024 ? `${n} B` : n < 1024 * 1024 ? `${(n / 1024).toFixed(1)} kB` : `${(n / 1024 / 1024).toFixed(1)} MB`;
|
|
2296
|
+
function sizeOf(file) {
|
|
2297
|
+
try {
|
|
2298
|
+
return fs5.statSync(file).size;
|
|
2299
|
+
} catch {
|
|
2300
|
+
return 0;
|
|
2301
|
+
}
|
|
2302
|
+
}
|
|
2303
|
+
function inspect() {
|
|
2304
|
+
const payloadFiles = fs5.existsSync(OUT_DIR) ? fs5.readdirSync(OUT_DIR).filter((f) => f.endsWith(".json")) : [];
|
|
2305
|
+
const payloadBytes = payloadFiles.reduce((sum, f) => sum + sizeOf(path5.join(OUT_DIR, f)), 0);
|
|
2306
|
+
const logBytes = sizeOf(logPath);
|
|
2307
|
+
const configBytes = sizeOf(CONFIG_PATH);
|
|
2308
|
+
return {
|
|
2309
|
+
payloads: { count: payloadFiles.length, bytes: payloadBytes, label: `${payloadFiles.length} saved payload${payloadFiles.length === 1 ? "" : "s"} (${bytes(payloadBytes)})`, path: OUT_DIR },
|
|
2310
|
+
logs: { count: logBytes ? 1 : 0, bytes: logBytes, label: logBytes ? `activity log (${bytes(logBytes)})` : "no log file", path: logPath },
|
|
2311
|
+
config: { count: configBytes ? 1 : 0, bytes: configBytes, label: configBytes ? `config incl. Shopify token + webhook secret (${bytes(configBytes)})` : "no config file", path: CONFIG_PATH }
|
|
2312
|
+
};
|
|
2313
|
+
}
|
|
2314
|
+
function clearPayloads() {
|
|
2315
|
+
if (!fs5.existsSync(OUT_DIR)) return { removed: 0 };
|
|
2316
|
+
const files = fs5.readdirSync(OUT_DIR).filter((f) => f.endsWith(".json"));
|
|
2317
|
+
for (const f of files) fs5.rmSync(path5.join(OUT_DIR, f), { force: true });
|
|
2318
|
+
fs5.rmSync(path5.join(OUT_DIR, "last-body.json"), { force: true });
|
|
2319
|
+
fs5.rmSync(path5.join(STATE_DIR, "last-body.json"), { force: true });
|
|
2320
|
+
return { removed: files.length };
|
|
2321
|
+
}
|
|
2322
|
+
function clearLogs() {
|
|
2323
|
+
const existed = fs5.existsSync(logPath);
|
|
2324
|
+
fs5.rmSync(logPath, { force: true });
|
|
2325
|
+
return { removed: existed ? 1 : 0 };
|
|
2326
|
+
}
|
|
2327
|
+
function clearShopCache(cfg) {
|
|
2328
|
+
const had = Boolean(cfg.shopify.shop);
|
|
2329
|
+
cfg.shopify.shop = null;
|
|
2330
|
+
saveConfig(cfg);
|
|
2331
|
+
return { removed: had ? 1 : 0 };
|
|
2332
|
+
}
|
|
2333
|
+
function disconnectShopify(cfg) {
|
|
2334
|
+
cfg.shopify.domain = "";
|
|
2335
|
+
cfg.shopify.accessToken = "";
|
|
2336
|
+
cfg.shopify.shop = null;
|
|
2337
|
+
saveConfig(cfg);
|
|
2338
|
+
return { removed: 1 };
|
|
2339
|
+
}
|
|
2340
|
+
function clearConfig() {
|
|
2341
|
+
const existed = fs5.existsSync(CONFIG_PATH);
|
|
2342
|
+
fs5.rmSync(CONFIG_PATH, { force: true });
|
|
2343
|
+
return { removed: existed ? 1 : 0 };
|
|
2344
|
+
}
|
|
2345
|
+
function clearAll() {
|
|
2346
|
+
const payloads = clearPayloads();
|
|
2347
|
+
const logs = clearLogs();
|
|
2348
|
+
const config = clearConfig();
|
|
2349
|
+
return {
|
|
2350
|
+
payloads: payloads.removed,
|
|
2351
|
+
logs: logs.removed,
|
|
2352
|
+
config: config.removed,
|
|
2353
|
+
freshConfig: structuredClone(DEFAULT_CONFIG)
|
|
2354
|
+
};
|
|
2355
|
+
}
|
|
2356
|
+
var TARGETS = {
|
|
2357
|
+
payloads: { label: "Saved payloads", description: "the build history in the payloads directory", run: (cfg) => clearPayloads() },
|
|
2358
|
+
logs: { label: "Activity log", description: "the local run log", run: (cfg) => clearLogs() },
|
|
2359
|
+
shopCache: { label: "Cached store record", description: "the store name/currency snapshot \u2014 credentials kept", run: (cfg) => clearShopCache(cfg) },
|
|
2360
|
+
disconnect: { label: "Disconnect Shopify", description: "forget the store domain and access token", run: (cfg) => disconnectShopify(cfg) },
|
|
2361
|
+
all: { label: "Everything", description: "payloads, log and config \u2014 back to first run", run: () => clearAll() }
|
|
2362
|
+
};
|
|
2363
|
+
|
|
2364
|
+
// src/ui/screens/Clear.jsx
|
|
2365
|
+
import { jsx as jsx13, jsxs as jsxs13 } from "react/jsx-runtime";
|
|
2366
|
+
function Clear({ config, onDone }) {
|
|
2367
|
+
const [state] = useState7(() => inspect());
|
|
2368
|
+
const [choice, setChoice] = useState7(null);
|
|
2369
|
+
const [result, setResult] = useState7(null);
|
|
2370
|
+
useInput9((input, key) => {
|
|
2371
|
+
if (key.escape) onDone();
|
|
2372
|
+
if (result && (key.return || input === "q")) onDone(result.freshConfig ?? config);
|
|
2373
|
+
});
|
|
2374
|
+
const run2 = () => {
|
|
2375
|
+
const outcome = TARGETS[choice].run(config);
|
|
2376
|
+
setResult(outcome);
|
|
2377
|
+
};
|
|
2378
|
+
if (result) {
|
|
2379
|
+
return /* @__PURE__ */ jsxs13(Box13, { flexDirection: "column", children: [
|
|
2380
|
+
/* @__PURE__ */ jsx13(Header, { title: "Clear", subtitle: "done" }),
|
|
2381
|
+
/* @__PURE__ */ jsxs13(Box13, { marginBottom: 1, children: [
|
|
2382
|
+
/* @__PURE__ */ jsx13(Badge5, { color: "green", children: "CLEARED" }),
|
|
2383
|
+
/* @__PURE__ */ jsx13(Text13, { children: " " }),
|
|
2384
|
+
/* @__PURE__ */ jsx13(Text13, { color: palette.dim, children: choice === "all" ? `${result.payloads} payload(s), ${result.logs} log, ${result.config} config file removed` : `${result.removed} item(s) removed` })
|
|
2385
|
+
] }),
|
|
2386
|
+
choice === "all" ? /* @__PURE__ */ jsx13(Alert6, { variant: "info", children: "back to first-run state \u2014 reconnect Shopify to continue" }) : null,
|
|
2387
|
+
/* @__PURE__ */ jsx13(Box13, { marginTop: 1, children: /* @__PURE__ */ jsx13(Text13, { color: palette.dim, children: "press enter to return" }) })
|
|
2388
|
+
] });
|
|
2389
|
+
}
|
|
2390
|
+
if (choice) {
|
|
2391
|
+
const target = TARGETS[choice];
|
|
2392
|
+
return /* @__PURE__ */ jsxs13(Box13, { flexDirection: "column", children: [
|
|
2393
|
+
/* @__PURE__ */ jsx13(Header, { title: "Clear", subtitle: target.label, right: "esc to cancel" }),
|
|
2394
|
+
/* @__PURE__ */ jsx13(Alert6, { variant: "warning", children: `This permanently deletes ${target.description}.` }),
|
|
2395
|
+
/* @__PURE__ */ jsx13(Section, { title: "Will be removed", children: choice === "all" ? /* @__PURE__ */ jsx13(
|
|
2396
|
+
KeyValue,
|
|
2397
|
+
{
|
|
2398
|
+
keyWidth: 10,
|
|
2399
|
+
rows: [
|
|
2400
|
+
["payloads", state.payloads.label],
|
|
2401
|
+
["log", state.logs.label],
|
|
2402
|
+
["config", state.config.label]
|
|
2403
|
+
]
|
|
2404
|
+
}
|
|
2405
|
+
) : /* @__PURE__ */ jsx13(Text13, { color: palette.dim, children: ` ${glyph.dot} ${choice === "payloads" ? state.payloads.label : choice === "logs" ? state.logs.label : target.description}` }) }),
|
|
2406
|
+
choice === "all" || choice === "disconnect" ? /* @__PURE__ */ jsx13(Box13, { marginTop: 1, children: /* @__PURE__ */ jsx13(Text13, { color: palette.warn, children: " your Shopify access token will have to be entered again" }) }) : null,
|
|
2407
|
+
/* @__PURE__ */ jsxs13(Box13, { marginTop: 1, children: [
|
|
2408
|
+
/* @__PURE__ */ jsx13(Text13, { children: "Are you sure? " }),
|
|
2409
|
+
/* @__PURE__ */ jsx13(ConfirmInput3, { defaultChoice: "cancel", onConfirm: run2, onCancel: () => setChoice(null) })
|
|
2410
|
+
] })
|
|
2411
|
+
] });
|
|
2412
|
+
}
|
|
2413
|
+
return /* @__PURE__ */ jsxs13(Box13, { flexDirection: "column", children: [
|
|
2414
|
+
/* @__PURE__ */ jsx13(Header, { title: "Clear", subtitle: "remove locally cached data", right: "esc to go back" }),
|
|
2415
|
+
/* @__PURE__ */ jsxs13(Box13, { flexDirection: "column", marginBottom: 1, paddingX: 1, children: [
|
|
2416
|
+
/* @__PURE__ */ jsx13(Text13, { color: palette.dim, children: `${glyph.dot} ${state.payloads.label}` }),
|
|
2417
|
+
/* @__PURE__ */ jsx13(Text13, { color: palette.dim, children: `${glyph.dot} ${state.logs.label}` }),
|
|
2418
|
+
/* @__PURE__ */ jsx13(Text13, { color: palette.dim, children: `${glyph.dot} ${state.config.label}` })
|
|
2419
|
+
] }),
|
|
2420
|
+
/* @__PURE__ */ jsx13(
|
|
2421
|
+
Select8,
|
|
2422
|
+
{
|
|
2423
|
+
visibleOptionCount: 8,
|
|
2424
|
+
options: Object.entries(TARGETS).map(([key, t]) => ({
|
|
2425
|
+
label: `${t.label.padEnd(24)} ${t.description}`,
|
|
2426
|
+
value: key
|
|
2427
|
+
})),
|
|
2428
|
+
onChange: setChoice
|
|
2429
|
+
}
|
|
2430
|
+
)
|
|
2431
|
+
] });
|
|
2432
|
+
}
|
|
2433
|
+
|
|
2434
|
+
// src/ui/App.jsx
|
|
2435
|
+
import { jsx as jsx14 } from "react/jsx-runtime";
|
|
2436
|
+
function App({ config: initialConfig, initialScreen = "home" }) {
|
|
2437
|
+
const { exit } = useApp2();
|
|
2438
|
+
const [config, setConfig] = useState8(initialConfig);
|
|
2439
|
+
const [screen, setScreen] = useState8(initialScreen);
|
|
2440
|
+
const [provider, setProvider] = useState8("cashfree-occ");
|
|
2441
|
+
const [section, setSection] = useState8(null);
|
|
2442
|
+
const go = (next) => (updated) => {
|
|
2443
|
+
if (updated) setConfig(updated);
|
|
2444
|
+
setSection(null);
|
|
2445
|
+
setScreen(next);
|
|
2446
|
+
};
|
|
2447
|
+
const toHome = go("home");
|
|
2448
|
+
const toSetup = go("setup");
|
|
2449
|
+
const toIntegrations = go("integrations");
|
|
2450
|
+
switch (screen) {
|
|
2451
|
+
case "setup":
|
|
2452
|
+
return /* @__PURE__ */ jsx14(
|
|
2453
|
+
Setup,
|
|
2454
|
+
{
|
|
2455
|
+
config,
|
|
2456
|
+
onBack: toHome,
|
|
2457
|
+
onPick: (what) => {
|
|
2458
|
+
if (what === "customer") {
|
|
2459
|
+
setSection("Customer");
|
|
2460
|
+
setScreen("configure");
|
|
2461
|
+
} else if (what === "defaults") {
|
|
2462
|
+
setSection("Defaults");
|
|
2463
|
+
setScreen("configure");
|
|
2464
|
+
} else setScreen(what);
|
|
2465
|
+
}
|
|
2466
|
+
}
|
|
2467
|
+
);
|
|
2468
|
+
case "integrations":
|
|
2469
|
+
return /* @__PURE__ */ jsx14(
|
|
2470
|
+
Integrations,
|
|
2471
|
+
{
|
|
2472
|
+
config,
|
|
2473
|
+
onBack: toHome,
|
|
2474
|
+
onSetup: () => setScreen("setup"),
|
|
2475
|
+
onPick: (id) => {
|
|
2476
|
+
setProvider(id);
|
|
2477
|
+
setScreen("build");
|
|
2478
|
+
}
|
|
2479
|
+
}
|
|
2480
|
+
);
|
|
2481
|
+
case "connect":
|
|
2482
|
+
return /* @__PURE__ */ jsx14(ConnectShopify, { config, onDone: toSetup });
|
|
2483
|
+
case "webhook":
|
|
2484
|
+
return /* @__PURE__ */ jsx14(Webhook, { config, onDone: toSetup });
|
|
2485
|
+
case "configure":
|
|
2486
|
+
return /* @__PURE__ */ jsx14(Configure, { config, section, onDone: toSetup });
|
|
2487
|
+
case "build":
|
|
2488
|
+
return /* @__PURE__ */ jsx14(Build, { config, providerId: provider, onDone: toIntegrations });
|
|
2489
|
+
case "history":
|
|
2490
|
+
return /* @__PURE__ */ jsx14(History, { config, onDone: toHome });
|
|
2491
|
+
case "doctor":
|
|
2492
|
+
return /* @__PURE__ */ jsx14(Doctor, { config, onDone: toHome });
|
|
2493
|
+
case "clear":
|
|
2494
|
+
return /* @__PURE__ */ jsx14(Clear, { config, onDone: toHome });
|
|
2495
|
+
default:
|
|
2496
|
+
return /* @__PURE__ */ jsx14(Home, { config, onPick: setScreen, onQuit: exit });
|
|
2497
|
+
}
|
|
2498
|
+
}
|
|
2499
|
+
|
|
2500
|
+
// src/pretty.mjs
|
|
2501
|
+
var ESC = "\x1B";
|
|
2502
|
+
var isTTY = process.stdout.isTTY;
|
|
2503
|
+
var wrap = (open, close) => (s) => process.stdout.isTTY ? `${ESC}[${open}m${s}${ESC}[${close}m` : String(s);
|
|
2504
|
+
var c = {
|
|
2505
|
+
reset: (s) => s,
|
|
2506
|
+
bold: wrap(1, 22),
|
|
2507
|
+
dim: wrap(2, 22),
|
|
2508
|
+
italic: wrap(3, 23),
|
|
2509
|
+
underline: wrap(4, 24),
|
|
2510
|
+
red: wrap(31, 39),
|
|
2511
|
+
green: wrap(32, 39),
|
|
2512
|
+
yellow: wrap(33, 39),
|
|
2513
|
+
blue: wrap(34, 39),
|
|
2514
|
+
magenta: wrap(35, 39),
|
|
2515
|
+
cyan: wrap(36, 39),
|
|
2516
|
+
grey: wrap(90, 39),
|
|
2517
|
+
bgCyan: wrap(46, 49),
|
|
2518
|
+
bgRed: wrap(41, 49)
|
|
2519
|
+
};
|
|
2520
|
+
var sym = {
|
|
2521
|
+
tick: "\u2714",
|
|
2522
|
+
cross: "\u2716",
|
|
2523
|
+
warn: "\u25B2",
|
|
2524
|
+
info: "\u2139",
|
|
2525
|
+
arrow: "\u203A",
|
|
2526
|
+
bullet: "\u2022",
|
|
2527
|
+
pointer: "\u276F",
|
|
2528
|
+
radioOn: "\u25C9",
|
|
2529
|
+
radioOff: "\u25EF",
|
|
2530
|
+
boxOn: "\u25FC",
|
|
2531
|
+
boxOff: "\u25FB"
|
|
2532
|
+
};
|
|
2533
|
+
var plain = (s) => String(s).replace(/\x1b\[[0-9;]*m/g, "");
|
|
2534
|
+
var width = (s) => plain(s).length;
|
|
2535
|
+
function box(title, lines, colour = c.grey) {
|
|
2536
|
+
const body = Array.isArray(lines) ? lines : [lines];
|
|
2537
|
+
const w = Math.max(width(title) + 4, ...body.map((l) => width(l) + 2), 40);
|
|
2538
|
+
const pad = (s) => s + " ".repeat(Math.max(0, w - width(s)));
|
|
2539
|
+
console.log(colour("\u250C\u2500 ") + c.bold(title) + colour(" " + "\u2500".repeat(Math.max(0, w - width(title) - 3)) + "\u2510"));
|
|
2540
|
+
for (const l of body) console.log(colour("\u2502") + pad(" " + l) + colour("\u2502"));
|
|
2541
|
+
console.log(colour("\u2514" + "\u2500".repeat(w) + "\u2518"));
|
|
2542
|
+
}
|
|
2543
|
+
var log = {
|
|
2544
|
+
ok: (m) => console.log(`${c.green(sym.tick)} ${m}`),
|
|
2545
|
+
fail: (m) => console.log(`${c.red(sym.cross)} ${m}`),
|
|
2546
|
+
warn: (m) => console.log(`${c.yellow(sym.warn)} ${m}`),
|
|
2547
|
+
info: (m) => console.log(`${c.blue(sym.info)} ${m}`),
|
|
2548
|
+
step: (m) => console.log(`${c.cyan(sym.arrow)} ${m}`),
|
|
2549
|
+
dim: (m) => console.log(c.grey(` ${m}`)),
|
|
2550
|
+
blank: () => console.log()
|
|
2551
|
+
};
|
|
2552
|
+
function table(rows, headers) {
|
|
2553
|
+
if (!rows.length) return;
|
|
2554
|
+
const cols = headers || Object.keys(rows[0]);
|
|
2555
|
+
const widths = cols.map((col) => Math.max(width(col), ...rows.map((r) => width(String(r[col] ?? "")))));
|
|
2556
|
+
const line = (cells, colour = (s) => s) => " " + cells.map((cell, i) => colour(String(cell) + " ".repeat(Math.max(0, widths[i] - width(String(cell)))))).join(" ");
|
|
2557
|
+
console.log(line(cols, c.bold));
|
|
2558
|
+
console.log(" " + c.grey(widths.map((w) => "\u2500".repeat(w)).join(" ")));
|
|
2559
|
+
for (const r of rows) console.log(line(cols.map((col) => r[col] ?? "")));
|
|
2560
|
+
}
|
|
2561
|
+
|
|
2562
|
+
// src/headless/run.mjs
|
|
2563
|
+
async function headless(command, args, config) {
|
|
2564
|
+
switch (command) {
|
|
2565
|
+
case "build":
|
|
2566
|
+
return cmdBuild(args, config);
|
|
2567
|
+
case "send":
|
|
2568
|
+
return cmdSend(args, config);
|
|
2569
|
+
case "doctor":
|
|
2570
|
+
return cmdDoctor(args, config);
|
|
2571
|
+
case "history":
|
|
2572
|
+
return cmdHistory(args);
|
|
2573
|
+
case "clear":
|
|
2574
|
+
return cmdClear(args, config);
|
|
2575
|
+
case "configure":
|
|
2576
|
+
log.fail("configure needs an interactive terminal");
|
|
2577
|
+
log.dim(`edit ${config._path} directly, or run hookwright configure from a TTY`);
|
|
2578
|
+
return 1;
|
|
2579
|
+
default:
|
|
2580
|
+
log.fail(`unknown command "${command}"`);
|
|
2581
|
+
log.dim("try: build \xB7 send \xB7 doctor \xB7 history \xB7 configure \xB7 help");
|
|
2582
|
+
return 1;
|
|
2583
|
+
}
|
|
2584
|
+
}
|
|
2585
|
+
function requireConfig(config) {
|
|
2586
|
+
if (isConfigured(config)) return null;
|
|
2587
|
+
const missing = configStatus(config).filter((r) => !r.ok).map((r) => r.key);
|
|
2588
|
+
log.fail(`configuration incomplete: ${missing.join(", ")}`);
|
|
2589
|
+
log.dim(`run \`hookwright configure\` or edit ${config._path}`);
|
|
2590
|
+
return 1;
|
|
2591
|
+
}
|
|
2592
|
+
async function cmdBuild(args, config) {
|
|
2593
|
+
const bad = requireConfig(config);
|
|
2594
|
+
if (bad) return bad;
|
|
2595
|
+
const count = Number.parseInt(args.flags.items ?? "1", 10) || 1;
|
|
2596
|
+
const discount = Number.parseFloat(args.flags.discount ?? "0") || 0;
|
|
2597
|
+
const search = typeof args.flags.search === "string" ? args.flags.search : void 0;
|
|
2598
|
+
const checkImageUrls = args.flags.image !== false && args.flags["image-check"] !== false;
|
|
2599
|
+
log.step(`fetching products from ${config.shopify.domain}\u2026`);
|
|
2600
|
+
const products = await fetchCatalogue(config, { search });
|
|
2601
|
+
if (!products.length) {
|
|
2602
|
+
log.fail("Shopify returned no products");
|
|
2603
|
+
return 1;
|
|
2604
|
+
}
|
|
2605
|
+
const items = autoSelect(products, count);
|
|
2606
|
+
log.ok(`${items.length} product(s): ${items.map((i) => i.product.title).join(", ")}`);
|
|
2607
|
+
const built = await buildPayload2({
|
|
2608
|
+
cfg: config,
|
|
2609
|
+
items,
|
|
2610
|
+
discount,
|
|
2611
|
+
phone: typeof args.flags.phone === "string" ? args.flags.phone : void 0,
|
|
2612
|
+
checkImageUrls
|
|
2613
|
+
});
|
|
2614
|
+
if (args.flags.json) {
|
|
2615
|
+
console.log(JSON.stringify({ file: built.file, valid: built.report.ok, checks: built.report.checks, payload: built.payload }, null, 2));
|
|
2616
|
+
} else {
|
|
2617
|
+
printReport(built);
|
|
2618
|
+
if (args.flags.payload) {
|
|
2619
|
+
log.blank();
|
|
2620
|
+
log.step(c.bold("Payload"));
|
|
2621
|
+
console.log(JSON.stringify(built.payload, null, 2));
|
|
2622
|
+
}
|
|
2623
|
+
}
|
|
2624
|
+
if (args.flags.copy !== false && !args.flags.json) {
|
|
2625
|
+
const result = await copy(built.body);
|
|
2626
|
+
if (result.ok) log.ok(`payload copied to clipboard via ${result.via}`);
|
|
2627
|
+
else log.dim(`clipboard unavailable \u2014 install xclip, xsel or wl-copy to enable copying`);
|
|
2628
|
+
}
|
|
2629
|
+
if (!built.report.ok && !args.flags.force) {
|
|
2630
|
+
log.fail("pre-flight failed \u2014 not sending");
|
|
2631
|
+
return 2;
|
|
2632
|
+
}
|
|
2633
|
+
if (args.flags.send || args.flags["dry-run"]) {
|
|
2634
|
+
return cmdSend({ ...args, flags: { ...args.flags, _record: built } }, config, built);
|
|
2635
|
+
}
|
|
2636
|
+
log.blank();
|
|
2637
|
+
log.dim(`send it with: hookwright send${args.flags["dry-run"] ? " --dry-run" : ""}`);
|
|
2638
|
+
return 0;
|
|
2639
|
+
}
|
|
2640
|
+
async function cmdSend(args, config, built) {
|
|
2641
|
+
const bad = requireConfig(config);
|
|
2642
|
+
if (bad) return bad;
|
|
2643
|
+
let record = built ? { provider: built.provider.id, payload: built.payload, body: built.body } : null;
|
|
2644
|
+
if (!record && typeof args.flags.file === "string") record = loadPayload(args.flags.file);
|
|
2645
|
+
if (!record) record = loadLast();
|
|
2646
|
+
if (!record) {
|
|
2647
|
+
log.fail("nothing to send \u2014 build a payload first");
|
|
2648
|
+
return 1;
|
|
2649
|
+
}
|
|
2650
|
+
const result = await dispatch({
|
|
2651
|
+
cfg: config,
|
|
2652
|
+
record,
|
|
2653
|
+
force: Boolean(args.flags.force),
|
|
2654
|
+
dryRun: Boolean(args.flags["dry-run"])
|
|
2655
|
+
});
|
|
2656
|
+
if (result.dryRun) {
|
|
2657
|
+
log.blank();
|
|
2658
|
+
log.info("dry run \u2014 not sent");
|
|
2659
|
+
box("Request", [
|
|
2660
|
+
`POST ${result.request.url}`,
|
|
2661
|
+
`x-webhook-timestamp ${result.request.timestamp}`,
|
|
2662
|
+
`x-webhook-signature ${result.request.signature}`,
|
|
2663
|
+
`body ${record.body.length} bytes`
|
|
2664
|
+
], c.cyan);
|
|
2665
|
+
log.blank();
|
|
2666
|
+
console.log(c.grey(result.curl));
|
|
2667
|
+
return 0;
|
|
2668
|
+
}
|
|
2669
|
+
log.blank();
|
|
2670
|
+
if (result.ok) log.ok(`HTTP ${result.status} in ${result.durationMs}ms \u2014 ${explainStatus(result.status)}`);
|
|
2671
|
+
else log.fail(result.error ? `request failed: ${result.error}` : `HTTP ${result.status} \u2014 ${explainStatus(result.status)}`);
|
|
2672
|
+
if (result.body) log.dim(`response: ${String(result.body).slice(0, 300)}`);
|
|
2673
|
+
log.blank();
|
|
2674
|
+
console.log(c.grey(result.curl));
|
|
2675
|
+
return result.ok ? 0 : 3;
|
|
2676
|
+
}
|
|
2677
|
+
async function cmdDoctor(args, config) {
|
|
2678
|
+
const result = await run(config);
|
|
2679
|
+
const groups = [
|
|
2680
|
+
["Configuration", result.config],
|
|
2681
|
+
["Connectivity", result.connectivity],
|
|
2682
|
+
["Runtime", result.runtime]
|
|
2683
|
+
];
|
|
2684
|
+
for (const [title, checks] of groups) {
|
|
2685
|
+
log.blank();
|
|
2686
|
+
log.step(c.bold(title));
|
|
2687
|
+
for (const check of checks) {
|
|
2688
|
+
const mark = check.ok ? c.green(sym.tick) : c.red(sym.cross);
|
|
2689
|
+
console.log(` ${mark} ${c.bold(String(check.name).padEnd(20))} ${c.grey(check.detail)}`);
|
|
2690
|
+
}
|
|
2691
|
+
}
|
|
2692
|
+
log.blank();
|
|
2693
|
+
const failed = [...result.config, ...result.connectivity, ...result.runtime].filter((c2) => !c2.ok);
|
|
2694
|
+
if (args.flags.json) console.log(JSON.stringify(result, null, 2));
|
|
2695
|
+
return failed.length ? 1 : 0;
|
|
2696
|
+
}
|
|
2697
|
+
function cmdHistory(args) {
|
|
2698
|
+
const items = listPayloads(Number.parseInt(args.flags.limit ?? "20", 10) || 20);
|
|
2699
|
+
if (args.flags.json) {
|
|
2700
|
+
console.log(JSON.stringify(items, null, 2));
|
|
2701
|
+
return 0;
|
|
2702
|
+
}
|
|
2703
|
+
if (!items.length) {
|
|
2704
|
+
log.dim("no payloads built yet");
|
|
2705
|
+
return 0;
|
|
2706
|
+
}
|
|
2707
|
+
table(
|
|
2708
|
+
items.map((i) => ({
|
|
2709
|
+
when: i.savedAt.replace("T", " ").slice(0, 19),
|
|
2710
|
+
provider: i.provider,
|
|
2711
|
+
env: i.meta.environment ?? "\u2014",
|
|
2712
|
+
total: i.meta.totalPrice != null ? `${i.meta.totalPrice} ${i.meta.currency ?? ""}` : "\u2014",
|
|
2713
|
+
valid: i.meta.valid ? c.green(sym.tick) : c.red(sym.cross),
|
|
2714
|
+
file: i.file
|
|
2715
|
+
})),
|
|
2716
|
+
["when", "provider", "env", "total", "valid", "file"]
|
|
2717
|
+
);
|
|
2718
|
+
return 0;
|
|
2719
|
+
}
|
|
2720
|
+
function cmdClear(args, config) {
|
|
2721
|
+
const state = inspect();
|
|
2722
|
+
const target = args.flags.all ? "all" : args.flags.payloads ? "payloads" : args.flags.logs ? "logs" : args.flags.disconnect ? "disconnect" : args.flags["shop-cache"] ? "shopCache" : null;
|
|
2723
|
+
if (!target) {
|
|
2724
|
+
log.step("locally cached data");
|
|
2725
|
+
log.dim(state.payloads.label);
|
|
2726
|
+
log.dim(state.logs.label);
|
|
2727
|
+
log.dim(state.config.label);
|
|
2728
|
+
log.blank();
|
|
2729
|
+
log.info("choose what to remove:");
|
|
2730
|
+
log.dim("--payloads saved build history");
|
|
2731
|
+
log.dim("--logs activity log");
|
|
2732
|
+
log.dim("--shop-cache cached store record (credentials kept)");
|
|
2733
|
+
log.dim("--disconnect forget the Shopify domain and token");
|
|
2734
|
+
log.dim("--all payloads, log and config \u2014 back to first run");
|
|
2735
|
+
log.dim("add --yes to skip the confirmation");
|
|
2736
|
+
return 0;
|
|
2737
|
+
}
|
|
2738
|
+
if (!args.flags.yes) {
|
|
2739
|
+
log.warn(`this permanently deletes ${TARGETS[target].description}`);
|
|
2740
|
+
log.dim("re-run with --yes to confirm");
|
|
2741
|
+
return 1;
|
|
2742
|
+
}
|
|
2743
|
+
const result = TARGETS[target].run(config);
|
|
2744
|
+
if (target === "all") {
|
|
2745
|
+
log.ok(`removed ${result.payloads} payload(s), ${result.logs} log, ${result.config} config file \u2014 back to first-run state`);
|
|
2746
|
+
} else {
|
|
2747
|
+
log.ok(`${TARGETS[target].label}: ${result.removed} item(s) removed`);
|
|
2748
|
+
}
|
|
2749
|
+
return 0;
|
|
2750
|
+
}
|
|
2751
|
+
function printReport(built) {
|
|
2752
|
+
const d = built.payload.data;
|
|
2753
|
+
log.blank();
|
|
2754
|
+
box("Payload", [
|
|
2755
|
+
`type ${built.payload.type}`,
|
|
2756
|
+
`store ${d.store_url}`,
|
|
2757
|
+
`customer ${d.customer.shipping_address.customer_name} \xB7 ${d.phone}`,
|
|
2758
|
+
`items ${d.line_items.map((i) => `${i.name} x${i.quantity}`).join(", ")}`,
|
|
2759
|
+
`total ${d.total_price} ${d.line_items[0]?.currency} (was ${d.original_total_price}, discount ${d.total_discount})`,
|
|
2760
|
+
`saved ${built.file}`
|
|
2761
|
+
], c.green);
|
|
2762
|
+
log.blank();
|
|
2763
|
+
log.step(c.bold("Pre-flight"));
|
|
2764
|
+
for (const check of built.report.checks) {
|
|
2765
|
+
const mark = check.ok ? c.green(sym.tick) : check.warn ? c.yellow(sym.warn) : c.red(sym.cross);
|
|
2766
|
+
console.log(` ${mark} ${c.bold(check.name.padEnd(24))} ${c.grey(check.detail)}`);
|
|
2767
|
+
}
|
|
2768
|
+
log.blank();
|
|
2769
|
+
log.step(c.bold("Field coverage"));
|
|
2770
|
+
table(
|
|
2771
|
+
built.report.coverage.map((c2) => ({
|
|
2772
|
+
field: c2.field,
|
|
2773
|
+
"maps to": c2.maps_to,
|
|
2774
|
+
value: c2.status === "MISSING" ? c.red(c2.value) : c2.value
|
|
2775
|
+
})),
|
|
2776
|
+
["field", "maps to", "value"]
|
|
2777
|
+
);
|
|
2778
|
+
}
|
|
2779
|
+
|
|
2780
|
+
// src/cli.jsx
|
|
2781
|
+
import { jsx as jsx15 } from "react/jsx-runtime";
|
|
2782
|
+
var HELP = `
|
|
2783
|
+
${c.bold("hookwright")} \u2014 generate real, signed provider webhooks from a live Shopify catalogue
|
|
2784
|
+
|
|
2785
|
+
${c.bold("USAGE")}
|
|
2786
|
+
hookwright launch the interactive UI
|
|
2787
|
+
hookwright <command> [options] run headless (CI / scripting)
|
|
2788
|
+
|
|
2789
|
+
${c.bold("COMMANDS")}
|
|
2790
|
+
build build a payload from real Shopify products
|
|
2791
|
+
send sign and POST the last built payload
|
|
2792
|
+
doctor verify credentials and connectivity
|
|
2793
|
+
history list saved payloads
|
|
2794
|
+
clear remove locally cached data (payloads, log, config)
|
|
2795
|
+
configure launch the setup wizard
|
|
2796
|
+
help show this message
|
|
2797
|
+
|
|
2798
|
+
${c.bold("BUILD OPTIONS")}
|
|
2799
|
+
--items <n> how many products to put in the cart (default 1)
|
|
2800
|
+
--search <text> only consider products matching a title
|
|
2801
|
+
--discount <n> cart discount in major currency units (default 0)
|
|
2802
|
+
--phone <e164> override the configured test phone
|
|
2803
|
+
--send transmit immediately after building
|
|
2804
|
+
--dry-run print the request and curl, do not send
|
|
2805
|
+
--no-image-check skip HEAD-checking product image URLs
|
|
2806
|
+
--payload print the full generated payload
|
|
2807
|
+
--no-copy do not copy the payload to the clipboard
|
|
2808
|
+
--json machine-readable output
|
|
2809
|
+
|
|
2810
|
+
${c.bold("SEND OPTIONS")}
|
|
2811
|
+
--file <path> send a specific saved payload
|
|
2812
|
+
--dry-run print the request without sending
|
|
2813
|
+
--force send even if pre-flight fails or target looks like prod
|
|
2814
|
+
|
|
2815
|
+
${c.bold("CLEAR OPTIONS")}
|
|
2816
|
+
--payloads delete saved payloads
|
|
2817
|
+
--logs delete the activity log
|
|
2818
|
+
--shop-cache drop the cached store record, keep credentials
|
|
2819
|
+
--disconnect forget the Shopify domain and token
|
|
2820
|
+
--all payloads + log + config, back to first run
|
|
2821
|
+
--yes skip the confirmation
|
|
2822
|
+
|
|
2823
|
+
${c.bold("GLOBAL")}
|
|
2824
|
+
--config <path> use a different config file
|
|
2825
|
+
--verbose verbose logging
|
|
2826
|
+
--version print version
|
|
2827
|
+
|
|
2828
|
+
${c.grey(`config: ${CONFIG_PATH}`)}
|
|
2829
|
+
`;
|
|
2830
|
+
function parseArgs(argv) {
|
|
2831
|
+
const args = { _: [], flags: {} };
|
|
2832
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
2833
|
+
const token = argv[i];
|
|
2834
|
+
if (token.startsWith("--")) {
|
|
2835
|
+
const key = token.slice(2);
|
|
2836
|
+
const next = argv[i + 1];
|
|
2837
|
+
if (key.startsWith("no-")) {
|
|
2838
|
+
args.flags[key.slice(3)] = false;
|
|
2839
|
+
} else if (next && !next.startsWith("--")) {
|
|
2840
|
+
args.flags[key] = next;
|
|
2841
|
+
i += 1;
|
|
2842
|
+
} else {
|
|
2843
|
+
args.flags[key] = true;
|
|
2844
|
+
}
|
|
2845
|
+
} else {
|
|
2846
|
+
args._.push(token);
|
|
2847
|
+
}
|
|
2848
|
+
}
|
|
2849
|
+
return args;
|
|
2850
|
+
}
|
|
2851
|
+
async function main() {
|
|
2852
|
+
const args = parseArgs(process.argv.slice(2));
|
|
2853
|
+
const command = args._[0];
|
|
2854
|
+
if (args.flags.verbose) setVerbose(true);
|
|
2855
|
+
if (args.flags.version) {
|
|
2856
|
+
const { version } = await Promise.resolve().then(() => (init_package(), package_exports)).then((m) => m.default).catch(() => ({ version: "dev" }));
|
|
2857
|
+
console.log(version);
|
|
2858
|
+
return;
|
|
2859
|
+
}
|
|
2860
|
+
if (command === "help" || args.flags.help) {
|
|
2861
|
+
console.log(HELP);
|
|
2862
|
+
return;
|
|
2863
|
+
}
|
|
2864
|
+
const config = loadConfig(args.flags.config);
|
|
2865
|
+
if (command && command !== "ui") {
|
|
2866
|
+
if (command === "configure" && process.stdin.isTTY) {
|
|
2867
|
+
render(/* @__PURE__ */ jsx15(App, { config, initialScreen: "connect" }));
|
|
2868
|
+
return;
|
|
2869
|
+
}
|
|
2870
|
+
const code = await headless(command, args, config);
|
|
2871
|
+
process.exitCode = code;
|
|
2872
|
+
return;
|
|
2873
|
+
}
|
|
2874
|
+
if (!process.stdin.isTTY) {
|
|
2875
|
+
console.log(HELP);
|
|
2876
|
+
console.error(c.yellow("\nno TTY detected \u2014 use a headless command (build / send / doctor / history)"));
|
|
2877
|
+
process.exitCode = 1;
|
|
2878
|
+
return;
|
|
2879
|
+
}
|
|
2880
|
+
render(/* @__PURE__ */ jsx15(App, { config, initialScreen: "home" }));
|
|
2881
|
+
}
|
|
2882
|
+
main().catch((err) => {
|
|
2883
|
+
console.error(c.red(`
|
|
2884
|
+
${err.message}`));
|
|
2885
|
+
if (err.hint) console.error(c.yellow(` ${err.hint}`));
|
|
2886
|
+
process.exitCode = 1;
|
|
2887
|
+
});
|
|
2888
|
+
export {
|
|
2889
|
+
parseArgs
|
|
2890
|
+
};
|