sellify-cli 0.1.2 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # sellify-cli
2
2
 
3
- Command line for Sellify stores: build themes in your own editor with live sync, and call the GraphQL Admin API.
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>
@@ -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`) and call the GraphQL Admin API.
3
- // No dependencies; needs Node 20+. Authenticates with a custom app access token (read_themes / write_themes).
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";
@@ -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 key = a === "theme" ? `theme ${b ?? ""}` : a;
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, a === "theme" ? rest : [b, ...rest].filter(Boolean));
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.1.2",
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,3 @@
1
+ node_modules/
2
+ .env
3
+ data/
@@ -0,0 +1,12 @@
1
+ {
2
+ "name": "my-sellify-app",
3
+ "private": true,
4
+ "type": "module",
5
+ "scripts": {
6
+ "dev": "sellify app dev",
7
+ "start": "node server.mjs"
8
+ },
9
+ "engines": {
10
+ "node": ">=20"
11
+ }
12
+ }
@@ -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
+ }