easyorders 0.1.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.
Files changed (32) hide show
  1. package/README.md +94 -0
  2. package/cli/bin/cli.ts +240 -0
  3. package/cli/server/api.ts +56 -0
  4. package/cli/server/index.ts +320 -0
  5. package/cli/server/token-store.ts +35 -0
  6. package/cli/server/tunnel.ts +161 -0
  7. package/cli/template/theme/config.json +123 -0
  8. package/cli/template/theme/schema.json +145 -0
  9. package/cli/template/theme/script.js +412 -0
  10. package/cli/template/theme/sections/breadcrumbs.liquid +17 -0
  11. package/cli/template/theme/sections/categories.liquid +10 -0
  12. package/cli/template/theme/sections/fake-counter.liquid +27 -0
  13. package/cli/template/theme/sections/fake-stock.liquid +6 -0
  14. package/cli/template/theme/sections/fake-visitor.liquid +6 -0
  15. package/cli/template/theme/sections/featured-products.liquid +110 -0
  16. package/cli/template/theme/sections/fixed-buy-button.liquid +46 -0
  17. package/cli/template/theme/sections/footer.liquid +129 -0
  18. package/cli/template/theme/sections/gallery.liquid +61 -0
  19. package/cli/template/theme/sections/header.liquid +152 -0
  20. package/cli/template/theme/sections/home-products-grid.liquid +110 -0
  21. package/cli/template/theme/sections/list-products.liquid +93 -0
  22. package/cli/template/theme/sections/order-invoice.liquid +154 -0
  23. package/cli/template/theme/sections/product-description.liquid +30 -0
  24. package/cli/template/theme/sections/product-details.liquid +63 -0
  25. package/cli/template/theme/sections/products-grid.liquid +86 -0
  26. package/cli/template/theme/sections/related-products.liquid +88 -0
  27. package/cli/template/theme/sections/reviews.liquid +55 -0
  28. package/cli/template/theme/sections/slider.liquid +43 -0
  29. package/cli/template/theme/sections/thanks.liquid +33 -0
  30. package/cli/template/theme/style.css +3923 -0
  31. package/cli/template/theme/theme-data.json +9 -0
  32. package/package.json +37 -0
