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.
- package/README.md +94 -0
- package/cli/bin/cli.ts +240 -0
- package/cli/server/api.ts +56 -0
- package/cli/server/index.ts +320 -0
- package/cli/server/token-store.ts +35 -0
- package/cli/server/tunnel.ts +161 -0
- package/cli/template/theme/config.json +123 -0
- package/cli/template/theme/schema.json +145 -0
- package/cli/template/theme/script.js +412 -0
- package/cli/template/theme/sections/breadcrumbs.liquid +17 -0
- package/cli/template/theme/sections/categories.liquid +10 -0
- package/cli/template/theme/sections/fake-counter.liquid +27 -0
- package/cli/template/theme/sections/fake-stock.liquid +6 -0
- package/cli/template/theme/sections/fake-visitor.liquid +6 -0
- package/cli/template/theme/sections/featured-products.liquid +110 -0
- package/cli/template/theme/sections/fixed-buy-button.liquid +46 -0
- package/cli/template/theme/sections/footer.liquid +129 -0
- package/cli/template/theme/sections/gallery.liquid +61 -0
- package/cli/template/theme/sections/header.liquid +152 -0
- package/cli/template/theme/sections/home-products-grid.liquid +110 -0
- package/cli/template/theme/sections/list-products.liquid +93 -0
- package/cli/template/theme/sections/order-invoice.liquid +154 -0
- package/cli/template/theme/sections/product-description.liquid +30 -0
- package/cli/template/theme/sections/product-details.liquid +63 -0
- package/cli/template/theme/sections/products-grid.liquid +86 -0
- package/cli/template/theme/sections/related-products.liquid +88 -0
- package/cli/template/theme/sections/reviews.liquid +55 -0
- package/cli/template/theme/sections/slider.liquid +43 -0
- package/cli/template/theme/sections/thanks.liquid +33 -0
- package/cli/template/theme/style.css +3923 -0
- package/cli/template/theme/theme-data.json +9 -0
- 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();
|