sellify-cli 0.1.1 → 0.2.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/README.md +20 -2
- package/bin/sellify.mjs +272 -5
- package/package.json +5 -3
- package/templates/app/README.md +32 -0
- package/templates/app/env.example +7 -0
- package/templates/app/gitignore +3 -0
- package/templates/app/package.json +12 -0
- package/templates/app/sellify.app.json +12 -0
- package/templates/app/server.mjs +228 -0
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# sellify-cli
|
|
2
2
|
|
|
3
|
-
Command line for Sellify
|
|
3
|
+
Command line for Sellify: build themes in your own editor with live sync, build apps (init, dev, deploy), and call the GraphQL Admin API.
|
|
4
4
|
|
|
5
5
|
```bash
|
|
6
6
|
npm install -g sellify-cli # or run any command with: npx sellify-cli <command>
|
|
@@ -13,7 +13,7 @@ Needs Node 20 or newer. No other dependencies.
|
|
|
13
13
|
```bash
|
|
14
14
|
sellify login # opens your browser; pick the store and click Allow
|
|
15
15
|
sellify login --store mystore # straight to that store
|
|
16
|
-
sellify login --url
|
|
16
|
+
sellify login --url http://admin.localhost:3000 # a local or self-hosted Sellify
|
|
17
17
|
```
|
|
18
18
|
|
|
19
19
|
Sign in to Sellify in the browser (password or Google), check the computer name and click **Allow**. The CLI receives its token directly from the browser (a one-off listener on `127.0.0.1` with PKCE), so you never see or copy it. It is saved in `~/.config/sellify/config.json`, readable only by you.
|
|
@@ -47,6 +47,24 @@ Anything that changes the **live** theme asks first. Pass `--allow-live` to skip
|
|
|
47
47
|
|
|
48
48
|
Every file is checked on upload like in the admin code editor (Liquid syntax, section schemas, JSON templates). Files with errors are reported and skipped; the rest are saved. Themes hold text files only: `.liquid`, `.json`, `.css`, `.js` and `.svg` in `layout/`, `templates/`, `sections/`, `snippets/`, `assets/`, `config/` and `locales/`.
|
|
49
49
|
|
|
50
|
+
## Apps
|
|
51
|
+
|
|
52
|
+
```sh
|
|
53
|
+
sellify app init my-app # starter app: OAuth install, Admin API page, signed webhooks (no dependencies)
|
|
54
|
+
cd my-app
|
|
55
|
+
sellify app dev # creates the app in your Partner dashboard, runs it, prints an install link
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
| Command | What it does |
|
|
59
|
+
|---|---|
|
|
60
|
+
| `sellify app init [dir] [--name <name>]` | New app folder from the starter template |
|
|
61
|
+
| `sellify app dev [--port <port>] [--tunnel-url <url>] [--no-tunnel]` | Run the app locally, point its URLs at it, print an install link |
|
|
62
|
+
| `sellify app link [--app <id\|client id>] [--create]` | Connect the folder to one of your apps; credentials go to `.env` |
|
|
63
|
+
| `sellify app deploy` | Point the app's URLs, scopes and uninstall webhook at `application_url` |
|
|
64
|
+
| `sellify app info` / `sellify app list` | The linked app / all your apps |
|
|
65
|
+
|
|
66
|
+
Webhooks need a public URL: with [cloudflared](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/) installed, `app dev` opens a free tunnel automatically, or pass `--tunnel-url`.
|
|
67
|
+
|
|
50
68
|
## Admin API
|
|
51
69
|
|
|
52
70
|
```bash
|
package/bin/sellify.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// Sellify CLI: develop themes locally (pull, push, live-sync with `theme dev`)
|
|
3
|
-
// No dependencies; needs Node 20+. Authenticates with a
|
|
2
|
+
// Sellify CLI: develop themes locally (pull, push, live-sync with `theme dev`), build apps (`app init`, `app dev`,
|
|
3
|
+
// `app deploy`) and call the GraphQL Admin API. No dependencies; needs Node 20+. Authenticates with a browser login
|
|
4
|
+
// (`sellify login`) or a custom app access token.
|
|
4
5
|
import { spawn } from "node:child_process";
|
|
5
6
|
import { createHash, randomBytes } from "node:crypto";
|
|
6
7
|
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, watch, writeFileSync } from "node:fs";
|
|
@@ -13,7 +14,7 @@ import { parseArgs } from "node:util";
|
|
|
13
14
|
const VERSION = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
|
|
14
15
|
const API_VERSION = "2026-10";
|
|
15
16
|
// The Sellify admin host. Point at your own deployment with --url or SELLIFY_URL.
|
|
16
|
-
const DEFAULT_URL = "
|
|
17
|
+
const DEFAULT_URL = "https://admin-sellifyhq.vercel.app";
|
|
17
18
|
// Same rule as the server (lib/theme-code FILE_PATH_RE): which files a theme can hold. All are text.
|
|
18
19
|
const THEME_PATH_RE =
|
|
19
20
|
/^(layout|templates|sections|snippets)\/[a-z0-9][a-z0-9_.-]{0,60}\.liquid$|^(templates|sections)\/[a-z0-9][a-z0-9_.-]{0,60}\.json$|^assets\/[a-z0-9][a-z0-9_.-]{0,60}\.(css|js|svg|json)$|^config\/settings_(schema|data)\.json$|^locales\/[a-z]{2}(-[a-z]{2})?(\.default)?(\.schema)?\.json$/;
|
|
@@ -475,6 +476,251 @@ async function graphql(flags, positionals) {
|
|
|
475
476
|
log(JSON.stringify(await gql(s, query, variables), null, 2));
|
|
476
477
|
}
|
|
477
478
|
|
|
479
|
+
// ---------- Apps (`sellify app …`: your Partner dashboard apps, through /api/cli/apps) ----------
|
|
480
|
+
|
|
481
|
+
const APP_CONFIG = "sellify.app.json";
|
|
482
|
+
const isLocalUrl = (u) => /^https?:\/\/(localhost|127\.0\.0\.1|[^/]+\.localhost)(:\d+)?(\/|$)/.test(u);
|
|
483
|
+
|
|
484
|
+
async function appApi(s, method, path = "", body) {
|
|
485
|
+
let res;
|
|
486
|
+
try {
|
|
487
|
+
res = await fetch(`${s.url}/api/cli/apps${path}`, {
|
|
488
|
+
method,
|
|
489
|
+
headers: { "Content-Type": "application/json", "X-Sellify-Access-Token": s.token, "User-Agent": `sellify-cli/${VERSION}` },
|
|
490
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
491
|
+
});
|
|
492
|
+
} catch (e) {
|
|
493
|
+
fail(`Can't reach ${s.url} (${e.cause?.code ?? e.message}). Check --url.`);
|
|
494
|
+
}
|
|
495
|
+
const data = await res.json().catch(() => ({}));
|
|
496
|
+
if (res.status === 401) fail("Apps need a browser login. Run `sellify login` (custom app tokens can't manage apps).");
|
|
497
|
+
if (!res.ok) fail(data.error ?? `Unexpected response from ${s.url} (HTTP ${res.status}).`);
|
|
498
|
+
return data;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
const appRoot = (flags) => resolve(flags.path || ".");
|
|
502
|
+
|
|
503
|
+
function readAppConfig(root) {
|
|
504
|
+
const p = join(root, APP_CONFIG);
|
|
505
|
+
if (!existsSync(p)) fail(`${root} isn't an app folder (no ${APP_CONFIG}). Run \`sellify app init\` first or pass --path.`);
|
|
506
|
+
try {
|
|
507
|
+
return JSON.parse(readFileSync(p, "utf8"));
|
|
508
|
+
} catch (e) {
|
|
509
|
+
fail(`${APP_CONFIG} isn't valid JSON: ${e.message}`);
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
const writeAppConfig = (root, cfg) => writeFileSync(join(root, APP_CONFIG), JSON.stringify(cfg, null, 2) + "\n");
|
|
514
|
+
|
|
515
|
+
/** Sets keys in the app's .env (readable only by you), keeping everything else in it. */
|
|
516
|
+
function writeDotEnv(root, vars) {
|
|
517
|
+
const p = join(root, ".env");
|
|
518
|
+
const lines = existsSync(p) ? readFileSync(p, "utf8").split(/\r?\n/).filter((l, i, all) => l || i < all.length - 1) : [];
|
|
519
|
+
for (const [k, v] of Object.entries(vars)) {
|
|
520
|
+
const i = lines.findIndex((l) => l.startsWith(`${k}=`));
|
|
521
|
+
if (i >= 0) lines[i] = `${k}=${v}`;
|
|
522
|
+
else lines.push(`${k}=${v}`);
|
|
523
|
+
}
|
|
524
|
+
writeFileSync(p, lines.join("\n") + "\n", { mode: 0o600 });
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
function readDotEnv(root) {
|
|
528
|
+
const p = join(root, ".env");
|
|
529
|
+
if (!existsSync(p)) return {};
|
|
530
|
+
return Object.fromEntries(readFileSync(p, "utf8").split(/\r?\n/).flatMap((l) => (/^[A-Z0-9_]+=/.test(l) ? [[l.slice(0, l.indexOf("=")), l.slice(l.indexOf("=") + 1)]] : [])));
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
const appLine = (a) => `${a.name} ${a.listed ? c.green(`[${a.reviewStatus === "approved" ? "listed" : `listed · ${a.reviewStatus}`}]`) : c.dim("[unlisted]")}${a.suspended ? c.red(" [suspended]") : ""} ${c.dim(`${a.installs} install${a.installs === 1 ? "" : "s"} · ${a.clientId}`)}`;
|
|
534
|
+
|
|
535
|
+
async function appInit(flags, positionals) {
|
|
536
|
+
const dir = positionals[0] || "my-sellify-app";
|
|
537
|
+
const root = resolve(dir);
|
|
538
|
+
if (existsSync(root) && readdirSync(root).length) fail(`${root} already exists and isn't empty.`);
|
|
539
|
+
const slug = dir.split(/[\\/]/).pop().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "my-sellify-app";
|
|
540
|
+
const name = flags.name || slug.replace(/-/g, " ").replace(/\b\w/g, (ch) => ch.toUpperCase());
|
|
541
|
+
const template = new URL("../templates/app/", import.meta.url);
|
|
542
|
+
mkdirSync(root, { recursive: true });
|
|
543
|
+
// npm drops dotfiles from published packages, so the template stores them without the dot.
|
|
544
|
+
for (const file of readdirSync(template)) writeFileSync(join(root, { gitignore: ".gitignore", "env.example": ".env.example" }[file] ?? file), readFileSync(new URL(file, template)));
|
|
545
|
+
const cfg = readAppConfig(root);
|
|
546
|
+
writeAppConfig(root, { ...cfg, name });
|
|
547
|
+
const pkg = JSON.parse(readFileSync(join(root, "package.json"), "utf8"));
|
|
548
|
+
writeFileSync(join(root, "package.json"), JSON.stringify({ ...pkg, name: slug }, null, 2) + "\n");
|
|
549
|
+
log(`${c.green("✓")} Created ${c.bold(name)} in ${root}\n\n cd ${relative(process.cwd(), root) || "."}\n sellify app dev ${c.dim("Creates the app in your Partner dashboard, runs it and prints an install link")}\n\n${c.dim("Edit server.mjs to build your app. No dependencies needed (Node 20+).")}`);
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
async function appList(flags) {
|
|
553
|
+
const s = session(flags);
|
|
554
|
+
const { apps } = await appApi(s, "GET");
|
|
555
|
+
if (!apps.length) return log(`No apps yet. Run ${c.bold("sellify app init")} to start one.`);
|
|
556
|
+
for (const a of apps) log(appLine(a));
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/** Connects the folder to a Partner app (an existing one with --app, or a new one), and writes its credentials to .env. */
|
|
560
|
+
async function linkApp(s, root, cfg, flags) {
|
|
561
|
+
let app;
|
|
562
|
+
if (flags.app) {
|
|
563
|
+
const { apps } = await appApi(s, "GET");
|
|
564
|
+
app = apps.find((a) => a.id === flags.app || a.clientId === flags.app || a.handle === flags.app);
|
|
565
|
+
if (!app) fail(`No app ${flags.app} in your Partner dashboard. Run \`sellify app list\`.`);
|
|
566
|
+
} else {
|
|
567
|
+
if (!flags.create && process.stdin.isTTY) {
|
|
568
|
+
const { apps } = await appApi(s, "GET");
|
|
569
|
+
if (apps.length) {
|
|
570
|
+
apps.forEach((a, i) => log(` ${c.bold(i + 1)}. ${appLine(a)}`));
|
|
571
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
572
|
+
const answer = (await rl.question(`Link to which app? Number, or ${c.bold("n")} for a new one ${c.dim(`(n)`)} `)).trim();
|
|
573
|
+
rl.close();
|
|
574
|
+
app = apps[Number(answer) - 1];
|
|
575
|
+
if (answer && !/^n(ew)?$/i.test(answer) && !app) fail("Stopped.");
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
if (!app) {
|
|
579
|
+
({ app } = await appApi(s, "POST", "", { name: cfg.name }));
|
|
580
|
+
log(`${c.green("✓")} Created ${c.bold(app.name)} in your Partner dashboard`);
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
const { app: full } = await appApi(s, "GET", `/${app.id}?secret=1`);
|
|
584
|
+
writeAppConfig(root, { ...cfg, app_id: full.id, client_id: full.clientId });
|
|
585
|
+
writeDotEnv(root, { SELLIFY_CLIENT_ID: full.clientId, SELLIFY_CLIENT_SECRET: full.clientSecret });
|
|
586
|
+
log(`${c.green("✓")} Linked to ${c.bold(full.name)} ${c.dim(`(${full.clientId}) · credentials saved to .env`)}`);
|
|
587
|
+
return full;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
async function appLink(flags) {
|
|
591
|
+
const s = session(flags);
|
|
592
|
+
const root = appRoot(flags);
|
|
593
|
+
await linkApp(s, root, readAppConfig(root), flags);
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
/** A free Cloudflare quick tunnel (if cloudflared is installed), so Sellify can reach the local app with webhooks. */
|
|
597
|
+
function startTunnel(port) {
|
|
598
|
+
return new Promise((done) => {
|
|
599
|
+
const proc = spawn("cloudflared", ["tunnel", "--no-autoupdate", "--url", `http://localhost:${port}`], { stdio: ["ignore", "pipe", "pipe"] });
|
|
600
|
+
const timer = setTimeout(() => {
|
|
601
|
+
proc.kill();
|
|
602
|
+
done(null);
|
|
603
|
+
}, 30_000);
|
|
604
|
+
proc.on("error", () => {
|
|
605
|
+
clearTimeout(timer);
|
|
606
|
+
done(null);
|
|
607
|
+
});
|
|
608
|
+
const onData = (d) => {
|
|
609
|
+
const m = String(d).match(/https:\/\/[a-z0-9-]+\.trycloudflare\.com/);
|
|
610
|
+
if (!m) return;
|
|
611
|
+
clearTimeout(timer);
|
|
612
|
+
done({ url: m[0], proc });
|
|
613
|
+
};
|
|
614
|
+
proc.stdout.on("data", onData);
|
|
615
|
+
proc.stderr.on("data", onData);
|
|
616
|
+
});
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
async function waitForApp(port, child) {
|
|
620
|
+
for (let i = 0; i < 100; i++) {
|
|
621
|
+
if (child.exitCode !== null) fail(`The app exited (code ${child.exitCode}) before it started. See the output above.`);
|
|
622
|
+
try {
|
|
623
|
+
if ((await fetch(`http://localhost:${port}/health`)).ok) return;
|
|
624
|
+
} catch {
|
|
625
|
+
// Not listening yet.
|
|
626
|
+
}
|
|
627
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
628
|
+
}
|
|
629
|
+
fail(`The app didn't answer on http://localhost:${port}/health within 20 seconds.`);
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
/** Runs the app locally, points the Partner app's URLs at it and prints an install link for the logged-in store. */
|
|
633
|
+
async function appDev(flags) {
|
|
634
|
+
const s = session(flags);
|
|
635
|
+
const root = appRoot(flags);
|
|
636
|
+
let cfg = readAppConfig(root);
|
|
637
|
+
let app = cfg.app_id ? (await appApi(s, "GET", `/${cfg.app_id}`)).app : await linkApp(s, root, cfg, { create: true });
|
|
638
|
+
cfg = readAppConfig(root);
|
|
639
|
+
if (app.listed) fail(`${app.name} is listed in the App Store: app dev would point merchants' installs at your computer.\nUse a separate app for development: ${c.bold("sellify app link --create")}`);
|
|
640
|
+
let env = readDotEnv(root);
|
|
641
|
+
if (env.SELLIFY_CLIENT_ID !== app.clientId || !env.SELLIFY_CLIENT_SECRET) {
|
|
642
|
+
await linkApp(s, root, cfg, { app: app.id });
|
|
643
|
+
env = readDotEnv(root);
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
const port = Number(flags.port || cfg.dev_port || 3456);
|
|
647
|
+
let base = `http://localhost:${port}`;
|
|
648
|
+
let tunnel = null;
|
|
649
|
+
let webhooks = true;
|
|
650
|
+
if (flags["tunnel-url"]) base = flags["tunnel-url"].replace(/\/+$/, "");
|
|
651
|
+
else if (!isLocalUrl(s.url)) {
|
|
652
|
+
if (!flags["no-tunnel"]) {
|
|
653
|
+
log(c.dim("Opening a tunnel with cloudflared…"));
|
|
654
|
+
tunnel = await startTunnel(port);
|
|
655
|
+
}
|
|
656
|
+
if (tunnel) base = tunnel.url;
|
|
657
|
+
else webhooks = false;
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
const redirectPath = cfg.redirect_path || "/auth/callback";
|
|
661
|
+
const webhookPath = cfg.webhook_path || "/webhooks";
|
|
662
|
+
const { app: updated } = await appApi(s, "PATCH", `/${app.id}`, {
|
|
663
|
+
name: cfg.name,
|
|
664
|
+
appUrl: base,
|
|
665
|
+
redirectUrls: [base + redirectPath],
|
|
666
|
+
scopes: cfg.scopes ?? [],
|
|
667
|
+
uninstallWebhookUrl: webhooks ? base + webhookPath : "",
|
|
668
|
+
});
|
|
669
|
+
|
|
670
|
+
const child = spawn(cfg.dev_command || "node --watch server.mjs", {
|
|
671
|
+
cwd: root,
|
|
672
|
+
shell: true,
|
|
673
|
+
stdio: "inherit",
|
|
674
|
+
env: { ...process.env, PORT: String(port), SELLIFY_APP_URL: base, SELLIFY_ADMIN_URL: s.url, SELLIFY_CLIENT_ID: env.SELLIFY_CLIENT_ID, SELLIFY_CLIENT_SECRET: env.SELLIFY_CLIENT_SECRET },
|
|
675
|
+
});
|
|
676
|
+
const stop = (code = 0) => {
|
|
677
|
+
tunnel?.proc.kill();
|
|
678
|
+
if (child.exitCode === null) child.kill();
|
|
679
|
+
process.exit(code);
|
|
680
|
+
};
|
|
681
|
+
process.on("SIGINT", () => stop(0));
|
|
682
|
+
process.on("SIGTERM", () => stop(0));
|
|
683
|
+
await waitForApp(port, child);
|
|
684
|
+
child.on("exit", (code) => {
|
|
685
|
+
log(`${c.red("✗")} The app exited (code ${code}).`);
|
|
686
|
+
stop(code ?? 1);
|
|
687
|
+
});
|
|
688
|
+
|
|
689
|
+
log(`\n${c.green("●")} ${c.bold(updated.name)} is running
|
|
690
|
+
App ${c.cyan(base)}
|
|
691
|
+
Install ${c.cyan(`${base}/install?store=${encodeURIComponent(s.store)}`)} ${c.dim(`on ${s.store}`)}
|
|
692
|
+
Webhooks ${webhooks ? base + webhookPath : c.yellow("off: Sellify can't reach localhost. Install cloudflared, or pass --tunnel-url https://…")}
|
|
693
|
+
Partner ${c.dim(updated.dashboardUrl)}
|
|
694
|
+
${c.dim("Edits to your code restart the app. Ctrl+C to stop.")}`);
|
|
695
|
+
await new Promise(() => {});
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
/** Points the app's URLs, scopes and uninstall webhook at its production URL (application_url). */
|
|
699
|
+
async function appDeploy(flags) {
|
|
700
|
+
const s = session(flags);
|
|
701
|
+
const root = appRoot(flags);
|
|
702
|
+
const cfg = readAppConfig(root);
|
|
703
|
+
if (!cfg.app_id) fail("This folder isn't linked to an app yet. Run `sellify app link` (or `sellify app dev`).");
|
|
704
|
+
const base = String(cfg.application_url || "").replace(/\/+$/, "");
|
|
705
|
+
if (!/^https:\/\//.test(base)) fail(`Set application_url in ${APP_CONFIG} to your app's public https URL first.`);
|
|
706
|
+
const { app, message } = await appApi(s, "PATCH", `/${cfg.app_id}`, {
|
|
707
|
+
name: cfg.name,
|
|
708
|
+
appUrl: base,
|
|
709
|
+
redirectUrls: [base + (cfg.redirect_path || "/auth/callback")],
|
|
710
|
+
scopes: cfg.scopes ?? [],
|
|
711
|
+
uninstallWebhookUrl: base + (cfg.webhook_path || "/webhooks"),
|
|
712
|
+
});
|
|
713
|
+
log(`${c.green("✓")} ${message}: ${c.bold(app.name)} now uses ${base}\n Scopes ${app.scopes.join(", ") || "none"}\n Install ${c.cyan(`${base}/install?store=${encodeURIComponent(s.store)}`)}\n Partner ${c.dim(app.dashboardUrl)}`);
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
async function appInfo(flags) {
|
|
717
|
+
const s = session(flags);
|
|
718
|
+
const cfg = readAppConfig(appRoot(flags));
|
|
719
|
+
if (!cfg.app_id) fail("This folder isn't linked to an app yet. Run `sellify app link` (or `sellify app dev`).");
|
|
720
|
+
const { app } = await appApi(s, "GET", `/${cfg.app_id}`);
|
|
721
|
+
log(`${appLine(app)}\n App URL ${app.appUrl || c.dim("not set")}\n Redirect ${app.redirectUrls.join(", ") || c.dim("not set")}\n Scopes ${app.scopes.join(", ") || "none"}\n Uninstall ${app.uninstallWebhookUrl || c.dim("no webhook")}\n Partner ${c.dim(app.dashboardUrl)}`);
|
|
722
|
+
}
|
|
723
|
+
|
|
478
724
|
// ---------- Entry ----------
|
|
479
725
|
|
|
480
726
|
const HELP = `${c.bold("sellify")} ${VERSION} — Sellify command line
|
|
@@ -494,6 +740,15 @@ ${c.bold("Themes")} (run inside a theme folder, or pass --path <dir>)
|
|
|
494
740
|
sellify theme publish --theme <id>
|
|
495
741
|
sellify theme delete --theme <id> [--force]
|
|
496
742
|
|
|
743
|
+
${c.bold("Apps")} (run inside an app folder, or pass --path <dir>)
|
|
744
|
+
sellify app init [dir] [--name <name>] Start a new app from the starter template
|
|
745
|
+
sellify app dev [--port <port>] [--tunnel-url <https url>] [--no-tunnel]
|
|
746
|
+
Run it locally, point the app at it and print an install link
|
|
747
|
+
sellify app link [--app <id|client id>] [--create] Connect the folder to an app in your Partner dashboard
|
|
748
|
+
sellify app deploy Point the app's URLs and scopes at application_url (production)
|
|
749
|
+
sellify app info Show the linked app's settings
|
|
750
|
+
sellify app list Your apps in the Partner dashboard
|
|
751
|
+
|
|
497
752
|
${c.bold("Admin API")}
|
|
498
753
|
sellify graphql '<query>' [--variables '<json>']
|
|
499
754
|
sellify graphql --file query.graphql
|
|
@@ -524,6 +779,11 @@ const OPTIONS = {
|
|
|
524
779
|
force: { type: "boolean" },
|
|
525
780
|
"allow-live": { type: "boolean" },
|
|
526
781
|
"no-browser": { type: "boolean" },
|
|
782
|
+
app: { type: "string" },
|
|
783
|
+
create: { type: "boolean" },
|
|
784
|
+
port: { type: "string" },
|
|
785
|
+
"tunnel-url": { type: "string" },
|
|
786
|
+
"no-tunnel": { type: "boolean" },
|
|
527
787
|
help: { type: "boolean", short: "h" },
|
|
528
788
|
version: { type: "boolean", short: "v" },
|
|
529
789
|
};
|
|
@@ -540,6 +800,12 @@ const COMMANDS = {
|
|
|
540
800
|
"theme open": (f) => themeOpen(f),
|
|
541
801
|
"theme publish": (f) => themePublish(f),
|
|
542
802
|
"theme delete": (f) => themeDelete(f),
|
|
803
|
+
"app init": (f, p) => appInit(f, p),
|
|
804
|
+
"app dev": (f) => appDev(f),
|
|
805
|
+
"app link": (f) => appLink(f),
|
|
806
|
+
"app deploy": (f) => appDeploy(f),
|
|
807
|
+
"app info": (f) => appInfo(f),
|
|
808
|
+
"app list": (f) => appList(f),
|
|
543
809
|
};
|
|
544
810
|
|
|
545
811
|
async function main() {
|
|
@@ -552,13 +818,14 @@ async function main() {
|
|
|
552
818
|
const { values: flags, positionals } = parsed;
|
|
553
819
|
if (flags.version) return log(VERSION);
|
|
554
820
|
const [a, b, ...rest] = positionals;
|
|
555
|
-
const
|
|
821
|
+
const grouped = a === "theme" || a === "app";
|
|
822
|
+
const key = grouped ? `${a} ${b ?? ""}` : a;
|
|
556
823
|
if (flags.help || !a || !COMMANDS[key]) {
|
|
557
824
|
log(HELP);
|
|
558
825
|
if (a && !flags.help && !COMMANDS[key]) process.exitCode = 1;
|
|
559
826
|
return;
|
|
560
827
|
}
|
|
561
|
-
await COMMANDS[key](flags,
|
|
828
|
+
await COMMANDS[key](flags, grouped ? rest : [b, ...rest].filter(Boolean));
|
|
562
829
|
}
|
|
563
830
|
|
|
564
831
|
main().catch((e) => {
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sellify-cli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Command line for Sellify: develop themes locally (pull, push, live sync) and call the GraphQL Admin API.",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Command line for Sellify: develop themes locally (pull, push, live sync), build apps (init, dev, deploy) and call the GraphQL Admin API.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"bin": {
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
},
|
|
10
10
|
"files": [
|
|
11
11
|
"bin",
|
|
12
|
+
"templates",
|
|
12
13
|
"README.md",
|
|
13
14
|
"LICENSE"
|
|
14
15
|
],
|
|
@@ -20,6 +21,7 @@
|
|
|
20
21
|
"ecommerce",
|
|
21
22
|
"liquid",
|
|
22
23
|
"theme",
|
|
23
|
-
"cli"
|
|
24
|
+
"cli",
|
|
25
|
+
"apps"
|
|
24
26
|
]
|
|
25
27
|
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# My Sellify app
|
|
2
|
+
|
|
3
|
+
A Sellify app with no dependencies (Node 20+). It installs on a store with OAuth, shows a page built with the GraphQL Admin API, and receives signed webhooks.
|
|
4
|
+
|
|
5
|
+
## Develop
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
sellify login # once
|
|
9
|
+
sellify app dev # creates the app in your Partner dashboard on first run, then runs it
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
`app dev` starts `server.mjs`, points your app's URLs at it and prints an install link for your store. Open the link, approve, and you land on the app's page. Edits restart the server automatically.
|
|
13
|
+
|
|
14
|
+
Webhooks need a public URL. If [cloudflared](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/) is installed, `app dev` opens a tunnel for you; or pass your own with `--tunnel-url https://…`.
|
|
15
|
+
|
|
16
|
+
## Files
|
|
17
|
+
|
|
18
|
+
| File | What it does |
|
|
19
|
+
|---|---|
|
|
20
|
+
| `server.mjs` | The app: `/install` → Sellify approval → `/auth/callback` (token) → `/` (your page), and `/webhooks` |
|
|
21
|
+
| `sellify.app.json` | Name, scopes, webhook topics, paths, and the production `application_url` |
|
|
22
|
+
| `.env` | Client ID and secret (written by `sellify app dev`; never commit it) |
|
|
23
|
+
| `data/stores.json` | Access tokens per store (use a database in production) |
|
|
24
|
+
|
|
25
|
+
## Ship it
|
|
26
|
+
|
|
27
|
+
1. Host the app anywhere that runs Node (Render, Railway, Fly.io, a VPS) with the variables from `.env.example`.
|
|
28
|
+
2. Set `application_url` in `sellify.app.json` to its https URL.
|
|
29
|
+
3. Run `sellify app deploy` to point your app's URLs, scopes and uninstall webhook at it.
|
|
30
|
+
4. To list it in the App Store, add a tagline, description and support email in the Partner dashboard and submit it for review.
|
|
31
|
+
|
|
32
|
+
Use a separate app for development (`sellify app link --create`) once your app is live, so `app dev` never changes the URLs merchants use.
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# Production settings. `sellify app dev` sets these for you while developing.
|
|
2
|
+
SELLIFY_CLIENT_ID=
|
|
3
|
+
SELLIFY_CLIENT_SECRET=
|
|
4
|
+
# Your app's public https URL (same as application_url in sellify.app.json)
|
|
5
|
+
SELLIFY_APP_URL=
|
|
6
|
+
SELLIFY_ADMIN_URL=https://admin-sellifyhq.vercel.app
|
|
7
|
+
PORT=3456
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "My Sellify app",
|
|
3
|
+
"app_id": "",
|
|
4
|
+
"client_id": "",
|
|
5
|
+
"application_url": "",
|
|
6
|
+
"redirect_path": "/auth/callback",
|
|
7
|
+
"webhook_path": "/webhooks",
|
|
8
|
+
"scopes": ["read_products", "read_orders"],
|
|
9
|
+
"webhook_topics": ["ORDERS_CREATE"],
|
|
10
|
+
"dev_command": "node --watch server.mjs",
|
|
11
|
+
"dev_port": 3456
|
|
12
|
+
}
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
// A Sellify app with no dependencies (Node 20+):
|
|
2
|
+
// /install?store=… → the merchant approves on Sellify (OAuth) → /auth/callback stores the store's access token
|
|
3
|
+
// / → the app's page: opened from the Sellify admin (signed link) or after installing
|
|
4
|
+
// /webhooks → signed webhooks (orders/create, app/uninstalled, …)
|
|
5
|
+
// `sellify app dev` runs it with the right URLs and credentials. In production set the variables from .env.example.
|
|
6
|
+
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
|
7
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
8
|
+
import { createServer } from "node:http";
|
|
9
|
+
|
|
10
|
+
loadEnvFile(".env");
|
|
11
|
+
const config = JSON.parse(readFileSync(new URL("./sellify.app.json", import.meta.url), "utf8"));
|
|
12
|
+
const PORT = Number(process.env.PORT || 3456);
|
|
13
|
+
const APP_URL = (process.env.SELLIFY_APP_URL || config.application_url || `http://localhost:${PORT}`).replace(/\/+$/, "");
|
|
14
|
+
const ADMIN_URL = (process.env.SELLIFY_ADMIN_URL || "https://admin-sellifyhq.vercel.app").replace(/\/+$/, "");
|
|
15
|
+
const CLIENT_ID = process.env.SELLIFY_CLIENT_ID;
|
|
16
|
+
const CLIENT_SECRET = process.env.SELLIFY_CLIENT_SECRET;
|
|
17
|
+
const SCOPES = config.scopes ?? [];
|
|
18
|
+
const REDIRECT_URL = APP_URL + (config.redirect_path ?? "/auth/callback");
|
|
19
|
+
const WEBHOOK_URL = APP_URL + (config.webhook_path ?? "/webhooks");
|
|
20
|
+
const API_VERSION = "2026-10";
|
|
21
|
+
|
|
22
|
+
if (!CLIENT_ID || !CLIENT_SECRET) {
|
|
23
|
+
console.error("Missing SELLIFY_CLIENT_ID / SELLIFY_CLIENT_SECRET. Run `sellify app dev` (it sets them), or copy .env.example to .env.");
|
|
24
|
+
process.exit(1);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// ---------- Installs (one access token per store; a database in a real app) ----------
|
|
28
|
+
|
|
29
|
+
const DATA = new URL("./data/stores.json", import.meta.url);
|
|
30
|
+
const stores = existsSync(DATA) ? JSON.parse(readFileSync(DATA, "utf8")) : {};
|
|
31
|
+
const saveStores = () => {
|
|
32
|
+
mkdirSync(new URL("./data/", import.meta.url), { recursive: true });
|
|
33
|
+
writeFileSync(DATA, JSON.stringify(stores, null, 2));
|
|
34
|
+
};
|
|
35
|
+
const recentWebhooks = [];
|
|
36
|
+
|
|
37
|
+
// ---------- Signatures ----------
|
|
38
|
+
|
|
39
|
+
const hmacHex = (message) => createHmac("sha256", CLIENT_SECRET).update(message).digest("hex");
|
|
40
|
+
const same = (a, b) => a.length === b.length && timingSafeEqual(Buffer.from(a), Buffer.from(b));
|
|
41
|
+
|
|
42
|
+
/** Links from Sellify (app launch, OAuth callback) carry hmac = hex HMAC-SHA256 of the other params, sorted, "k=v&k=v". */
|
|
43
|
+
function verifyQuery(params) {
|
|
44
|
+
const hmac = params.get("hmac") ?? "";
|
|
45
|
+
const message = [...params.keys()].filter((k) => k !== "hmac").sort().map((k) => `${k}=${params.get(k)}`).join("&");
|
|
46
|
+
const fresh = Math.abs(Date.now() / 1000 - Number(params.get("timestamp"))) < 600;
|
|
47
|
+
return fresh && same(hmac, hmacHex(message));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Webhooks carry X-Sellify-Hmac-Sha256 = base64 HMAC-SHA256 of the raw body. */
|
|
51
|
+
const verifyWebhook = (raw, header) => same(header ?? "", createHmac("sha256", CLIENT_SECRET).update(raw).digest("base64"));
|
|
52
|
+
|
|
53
|
+
// A signed cookie remembers which store the browser is using the app for.
|
|
54
|
+
const sign = (value) => `${value}.${hmacHex(`cookie:${value}`).slice(0, 32)}`;
|
|
55
|
+
const unsign = (signed = "") => {
|
|
56
|
+
const i = signed.lastIndexOf(".");
|
|
57
|
+
const value = signed.slice(0, i);
|
|
58
|
+
return i > 0 && same(signed, sign(value)) ? value : null;
|
|
59
|
+
};
|
|
60
|
+
const cookies = (req) => Object.fromEntries((req.headers.cookie ?? "").split(";").map((p) => p.trim().split("=")).filter(([k]) => k).map(([k, ...v]) => [k, decodeURIComponent(v.join("="))]));
|
|
61
|
+
const secure = APP_URL.startsWith("https:") ? "; Secure" : "";
|
|
62
|
+
const setCookie = (name, value, maxAge) => `${name}=${encodeURIComponent(value)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${maxAge}${secure}`;
|
|
63
|
+
|
|
64
|
+
// ---------- Admin API ----------
|
|
65
|
+
|
|
66
|
+
async function graphql(store, query, variables = {}) {
|
|
67
|
+
const res = await fetch(`${ADMIN_URL}/api/admin/${API_VERSION}/graphql`, {
|
|
68
|
+
method: "POST",
|
|
69
|
+
headers: { "Content-Type": "application/json", "X-Sellify-Access-Token": stores[store].token },
|
|
70
|
+
body: JSON.stringify({ query, variables }),
|
|
71
|
+
});
|
|
72
|
+
const body = await res.json();
|
|
73
|
+
if (res.status === 401) throw Object.assign(new Error("Token revoked"), { revoked: true });
|
|
74
|
+
if (body.errors?.length) throw new Error(body.errors[0].message);
|
|
75
|
+
return body.data;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Points the store's webhook subscriptions at this app's current URL (tunnel URLs change between `app dev` runs). */
|
|
79
|
+
async function syncWebhooks(store) {
|
|
80
|
+
const topics = (config.webhook_topics ?? []).filter((t) => t !== "APP_UNINSTALLED");
|
|
81
|
+
const { webhookSubscriptions } = await graphql(store, "{ webhookSubscriptions { id topic callbackUrl } }");
|
|
82
|
+
for (const sub of webhookSubscriptions) {
|
|
83
|
+
if (sub.callbackUrl !== WEBHOOK_URL || !topics.includes(sub.topic)) await graphql(store, "mutation($id: ID!) { webhookSubscriptionDelete(id: $id) { userErrors { message } } }", { id: sub.id });
|
|
84
|
+
}
|
|
85
|
+
for (const topic of topics) {
|
|
86
|
+
if (webhookSubscriptions.some((s) => s.topic === topic && s.callbackUrl === WEBHOOK_URL)) continue;
|
|
87
|
+
const { webhookSubscriptionCreate: r } = await graphql(
|
|
88
|
+
store,
|
|
89
|
+
"mutation($topic: WebhookTopic!, $url: String!) { webhookSubscriptionCreate(topic: $topic, callbackUrl: $url) { userErrors { message } } }",
|
|
90
|
+
{ topic, url: WEBHOOK_URL },
|
|
91
|
+
);
|
|
92
|
+
if (r.userErrors.length) console.warn(`[webhooks] ${store} ${topic}: ${r.userErrors[0].message}`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ---------- Pages ----------
|
|
97
|
+
|
|
98
|
+
const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (ch) => `&#${ch.charCodeAt(0)};`);
|
|
99
|
+
const page = (title, body) => `<!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${esc(title)}</title>
|
|
100
|
+
<style>body{margin:0;font:15px/1.5 system-ui,sans-serif;background:#f6f5f0;color:#111}main{max-width:760px;margin:0 auto;padding:40px 20px}h1{font-size:24px;margin:0 0 4px}
|
|
101
|
+
.muted{color:#666}.card{background:#fff;border-radius:12px;padding:18px 20px;margin-top:16px;box-shadow:0 0 0 1px #0000000f}table{width:100%;border-collapse:collapse}
|
|
102
|
+
td,th{text-align:left;padding:6px 0;border-bottom:1px solid #eee;font-size:14px}th{font-weight:500;color:#666}code{background:#f0efe9;padding:1px 5px;border-radius:4px}
|
|
103
|
+
input{padding:8px 10px;border:1px solid #ccc;border-radius:8px}button{padding:8px 14px;border:0;border-radius:8px;background:#0b5d3b;color:#fff;cursor:pointer}</style>
|
|
104
|
+
<main>${body}</main></html>`;
|
|
105
|
+
|
|
106
|
+
async function home(store) {
|
|
107
|
+
const canOrders = SCOPES.includes("read_orders") || SCOPES.includes("write_orders");
|
|
108
|
+
const data = await graphql(
|
|
109
|
+
store,
|
|
110
|
+
`{ shop { name myshopifyDomain } products(first: 5) { nodes { title totalInventory } } ${canOrders ? "orders(first: 5) { nodes { name createdAt displayFinancialStatus totalPrice { amount currencyCode } } }" : ""} }`,
|
|
111
|
+
);
|
|
112
|
+
const rows = (items, cells) => items.map((x) => `<tr>${cells(x).map((v) => `<td>${esc(v)}</td>`).join("")}</tr>`).join("") || `<tr><td class="muted">Nothing yet</td></tr>`;
|
|
113
|
+
return page(
|
|
114
|
+
config.name,
|
|
115
|
+
`<h1>${esc(config.name)}</h1><p class="muted">Installed on <b>${esc(data.shop.name)}</b> · ${esc(data.shop.myshopifyDomain)}</p>
|
|
116
|
+
<div class="card"><b>Latest products</b><table><tr><th>Title</th><th>Stock</th></tr>${rows(data.products.nodes, (p) => [p.title, p.totalInventory])}</table></div>
|
|
117
|
+
${canOrders ? `<div class="card"><b>Latest orders</b><table><tr><th>Order</th><th>Status</th><th>Total</th></tr>${rows(data.orders.nodes, (o) => [o.name, o.displayFinancialStatus, `${o.totalPrice.currencyCode} ${o.totalPrice.amount}`])}</table></div>` : ""}
|
|
118
|
+
<div class="card"><b>Webhooks received</b> <span class="muted">(since the app started)</span><table>${rows(recentWebhooks.filter((w) => w.store === store), (w) => [w.at, w.topic])}</table></div>
|
|
119
|
+
<p class="muted">Edit <code>server.mjs</code> to build your app. Docs: ${esc(ADMIN_URL.replace("admin", "dev"))}/docs</p>`,
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const landing = () =>
|
|
124
|
+
page(config.name, `<h1>${esc(config.name)}</h1><p class="muted">Open this app from your Sellify admin, or install it on a store:</p>
|
|
125
|
+
<form class="card" action="/install"><input name="store" placeholder="your-store" required> <button>Install</button></form>`);
|
|
126
|
+
|
|
127
|
+
// ---------- Server ----------
|
|
128
|
+
|
|
129
|
+
const send = (res, status, body, headers = {}) => res.writeHead(status, { "Content-Type": "text/html; charset=utf-8", ...headers }).end(body);
|
|
130
|
+
const redirect = (res, location, headers = {}) => res.writeHead(302, { Location: location, ...headers }).end();
|
|
131
|
+
const readBody = (req) => new Promise((resolve) => {
|
|
132
|
+
const chunks = [];
|
|
133
|
+
req.on("data", (c) => chunks.push(c)).on("end", () => resolve(Buffer.concat(chunks)));
|
|
134
|
+
});
|
|
135
|
+
const validStore = (s) => /^[a-z0-9][a-z0-9-]{0,62}$/.test(s ?? "");
|
|
136
|
+
|
|
137
|
+
async function handle(req, res) {
|
|
138
|
+
const url = new URL(req.url, APP_URL);
|
|
139
|
+
const q = url.searchParams;
|
|
140
|
+
|
|
141
|
+
if (url.pathname === "/health") return send(res, 200, "ok", { "Content-Type": "text/plain" });
|
|
142
|
+
|
|
143
|
+
// 1. Start an install: send the merchant to Sellify to approve the scopes.
|
|
144
|
+
if (url.pathname === "/install") {
|
|
145
|
+
const store = q.get("store")?.trim().toLowerCase();
|
|
146
|
+
if (!validStore(store)) return send(res, 400, landing());
|
|
147
|
+
const state = randomBytes(16).toString("hex");
|
|
148
|
+
const authorize = new URL(`${ADMIN_URL}/oauth/authorize`);
|
|
149
|
+
for (const [k, v] of Object.entries({ client_id: CLIENT_ID, scope: SCOPES.join(","), redirect_uri: REDIRECT_URL, state, store })) authorize.searchParams.set(k, v);
|
|
150
|
+
return redirect(res, authorize.toString(), { "Set-Cookie": setCookie("sellify_state", state, 600) });
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// 2. The merchant approved (or cancelled): check the signature and state, then swap the code for a token.
|
|
154
|
+
if (url.pathname === (config.redirect_path ?? "/auth/callback")) {
|
|
155
|
+
if (!verifyQuery(q)) return send(res, 400, page("Error", "<h1>Invalid signature</h1><p>Start the install again.</p>"));
|
|
156
|
+
if (q.get("state") !== cookies(req).sellify_state) return send(res, 400, page("Error", "<h1>This install link expired</h1><p>Start the install again.</p>"));
|
|
157
|
+
if (q.get("error")) return send(res, 200, page("Cancelled", "<h1>Install cancelled</h1>"));
|
|
158
|
+
const store = q.get("store");
|
|
159
|
+
const r = await fetch(`${ADMIN_URL}/api/oauth/access_token`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ client_id: CLIENT_ID, client_secret: CLIENT_SECRET, code: q.get("code") }) });
|
|
160
|
+
const grant = await r.json();
|
|
161
|
+
if (!r.ok || !grant.access_token) return send(res, 400, page("Error", `<h1>Couldn't finish installing</h1><p>${esc(grant.error)}</p>`));
|
|
162
|
+
stores[store] = { token: grant.access_token, scope: grant.scope, shop: q.get("shop"), installedAt: new Date().toISOString() };
|
|
163
|
+
saveStores();
|
|
164
|
+
await syncWebhooks(store).catch((e) => console.warn(`[webhooks] ${store}: ${e.message}`));
|
|
165
|
+
console.log(`[install] ${store} installed the app (${grant.scope})`);
|
|
166
|
+
return redirect(res, "/", { "Set-Cookie": [setCookie("sellify_store", sign(store), 86400), setCookie("sellify_state", "", 0)] });
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// 3. Webhooks: check the signature on the raw body before trusting anything in it.
|
|
170
|
+
if (url.pathname === (config.webhook_path ?? "/webhooks") && req.method === "POST") {
|
|
171
|
+
const raw = await readBody(req);
|
|
172
|
+
if (!verifyWebhook(raw, req.headers["x-sellify-hmac-sha256"])) return send(res, 401, "bad signature", { "Content-Type": "text/plain" });
|
|
173
|
+
const topic = req.headers["x-sellify-topic"];
|
|
174
|
+
const payload = JSON.parse(raw.toString("utf8"));
|
|
175
|
+
const shop = req.headers["x-sellify-shop-domain"];
|
|
176
|
+
const store = payload.store ?? Object.keys(stores).find((s) => stores[s].shop === shop) ?? shop;
|
|
177
|
+
if (topic === "app/uninstalled") {
|
|
178
|
+
delete stores[store];
|
|
179
|
+
saveStores();
|
|
180
|
+
}
|
|
181
|
+
recentWebhooks.unshift({ at: new Date().toLocaleTimeString(), topic, store });
|
|
182
|
+
recentWebhooks.length = Math.min(recentWebhooks.length, 20);
|
|
183
|
+
console.log(`[webhook] ${topic} from ${store}`);
|
|
184
|
+
return send(res, 200, "ok", { "Content-Type": "text/plain" });
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// 4. The app's page. Opened from the Sellify admin with a signed link, or after installing (cookie).
|
|
188
|
+
if (url.pathname === "/") {
|
|
189
|
+
let store = unsign(cookies(req).sellify_store);
|
|
190
|
+
const headers = {};
|
|
191
|
+
if (q.has("hmac")) {
|
|
192
|
+
if (!verifyQuery(q)) return send(res, 400, page("Error", "<h1>Invalid signature</h1>"));
|
|
193
|
+
store = q.get("store");
|
|
194
|
+
headers["Set-Cookie"] = setCookie("sellify_store", sign(store), 86400);
|
|
195
|
+
}
|
|
196
|
+
if (!store) return send(res, 200, landing());
|
|
197
|
+
if (!stores[store]) return redirect(res, `/install?store=${encodeURIComponent(store)}`);
|
|
198
|
+
try {
|
|
199
|
+
return send(res, 200, await home(store), headers);
|
|
200
|
+
} catch (e) {
|
|
201
|
+
if (!e.revoked) throw e;
|
|
202
|
+
delete stores[store];
|
|
203
|
+
saveStores();
|
|
204
|
+
return redirect(res, `/install?store=${encodeURIComponent(store)}`);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
send(res, 404, page("Not found", "<h1>Not found</h1>"));
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
createServer((req, res) =>
|
|
212
|
+
handle(req, res).catch((e) => {
|
|
213
|
+
console.error(e);
|
|
214
|
+
if (!res.headersSent) send(res, 500, page("Error", "<h1>Something went wrong</h1>"));
|
|
215
|
+
}),
|
|
216
|
+
).listen(PORT, () => {
|
|
217
|
+
console.log(`${config.name} is running on ${APP_URL} (port ${PORT})`);
|
|
218
|
+
// Installs from earlier runs: move their webhooks to this run's URL.
|
|
219
|
+
for (const store of Object.keys(stores)) syncWebhooks(store).catch((e) => console.warn(`[webhooks] ${store}: ${e.message}`));
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
function loadEnvFile(file) {
|
|
223
|
+
if (!existsSync(file)) return;
|
|
224
|
+
for (const line of readFileSync(file, "utf8").split(/\r?\n/)) {
|
|
225
|
+
const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*?)\s*$/);
|
|
226
|
+
if (m && process.env[m[1]] === undefined) process.env[m[1]] = m[2].replace(/^(['"])(.*)\1$/, "$2");
|
|
227
|
+
}
|
|
228
|
+
}
|