package/README.md ADDED
@@ -0,0 +1,94 @@
1
+ # Easy Orders CLI
2
+
3
+ CLI tool for creating and developing [EasyOrders](https://www.easy-orders.net) storefront themes. Edit Liquid templates, CSS, and JavaScript in the `theme/` folder and preview changes on a live store instantly.
4
+
5
+ Full theme documentation — Liquid variables, palette, sections, events:
6
+
7
+ **https://themes-docs.easy-orders.net/docs/custom-themes/getting-started**
8
+
9
+ ## ⚠️ Important Notice
10
+
11
+ The tunnel URL is tied to your current browser session. When you stop the dev server (tunnel) or close the theme editor, you **must close the browser entirely** to end the session. Otherwise the browser may continue trying to load the old tunnel URL, causing errors on subsequent runs.
12
+
13
+ ## Installation
14
+
15
+ ```bash
16
+ npm install -g easy-orders
17
+ ```
18
+
19
+ Or use directly with `npx`:
20
+
21
+ ```bash
22
+ npx easy-orders
23
+ ```
24
+
25
+ ## Usage
26
+
27
+ ### Create a new theme project
28
+
29
+ ```bash
30
+ npx easy-orders create-theme my-theme
31
+ ```
32
+
33
+ This scaffolds a new directory `my-theme/` containing only the `theme/` folder and a minimal `package.json`. No server code is included — the server runs from the CLI package itself.
34
+
35
+ ### Start the dev server
36
+
37
+ ```bash
38
+ cd my-theme
39
+ npx easy-orders start
40
+ ```
41
+
42
+ This will:
43
+
44
+ 1. Prompt you for your store subdomain (e.g. `sand` from `sand.myeasyorders.com`)
45
+ 2. Authenticate with the Easy Orders API
46
+ 3. Start a local Express server that serves your theme
47
+ 4. Open a Cloudflare tunnel so your store can load the theme in real-time
48
+
49
+ ### Interactive mode
50
+
51
+ Running `npx easy-orders` without arguments shows an interactive menu where you can choose to create a theme or start the dev server.
52
+
53
+ ## Environment Variables
54
+
55
+ Create a `.env` file in your theme project root (or export the variables) to customise the defaults:
56
+
57
+ | Variable | Description | Default |
58
+ | ------------------- | ------------------------------------------------- | ----------------------------- |
59
+ | `PORT` | Local server port | `4000` |
60
+ | `API_BASE_URL` | Backend API base URL used for CLI token endpoints | `https://api.easy-orders.net` |
61
+ | `FRONTEND_BASE_URL` | Frontend base URL used for the approval page | `https://app.easy-orders.net` |
62
+ | `STORE_DOMAIN` | Domain used to build the store preview URL | `myeasyorders.com` |
63
+
64
+ ## Theme Project Structure
65
+
66
+ After running `create-theme`, your project will look like:
67
+
68
+ ```
69
+ my-theme/
70
+ ├── package.json
71
+ ├── .gitignore
72
+ └── theme/
73
+ ├── config.json
74
+ ├── schema.json
75
+ ├── theme-data.json
76
+ ├── style.css
77
+ ├── script.js
78
+ └── sections/
79
+ ├── header.liquid
80
+ ├── footer.liquid
81
+ └── ...
82
+ ```
83
+
84
+ You only need to edit files inside the `theme/` folder. The server code is handled by the CLI.
85
+
86
+ ## 🔑 Clearing Sessions & Auth
87
+
88
+ If you want to delete any saved sessions or store authentication, simply delete the `.cli-tokens.json` file in your theme project root:
89
+
90
+ ```bash
91
+ rm .cli-tokens.json
92
+ ```
93
+
94
+ The next time you run the dev server, you will be prompted to authenticate again.
package/cli/bin/cli.ts ADDED
@@ -0,0 +1,240 @@
1
+ #!/usr/bin/env tsx
2
+
3
+ import "dotenv/config";
4
+ import readline from "node:readline";
5
+ import fs from "node:fs";
6
+ import path from "node:path";
7
+ import { spawn } from "node:child_process";
8
+ import { fileURLToPath } from "node:url";
9
+ import { getCliVersion } from "../server/api.js";
10
+ // @ts-ignore
11
+ const __filename = fileURLToPath(import.meta.url);
12
+ const __dirname = path.dirname(__filename);
13
+ const CLI_ROOT = path.resolve(__dirname, "..");
14
+
15
+ const CYAN = "\x1b[36m";
16
+ const GREEN = "\x1b[32m";
17
+ const RED = "\x1b[31m";
18
+ const DIM = "\x1b[2m";
19
+ const BOLD = "\x1b[1m";
20
+ const RESET = "\x1b[0m";
21
+ const YELLOW = "\x1b[33m";
22
+
23
+ // ── Helpers ─────────────────────────────────────────────────
24
+
25
+ function ask(question: string): Promise<string> {
26
+ const rl = readline.createInterface({
27
+ input: process.stdin,
28
+ output: process.stdout,
29
+ });
30
+ return new Promise((resolve) => {
31
+ rl.question(question, (answer) => {
32
+ rl.close();
33
+ resolve(answer.trim());
34
+ });
35
+ });
36
+ }
37
+
38
+ function copyDirSync(src: string, dest: string): void {
39
+ fs.mkdirSync(dest, { recursive: true });
40
+ for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
41
+ const srcPath = path.join(src, entry.name);
42
+ const destPath = path.join(dest, entry.name);
43
+ if (entry.isDirectory()) {
44
+ copyDirSync(srcPath, destPath);
45
+ } else {
46
+ fs.copyFileSync(srcPath, destPath);
47
+ }
48
+ }
49
+ }
50
+
51
+ // ── Commands ────────────────────────────────────────────────
52
+
53
+ async function createTheme(): Promise<void> {
54
+ let projectName = process.argv[3];
55
+
56
+ if (!projectName) {
57
+ projectName = await ask(` ${BOLD}▸ Theme project name: ${RESET}`);
58
+ }
59
+
60
+ if (!projectName) {
61
+ console.error(`\n ${RED}${BOLD}✖ Project name is required.${RESET}\n`);
62
+ process.exit(1);
63
+ }
64
+
65
+ const dest = path.resolve(process.cwd(), projectName);
66
+
67
+ if (fs.existsSync(dest)) {
68
+ console.error(
69
+ `\n ${RED}${BOLD}✖ Directory "${projectName}" already exists.${RESET}\n`
70
+ );
71
+ process.exit(1);
72
+ }
73
+
74
+ console.log(
75
+ `\n ${DIM}Creating theme project${RESET} ${BOLD}${projectName}${RESET}…`
76
+ );
77
+
78
+ // Create project directory
79
+ fs.mkdirSync(dest, { recursive: true });
80
+
81
+ // Copy template theme folder
82
+ const templateDir = path.join(CLI_ROOT, "template", "theme");
83
+ const themeDir = path.join(dest, "theme");
84
+ copyDirSync(templateDir, themeDir);
85
+
86
+ // Create a minimal package.json for the theme project
87
+ const pkg = {
88
+ name: projectName,
89
+ version: "0.1.0",
90
+ private: true,
91
+ scripts: {
92
+ dev: "easy-orders start",
93
+ },
94
+ };
95
+ fs.writeFileSync(
96
+ path.join(dest, "package.json"),
97
+ JSON.stringify(pkg, null, 2) + "\n",
98
+ "utf-8"
99
+ );
100
+
101
+ // Create .gitignore
102
+ fs.writeFileSync(
103
+ path.join(dest, ".gitignore"),
104
+ [
105
+ "node_modules",
106
+ ".tunnel-url",
107
+ ".tunnel-pid",
108
+ ".auth-ready",
109
+ ".cli-tokens.json",
110
+ ".env",
111
+ ].join("\n") + "\n",
112
+ "utf-8"
113
+ );
114
+
115
+ console.log(` ${GREEN}${BOLD}✔${RESET} Theme project created at ${BOLD}${dest}${RESET}`);
116
+ console.log("");
117
+ console.log(` ${DIM}Next steps:${RESET}`);
118
+ console.log(` ${CYAN}cd ${projectName}${RESET}`);
119
+ console.log(` ${CYAN}npx easy-orders start${RESET}`);
120
+ console.log("");
121
+ }
122
+
123
+ async function startDev(): Promise<void> {
124
+ // Resolve the theme directory from the current working directory
125
+ const cwd = process.cwd();
126
+ const themeDir = path.join(cwd, "theme");
127
+
128
+ if (!fs.existsSync(themeDir)) {
129
+ console.error(
130
+ `\n ${RED}${BOLD}✖ No "theme" folder found in the current directory.${RESET}`
131
+ );
132
+ console.error(
133
+ ` ${DIM}Run ${CYAN}npx easy-orders create-theme <name>${DIM} first to create a project.${RESET}\n`
134
+ );
135
+ process.exit(1);
136
+ }
137
+
138
+ // The server files are bundled inside the CLI package
139
+ const serverDir = path.join(CLI_ROOT, "server");
140
+ const indexScript = path.join(serverDir, "index.ts");
141
+ const tunnelScript = path.join(serverDir, "tunnel.ts");
142
+
143
+ const cmd = `npx tsx "${tunnelScript}" & npx tsx "${indexScript}"`;
144
+
145
+ const child = spawn(cmd, {
146
+ stdio: "inherit",
147
+ cwd,
148
+ env: { ...process.env, THEME_DIR: themeDir },
149
+ shell: true,
150
+ });
151
+
152
+ child.on("exit", (code) => {
153
+ process.exit(code ?? 0);
154
+ });
155
+ }
156
+
157
+ function showMenu(): void {
158
+ console.log("");
159
+ console.log(` ${CYAN}${BOLD}Easy Orders CLI${RESET}`);
160
+ console.log("");
161
+ console.log(` ${BOLD}Usage:${RESET}`);
162
+ console.log(` ${CYAN}npx easy-orders create-theme ${DIM}<name>${RESET} Create a new theme project`);
163
+ console.log(` ${CYAN}npx easy-orders start${RESET} Start the dev server`);
164
+ console.log("");
165
+ }
166
+
167
+ async function interactiveMenu(): Promise<void> {
168
+ console.log("");
169
+ console.log(` ${CYAN}${BOLD}Easy Orders CLI${RESET}`);
170
+ console.log("");
171
+ console.log(` ${BOLD}What would you like to do?${RESET}`);
172
+ console.log(` ${BOLD}1)${RESET} Create a new theme`);
173
+ console.log(` ${BOLD}2)${RESET} Start dev server`);
174
+ console.log("");
175
+
176
+ const choice = await ask(` ${BOLD}▸ Choose (1/2): ${RESET}`);
177
+
178
+ switch (choice) {
179
+ case "1":
180
+ await createTheme();
181
+ break;
182
+ case "2":
183
+ await startDev();
184
+ break;
185
+ default:
186
+ console.error(`\n ${RED}${BOLD}✖ Invalid choice.${RESET}\n`);
187
+ showMenu();
188
+ process.exit(1);
189
+ }
190
+ }
191
+
192
+ // ── Version check ───────────────────────────────────────────
193
+
194
+ async function checkForUpdates(): Promise<void> {
195
+ const pkgPath = path.resolve(__dirname, "..", "..", "package.json");
196
+ const localVersion: string = JSON.parse(
197
+ fs.readFileSync(pkgPath, "utf-8")
198
+ ).version;
199
+
200
+ try {
201
+ const latestVersion = await getCliVersion();
202
+ if (latestVersion !== localVersion) {
203
+ console.log("");
204
+ console.log(
205
+ ` ${YELLOW}${BOLD}⚠ Update available!${RESET} ${DIM}${localVersion}${RESET} → ${GREEN}${BOLD}${latestVersion}${RESET}`
206
+ );
207
+ console.log(
208
+ ` ${DIM}Run${RESET} ${CYAN}npm install -g easy-orders@latest${RESET} ${DIM}to update.${RESET}`
209
+ );
210
+ console.log("");
211
+ process.exit(1);
212
+ }
213
+ } catch {
214
+ // If the version check fails (e.g. no network), skip silently
215
+ }
216
+ }
217
+
218
+ // ── Entry point ─────────────────────────────────────────────
219
+
220
+ (async () => {
221
+ await checkForUpdates();
222
+
223
+ const command = process.argv[2];
224
+
225
+ switch (command) {
226
+ case "create-theme":
227
+ await createTheme();
228
+ break;
229
+ case "start":
230
+ await startDev();
231
+ break;
232
+ case "--help":
233
+ case "-h":
234
+ showMenu();
235
+ break;
236
+ default:
237
+ await interactiveMenu();
238
+ break;
239
+ }
240
+ })();
@@ -0,0 +1,56 @@
1
+ import axios, { AxiosInstance } from "axios";
2
+
3
+ const API_BASE_URL =
4
+ process.env.API_BASE_URL || "https://api.easy-orders.net";
5
+
6
+ interface GenerateTokenResponse {
7
+ expires_at: string;
8
+ store_id: string;
9
+ token: string;
10
+ token_id: string;
11
+ }
12
+
13
+ interface TokenStatusResponse {
14
+ approved: boolean;
15
+ }
16
+
17
+ function createClient(): AxiosInstance {
18
+ return axios.create({
19
+ baseURL: API_BASE_URL,
20
+ headers: { "Content-Type": "application/json" },
21
+ timeout: 10_000,
22
+ });
23
+ }
24
+
25
+ const client = createClient();
26
+
27
+ export async function generateCliToken(
28
+ storeName: string
29
+ ): Promise<GenerateTokenResponse> {
30
+ const { data } = await client.post<GenerateTokenResponse>(
31
+ "/api/v1/themes/cli-token",
32
+ { store_name: storeName }
33
+ );
34
+ return data;
35
+ }
36
+
37
+ export async function checkTokenStatus(
38
+ tokenId: string
39
+ ): Promise<TokenStatusResponse> {
40
+ const { data } = await client.get<TokenStatusResponse>(
41
+ "/api/v1/themes/cli-token/status",
42
+ { params: { token_id: tokenId } }
43
+ );
44
+ return data;
45
+ }
46
+
47
+ interface CliVersionResponse {
48
+ version: string;
49
+ }
50
+
51
+ export async function getCliVersion(): Promise<string> {
52
+ const { data } = await client.get<CliVersionResponse>(
53
+ "/api/v1/themes/cli-version"
54
+ );
55
+ return data.version;
56
+ }
@@ -0,0 +1,320 @@
1
+ import "dotenv/config";
2
+ import cors from "cors";
3
+ import express from "express";
4
+ import fs from "node:fs";
5
+ import path from "node:path";
6
+ import readline from "node:readline";
7
+ import { generateCliToken, checkTokenStatus } from "./api.js";
8
+ import { getToken, saveToken } from "./token-store.js";
9
+
10
+ const CYAN = "\x1b[36m";
11
+ const GREEN = "\x1b[32m";
12
+ const RED = "\x1b[31m";
13
+ const DIM = "\x1b[2m";
14
+ const BOLD = "\x1b[1m";
15
+ const RESET = "\x1b[0m";
16
+ const UNDERLINE = "\x1b[4m";
17
+ const YELLOW = "\x1b[33m";
18
+
19
+ const FRONTEND_BASE_URL =
20
+ process.env.FRONTEND_BASE_URL || "https://app.easy-orders.net";
21
+ const STORE_DOMAIN =
22
+ process.env.STORE_DOMAIN || "myeasyorders.com";
23
+
24
+ const app = express();
25
+ app.use(cors());
26
+ const port = Number(process.env.PORT) || 4000;
27
+ const URL_FILE = path.resolve(process.cwd(), ".tunnel-url");
28
+ const THEME_DIR = process.env.THEME_DIR || path.resolve(process.cwd(), "theme");
29
+ const SECTIONS_DIR = path.join(THEME_DIR, "sections");
30
+ const AUTH_READY_FILE = path.resolve(process.cwd(), ".auth-ready");
31
+
32
+ app.use(express.json());
33
+
34
+ function getBaseUrl(req: express.Request): string {
35
+ try {
36
+ const tunnelUrl = fs.readFileSync(URL_FILE, "utf-8").trim();
37
+ if (tunnelUrl) return tunnelUrl;
38
+ } catch {}
39
+ return `${req.protocol}://${req.get("host")}`;
40
+ }
41
+
42
+ function toSnakeCase(filename: string): string {
43
+ return filename.replace(/\.liquid$/, "").replace(/-/g, "_");
44
+ }
45
+
46
+ function readJsonFile(filepath: string): unknown {
47
+ return JSON.parse(fs.readFileSync(filepath, "utf-8"));
48
+ }
49
+
50
+ function buildSections(): { key: string; template: string }[] {
51
+ const files = fs
52
+ .readdirSync(SECTIONS_DIR)
53
+ .filter((f) => f.endsWith(".liquid"));
54
+ return files.map((file) => ({
55
+ key: toSnakeCase(file),
56
+ template: fs.readFileSync(path.join(SECTIONS_DIR, file), "utf-8"),
57
+ }));
58
+ }
59
+
60
+ function extractSubdomain(input: string): string {
61
+ const trimmed = input.trim();
62
+ try {
63
+ const url = new URL(trimmed);
64
+ const hostname = url.hostname;
65
+ const parts = hostname.split(".");
66
+ if (parts.length >= 2) {
67
+ return parts[0];
68
+ }
69
+ } catch {
70
+ // Not a URL – continue
71
+ }
72
+ if (trimmed.includes(".")) {
73
+ return trimmed.split(".")[0];
74
+ }
75
+ return trimmed;
76
+ }
77
+
78
+ function promptStoreName(): Promise<string> {
79
+ const args = process.argv.slice(2);
80
+ const idx = args.indexOf("--store_name");
81
+ if (idx !== -1 && idx + 1 < args.length) {
82
+ return Promise.resolve(args[idx + 1]);
83
+ }
84
+ if (process.env.STORE_NAME) {
85
+ return Promise.resolve(process.env.STORE_NAME);
86
+ }
87
+
88
+ const rl = readline.createInterface({
89
+ input: process.stdin,
90
+ output: process.stdout,
91
+ });
92
+
93
+ return new Promise((resolve) => {
94
+ console.log(
95
+ `\n ${CYAN}${BOLD}Enter your store subdomain${RESET} ${DIM}(e.g. "sand" from sand.${STORE_DOMAIN})${RESET}`
96
+ );
97
+ rl.question(` ${BOLD}▸ Subdomain: ${RESET}`, (answer) => {
98
+ rl.close();
99
+ const storeName = extractSubdomain(answer);
100
+ if (!storeName) {
101
+ console.error(
102
+ `\n ${RED}${BOLD}✖ Invalid input.${RESET} Please provide a valid subdomain.\n`
103
+ );
104
+ process.exit(1);
105
+ }
106
+ console.log(
107
+ ` ${GREEN}${BOLD}✔${RESET} ${DIM}Using store:${RESET} ${BOLD}${storeName}${RESET}`
108
+ );
109
+ resolve(storeName);
110
+ });
111
+ });
112
+ }
113
+
114
+ function openBrowser(url: string): void {
115
+ const { exec } = require("node:child_process");
116
+ const cmd =
117
+ process.platform === "darwin"
118
+ ? "open"
119
+ : process.platform === "win32"
120
+ ? "start"
121
+ : "xdg-open";
122
+ exec(`${cmd} "${url}"`);
123
+ }
124
+
125
+ function waitForApproval(tokenId: string): Promise<void> {
126
+ return new Promise((resolve, reject) => {
127
+ const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
128
+ let i = 0;
129
+ const spin = setInterval(() => {
130
+ process.stdout.write(
131
+ `\r ${YELLOW}${frames[i++ % frames.length]}${RESET} ${DIM}Waiting for approval…${RESET}`
132
+ );
133
+ }, 100);
134
+
135
+ const poll = async () => {
136
+ try {
137
+ const { approved } = await checkTokenStatus(tokenId);
138
+ if (approved) {
139
+ clearInterval(spin);
140
+ process.stdout.write("\r\x1b[K");
141
+ return resolve();
142
+ }
143
+ setTimeout(poll, 3000);
144
+ } catch (err: unknown) {
145
+ clearInterval(spin);
146
+ process.stdout.write("\r\x1b[K");
147
+ reject(err);
148
+ }
149
+ };
150
+ poll();
151
+ });
152
+ }
153
+
154
+ async function authenticate(storeName: string): Promise<string> {
155
+ const existing = getToken(storeName);
156
+ if (existing) {
157
+ console.log(
158
+ `\n ${DIM}Found saved token for${RESET} ${BOLD}${storeName}${RESET}${DIM}, checking validity…${RESET}`
159
+ );
160
+ try {
161
+ const { approved } = await checkTokenStatus(existing.token_id);
162
+ if (approved) {
163
+ console.log(` ${GREEN}${BOLD}✔ Existing token is still valid!${RESET}\n`);
164
+ return existing.token;
165
+ }
166
+ console.log(
167
+ ` ${YELLOW}${BOLD}⚠ Saved token is no longer valid. Generating a new one…${RESET}\n`
168
+ );
169
+ } catch {
170
+ console.log(
171
+ ` ${YELLOW}${BOLD}⚠ Could not verify saved token. Generating a new one…${RESET}\n`
172
+ );
173
+ }
174
+ }
175
+
176
+ console.log(
177
+ `\n ${DIM}Generating CLI token for store${RESET} ${BOLD}${storeName}${RESET}…`
178
+ );
179
+
180
+ let tokenData;
181
+ try {
182
+ tokenData = await generateCliToken(storeName);
183
+ } catch (err: unknown) {
184
+ const message =
185
+ err instanceof Error ? err.message : String(err);
186
+ console.error(
187
+ `\n ${RED}${BOLD}✖ Failed to generate CLI token:${RESET} ${message}\n`
188
+ );
189
+ process.exit(1);
190
+ }
191
+
192
+ const { store_id, token_id, token, expires_at } = tokenData;
193
+
194
+ const approveUrl = `${FRONTEND_BASE_URL}/#/approve-cli-token?store_id=${store_id}&token_id=${token_id}`;
195
+
196
+ console.log(
197
+ `\n ${YELLOW}${BOLD}⟶ Please approve the token in your browser:${RESET}`
198
+ );
199
+ console.log(` ${CYAN}${UNDERLINE}${approveUrl}${RESET}\n`);
200
+
201
+ openBrowser(approveUrl);
202
+
203
+ try {
204
+ await waitForApproval(token_id);
205
+ } catch (err: unknown) {
206
+ const message =
207
+ err instanceof Error ? err.message : String(err);
208
+ console.error(
209
+ `\n ${RED}${BOLD}✖ Token approval check failed:${RESET} ${message}\n`
210
+ );
211
+ process.exit(1);
212
+ }
213
+
214
+ console.log(` ${GREEN}${BOLD}✔ Token approved!${RESET}\n`);
215
+
216
+ saveToken({
217
+ store_name: storeName,
218
+ store_id,
219
+ token_id,
220
+ token,
221
+ expires_at,
222
+ });
223
+
224
+ return token;
225
+ }
226
+
227
+ // ── Express routes ──────────────────────────────────────────
228
+
229
+ app.get("/health", (_req, res) => {
230
+ res.json({ ok: true });
231
+ });
232
+
233
+ app.get("/style.css", (_req, res) => {
234
+ const cssPath = path.join(THEME_DIR, "style.css");
235
+ try {
236
+ const css = fs.readFileSync(cssPath, "utf-8");
237
+ res.type("text/css").send(css);
238
+ } catch {
239
+ res.status(404).type("text/css").send("/* style.css not found */");
240
+ }
241
+ });
242
+
243
+ app.get("/script.js", (_req, res) => {
244
+ const jsPath = path.join(THEME_DIR, "script.js");
245
+ try {
246
+ const js = fs.readFileSync(jsPath, "utf-8");
247
+ res.type("application/javascript").send(js);
248
+ } catch {
249
+ res
250
+ .status(404)
251
+ .type("application/javascript")
252
+ .send("// script.js not found");
253
+ }
254
+ });
255
+
256
+ app.get("/", (_req, res) => {
257
+ res.json({ version: "1.0.0" });
258
+ });
259
+
260
+ app.get("/theme", (req, res) => {
261
+ const baseUrl = getBaseUrl(req);
262
+
263
+ res.json({
264
+ sections: buildSections(),
265
+ theme_data: readJsonFile(path.join(THEME_DIR, "theme-data.json")),
266
+ config: readJsonFile(path.join(THEME_DIR, "config.json")),
267
+ style: `${baseUrl}/style.css`,
268
+ script: `${baseUrl}/script.js`,
269
+ });
270
+ });
271
+
272
+ // ── Main ────────────────────────────────────────────────────
273
+
274
+ function signalAuthReady(storeName: string, code: string): void {
275
+ const data = JSON.stringify({ store_name: storeName, code });
276
+ fs.writeFileSync(AUTH_READY_FILE, data, "utf-8");
277
+ }
278
+
279
+ function cleanupAuthReady(): void {
280
+ try {
281
+ fs.unlinkSync(AUTH_READY_FILE);
282
+ } catch {}
283
+ }
284
+
285
+ async function main() {
286
+ cleanupAuthReady();
287
+
288
+ const storeName = await promptStoreName();
289
+ const code = await authenticate(storeName);
290
+
291
+ signalAuthReady(storeName, code);
292
+
293
+ app.listen(port, () => {
294
+ console.log("");
295
+ console.log(` ${CYAN}${BOLD}Theme Builder${RESET}`);
296
+ console.log(` ${DIM}Server listening on${RESET} http://localhost:${port}`);
297
+
298
+ try {
299
+ const tunnelUrl = fs.readFileSync(URL_FILE, "utf-8").trim();
300
+ if (tunnelUrl) {
301
+ const themeUrl = `${tunnelUrl}/theme`;
302
+ const storeUrl = `https://${storeName}.${STORE_DOMAIN}?load_theme=${encodeURIComponent(themeUrl)}&code=${code}`;
303
+
304
+ console.log(
305
+ ` ${GREEN}${BOLD}✔${RESET} ${DIM}Tunnel:${RESET} ${CYAN}${UNDERLINE}${tunnelUrl}${RESET}`
306
+ );
307
+ console.log(
308
+ ` ${GREEN}${BOLD}✔${RESET} ${DIM}Open your store:${RESET} ${CYAN}${UNDERLINE}${storeUrl}${RESET}`
309
+ );
310
+ }
311
+ } catch {}
312
+
313
+ console.log("");
314
+ });
315
+ }
316
+
317
+ process.once("SIGINT", () => cleanupAuthReady());
318
+ process.once("SIGTERM", () => cleanupAuthReady());
319
+
320
+ main();