@prys/seed 0.0.0-stage → 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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Prys
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,94 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
1
+ # @prys/seed
2
+
3
+ Backfill for the [Prys](https://prys.io) **Customer CRM**: fills in shop details (Shopify plan, country, store age,
4
+ store contacts) for the stores that installed your Shopify app before you added the Prys identify call.
5
+
6
+ It runs on your side, with your app's own Shopify client. Your access tokens never leave your server; Prys receives
7
+ only the answer to the same shop query as the Customer CRM's step 1 snippet.
8
+
9
+ ```bash
10
+ npx @prys/seed --key=<your Prys key>
11
+ ```
12
+
13
+ Your key is in Prys > Customer CRM > Enrichment (step 1, "Show key").
14
+
15
+ ## What it does
16
+
17
+ 1. Loads `./app/shopify.server` (the Shopify React Router / Remix template's `shopifyApp()` file, `.ts` or `.js`),
18
+ so your own session storage and token handling are used.
19
+ 2. Asks Prys which stores have your app installed (`GET /installs`).
20
+ 3. Looks up your app's offline session for each store. Stores without one are skipped; they fill in when the merchant
21
+ next opens your app (your step 1 call).
22
+ 4. Shows the counts and a sample, then asks:
23
+
24
+ ```
25
+ › loading ./app/shopify.server (your own Shopify config)
26
+ › asking Prys which stores are installed: 8,240
27
+ › 8,102 have an offline session your app can use
28
+ › 138 have none yet; they fill in as they open the app
29
+ › sample: hyperspeed-shop, fable-books, tide-swim
30
+
31
+ ? Read these 8,102 with your app's client and send to Prys? (y/N)
32
+ ```
33
+
34
+ **Nothing is read from Shopify or sent to Prys until you answer `y`.** Enter alone means No.
35
+ 5. For each store it calls `unauthenticated.admin(shop)`, so your library loads, decrypts and refreshes the token exactly
36
+ as your app does, runs the step 1 shop query and sends the `data` objects to Prys in batches of 200
37
+ (`POST /identify/bulk`), printing what Prys received, stored and rejected per batch:
38
+
39
+ ```
40
+ › batch 1: received 200, stored 198, rejected 2 (not_installed 1, dev_store 1)
41
+ ```
42
+
43
+ Rejections are normal: `not_installed` (Prys's Partner data shows the store uninstalled), `dev_store` (development
44
+ stores are kept out of the CRM). "Already sent in the last 24 h" means Prys has that store fresh from your step 1 call.
45
+
46
+ ## Where to run it
47
+
48
+ From your app's project folder, on a machine where your app runs or can reach the same session storage: the same
49
+ environment variables your app needs (`SHOPIFY_API_KEY`, `SHOPIFY_API_SECRET`, `SHOPIFY_APP_URL`, `SCOPES`, your
50
+ database address) and after `npm install`. A `.env` file in the folder is read for any variable that is not already set.
51
+
52
+ Your `shopify.server` file must export `unauthenticated` and `sessionStorage`, as the template does:
53
+
54
+ ```ts
55
+ export const unauthenticated = shopify.unauthenticated;
56
+ export const sessionStorage = shopify.sessionStorage;
57
+ ```
58
+
59
+ ## Options
60
+
61
+ | Option | |
62
+ |---|---|
63
+ | `--key=<key>` | Your app's Prys key. Or set `PRYS_KEY` (keeps it out of your shell history). |
64
+ | `--base-url=<url>` | Prys API address. Default `https://admin.prys.io/api/v1`. Plain `http://` only for localhost. |
65
+ | `--config=<path>` | Your `shopifyApp()` file, if it is not `./app/shopify.server.{ts,js}`. |
66
+ | `--concurrency=<n>` | Stores read at once, 1 to 20 (default 4). Each store has its own Shopify limit. |
67
+ | `--help`, `--version` | |
68
+
69
+ Prys allows 600 calls a minute per key; when it answers `rate_limited` the CLI waits as told and sends the same batch
70
+ again. The key is never printed.
71
+
72
+ ## Other frameworks
73
+
74
+ Node / Express, Laravel, Rails and other backends: Prys > Customer CRM > Enrichment > step 2 shows a short script
75
+ for each, using the same two calls (`GET /installs`, `POST /identify/bulk`).
76
+
77
+ ## Requirements
78
+
79
+ Node.js 18.18 or newer. TypeScript config files are loaded with [tsx](https://tsx.is) (installed with this package).
80
+
81
+ ## Development
82
+
83
+ ```bash
84
+ npm install
85
+ npm test
86
+ npm run check-query -- <front-end-crm>/app/src/sections/Dashboards/CustomerCrm/Enrichment/snippetTemplates.ts
87
+ ```
88
+
89
+ `npm test` runs the CLI against a stub Prys server and a fake Shopify app (TypeScript and JavaScript
90
+ `shopify.server`). `check-query` fails when the shop query here and the CRM screen's step 1 query drift apart.
91
+
92
+ ## License
93
+
94
+ MIT
@@ -0,0 +1,25 @@
1
+ #!/usr/bin/env node
2
+ import { run } from "../src/cli.js";
3
+ import { redact } from "../src/prys.js";
4
+
5
+ const argv = process.argv.slice(2);
6
+ const keyArg = argv.find((a) => a.startsWith("--key="))?.slice(6) ?? (argv.includes("--key") ? argv[argv.indexOf("--key") + 1] : "") ?? "";
7
+ const scrub = (text) => [keyArg, process.env.PRYS_KEY].filter((k) => k && k.length > 3).reduce((t, k) => redact(t, k), String(text));
8
+
9
+ // Exit on its own when nothing is left; the app's own modules (database clients, Redis) may keep the process alive,
10
+ // so a short unref'd timer ends it then. (An immediate process.exit() right after reading a piped stdin can trip a
11
+ // libuv assertion on Windows.)
12
+ function finish(code) {
13
+ process.exitCode = code;
14
+ try {
15
+ process.stdin.destroy();
16
+ } catch {
17
+ // stdin already closed
18
+ }
19
+ setTimeout(() => process.exit(code), 200).unref();
20
+ }
21
+
22
+ run(argv).then(finish, (err) => {
23
+ process.stderr.write(` ✖ Unexpected failure: ${scrub(err?.message ?? err)}\n`);
24
+ finish(1);
25
+ });
package/package.json CHANGED
@@ -1,6 +1,38 @@
1
- {
2
- "name": "@prys/seed",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
1
+ {
2
+ "name": "@prys/seed",
3
+ "version": "1.0.0",
4
+ "description": "Backfill Prys Customer CRM: reads your installed stores' shop details with your Shopify app's own client and sends them to Prys.",
5
+ "keywords": [
6
+ "prys",
7
+ "shopify",
8
+ "shopify-app",
9
+ "crm",
10
+ "backfill"
11
+ ],
12
+ "homepage": "https://prys.io",
13
+ "license": "MIT",
14
+ "author": "Prys <connect@prys.io>",
15
+ "type": "module",
16
+ "bin": {
17
+ "prys-seed": "bin/prys-seed.js"
18
+ },
19
+ "files": [
20
+ "bin/",
21
+ "src/",
22
+ "README.md",
23
+ "LICENSE"
24
+ ],
25
+ "engines": {
26
+ "node": ">=18.18"
27
+ },
28
+ "scripts": {
29
+ "test": "node --test test/*.test.js",
30
+ "check-query": "node scripts/check-query.js"
31
+ },
32
+ "dependencies": {
33
+ "tsx": "^4.20.0"
34
+ },
35
+ "publishConfig": {
36
+ "access": "public"
37
+ }
38
+ }
package/src/app.js ADDED
@@ -0,0 +1,94 @@
1
+ // Loads the app's own Shopify config (the Shopify React Router / Remix template's app/shopify.server.{ts,js}), so the
2
+ // backfill uses the app's own session storage and token handling: unauthenticated.admin(shop) loads, decrypts and
3
+ // refreshes the offline token exactly as the app does.
4
+ import { existsSync, readFileSync } from "node:fs";
5
+ import path from "node:path";
6
+ import { pathToFileURL } from "node:url";
7
+
8
+ export const CONFIG_CANDIDATES = [
9
+ "app/shopify.server.ts",
10
+ "app/shopify.server.js",
11
+ "app/shopify.server.mts",
12
+ "app/shopify.server.mjs",
13
+ "app/shopify.server.tsx",
14
+ "app/shopify.server.jsx",
15
+ ];
16
+
17
+ export class AppConfigError extends Error {
18
+ constructor(message) {
19
+ super(message);
20
+ this.name = "AppConfigError";
21
+ }
22
+ }
23
+
24
+ export function findConfig(cwd, explicit) {
25
+ if (explicit) {
26
+ const file = path.resolve(cwd, explicit);
27
+ if (!existsSync(file)) throw new AppConfigError(`--config file not found: ${explicit}`);
28
+ return file;
29
+ }
30
+ for (const candidate of CONFIG_CANDIDATES) {
31
+ const file = path.join(cwd, candidate);
32
+ if (existsSync(file)) return file;
33
+ }
34
+ throw new AppConfigError(
35
+ "No app/shopify.server.ts or .js here. Run this from your Shopify app's project folder (React Router / Remix template), " +
36
+ "or pass --config=<path to the file that calls shopifyApp()>.",
37
+ );
38
+ }
39
+
40
+ /** Reads KEY=VALUE lines from .env into process.env without overriding what is already set (the host's values win). */
41
+ export function loadDotEnv(cwd) {
42
+ const file = path.join(cwd, ".env");
43
+ if (!existsSync(file)) return false;
44
+ for (const line of readFileSync(file, "utf8").split(/\r?\n/)) {
45
+ const m = line.match(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/);
46
+ if (!m || m[1] in process.env) continue;
47
+ let value = m[2];
48
+ if (/^(['"]).*\1$/.test(value)) value = value.slice(1, -1);
49
+ else value = value.replace(/\s+#.*$/, "");
50
+ process.env[m[1]] = value;
51
+ }
52
+ return true;
53
+ }
54
+
55
+ function pick(mod, name) {
56
+ return mod?.[name] ?? mod?.default?.[name] ?? mod?.default?.default?.[name];
57
+ }
58
+
59
+ /** Imports the config (TypeScript through tsx, so extensionless and `~/` imports resolve as in the app) and checks it. */
60
+ export async function loadApp({ cwd = process.cwd(), config } = {}) {
61
+ const file = findConfig(cwd, config);
62
+ loadDotEnv(cwd);
63
+ let mod;
64
+ try {
65
+ const { tsImport } = await import("tsx/esm/api");
66
+ mod = await tsImport(pathToFileURL(file).href, { parentURL: import.meta.url });
67
+ } catch (err) {
68
+ throw new AppConfigError(
69
+ `Could not load ${path.relative(cwd, file) || file}: ${err?.message ?? err}\n` +
70
+ " Run it where your app runs (same folder, same environment variables: SHOPIFY_API_KEY, SHOPIFY_API_SECRET, " +
71
+ "SHOPIFY_APP_URL, SCOPES, your database address), after npm install.",
72
+ );
73
+ }
74
+ const unauthenticated = pick(mod, "unauthenticated");
75
+ const sessionStorage = pick(mod, "sessionStorage");
76
+ if (typeof unauthenticated?.admin !== "function") {
77
+ throw new AppConfigError(`${path.basename(file)} does not export unauthenticated (export const unauthenticated = shopify.unauthenticated;)`);
78
+ }
79
+ if (typeof sessionStorage?.loadSession !== "function") {
80
+ throw new AppConfigError(`${path.basename(file)} does not export sessionStorage (export const sessionStorage = shopify.sessionStorage;)`);
81
+ }
82
+ return { file, unauthenticated, sessionStorage };
83
+ }
84
+
85
+ /** The app's offline session for a shop (Shopify's offline id is `offline_<shop>`), or null. */
86
+ export async function findOfflineSession(sessionStorage, shop) {
87
+ const session = await sessionStorage.loadSession(`offline_${shop}`);
88
+ if (session?.accessToken) return session;
89
+ if (typeof sessionStorage.findSessionsByShop === "function") {
90
+ const sessions = (await sessionStorage.findSessionsByShop(shop)) ?? [];
91
+ return sessions.find((s) => s && !s.isOnline && s.accessToken) ?? null;
92
+ }
93
+ return null;
94
+ }
package/src/cli.js ADDED
@@ -0,0 +1,246 @@
1
+ // npx @prys/seed --key=<your Prys key>
2
+ // Customer CRM, Enrichment step 2 "Backfill existing customers" (React Router / Remix tab). Nothing leaves the server
3
+ // before the person running it answers y.
4
+ import { readFileSync } from "node:fs";
5
+ import { parseArgs } from "node:util";
6
+ import { AppConfigError, findOfflineSession, loadApp } from "./app.js";
7
+ import { mapLimit, sleep as realSleep } from "./pool.js";
8
+ import { BULK_MAX, DEFAULT_BASE_URL, PrysError, createPrys, redact } from "./prys.js";
9
+ import { readFailure, readShop } from "./read.js";
10
+
11
+ const VERSION = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
12
+
13
+ const HELP = `Usage: npx @prys/seed --key=<your Prys key> [options]
14
+
15
+ Run it from your Shopify app's project folder (React Router / Remix template), where the app runs.
16
+ It asks Prys which stores have your app installed, reads each store's shop details with your app's own
17
+ client (unauthenticated.admin), and, after you answer y, sends them to Prys in batches of ${BULK_MAX}.
18
+
19
+ Options:
20
+ --key=<key> Your app's Prys key (Customer CRM > Enrichment). Or set PRYS_KEY.
21
+ --base-url=<url> Prys API address (default ${DEFAULT_BASE_URL}).
22
+ --config=<path> Your shopifyApp() file (default ./app/shopify.server.ts or .js).
23
+ --concurrency=<n> Stores read at once (default 4, max 20).
24
+ -h, --help This text.
25
+ -v, --version The version.
26
+ `;
27
+
28
+ const fmt = (n) => Number(n).toLocaleString("en-US");
29
+ const shortName = (domain) => domain.replace(/\.myshopify\.com$/, "");
30
+ // Prys counts a shop it identified in the last 24 h as sent without writing it again (IN-2): received - stored - rejected.
31
+ const recentNote = (n) => (n > 0 ? `; ${fmt(n)} already sent in the last 24 h` : "");
32
+ const tally = (map, key) => map.set(key, (map.get(key) ?? 0) + 1);
33
+ const listTally = (map) =>
34
+ [...map.entries()]
35
+ .sort((a, b) => b[1] - a[1])
36
+ .map(([k, v]) => `${k} ${fmt(v)}`)
37
+ .join(", ");
38
+
39
+ /** One y/N question; anything but y or yes (or a closed input) is No. */
40
+ export function ask(stdin, stdout, question) {
41
+ return new Promise((resolve) => {
42
+ stdout.write(question);
43
+ let buffer = "";
44
+ const done = (answer) => {
45
+ stdin.off("data", onData);
46
+ stdin.off("end", onEnd);
47
+ if (typeof stdin.pause === "function") stdin.pause();
48
+ // A terminal echoes the answer and the Enter; a piped answer does not, so end the question's line here.
49
+ if (!stdin.isTTY) stdout.write(`${answer.trim()}\n`);
50
+ resolve(/^\s*(y|yes)\s*$/i.test(answer));
51
+ };
52
+ const onData = (chunk) => {
53
+ buffer += chunk.toString();
54
+ const nl = buffer.indexOf("\n");
55
+ if (nl >= 0) done(buffer.slice(0, nl));
56
+ };
57
+ const onEnd = () => done(buffer);
58
+ stdin.on("data", onData);
59
+ stdin.on("end", onEnd);
60
+ if (typeof stdin.resume === "function") stdin.resume();
61
+ });
62
+ }
63
+
64
+ export async function run(argv, io = {}) {
65
+ const {
66
+ cwd = process.cwd(),
67
+ env = process.env,
68
+ stdin = process.stdin,
69
+ stdout = process.stdout,
70
+ stderr = process.stderr,
71
+ fetchImpl = globalThis.fetch,
72
+ sleep = realSleep,
73
+ load = loadApp,
74
+ } = io;
75
+ let key = null;
76
+ const say = (line = "") => stdout.write(`${line ? ` › ${redact(line, key)}` : ""}\n`);
77
+ const fail = (line) => {
78
+ stderr.write(` ✖ ${redact(line, key)}\n`);
79
+ return 1;
80
+ };
81
+
82
+ let args;
83
+ try {
84
+ args = parseArgs({
85
+ args: argv,
86
+ options: {
87
+ key: { type: "string" },
88
+ "base-url": { type: "string" },
89
+ config: { type: "string" },
90
+ concurrency: { type: "string" },
91
+ help: { type: "boolean", short: "h" },
92
+ version: { type: "boolean", short: "v" },
93
+ },
94
+ strict: true,
95
+ }).values;
96
+ } catch (err) {
97
+ // parseArgs may quote the bad argument; it may hold the key (e.g. -key=...), so never echo it.
98
+ return fail(`${String(err.message).replace(/'[^']*'/g, "'…'")}\n${HELP}`);
99
+ }
100
+ if (args.help) {
101
+ stdout.write(HELP);
102
+ return 0;
103
+ }
104
+ if (args.version) {
105
+ stdout.write(`${VERSION}\n`);
106
+ return 0;
107
+ }
108
+ key = (args.key || env.PRYS_KEY || "").trim();
109
+ if (!key) return fail(`Missing --key (your app's Prys key, from Customer CRM > Enrichment).\n${HELP}`);
110
+ if (/\s/.test(key)) return fail("--key has a space in it; copy the key again.");
111
+ const concurrency = args.concurrency === undefined ? 4 : Number(args.concurrency);
112
+ if (!Number.isInteger(concurrency) || concurrency < 1 || concurrency > 20) return fail("--concurrency must be a whole number from 1 to 20");
113
+
114
+ let prys;
115
+ try {
116
+ prys = createPrys({
117
+ baseUrl: args["base-url"] || env.PRYS_BASE_URL || DEFAULT_BASE_URL,
118
+ key,
119
+ fetchImpl,
120
+ sleep,
121
+ onWait: (seconds, why) => say(`Prys: ${why}; waiting ${fmt(seconds)} s`),
122
+ });
123
+ } catch (err) {
124
+ return fail(err.message);
125
+ }
126
+
127
+ // 1. The app's own Shopify config.
128
+ let app;
129
+ try {
130
+ const shown = args.config ? args.config : "./app/shopify.server";
131
+ say(`loading ${shown} (your own Shopify config)`);
132
+ app = await load({ cwd, config: args.config });
133
+ } catch (err) {
134
+ return fail(err instanceof AppConfigError ? err.message : `Could not load your Shopify config: ${err?.message ?? err}`);
135
+ }
136
+
137
+ // 2. Which stores Prys sees installed.
138
+ let installs;
139
+ try {
140
+ installs = await prys.installs();
141
+ } catch (err) {
142
+ return fail(err instanceof PrysError ? err.message : `Could not ask Prys: ${err?.message ?? err}`);
143
+ }
144
+ say(`asking Prys which stores are installed: ${fmt(installs.domains.length)}${prys.base === DEFAULT_BASE_URL ? "" : ` (${prys.base})`}`);
145
+ if (installs.domains.length === 0) {
146
+ say("Prys lists no installed stores for this app yet. Nothing to send.");
147
+ return 0;
148
+ }
149
+
150
+ // 3. Which of them the app holds an offline session for. Nothing is read from Shopify yet.
151
+ const withSession = [];
152
+ let lookupErrors = 0;
153
+ await mapLimit(installs.domains, 10, async (shop) => {
154
+ try {
155
+ if (await findOfflineSession(app.sessionStorage, shop)) withSession.push(shop);
156
+ } catch {
157
+ lookupErrors++;
158
+ }
159
+ });
160
+ const has = new Set(withSession);
161
+ const ordered = installs.domains.filter((d) => has.has(d));
162
+ const without = installs.domains.length - ordered.length;
163
+ say(`${fmt(ordered.length)} have an offline session your app can use`);
164
+ if (without > 0) say(`${fmt(without)} have none yet; they fill in as they open the app`);
165
+ if (lookupErrors > 0) say(`${fmt(lookupErrors)} could not be looked up in your session storage (counted as none)`);
166
+ if (ordered.length === 0) {
167
+ say("No store to read. Nothing was sent.");
168
+ return 0;
169
+ }
170
+ say(`sample: ${ordered.slice(0, 3).map(shortName).join(", ")}`);
171
+ say();
172
+
173
+ // 4. The person's yes.
174
+ const yes = await ask(stdin, stdout, ` ? Read these ${fmt(ordered.length)} with your app's client and send to Prys? (y/N) `);
175
+ if (!yes) {
176
+ say("Nothing was read or sent.");
177
+ return 0;
178
+ }
179
+
180
+ // 5. Read with the app's client, send in batches of 200.
181
+ const readFailures = new Map();
182
+ const rejectedReasons = new Map();
183
+ const totals = { read: 0, received: 0, stored: 0, rejected: 0, batches: 0, sent: 0 };
184
+ let buffer = [];
185
+ let chain = Promise.resolve();
186
+ let fatal = null;
187
+
188
+ const send = async (batch) => {
189
+ const n = ++totals.batches;
190
+ const r = await prys.identifyBulk(batch);
191
+ totals.sent++;
192
+ totals.received += r.received;
193
+ totals.stored += r.stored;
194
+ totals.rejected += r.rejected.length;
195
+ const reasons = new Map();
196
+ for (const rej of r.rejected) {
197
+ tally(reasons, rej?.reason ?? "unknown");
198
+ tally(rejectedReasons, rej?.reason ?? "unknown");
199
+ }
200
+ say(
201
+ `batch ${fmt(n)}: received ${fmt(r.received)}, stored ${fmt(r.stored)}, rejected ${fmt(r.rejected.length)}` +
202
+ (reasons.size ? ` (${listTally(reasons)})` : "") +
203
+ recentNote(r.received - r.stored - r.rejected.length),
204
+ );
205
+ };
206
+ const flush = (all) => {
207
+ while (buffer.length >= BULK_MAX || (all && buffer.length > 0)) {
208
+ const batch = buffer.splice(0, BULK_MAX);
209
+ chain = chain.then(() => (fatal ? undefined : send(batch))).catch((err) => {
210
+ fatal = fatal ?? err;
211
+ });
212
+ }
213
+ return chain;
214
+ };
215
+
216
+ await mapLimit(ordered, concurrency, async (shop) => {
217
+ if (fatal) return;
218
+ try {
219
+ const data = await readShop(app.unauthenticated, shop);
220
+ totals.read++;
221
+ buffer.push(data);
222
+ } catch (err) {
223
+ tally(readFailures, readFailure(err));
224
+ return;
225
+ }
226
+ await flush(false);
227
+ });
228
+ await flush(true);
229
+
230
+ const failedReads = [...readFailures.values()].reduce((a, b) => a + b, 0);
231
+ if (fatal) {
232
+ say(`stopped after ${fmt(totals.sent)} batch${totals.sent === 1 ? "" : "es"}: Prys received ${fmt(totals.received)}, stored ${fmt(totals.stored)}`);
233
+ return fail(fatal instanceof PrysError ? fatal.message : `Sending to Prys failed: ${fatal?.message ?? fatal}`);
234
+ }
235
+ say();
236
+ say(`read ${fmt(totals.read)} of ${fmt(ordered.length)} stores` + (failedReads ? `; ${fmt(failedReads)} could not be read (${listTally(readFailures)})` : ""));
237
+ say(
238
+ `Prys received ${fmt(totals.received)}, stored ${fmt(totals.stored)}, rejected ${fmt(totals.rejected)}` +
239
+ (rejectedReasons.size ? ` (${listTally(rejectedReasons)})` : "") +
240
+ recentNote(totals.received - totals.stored - totals.rejected),
241
+ );
242
+ say("done. The Customer CRM fills in as Prys processes them; the rest fill in as merchants open your app.");
243
+ return 0;
244
+ }
245
+
246
+ export const helpText = HELP;
package/src/pool.js ADDED
@@ -0,0 +1,15 @@
1
+ /** Runs `fn` over `items` with at most `limit` calls at once, keeping the input order in the result. */
2
+ export async function mapLimit(items, limit, fn) {
3
+ const results = new Array(items.length);
4
+ let next = 0;
5
+ const worker = async () => {
6
+ while (next < items.length) {
7
+ const i = next++;
8
+ results[i] = await fn(items[i], i);
9
+ }
10
+ };
11
+ await Promise.all(Array.from({ length: Math.max(1, Math.min(limit, items.length)) }, worker));
12
+ return results;
13
+ }
14
+
15
+ export const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
package/src/prys.js ADDED
@@ -0,0 +1,152 @@
1
+ // The Prys public ingest API, as the Customer CRM contract writes it (CONTRACT 3.2):
2
+ // IN-3 GET <base>/installs -> InstallsResult { count, domains }
3
+ // IN-2 POST <base>/identify/bulk BulkIdentifyBody { shops: [...] } (max 200) -> BulkIdentifyResult { received, stored, rejected }
4
+ // Every answer is the Prys API envelope { status, errors, response }; errors is { code: [messages] }.
5
+ // The three routes share 600 calls a minute per key; over it Prys answers 429 `rate_limited` with response.retryAfter (seconds).
6
+ import { sleep as realSleep } from "./pool.js";
7
+
8
+ export const DEFAULT_BASE_URL = "https://admin.prys.io/api/v1";
9
+ export const BULK_MAX = 200;
10
+ export const CLIENT_NAME = "node";
11
+
12
+ const LOCAL_HOST = /^(localhost|127\.0\.0\.1|\[::1\]|.+\.localhost|.+\.test)$/i;
13
+
14
+ export class PrysError extends Error {
15
+ constructor(message, { status = 0, code = null } = {}) {
16
+ super(message);
17
+ this.name = "PrysError";
18
+ this.status = status;
19
+ this.code = code;
20
+ }
21
+ }
22
+
23
+ /** Checks and normalises --base-url. The key travels in a header, so plain http is allowed only for a local Prys. */
24
+ export function normaliseBaseUrl(raw) {
25
+ let url;
26
+ try {
27
+ url = new URL(raw);
28
+ } catch {
29
+ throw new PrysError(`--base-url is not an address: ${raw}`);
30
+ }
31
+ if (url.protocol !== "https:" && !(url.protocol === "http:" && LOCAL_HOST.test(url.hostname))) {
32
+ throw new PrysError(`--base-url must start with https:// (plain http only for localhost): ${url.origin}`);
33
+ }
34
+ if (url.search || url.hash) throw new PrysError("--base-url must not carry ? or #");
35
+ return url.href.replace(/\/+$/, "");
36
+ }
37
+
38
+ /** Removes the key from any text before it is printed. */
39
+ export function redact(text, key) {
40
+ if (!key) return String(text);
41
+ return String(text).split(key).join("***");
42
+ }
43
+
44
+ function firstError(errors) {
45
+ if (!errors || typeof errors !== "object") return { code: null, message: null };
46
+ const code = Object.keys(errors)[0] ?? null;
47
+ const value = code ? errors[code] : null;
48
+ const message = Array.isArray(value) ? value[0] : typeof value === "string" ? value : null;
49
+ return { code, message };
50
+ }
51
+
52
+ export function createPrys({ baseUrl = DEFAULT_BASE_URL, key, fetchImpl = globalThis.fetch, sleep = realSleep, onWait = () => {} }) {
53
+ if (!key) throw new PrysError("Missing --key");
54
+ const base = normaliseBaseUrl(baseUrl);
55
+
56
+ async function once(method, path, body) {
57
+ let res;
58
+ try {
59
+ res = await fetchImpl(`${base}${path}`, {
60
+ method,
61
+ headers: {
62
+ Accept: "application/json",
63
+ Authorization: `Bearer ${key}`,
64
+ "X-Prys-Client": CLIENT_NAME,
65
+ ...(body ? { "Content-Type": "application/json" } : {}),
66
+ },
67
+ body: body ? JSON.stringify(body) : undefined,
68
+ });
69
+ } catch (err) {
70
+ return { network: true, message: redact(err?.cause?.code || err?.message || "network error", key) };
71
+ }
72
+ const text = await res.text();
73
+ let json = null;
74
+ try {
75
+ json = text ? JSON.parse(text) : null;
76
+ } catch {
77
+ json = null;
78
+ }
79
+ const envelope = json && typeof json === "object" && "response" in json ? json : null;
80
+ const data = envelope ? envelope.response : json;
81
+ const { code, message } = firstError(envelope?.errors ?? json?.errors);
82
+ const headerWait = Number(res.headers.get("retry-after"));
83
+ const bodyWait = Number(data?.retryAfter);
84
+ const retryAfter = bodyWait > 0 ? bodyWait : headerWait > 0 ? headerWait : 60;
85
+ return { status: res.status, data, code, message: message ? redact(message, key) : null, retryAfter };
86
+ }
87
+
88
+ /** One call with the waits the contract asks for: 429 -> wait retryAfter; network error or 5xx (not 503) -> back off. */
89
+ async function call(method, path, body) {
90
+ let rateWaits = 0;
91
+ let failures = 0;
92
+ for (;;) {
93
+ const r = await once(method, path, body);
94
+ if (r.network || (r.status >= 500 && r.status !== 503)) {
95
+ failures++;
96
+ if (failures > 3) {
97
+ throw new PrysError(`Prys did not answer ${method} ${path} (${r.network ? r.message : `HTTP ${r.status}`}). Try again later.`, {
98
+ status: r.status ?? 0,
99
+ });
100
+ }
101
+ const seconds = 5 * 3 ** (failures - 1);
102
+ onWait(seconds, r.network ? r.message : `HTTP ${r.status}`);
103
+ await sleep(seconds * 1000);
104
+ continue;
105
+ }
106
+ if (r.status === 429) {
107
+ rateWaits++;
108
+ if (rateWaits > 10) throw new PrysError("Prys kept answering rate_limited. Try again in a few minutes.", { status: 429, code: "rate_limited" });
109
+ onWait(r.retryAfter, "rate_limited (600 calls a minute per key)");
110
+ await sleep(r.retryAfter * 1000);
111
+ continue;
112
+ }
113
+ if (r.status === 401) {
114
+ throw new PrysError("Prys did not accept this key. Copy it again from Customer CRM > Enrichment (it may have been rotated or revoked).", {
115
+ status: 401,
116
+ code: r.code ?? "apiKey",
117
+ });
118
+ }
119
+ if (r.status === 503) {
120
+ throw new PrysError(r.message ?? "Prys is not ready for a backfill of this app yet. Try again later.", { status: 503, code: r.code });
121
+ }
122
+ if (r.status < 200 || r.status >= 300) {
123
+ throw new PrysError(`Prys refused ${method} ${path}: HTTP ${r.status}${r.code ? ` ${r.code}` : ""}${r.message ? ` (${r.message})` : ""}`, {
124
+ status: r.status,
125
+ code: r.code,
126
+ });
127
+ }
128
+ return r.data;
129
+ }
130
+ }
131
+
132
+ return {
133
+ base,
134
+ /** IN-3: the app's current installs (myshopify domains). */
135
+ async installs() {
136
+ const data = await call("GET", "/installs");
137
+ if (!data || !Array.isArray(data.domains)) throw new PrysError("Prys answered /installs without a domains list.");
138
+ const domains = [...new Set(data.domains.filter((d) => typeof d === "string" && d.trim()).map((d) => d.trim().toLowerCase()))];
139
+ return { count: Number.isFinite(data.count) ? data.count : domains.length, domains };
140
+ },
141
+ /** IN-2: up to 200 GraphQL `data` objects. */
142
+ async identifyBulk(shops) {
143
+ if (shops.length > BULK_MAX) throw new PrysError(`At most ${BULK_MAX} shops a call`);
144
+ const data = await call("POST", "/identify/bulk", { shops });
145
+ return {
146
+ received: Number(data?.received ?? 0),
147
+ stored: Number(data?.stored ?? 0),
148
+ rejected: Array.isArray(data?.rejected) ? data.rejected : [],
149
+ };
150
+ },
151
+ };
152
+ }
package/src/query.js ADDED
@@ -0,0 +1,19 @@
1
+ // The Customer CRM step 1 query ("Send the shop object once a day"), field for field. Source of truth: front-end-crm
2
+ // app/src/sections/Dashboards/CustomerCrm/Enrichment/snippetTemplates.ts, `call` step. `npm run check-query -- <that file>`
3
+ // fails when the two drift apart.
4
+ export const SHOP_QUERY = `{
5
+ shop {
6
+ id name myshopifyDomain email contactEmail shopOwnerName
7
+ currencyCode ianaTimezone createdAt
8
+ plan { publicDisplayName shopifyPlus partnerDevelopment }
9
+ billingAddress { countryCodeV2 country }
10
+ primaryDomain { host }
11
+ }
12
+ currentAppInstallation {
13
+ activeSubscriptions {
14
+ name currentPeriodEnd
15
+ lineItems { plan { pricingDetails { ... on AppUsagePricing {
16
+ cappedAmount { amount } balanceUsed { amount } } } } }
17
+ }
18
+ }
19
+ }`;
package/src/read.js ADDED
@@ -0,0 +1,37 @@
1
+ import { SHOP_QUERY } from "./query.js";
2
+
3
+ /** A short, token-free reason for a store the app's client could not read. */
4
+ export function readFailure(err) {
5
+ const status = err?.response?.code ?? err?.response?.status ?? err?.status;
6
+ if (status === 401 || status === 403) return `HTTP ${status} (token no longer valid; the store may have uninstalled)`;
7
+ if (status === 402) return "HTTP 402 (store frozen or unpaid)";
8
+ if (status === 404) return "HTTP 404 (store not found)";
9
+ if (status === 423) return "HTTP 423 (store locked)";
10
+ if (status === 429) return "HTTP 429 (Shopify throttled the store)";
11
+ if (status) return `HTTP ${status}`;
12
+ if (err?.name === "SessionNotFoundError") return "no offline session";
13
+ if (err?.name === "GraphqlQueryError") return "GraphQL error";
14
+ return err?.name && err.name !== "Error" ? err.name : "could not read";
15
+ }
16
+
17
+ /** Runs the step 1 query with the app's own client; returns the GraphQL `data` object ({shop, currentAppInstallation}). */
18
+ export async function readShop(unauthenticated, shop) {
19
+ const { admin } = await unauthenticated.admin(shop);
20
+ let body;
21
+ try {
22
+ const res = await admin.graphql(SHOP_QUERY, { tries: 3 });
23
+ body = typeof res?.json === "function" ? await res.json() : res;
24
+ } catch (err) {
25
+ // GraphqlQueryError carries the partial body; keep it when Shopify still sent the shop.
26
+ const partial = err?.body?.data;
27
+ if (partial?.shop) return partial;
28
+ throw err;
29
+ }
30
+ const data = body?.data;
31
+ if (!data?.shop) {
32
+ const err = new Error("no shop in the answer");
33
+ err.name = body?.errors ? "GraphqlQueryError" : "EmptyAnswer";
34
+ throw err;
35
+ }
36
+ return data;
37
+ }