@storifycli/cli 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 (3) hide show
  1. package/README.md +156 -0
  2. package/dist/cli.js +1737 -0
  3. package/package.json +51 -0
package/dist/cli.js ADDED
@@ -0,0 +1,1737 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/cli.ts
4
+ import { readFileSync } from "fs";
5
+ import path12 from "path";
6
+ import { fileURLToPath } from "url";
7
+ import { Command } from "commander";
8
+
9
+ // src/lib/constants.ts
10
+ var MAX_MANIFEST_BYTES = 95 * 1024;
11
+ var MAX_ZIP_BYTES = 50 * 1024 * 1024;
12
+ var MAX_SINGLE_FILE_BYTES = 10 * 1024 * 1024;
13
+ var DEFAULT_API_URL = "http://localhost:3001/api";
14
+ var DEFAULT_STOREFRONT_URL = "http://localhost:3004";
15
+ var DEFAULT_ADMIN_URL = "http://localhost:3003";
16
+ var CONFIG_DIR_NAME = ".storify";
17
+ var CONFIG_FILE_NAME = "config.json";
18
+
19
+ // src/lib/config.ts
20
+ import os from "os";
21
+ import path from "path";
22
+ import { promises as fs } from "fs";
23
+ function getConfigPath() {
24
+ return path.join(os.homedir(), CONFIG_DIR_NAME, CONFIG_FILE_NAME);
25
+ }
26
+ async function readStoredConfig() {
27
+ const configPath = getConfigPath();
28
+ try {
29
+ const raw = await fs.readFile(configPath, "utf8");
30
+ const parsed = JSON.parse(raw);
31
+ return parsed && typeof parsed === "object" ? parsed : {};
32
+ } catch {
33
+ return {};
34
+ }
35
+ }
36
+ async function writeStoredConfig(nextConfig) {
37
+ const configPath = getConfigPath();
38
+ const configDir = path.dirname(configPath);
39
+ await fs.mkdir(configDir, { recursive: true });
40
+ await fs.writeFile(configPath, `${JSON.stringify(nextConfig, null, 2)}
41
+ `, { mode: 384 });
42
+ }
43
+ async function resolveAuthConfig(overrides = {}) {
44
+ const stored = await readStoredConfig();
45
+ const apiUrl = overrides.api || process.env.STORIFY_API_URL || stored.apiUrl || DEFAULT_API_URL;
46
+ const storefrontUrl = process.env.STORIFY_STOREFRONT_URL || stored.storefrontUrl || DEFAULT_STOREFRONT_URL;
47
+ const adminUrl = process.env.STORIFY_ADMIN_URL || stored.adminUrl || DEFAULT_ADMIN_URL;
48
+ const storeId = overrides.storeId || process.env.STORIFY_STORE_ID || stored.storeId;
49
+ const token = overrides.token || process.env.STORIFY_AUTH_TOKEN || stored.token;
50
+ if (!storeId || !storeId.trim()) {
51
+ throw new Error("Missing store id. Provide --store-id, STORIFY_STORE_ID, or run `storify login`.");
52
+ }
53
+ if (!token || !token.trim()) {
54
+ throw new Error("Missing auth token. Provide --token, STORIFY_AUTH_TOKEN, or run `storify login`.");
55
+ }
56
+ return {
57
+ apiUrl: apiUrl.replace(/\/+$/, ""),
58
+ storeId: storeId.trim(),
59
+ token: token.trim(),
60
+ storefrontUrl: storefrontUrl.replace(/\/+$/, ""),
61
+ adminUrl: adminUrl.replace(/\/+$/, "")
62
+ };
63
+ }
64
+ function getConfigFilePathForDisplay() {
65
+ return getConfigPath();
66
+ }
67
+
68
+ // src/lib/auth-api.ts
69
+ function normalizeApiUrl(apiUrl) {
70
+ return apiUrl.replace(/\/+$/, "");
71
+ }
72
+ function extractErrorMessage(body, fallback) {
73
+ if (body && typeof body === "object" && "error" in body && typeof body.error === "string") {
74
+ return body.error;
75
+ }
76
+ return fallback;
77
+ }
78
+ async function loginWithPassword(apiUrl, email, password2) {
79
+ const base = normalizeApiUrl(apiUrl);
80
+ const response = await fetch(`${base}/auth/login`, {
81
+ method: "POST",
82
+ headers: { "Content-Type": "application/json" },
83
+ body: JSON.stringify({ email: email.trim(), password: password2 })
84
+ });
85
+ const body = await response.json().catch(() => ({}));
86
+ if (!response.ok) {
87
+ throw new Error(extractErrorMessage(body, `Login failed (${response.status})`));
88
+ }
89
+ const accessToken = typeof body.accessToken === "string" ? body.accessToken : "";
90
+ if (!accessToken) {
91
+ throw new Error("Login succeeded but no access token was returned. Set LEGACY_TOKEN_RESPONSE=true on the API.");
92
+ }
93
+ return {
94
+ accessToken,
95
+ refreshToken: typeof body.refreshToken === "string" ? body.refreshToken : void 0,
96
+ user: body.user
97
+ };
98
+ }
99
+ async function fetchUserStores(apiUrl, token) {
100
+ const base = normalizeApiUrl(apiUrl);
101
+ const response = await fetch(`${base}/auth/stores`, {
102
+ headers: {
103
+ Authorization: `Bearer ${token}`,
104
+ Accept: "application/json"
105
+ }
106
+ });
107
+ const body = await response.json().catch(() => ({}));
108
+ if (!response.ok) {
109
+ throw new Error(body.error || `Failed to load stores (${response.status})`);
110
+ }
111
+ return Array.isArray(body.stores) ? body.stores : [];
112
+ }
113
+ async function verifyAuthToken(apiUrl, token) {
114
+ const base = normalizeApiUrl(apiUrl || DEFAULT_API_URL);
115
+ const response = await fetch(`${base}/auth/me`, {
116
+ headers: {
117
+ Authorization: `Bearer ${token}`,
118
+ Accept: "application/json"
119
+ }
120
+ });
121
+ return response.ok;
122
+ }
123
+
124
+ // src/lib/login-prompts.ts
125
+ import * as p from "@clack/prompts";
126
+
127
+ // src/lib/local-dev.ts
128
+ function isLocalHostUrl(value) {
129
+ if (!value) return false;
130
+ try {
131
+ const host = new URL(value).hostname.toLowerCase();
132
+ return host === "localhost" || host === "127.0.0.1" || host === "::1";
133
+ } catch {
134
+ return value.includes("localhost") || value.includes("127.0.0.1");
135
+ }
136
+ }
137
+ function shouldUseLocalDev(options) {
138
+ if (options.remote) return false;
139
+ if (options.local) return true;
140
+ return isLocalHostUrl(options.apiUrl) || isLocalHostUrl(options.storefrontUrl);
141
+ }
142
+ function localStorefrontBaseUrl(configured) {
143
+ const base = (configured || DEFAULT_STOREFRONT_URL).replace(/\/+$/, "");
144
+ if (isLocalHostUrl(base)) return base;
145
+ return DEFAULT_STOREFRONT_URL.replace(/\/+$/, "");
146
+ }
147
+
148
+ // src/lib/storefront-url.ts
149
+ var PLATFORM_DOMAIN = "storify.it.com";
150
+ function normalizeBaseUrl(value) {
151
+ return value.replace(/\/+$/, "");
152
+ }
153
+ function resolveStorefrontBaseUrl(store, configuredBase, options = {}) {
154
+ const fallback = normalizeBaseUrl(configuredBase || DEFAULT_STOREFRONT_URL);
155
+ if (options.preferLocal || isLocalHostUrl(fallback)) {
156
+ return fallback;
157
+ }
158
+ if (!store) return fallback;
159
+ if (store.customDomain && store.domainVerified) {
160
+ return `https://${store.customDomain.replace(/^https?:\/\//, "").replace(/\/+$/, "")}`;
161
+ }
162
+ if (store.subdomain && store.subdomain.trim()) {
163
+ return `https://${store.subdomain.trim().toLowerCase()}.${PLATFORM_DOMAIN}`;
164
+ }
165
+ if (store.domain && store.domain.trim() && !store.domain.includes("localhost")) {
166
+ const domain = store.domain.trim();
167
+ if (domain.startsWith("http://") || domain.startsWith("https://")) {
168
+ return normalizeBaseUrl(domain);
169
+ }
170
+ return `https://${domain}`;
171
+ }
172
+ return fallback;
173
+ }
174
+ function buildStorefrontEntryUrl(storefrontBase, storeId, devSessionId) {
175
+ const base = normalizeBaseUrl(storefrontBase);
176
+ const isLocal = base.includes("localhost") || base.includes("127.0.0.1") || !base.includes("://");
177
+ let url;
178
+ if (isLocal) {
179
+ url = new URL(`${base}/${encodeURIComponent(storeId)}/`);
180
+ } else {
181
+ url = new URL(`${base}/`);
182
+ if (!url.hostname.startsWith(`${storeId}.`) && !url.pathname.includes(storeId)) {
183
+ url.pathname = `/${encodeURIComponent(storeId)}/`;
184
+ }
185
+ }
186
+ if (devSessionId) {
187
+ url.searchParams.set("devSession", devSessionId);
188
+ }
189
+ return url.toString();
190
+ }
191
+
192
+ // src/lib/login-prompts.ts
193
+ async function promptEmail() {
194
+ const answer = await p.text({
195
+ message: "Email",
196
+ placeholder: "you@example.com",
197
+ validate: (value) => value?.trim() ? void 0 : "Email is required"
198
+ });
199
+ if (p.isCancel(answer)) {
200
+ p.cancel("Login cancelled.");
201
+ process.exit(0);
202
+ }
203
+ return String(answer).trim();
204
+ }
205
+ async function promptPassword() {
206
+ const answer = await p.password({
207
+ message: "Password",
208
+ validate: (value) => value ? void 0 : "Password is required"
209
+ });
210
+ if (p.isCancel(answer)) {
211
+ p.cancel("Login cancelled.");
212
+ process.exit(0);
213
+ }
214
+ return String(answer);
215
+ }
216
+ function storeOptionHint(store) {
217
+ const parts = [];
218
+ if (store.subdomain?.trim()) parts.push(store.subdomain.trim());
219
+ if (store.role?.trim()) parts.push(store.role.trim());
220
+ return parts.length > 0 ? parts.join(" \xB7 ") : void 0;
221
+ }
222
+ function displayStoreName(store) {
223
+ const name = store.name?.trim();
224
+ return name || store.subdomain?.trim() || store.id;
225
+ }
226
+ async function promptStoreSelection(stores, currentStoreId) {
227
+ if (stores.length === 0) {
228
+ throw new Error("No stores found for this account.");
229
+ }
230
+ if (stores.length === 1) {
231
+ const store2 = stores[0];
232
+ p.log.success(`Store: ${displayStoreName(store2)}`);
233
+ return store2;
234
+ }
235
+ const selected = await p.select({
236
+ message: "Choose your store",
237
+ initialValue: currentStoreId && stores.some((s) => s.id === currentStoreId) ? currentStoreId : stores[0].id,
238
+ options: stores.map((store2) => ({
239
+ value: store2.id,
240
+ label: displayStoreName(store2),
241
+ hint: storeOptionHint(store2)
242
+ }))
243
+ });
244
+ if (p.isCancel(selected)) {
245
+ p.cancel("Store selection cancelled.");
246
+ process.exit(0);
247
+ }
248
+ const store = stores.find((s) => s.id === selected);
249
+ if (!store) throw new Error("Invalid store selection.");
250
+ return store;
251
+ }
252
+ async function runInteractiveLoginFlow(options) {
253
+ const stored = await readStoredConfig();
254
+ const apiUrl = (options?.apiUrl || stored.apiUrl || process.env.STORIFY_API_URL || DEFAULT_API_URL).replace(
255
+ /\/+$/,
256
+ ""
257
+ );
258
+ p.intro("Storify login");
259
+ const email = await promptEmail();
260
+ const password2 = await promptPassword();
261
+ const loginSpinner = p.spinner();
262
+ loginSpinner.start("Signing in");
263
+ let loginResult;
264
+ try {
265
+ loginResult = await loginWithPassword(apiUrl, email, password2);
266
+ loginSpinner.stop("Signed in");
267
+ } catch (error) {
268
+ loginSpinner.stop("Sign in failed");
269
+ throw error;
270
+ }
271
+ const storesSpinner = p.spinner();
272
+ storesSpinner.start("Loading stores");
273
+ let stores = loginResult.user?.stores;
274
+ if (!stores || stores.length === 0) {
275
+ stores = await fetchUserStores(apiUrl, loginResult.accessToken);
276
+ }
277
+ storesSpinner.stop(
278
+ stores.length === 1 ? `1 store available` : `${stores.length} stores available`
279
+ );
280
+ const store = await promptStoreSelection(stores, options?.currentStoreId || stored.storeId);
281
+ const preferLocal = isLocalHostUrl(apiUrl);
282
+ const storefrontUrl = resolveStorefrontBaseUrl(store, stored.storefrontUrl || DEFAULT_STOREFRONT_URL, {
283
+ preferLocal
284
+ });
285
+ const adminUrl = (stored.adminUrl || DEFAULT_ADMIN_URL).replace(/\/+$/, "");
286
+ await writeStoredConfig({
287
+ apiUrl,
288
+ token: loginResult.accessToken,
289
+ storeId: store.id,
290
+ storefrontUrl,
291
+ adminUrl
292
+ });
293
+ p.outro(`Ready \xB7 ${displayStoreName(store)}`);
294
+ return {
295
+ apiUrl,
296
+ token: loginResult.accessToken,
297
+ store,
298
+ storefrontUrl,
299
+ adminUrl
300
+ };
301
+ }
302
+
303
+ // src/lib/ui.ts
304
+ import pc from "picocolors";
305
+ import ora from "ora";
306
+ var brand = {
307
+ name: "Storify",
308
+ cli: "storify",
309
+ tagline: "Theme development toolkit"
310
+ };
311
+ function isInteractive() {
312
+ return Boolean(process.stdin.isTTY && process.stdout.isTTY && !process.env.CI);
313
+ }
314
+ function supportsColor() {
315
+ return pc.isColorSupported;
316
+ }
317
+ function paint(text3, tone) {
318
+ if (!supportsColor()) return text3;
319
+ switch (tone) {
320
+ case "brand":
321
+ return pc.bold(pc.cyan(text3));
322
+ case "muted":
323
+ return pc.dim(text3);
324
+ case "success":
325
+ return pc.green(text3);
326
+ case "warn":
327
+ return pc.yellow(text3);
328
+ case "error":
329
+ return pc.red(text3);
330
+ case "info":
331
+ return pc.blue(text3);
332
+ case "accent":
333
+ return pc.magenta(text3);
334
+ default:
335
+ return text3;
336
+ }
337
+ }
338
+ function formatBytes(bytes) {
339
+ if (bytes < 1024) return `${bytes} B`;
340
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
341
+ return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
342
+ }
343
+ function printBanner(version2) {
344
+ const line = "\u2500".repeat(52);
345
+ console.log("");
346
+ console.log(paint(` ${brand.name} CLI`, "brand") + paint(` v${version2}`, "muted"));
347
+ console.log(paint(` ${brand.tagline}`, "muted"));
348
+ console.log(paint(` ${line}`, "muted"));
349
+ console.log("");
350
+ }
351
+ function printHeading(title, subtitle) {
352
+ console.log("");
353
+ console.log(paint(title, "brand"));
354
+ if (subtitle) console.log(paint(subtitle, "muted"));
355
+ }
356
+ function printDivider() {
357
+ console.log(paint("\u2500".repeat(52), "muted"));
358
+ }
359
+ function printStep(label, value, pad = 14) {
360
+ console.log(` ${paint(label.padEnd(pad), "muted")}${value}`);
361
+ }
362
+ function printSuccess(message) {
363
+ console.log(`${paint("\u2714", "success")} ${message}`);
364
+ }
365
+ function printInfo(message) {
366
+ console.log(`${paint("\u2139", "info")} ${message}`);
367
+ }
368
+ function printWarn(message) {
369
+ console.log(`${paint("\u26A0", "warn")} ${message}`);
370
+ }
371
+ function printError(message) {
372
+ console.error(`${paint("\u2716", "error")} ${message}`);
373
+ }
374
+ function printHint(message) {
375
+ console.log(paint(` Tip: ${message}`, "muted"));
376
+ }
377
+ function printCommandExample(label, command) {
378
+ console.log(` ${paint(label.padEnd(16), "muted")}${paint(command, "accent")}`);
379
+ }
380
+ function printPreviewPanel(title, rows) {
381
+ console.log("");
382
+ console.log(paint(` ${title}`, "brand"));
383
+ printDivider();
384
+ const labelWidth = Math.max(16, ...rows.map((row) => row.label.length + 2));
385
+ for (const row of rows) {
386
+ printStep(row.label, row.value, labelWidth);
387
+ }
388
+ console.log("");
389
+ }
390
+ function printQuickStart() {
391
+ printHeading("Quick start");
392
+ printCommandExample("Live storefront preview", "storify theme dev");
393
+ printCommandExample("Local Vite only", "storify theme dev --no-link-store");
394
+ printCommandExample("Validate", "storify theme validate");
395
+ printCommandExample("Build + pack", "storify theme pack");
396
+ printCommandExample("Sign in", "storify login");
397
+ printCommandExample("Check setup", "storify status");
398
+ console.log("");
399
+ printHint("Run `storify theme --help` for all theme commands.");
400
+ console.log("");
401
+ }
402
+ function createSpinner(text3) {
403
+ return ora({
404
+ text: text3,
405
+ color: "cyan",
406
+ spinner: "dots"
407
+ });
408
+ }
409
+ async function withSpinner(text3, task) {
410
+ const spinner2 = createSpinner(text3);
411
+ spinner2.start();
412
+ try {
413
+ const result = await task();
414
+ spinner2.stop();
415
+ return result;
416
+ } catch (error) {
417
+ spinner2.fail(text3);
418
+ throw error;
419
+ }
420
+ }
421
+ function maskSecret(value, visible = 4) {
422
+ if (!value) return paint("not set", "warn");
423
+ if (value.length <= visible) return "****";
424
+ return `${value.slice(0, visible)}${"*".repeat(Math.min(8, value.length - visible))}`;
425
+ }
426
+ function handleCliError(error) {
427
+ const message = error instanceof Error ? error.message : String(error);
428
+ printError(message);
429
+ console.log("");
430
+ printHint("Run `storify status` to verify login and paths.");
431
+ process.exit(1);
432
+ }
433
+
434
+ // src/commands/login.ts
435
+ function normalizeUrl(value) {
436
+ return value.replace(/\/+$/, "");
437
+ }
438
+ async function runLoginCommand(options) {
439
+ const stored = await readStoredConfig();
440
+ if (isInteractive() && !options.token && !options.storeId) {
441
+ await runInteractiveLoginFlow({
442
+ apiUrl: options.api,
443
+ currentStoreId: stored.storeId
444
+ });
445
+ return;
446
+ }
447
+ let apiUrl = options.api || stored.apiUrl || DEFAULT_API_URL;
448
+ let storeId = options.storeId || stored.storeId || "";
449
+ let token = options.token || stored.token || "";
450
+ let storefrontUrl = options.storefront || stored.storefrontUrl || DEFAULT_STOREFRONT_URL;
451
+ let adminUrl = options.admin || stored.adminUrl || DEFAULT_ADMIN_URL;
452
+ if ((!storeId || !token) && token) {
453
+ const stores = await fetchUserStores(normalizeUrl(apiUrl), token);
454
+ if (stores.length === 1) {
455
+ storeId = stores[0].id;
456
+ storefrontUrl = resolveStorefrontBaseUrl(stores[0], storefrontUrl);
457
+ }
458
+ }
459
+ if (!storeId || !token) {
460
+ if (isInteractive()) {
461
+ await runInteractiveLoginFlow({ apiUrl: options.api, currentStoreId: stored.storeId });
462
+ return;
463
+ }
464
+ throw new Error("Login required. Run `storify login` or set STORIFY_AUTH_TOKEN and STORIFY_STORE_ID.");
465
+ }
466
+ await writeStoredConfig({
467
+ apiUrl: normalizeUrl(apiUrl),
468
+ storeId: storeId.trim(),
469
+ token: token.trim(),
470
+ storefrontUrl: normalizeUrl(storefrontUrl),
471
+ adminUrl: normalizeUrl(adminUrl)
472
+ });
473
+ if (!isInteractive()) {
474
+ printHeading("Login saved");
475
+ printDivider();
476
+ printStep("Config file", getConfigFilePathForDisplay());
477
+ printStep("Store ID", storeId.trim());
478
+ printStep("Storefront", normalizeUrl(storefrontUrl));
479
+ printSuccess("Ready for `storify theme dev` and `storify theme upload`.");
480
+ console.log("");
481
+ }
482
+ }
483
+
484
+ // src/commands/status.ts
485
+ import path3 from "path";
486
+ import { promises as fs3 } from "fs";
487
+
488
+ // src/lib/theme-root.ts
489
+ import path2 from "path";
490
+ import { promises as fs2 } from "fs";
491
+ async function fileExists(filePath) {
492
+ try {
493
+ await fs2.access(filePath);
494
+ return true;
495
+ } catch {
496
+ return false;
497
+ }
498
+ }
499
+ async function resolveThemeRoot(dirArg) {
500
+ let current = path2.resolve(dirArg || process.cwd());
501
+ while (true) {
502
+ const manifestPath = path2.join(current, "theme-manifest.json");
503
+ if (await fileExists(manifestPath)) {
504
+ return current;
505
+ }
506
+ const parent = path2.dirname(current);
507
+ if (parent === current) {
508
+ throw new Error("Could not find theme root. Run command inside theme directory (contains theme-manifest.json) or pass --dir.");
509
+ }
510
+ current = parent;
511
+ }
512
+ }
513
+ async function resolveRepoRoot(fromDir = process.cwd()) {
514
+ let current = path2.resolve(fromDir);
515
+ while (true) {
516
+ const gitPath = path2.join(current, ".git");
517
+ if (await fileExists(gitPath)) return current;
518
+ const parent = path2.dirname(current);
519
+ if (parent === current) {
520
+ throw new Error("Could not find repository root (.git).");
521
+ }
522
+ current = parent;
523
+ }
524
+ }
525
+
526
+ // src/commands/status.ts
527
+ async function fileExists2(filePath) {
528
+ try {
529
+ await fs3.access(filePath);
530
+ return true;
531
+ } catch {
532
+ return false;
533
+ }
534
+ }
535
+ async function runStatusCommand(options) {
536
+ printHeading("Environment status", "Current CLI configuration and theme context");
537
+ const configPath = getConfigFilePathForDisplay();
538
+ const stored = await readStoredConfig();
539
+ const hasConfig = Boolean(stored.storeId && stored.token);
540
+ printDivider();
541
+ printStep("Config file", configPath);
542
+ printStep("API URL", stored.apiUrl || "not set");
543
+ printStep("Store ID", stored.storeId || "not set");
544
+ printStep("Auth token", maskSecret(stored.token));
545
+ printStep("Storefront", stored.storefrontUrl || "not set");
546
+ printStep("Admin", stored.adminUrl || "not set");
547
+ console.log("");
548
+ if (hasConfig) {
549
+ printSuccess("Credentials found. Run `storify theme dev` for live storefront preview.");
550
+ } else {
551
+ printWarn("Credentials incomplete. Run `storify login` first.");
552
+ }
553
+ try {
554
+ const themeRoot = await resolveThemeRoot(options.dir);
555
+ const manifestPath = path3.join(themeRoot, "theme-manifest.json");
556
+ const manifestRaw = await fs3.readFile(manifestPath, "utf8");
557
+ const manifest = JSON.parse(manifestRaw);
558
+ const distExists = await fileExists2(path3.join(themeRoot, "dist", "theme-manifest.json"));
559
+ printDivider();
560
+ printStep("Theme root", themeRoot);
561
+ printStep("Theme name", manifest.name || "unknown");
562
+ printStep("Version", manifest.version || "unknown");
563
+ printStep("Build output", distExists ? "dist/ ready" : "not built yet");
564
+ console.log("");
565
+ printInfo(distExists ? "Run `storify theme pack` to create upload zip." : "Run `storify theme build` before packing.");
566
+ } catch {
567
+ console.log("");
568
+ printWarn("No theme found in current directory. cd into a theme folder or pass --dir.");
569
+ }
570
+ console.log("");
571
+ }
572
+
573
+ // src/commands/theme/build.ts
574
+ import path5 from "path";
575
+ import { promises as fs5 } from "fs";
576
+ import { execa } from "execa";
577
+
578
+ // src/lib/build-manifest.ts
579
+ import path4 from "path";
580
+ import { promises as fs4 } from "fs";
581
+ function readJson(filePath) {
582
+ return fs4.readFile(filePath, "utf8").then((raw) => JSON.parse(raw));
583
+ }
584
+ function toCanonicalId(value) {
585
+ return String(value || "").trim().toLowerCase().replace(/-/g, "_");
586
+ }
587
+ function optionValue(option) {
588
+ if (option && typeof option === "object") {
589
+ return option.value;
590
+ }
591
+ return option;
592
+ }
593
+ function defaultForField(field) {
594
+ if (field.default !== void 0) return field.default;
595
+ switch (field.type) {
596
+ case "repeater":
597
+ return Array.isArray(field.default) ? field.default : [];
598
+ case "menu":
599
+ return "";
600
+ case "select": {
601
+ const options = Array.isArray(field.options) ? field.options : [];
602
+ return options[0] !== void 0 ? optionValue(options[0]) : "";
603
+ }
604
+ default:
605
+ return "";
606
+ }
607
+ }
608
+ function normalizeFieldValue(value, field = {}, fieldPath = "") {
609
+ const type = field.type;
610
+ if (type === "repeater") {
611
+ if (!Array.isArray(value)) return [];
612
+ const itemSchema = field.fields || {};
613
+ return value.map((item, index) => {
614
+ if (!item || typeof item !== "object" || Array.isArray(item)) {
615
+ throw new Error(`Invalid repeater item at "${fieldPath}[${index}]"`);
616
+ }
617
+ return normalizeContentBySchema(item, itemSchema, `${fieldPath}[${index}]`);
618
+ });
619
+ }
620
+ if (type === "menu") {
621
+ if (Array.isArray(value) || typeof value === "string") return value;
622
+ return "";
623
+ }
624
+ if (type === "select") {
625
+ const options = Array.isArray(field.options) ? field.options.map(optionValue) : [];
626
+ const selected = value ?? defaultForField(field);
627
+ const nextValue = String(selected ?? "");
628
+ if (options.length > 0 && !options.includes(nextValue)) {
629
+ throw new Error(`Invalid select value "${nextValue}" at "${fieldPath}"`);
630
+ }
631
+ return nextValue;
632
+ }
633
+ if (type === "color" || type === "text" || type === "textarea" || type === "image") {
634
+ const raw2 = value ?? defaultForField(field);
635
+ return raw2 === null || raw2 === void 0 ? "" : String(raw2);
636
+ }
637
+ if (type === "link") {
638
+ const raw2 = value ?? defaultForField(field);
639
+ if (raw2 === null || raw2 === void 0) return "";
640
+ if (typeof raw2 === "string" || typeof raw2 === "object") return raw2;
641
+ return String(raw2);
642
+ }
643
+ const raw = value ?? defaultForField(field);
644
+ return raw === void 0 ? "" : raw;
645
+ }
646
+ function normalizeContentBySchema(content = {}, schema = {}, pathPrefix = "", section = null) {
647
+ const output = {};
648
+ const contentObj = content && typeof content === "object" && !Array.isArray(content) ? content : {};
649
+ for (const key of Object.keys(contentObj)) {
650
+ if (key === "blocks" && section) continue;
651
+ if (!(key in schema)) {
652
+ throw new Error(`Unknown key "${key}" in content "${pathPrefix || "root"}"`);
653
+ }
654
+ }
655
+ for (const [key, field] of Object.entries(schema)) {
656
+ const fieldPath = pathPrefix ? `${pathPrefix}.${key}` : key;
657
+ output[key] = normalizeFieldValue(contentObj[key], field, fieldPath);
658
+ }
659
+ if (section && Array.isArray(contentObj.blocks)) {
660
+ const blockDefinitions = Array.isArray(section.blocks) ? section.blocks : [];
661
+ output.blocks = contentObj.blocks.map((block, index) => {
662
+ if (!block || typeof block !== "object" || Array.isArray(block)) {
663
+ throw new Error(`Invalid block at "${pathPrefix}.blocks[${index}]"`);
664
+ }
665
+ const type = String(block.type || "");
666
+ const blockDef = blockDefinitions.find((candidate) => candidate.type === type);
667
+ if (!blockDef) {
668
+ throw new Error(`Unknown block type "${type}" at "${pathPrefix}.blocks[${index}]"`);
669
+ }
670
+ return {
671
+ type,
672
+ content: normalizeContentBySchema(
673
+ block.content || {},
674
+ blockDef.schema || {},
675
+ `${pathPrefix}.blocks[${index}]`
676
+ )
677
+ };
678
+ });
679
+ }
680
+ return output;
681
+ }
682
+ async function syncEntryFromDistIndex(themeRoot, manifest) {
683
+ const indexHtmlPath = path4.join(themeRoot, "dist", "index.html");
684
+ try {
685
+ const indexHtml = await fs4.readFile(indexHtmlPath, "utf8");
686
+ const scriptMatch = indexHtml.match(/<script[^>]*src="\.\/(assets\/[^"]+\.js)"/);
687
+ if (scriptMatch) {
688
+ manifest.entry = scriptMatch[1];
689
+ }
690
+ } catch {
691
+ }
692
+ }
693
+ async function buildThemeManifest(options) {
694
+ const { themeRoot, writeToDist, syncEntryFromDist } = options;
695
+ const manifestPath = path4.join(themeRoot, "theme-manifest.json");
696
+ const pagesDir = path4.join(themeRoot, "config", "pages");
697
+ const distDir = path4.join(themeRoot, "dist");
698
+ const outputPath = path4.join(distDir, "theme-manifest.json");
699
+ const manifest = await readJson(manifestPath);
700
+ if (!Array.isArray(manifest.sections) || !Array.isArray(manifest.pages)) {
701
+ throw new Error('Manifest must contain "sections" and "pages" arrays.');
702
+ }
703
+ const sectionById = /* @__PURE__ */ new Map();
704
+ manifest.sections = manifest.sections.map((section) => {
705
+ const sectionId = toCanonicalId(section.id);
706
+ if (!sectionId) throw new Error("Section id cannot be empty.");
707
+ if (sectionById.has(sectionId)) throw new Error(`Duplicate section id "${sectionId}".`);
708
+ const contentSchema = section.contentSchema || {};
709
+ const sectionDefaults = normalizeContentBySchema(
710
+ section.defaultContent || {},
711
+ contentSchema,
712
+ `section:${sectionId}`,
713
+ section
714
+ );
715
+ const normalizedSection = {
716
+ ...section,
717
+ id: sectionId,
718
+ contentSchema,
719
+ defaultContent: sectionDefaults,
720
+ settingsSchema: contentSchema,
721
+ fields: contentSchema
722
+ };
723
+ sectionById.set(sectionId, normalizedSection);
724
+ return normalizedSection;
725
+ });
726
+ manifest.pages = manifest.pages.map((page) => {
727
+ const pageId = toCanonicalId(page.id);
728
+ if (!pageId) throw new Error("Page id cannot be empty.");
729
+ if (!Array.isArray(page.layout)) throw new Error(`Page "${pageId}" layout must be an array.`);
730
+ const layout = page.layout.map((entry) => {
731
+ const sectionId = toCanonicalId(entry.sectionId);
732
+ if (!sectionById.has(sectionId)) {
733
+ throw new Error(`Page "${pageId}" references unknown sectionId "${entry.sectionId}".`);
734
+ }
735
+ if (!entry.handle || typeof entry.handle !== "string") {
736
+ throw new Error(`Page "${pageId}" has layout entry without a valid handle.`);
737
+ }
738
+ return { ...entry, sectionId };
739
+ });
740
+ return { ...page, id: pageId, layout };
741
+ });
742
+ if (manifest.themeSettingsSchema && !manifest.settingsSchema) {
743
+ manifest.settingsSchema = manifest.themeSettingsSchema;
744
+ }
745
+ try {
746
+ const pageFiles = (await fs4.readdir(pagesDir)).filter((fileName) => fileName.endsWith(".json")).sort();
747
+ for (const fileName of pageFiles) {
748
+ const pageId = toCanonicalId(path4.basename(fileName, ".json"));
749
+ const page = manifest.pages.find((item) => item.id === pageId);
750
+ if (!page) {
751
+ throw new Error(`Orphan page defaults file "${fileName}" has no matching manifest page id.`);
752
+ }
753
+ const rawPageDefaults = await readJson(path4.join(pagesDir, fileName));
754
+ const layoutByHandle = new Map(page.layout.map((item) => [item.handle, item]));
755
+ for (const handle of Object.keys(rawPageDefaults || {})) {
756
+ const layoutEntry = layoutByHandle.get(handle);
757
+ if (!layoutEntry) {
758
+ throw new Error(`Invalid handle "${handle}" in config/pages/${fileName}.`);
759
+ }
760
+ const section = sectionById.get(layoutEntry.sectionId);
761
+ normalizeContentBySchema(
762
+ rawPageDefaults[handle] || {},
763
+ section.contentSchema || {},
764
+ `${pageId}.${handle}`,
765
+ section
766
+ );
767
+ }
768
+ }
769
+ } catch (error) {
770
+ const code = error?.code;
771
+ if (code !== "ENOENT") throw error;
772
+ }
773
+ delete manifest.themeSettings;
774
+ delete manifest.settings;
775
+ if (syncEntryFromDist) {
776
+ await syncEntryFromDistIndex(themeRoot, manifest);
777
+ }
778
+ const serialized = JSON.stringify(manifest);
779
+ const sizeInBytes = Buffer.byteLength(serialized, "utf8");
780
+ if (sizeInBytes > MAX_MANIFEST_BYTES) {
781
+ throw new Error(`theme-manifest.json is too large (${sizeInBytes} bytes). Limit is ${MAX_MANIFEST_BYTES} bytes.`);
782
+ }
783
+ if (!writeToDist) {
784
+ return { sizeInBytes };
785
+ }
786
+ await fs4.mkdir(distDir, { recursive: true });
787
+ await fs4.writeFile(outputPath, serialized);
788
+ return { sizeInBytes, outputPath };
789
+ }
790
+
791
+ // src/commands/theme/build.ts
792
+ async function copyConfigIfExists(themeRoot) {
793
+ const source = path5.join(themeRoot, "config");
794
+ const destination = path5.join(themeRoot, "dist", "config");
795
+ try {
796
+ await fs5.access(source);
797
+ } catch {
798
+ return;
799
+ }
800
+ await fs5.rm(destination, { recursive: true, force: true });
801
+ await fs5.cp(source, destination, { recursive: true });
802
+ }
803
+ async function runViteBuild(themeRoot, quiet) {
804
+ const viteBin = path5.join(themeRoot, "node_modules", "vite", "bin", "vite.js");
805
+ await execa("node", [viteBin, "build"], {
806
+ cwd: themeRoot,
807
+ stdio: quiet ? "pipe" : "inherit"
808
+ });
809
+ }
810
+ async function runBuildCommand(options) {
811
+ const themeRoot = await resolveThemeRoot(options.dir);
812
+ const quiet = Boolean(options.quiet);
813
+ if (!quiet) {
814
+ printHeading("Building theme", themeRoot);
815
+ printStep("Step 1/2", "Vite production build");
816
+ }
817
+ await runViteBuild(themeRoot, quiet);
818
+ const result = quiet ? await buildThemeManifest({
819
+ themeRoot,
820
+ writeToDist: true,
821
+ syncEntryFromDist: true,
822
+ quiet: true
823
+ }) : await withSpinner(
824
+ "Generating theme-manifest.json",
825
+ async () => buildThemeManifest({
826
+ themeRoot,
827
+ writeToDist: true,
828
+ syncEntryFromDist: true,
829
+ quiet: true
830
+ })
831
+ );
832
+ await copyConfigIfExists(themeRoot);
833
+ if (!quiet) {
834
+ printDivider();
835
+ printStep("Output", path5.join(themeRoot, "dist"));
836
+ printStep("Manifest", formatBytes(result.sizeInBytes));
837
+ printSuccess("Build completed.");
838
+ console.log("");
839
+ }
840
+ }
841
+
842
+ // src/commands/theme/dev.ts
843
+ import path6 from "path";
844
+ import { execa as execa3 } from "execa";
845
+
846
+ // src/lib/dev-link.ts
847
+ async function updateDevLink(auth, payload) {
848
+ const response = await fetch(`${auth.apiUrl}/theme/dev-link`, {
849
+ method: "PATCH",
850
+ headers: {
851
+ "Content-Type": "application/json",
852
+ Authorization: `Bearer ${auth.token}`,
853
+ "X-Store-Id": auth.storeId
854
+ },
855
+ body: JSON.stringify(payload)
856
+ });
857
+ const body = await response.json().catch(() => ({}));
858
+ if (!response.ok) {
859
+ const message = (body && typeof body === "object" && "error" in body ? String(body.error) : "") || `Failed to update dev link (${response.status})`;
860
+ throw new Error(message);
861
+ }
862
+ return {
863
+ devThemeEnabled: body.devThemeEnabled === true,
864
+ devThemeBaseUrl: body.devThemeBaseUrl == null || body.devThemeBaseUrl === "" ? null : String(body.devThemeBaseUrl).trim(),
865
+ storefrontPreviewUrl: typeof body.storefrontPreviewUrl === "string" ? body.storefrontPreviewUrl : void 0,
866
+ devSessionId: typeof body.devSessionId === "string" ? body.devSessionId : void 0
867
+ };
868
+ }
869
+
870
+ // src/lib/port.ts
871
+ import net from "net";
872
+ function isPortFree(port, host) {
873
+ return new Promise((resolve) => {
874
+ const server = net.createServer();
875
+ server.once("error", () => resolve(false));
876
+ server.once("listening", () => {
877
+ server.close(() => resolve(true));
878
+ });
879
+ server.listen(port, host);
880
+ });
881
+ }
882
+ async function findAvailablePort(preferred, host = "127.0.0.1") {
883
+ if (!Number.isInteger(preferred) || preferred <= 0) {
884
+ throw new Error("Invalid port.");
885
+ }
886
+ for (let port = preferred; port < preferred + 30; port++) {
887
+ if (await isPortFree(port, host)) return port;
888
+ }
889
+ throw new Error(`No free port found near ${preferred}. Close other dev servers or pass --port.`);
890
+ }
891
+
892
+ // src/lib/preview-urls.ts
893
+ function buildPreviewUrls(auth, devBaseUrl, storefrontUrlOverride) {
894
+ const storefrontUrl = storefrontUrlOverride || `${auth.storefrontUrl.replace(/\/$/, "")}/${encodeURIComponent(auth.storeId)}/`;
895
+ return {
896
+ themeDirectUrl: `${devBaseUrl.replace(/\/$/, "")}/index.html?storeId=${encodeURIComponent(auth.storeId)}`,
897
+ storefrontUrl,
898
+ adminEditorUrl: `${auth.adminUrl.replace(/\/$/, "")}/${encodeURIComponent(auth.storeId)}/admin/theme/edit`
899
+ };
900
+ }
901
+
902
+ // src/lib/session.ts
903
+ import * as p2 from "@clack/prompts";
904
+ async function ensureAuthenticatedSession(options = {}) {
905
+ const stored = await readStoredConfig();
906
+ const hasCredentials = Boolean(stored.token?.trim() && stored.storeId?.trim());
907
+ const needsLogin = options.forceLogin || !hasCredentials;
908
+ if (needsLogin) {
909
+ if (!isInteractive()) {
910
+ throw new Error("Not logged in. Run `storify login` or set STORIFY_AUTH_TOKEN and STORIFY_STORE_ID.");
911
+ }
912
+ const session = await runInteractiveLoginFlow({
913
+ apiUrl: options.api,
914
+ currentStoreId: options.storeId || stored.storeId
915
+ });
916
+ return {
917
+ apiUrl: session.apiUrl,
918
+ token: session.token,
919
+ storeId: session.store.id,
920
+ storefrontUrl: session.storefrontUrl,
921
+ adminUrl: session.adminUrl,
922
+ store: session.store
923
+ };
924
+ }
925
+ const token = options.token || stored.token;
926
+ const apiUrl = (options.api || stored.apiUrl || DEFAULT_API_URL).replace(/\/+$/, "");
927
+ if (!options.token && !options.forceLogin) {
928
+ const valid = await verifyAuthToken(apiUrl, token);
929
+ if (!valid && isInteractive()) {
930
+ p2.log.warn("Session expired. Sign in again.");
931
+ const session = await runInteractiveLoginFlow({
932
+ apiUrl: options.api,
933
+ currentStoreId: options.storeId || stored.storeId
934
+ });
935
+ return {
936
+ apiUrl: session.apiUrl,
937
+ token: session.token,
938
+ storeId: session.store.id,
939
+ storefrontUrl: session.storefrontUrl,
940
+ adminUrl: session.adminUrl,
941
+ store: session.store
942
+ };
943
+ }
944
+ if (!valid) {
945
+ throw new Error("Saved auth token is invalid. Run `storify login` again.");
946
+ }
947
+ }
948
+ if (options.forceStorePick && isInteractive()) {
949
+ const stores = await fetchUserStores(apiUrl, token);
950
+ const store2 = await promptStoreSelection(stores, options.storeId || stored.storeId);
951
+ const storefrontUrl = resolveStorefrontBaseUrl(store2, stored.storefrontUrl);
952
+ await writeStoredConfig({
953
+ ...stored,
954
+ apiUrl,
955
+ token,
956
+ storeId: store2.id,
957
+ storefrontUrl
958
+ });
959
+ return {
960
+ ...await resolveAuthConfig({ ...options, api: apiUrl, storeId: store2.id, token }),
961
+ store: store2
962
+ };
963
+ }
964
+ const auth = await resolveAuthConfig(options);
965
+ let store;
966
+ try {
967
+ const stores = await fetchUserStores(auth.apiUrl, auth.token);
968
+ store = stores.find((s) => s.id === auth.storeId);
969
+ } catch {
970
+ }
971
+ return { ...auth, store };
972
+ }
973
+ function formatLivePreviewUrl(auth, devSessionId, store) {
974
+ const base = resolveStorefrontBaseUrl(store, auth.storefrontUrl, {
975
+ preferLocal: isLocalHostUrl(auth.apiUrl) || isLocalHostUrl(auth.storefrontUrl)
976
+ });
977
+ return buildStorefrontEntryUrl(base, auth.storeId, devSessionId);
978
+ }
979
+
980
+ // src/lib/tunnel.ts
981
+ import { execa as execa2 } from "execa";
982
+
983
+ // src/lib/wait-for-dev-server.ts
984
+ async function waitForHttpReachable(baseUrl, timeoutMs = 45e3, acceptResponse) {
985
+ const url = baseUrl.replace(/\/+$/, "/");
986
+ const deadline = Date.now() + timeoutMs;
987
+ const isAccepted = acceptResponse ?? ((response) => response.ok);
988
+ while (Date.now() < deadline) {
989
+ try {
990
+ const response = await fetch(url, { signal: AbortSignal.timeout(2e3) });
991
+ if (isAccepted(response)) return;
992
+ } catch {
993
+ }
994
+ await new Promise((resolve) => setTimeout(resolve, 300));
995
+ }
996
+ throw new Error(`Timed out waiting for HTTP endpoint at ${url}`);
997
+ }
998
+ async function waitForDevServer(baseUrl, timeoutMs = 45e3) {
999
+ return waitForHttpReachable(baseUrl, timeoutMs);
1000
+ }
1001
+
1002
+ // src/lib/tunnel.ts
1003
+ async function startCloudflaredTunnel(localUrl) {
1004
+ const child = execa2("cloudflared", ["tunnel", "--url", localUrl], {
1005
+ all: true,
1006
+ reject: false
1007
+ });
1008
+ const url = await new Promise((resolve, reject) => {
1009
+ const timeout = setTimeout(() => {
1010
+ reject(new Error("Timed out while waiting for cloudflared public URL."));
1011
+ }, 2e4);
1012
+ const onData = (chunk) => {
1013
+ const match = chunk.match(/https:\/\/[a-z0-9-]+\.trycloudflare\.com/i);
1014
+ if (match) {
1015
+ clearTimeout(timeout);
1016
+ resolve(match[0]);
1017
+ }
1018
+ };
1019
+ child.all?.on("data", (buffer) => onData(String(buffer)));
1020
+ child.on("error", (error) => {
1021
+ clearTimeout(timeout);
1022
+ reject(error);
1023
+ });
1024
+ child.on("exit", (code) => {
1025
+ if (code && code !== 0) {
1026
+ clearTimeout(timeout);
1027
+ reject(new Error(`cloudflared exited early with code ${code}`));
1028
+ }
1029
+ });
1030
+ });
1031
+ await waitForHttpReachable(
1032
+ url,
1033
+ 3e4,
1034
+ (response) => response.status >= 200 && response.status < 500
1035
+ );
1036
+ return { url, process: child };
1037
+ }
1038
+
1039
+ // src/commands/theme/dev.ts
1040
+ function toPort(value) {
1041
+ const parsed = Number(value || 3e3);
1042
+ if (!Number.isInteger(parsed) || parsed <= 0) throw new Error("Invalid port.");
1043
+ return parsed;
1044
+ }
1045
+ async function openInBrowser(url) {
1046
+ const platform = process.platform;
1047
+ if (platform === "linux") {
1048
+ await execa3("xdg-open", [url], { detached: true, stdio: "ignore" });
1049
+ } else if (platform === "darwin") {
1050
+ await execa3("open", [url], { detached: true, stdio: "ignore" });
1051
+ } else if (platform === "win32") {
1052
+ await execa3("cmd", ["/c", "start", "", url], { detached: true, stdio: "ignore" });
1053
+ }
1054
+ }
1055
+ async function runDevCommand(options) {
1056
+ const themeRoot = await resolveThemeRoot(options.dir);
1057
+ const preferredPort = toPort(options.port);
1058
+ const shouldLinkStore = options.linkStore !== false;
1059
+ const shouldUnlinkOnExit = options.unlinkOnExit !== false;
1060
+ printHeading("Theme preview", themeRoot);
1061
+ const session = await ensureAuthenticatedSession({
1062
+ api: options.api,
1063
+ storeId: options.storeId,
1064
+ token: options.token
1065
+ });
1066
+ const auth = { ...session };
1067
+ const useLocalDev = shouldUseLocalDev({
1068
+ local: options.local,
1069
+ remote: options.remote,
1070
+ apiUrl: auth.apiUrl,
1071
+ storefrontUrl: auth.storefrontUrl
1072
+ });
1073
+ if (useLocalDev) {
1074
+ auth.storefrontUrl = localStorefrontBaseUrl(auth.storefrontUrl);
1075
+ }
1076
+ const port = await findAvailablePort(preferredPort);
1077
+ const localUrl = `http://localhost:${port}`;
1078
+ const shouldUseTunnel = !useLocalDev && options.tunnel !== false;
1079
+ if (port !== preferredPort) {
1080
+ printWarn(`Port ${preferredPort} was busy. Using ${port} instead.`);
1081
+ }
1082
+ let tunnelProcess = null;
1083
+ let linkedDevUrl = null;
1084
+ let liveStorefrontUrl = null;
1085
+ const viteBin = path6.join(themeRoot, "node_modules", "vite", "bin", "vite.js");
1086
+ const viteArgs = [viteBin, "--host", "0.0.0.0", "--port", String(port), "--strictPort"];
1087
+ const viteEnv = { ...process.env };
1088
+ viteEnv.VITE_DEV_STORE_ID = auth.storeId;
1089
+ let viteProcess = null;
1090
+ const teardown = async () => {
1091
+ if (tunnelProcess) {
1092
+ tunnelProcess.kill("SIGTERM");
1093
+ }
1094
+ if (viteProcess) {
1095
+ viteProcess.kill("SIGTERM");
1096
+ }
1097
+ if (shouldLinkStore && shouldUnlinkOnExit) {
1098
+ try {
1099
+ await updateDevLink(auth, { devThemeEnabled: false, devThemeBaseUrl: null });
1100
+ printSuccess("Dev link disabled.");
1101
+ } catch (error) {
1102
+ printWarn(`Failed to disable dev link: ${error.message}`);
1103
+ }
1104
+ }
1105
+ };
1106
+ let tearingDown = false;
1107
+ const handleExit = async () => {
1108
+ if (tearingDown) return;
1109
+ tearingDown = true;
1110
+ await teardown();
1111
+ };
1112
+ process.on("SIGINT", async () => {
1113
+ await handleExit();
1114
+ process.exit(130);
1115
+ });
1116
+ process.on("SIGTERM", async () => {
1117
+ await handleExit();
1118
+ process.exit(143);
1119
+ });
1120
+ const viteSpinner = createSpinner(`Starting Vite on ${localUrl}`);
1121
+ viteSpinner.start();
1122
+ viteProcess = execa3("node", viteArgs, {
1123
+ cwd: themeRoot,
1124
+ env: viteEnv,
1125
+ stdout: "pipe",
1126
+ stderr: "pipe"
1127
+ });
1128
+ viteProcess.stdout?.pipe(process.stdout);
1129
+ viteProcess.stderr?.pipe(process.stderr);
1130
+ try {
1131
+ await waitForDevServer(localUrl);
1132
+ viteSpinner.succeed(`Vite ready at ${localUrl}`);
1133
+ } catch (error) {
1134
+ viteSpinner.fail("Vite failed to start");
1135
+ viteProcess.kill("SIGTERM");
1136
+ throw error;
1137
+ }
1138
+ if (shouldLinkStore) {
1139
+ if (useLocalDev) {
1140
+ linkedDevUrl = localUrl;
1141
+ printStep("Mode", "Localhost (storefront + theme on your machine)");
1142
+ } else if (options.publicUrl) {
1143
+ linkedDevUrl = options.publicUrl.replace(/\/+$/, "");
1144
+ } else if (shouldUseTunnel) {
1145
+ const tunnelSpinner = createSpinner("Creating public tunnel for theme assets");
1146
+ tunnelSpinner.start();
1147
+ try {
1148
+ const tunnel = await startCloudflaredTunnel(localUrl);
1149
+ linkedDevUrl = tunnel.url.replace(/\/+$/, "");
1150
+ tunnelProcess = tunnel.process;
1151
+ tunnelSpinner.succeed(`Public theme URL: ${linkedDevUrl}`);
1152
+ } catch (error) {
1153
+ tunnelSpinner.warn(`Tunnel unavailable (${error.message})`);
1154
+ linkedDevUrl = localUrl;
1155
+ printWarn("Storefront preview needs a public URL. Use --local or install cloudflared.");
1156
+ }
1157
+ } else {
1158
+ linkedDevUrl = localUrl;
1159
+ }
1160
+ const devLinkResult = await withSpinner(
1161
+ "Linking dev theme to your store on Storify",
1162
+ async () => updateDevLink(auth, { devThemeEnabled: true, devThemeBaseUrl: linkedDevUrl })
1163
+ );
1164
+ liveStorefrontUrl = (useLocalDev ? formatLivePreviewUrl(auth, devLinkResult.devSessionId, session.store) : devLinkResult.storefrontPreviewUrl) || formatLivePreviewUrl(auth, devLinkResult.devSessionId, session.store);
1165
+ const urls = buildPreviewUrls(auth, linkedDevUrl, liveStorefrontUrl);
1166
+ printPreviewPanel("Live preview", [
1167
+ { label: "Storefront", value: urls.storefrontUrl },
1168
+ { label: "Theme (Vite)", value: linkedDevUrl },
1169
+ { label: "Theme direct", value: urls.themeDirectUrl },
1170
+ { label: "Admin editor", value: urls.adminEditorUrl }
1171
+ ]);
1172
+ if (useLocalDev) {
1173
+ printHint(`Storefront must be running at ${DEFAULT_STOREFRONT_URL.replace(/\/+$/, "")}`);
1174
+ } else {
1175
+ printHint("Open the storefront link to see your theme with real store data.");
1176
+ }
1177
+ printHint("Press Ctrl+C to stop and disable dev link.");
1178
+ } else {
1179
+ printStep("Local URL", localUrl);
1180
+ printStep("Store ID", auth.storeId);
1181
+ printHint("Run without --no-link-store to preview on the storefront.");
1182
+ console.log("");
1183
+ }
1184
+ const openTarget = options.openStorefront && liveStorefrontUrl ? liveStorefrontUrl : options.open ? localUrl : useLocalDev && shouldLinkStore && liveStorefrontUrl ? liveStorefrontUrl : null;
1185
+ if (openTarget) {
1186
+ await openInBrowser(openTarget);
1187
+ }
1188
+ printSuccess("Waiting for file changes...");
1189
+ console.log("");
1190
+ try {
1191
+ await viteProcess;
1192
+ } finally {
1193
+ await handleExit();
1194
+ }
1195
+ }
1196
+
1197
+ // src/commands/theme/new.ts
1198
+ import path7 from "path";
1199
+ import { promises as fs6 } from "fs";
1200
+ import { execa as execa4 } from "execa";
1201
+ var EXCLUDED = /* @__PURE__ */ new Set(["node_modules", "dist", ".git"]);
1202
+ async function copyDirectory(source, destination) {
1203
+ await fs6.mkdir(destination, { recursive: true });
1204
+ const entries = await fs6.readdir(source, { withFileTypes: true });
1205
+ for (const entry of entries) {
1206
+ if (EXCLUDED.has(entry.name) || entry.name.endsWith(".zip")) continue;
1207
+ const sourcePath = path7.join(source, entry.name);
1208
+ const destinationPath = path7.join(destination, entry.name);
1209
+ if (entry.isDirectory()) {
1210
+ await copyDirectory(sourcePath, destinationPath);
1211
+ } else {
1212
+ await fs6.copyFile(sourcePath, destinationPath);
1213
+ }
1214
+ }
1215
+ }
1216
+ function titleCase(value) {
1217
+ return value.split(/[-_\s]+/).filter(Boolean).map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join(" ");
1218
+ }
1219
+ async function runNewCommand(name, options) {
1220
+ const cleanName = name.trim();
1221
+ if (!cleanName) throw new Error("Theme name is required.");
1222
+ const repoRoot = await resolveRepoRoot();
1223
+ const source = path7.join(repoRoot, "themes", "storify-templatesrap");
1224
+ const baseDir = options.dir ? path7.resolve(options.dir) : path7.join(repoRoot, "themes");
1225
+ const destination = path7.join(baseDir, cleanName);
1226
+ printHeading("Creating theme", cleanName);
1227
+ try {
1228
+ await fs6.access(destination);
1229
+ throw new Error(`Target directory already exists: ${destination}`);
1230
+ } catch (error) {
1231
+ if (error.code !== "ENOENT") throw error;
1232
+ }
1233
+ await withSpinner("Copying starter template", async () => {
1234
+ await copyDirectory(source, destination);
1235
+ });
1236
+ const manifestPath = path7.join(destination, "theme-manifest.json");
1237
+ const packagePath = path7.join(destination, "package.json");
1238
+ const manifest = JSON.parse(await fs6.readFile(manifestPath, "utf8"));
1239
+ manifest.name = titleCase(cleanName);
1240
+ manifest.version = "1.0.0";
1241
+ await fs6.writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}
1242
+ `);
1243
+ const packageJson2 = JSON.parse(await fs6.readFile(packagePath, "utf8"));
1244
+ packageJson2.name = `@storify/theme-${cleanName}`;
1245
+ await fs6.writeFile(packagePath, `${JSON.stringify(packageJson2, null, 2)}
1246
+ `);
1247
+ await withSpinner("Installing dependencies", async () => {
1248
+ await execa4("npm", ["install"], { cwd: destination, stdio: "pipe" });
1249
+ });
1250
+ printDivider();
1251
+ printStep("Theme path", destination);
1252
+ printStep("Package", packageJson2.name);
1253
+ printSuccess("Theme scaffold created.");
1254
+ printCommandExample("Start dev", `cd ${destination} && storify theme dev`);
1255
+ console.log("");
1256
+ }
1257
+
1258
+ // src/commands/theme/pack.ts
1259
+ import path9 from "path";
1260
+ import { promises as fs8 } from "fs";
1261
+
1262
+ // src/lib/zip-pack.ts
1263
+ import path8 from "path";
1264
+ import { promises as fs7 } from "fs";
1265
+ import archiver from "archiver";
1266
+ async function assertFileExists(filePath) {
1267
+ try {
1268
+ await fs7.access(filePath);
1269
+ } catch {
1270
+ throw new Error(`Missing required file: ${filePath}`);
1271
+ }
1272
+ }
1273
+ async function collectFiles(root, current = root) {
1274
+ const entries = await fs7.readdir(current, { withFileTypes: true });
1275
+ const files = [];
1276
+ for (const entry of entries) {
1277
+ const absPath = path8.join(current, entry.name);
1278
+ if (entry.isDirectory()) {
1279
+ const nested = await collectFiles(root, absPath);
1280
+ files.push(...nested);
1281
+ continue;
1282
+ }
1283
+ files.push(path8.relative(root, absPath));
1284
+ }
1285
+ return files;
1286
+ }
1287
+ function sanitizeRelativeZipPath(relPath) {
1288
+ return relPath.replace(/\\/g, "/").replace(/^\/+/, "");
1289
+ }
1290
+ async function packDist(themeRoot, outputZipPath) {
1291
+ const distPath = path8.join(themeRoot, "dist");
1292
+ await assertFileExists(path8.join(distPath, "theme-manifest.json"));
1293
+ await assertFileExists(path8.join(distPath, "index.html"));
1294
+ const files = await collectFiles(distPath);
1295
+ if (files.length === 0) {
1296
+ throw new Error("dist/ is empty. Run `storify theme build` first.");
1297
+ }
1298
+ let hasManifestAtRoot = false;
1299
+ for (const relPath of files) {
1300
+ const safe = sanitizeRelativeZipPath(relPath);
1301
+ if (safe.includes("..")) {
1302
+ throw new Error(`Unsafe path inside dist: ${relPath}`);
1303
+ }
1304
+ if (safe === "theme-manifest.json") {
1305
+ hasManifestAtRoot = true;
1306
+ }
1307
+ const abs = path8.join(distPath, relPath);
1308
+ const stat2 = await fs7.stat(abs);
1309
+ if (stat2.size > MAX_SINGLE_FILE_BYTES) {
1310
+ throw new Error(`File ${safe} exceeds ${Math.floor(MAX_SINGLE_FILE_BYTES / (1024 * 1024))} MB.`);
1311
+ }
1312
+ }
1313
+ if (!hasManifestAtRoot) {
1314
+ throw new Error("dist must contain theme-manifest.json at root.");
1315
+ }
1316
+ await fs7.mkdir(path8.dirname(outputZipPath), { recursive: true });
1317
+ await fs7.rm(outputZipPath, { force: true });
1318
+ const outputHandle = await fs7.open(outputZipPath, "w");
1319
+ const outputStream = outputHandle.createWriteStream();
1320
+ const archive = archiver("zip", { zlib: { level: 9 } });
1321
+ const donePromise = new Promise((resolve, reject) => {
1322
+ outputStream.on("close", resolve);
1323
+ outputStream.on("error", reject);
1324
+ archive.on("error", reject);
1325
+ });
1326
+ archive.pipe(outputStream);
1327
+ for (const relPath of files) {
1328
+ if (relPath.endsWith(".DS_Store")) continue;
1329
+ archive.file(path8.join(distPath, relPath), { name: sanitizeRelativeZipPath(relPath) });
1330
+ }
1331
+ await archive.finalize();
1332
+ await donePromise;
1333
+ await outputHandle.close();
1334
+ const stat = await fs7.stat(outputZipPath);
1335
+ if (stat.size > MAX_ZIP_BYTES) {
1336
+ throw new Error(`Theme zip must be under ${Math.floor(MAX_ZIP_BYTES / (1024 * 1024))} MB.`);
1337
+ }
1338
+ return { zipPath: outputZipPath, zipSize: stat.size, fileCount: files.length };
1339
+ }
1340
+
1341
+ // src/commands/theme/pack.ts
1342
+ async function exists(filePath) {
1343
+ try {
1344
+ await fs8.access(filePath);
1345
+ return true;
1346
+ } catch {
1347
+ return false;
1348
+ }
1349
+ }
1350
+ function toSafeSlug(value) {
1351
+ return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
1352
+ }
1353
+ async function runPackCommand(options) {
1354
+ const themeRoot = await resolveThemeRoot(options.dir);
1355
+ if (!options.quiet) {
1356
+ printHeading("Packaging theme", themeRoot);
1357
+ }
1358
+ const distManifestPath = path9.join(themeRoot, "dist", "theme-manifest.json");
1359
+ if (options.force || !await exists(distManifestPath)) {
1360
+ await runBuildCommand({ dir: themeRoot, quiet: true });
1361
+ }
1362
+ const manifest = JSON.parse(await fs8.readFile(path9.join(themeRoot, "theme-manifest.json"), "utf8"));
1363
+ const zipName = options.out || `${toSafeSlug(manifest.name || path9.basename(themeRoot) || "storify-theme")}-theme.zip`;
1364
+ const zipPath = path9.isAbsolute(zipName) ? zipName : path9.join(themeRoot, zipName);
1365
+ const packed = options.quiet ? await packDist(themeRoot, zipPath) : await withSpinner("Creating upload zip", async () => packDist(themeRoot, zipPath));
1366
+ if (!options.quiet) {
1367
+ printDivider();
1368
+ printStep("Archive", packed.zipPath);
1369
+ printStep("Size", formatBytes(packed.zipSize));
1370
+ printStep("Files", String(packed.fileCount));
1371
+ printSuccess("Theme package is ready.");
1372
+ printHint("Upload with `storify theme upload` or from Admin panel.");
1373
+ console.log("");
1374
+ }
1375
+ return packed.zipPath;
1376
+ }
1377
+
1378
+ // src/lib/upload-api.ts
1379
+ import { promises as fs9 } from "fs";
1380
+ async function uploadThemeZip(auth, zipPath, updateThemeId) {
1381
+ const payload = await fs9.readFile(zipPath);
1382
+ const form = new FormData();
1383
+ form.append("file", new Blob([payload], { type: "application/zip" }), "theme.zip");
1384
+ const endpoint = updateThemeId ? `${auth.apiUrl}/uploaded-themes/${encodeURIComponent(updateThemeId)}/update-dist` : `${auth.apiUrl}/uploaded-themes/upload`;
1385
+ const response = await fetch(endpoint, {
1386
+ method: "POST",
1387
+ headers: {
1388
+ Authorization: `Bearer ${auth.token}`,
1389
+ "X-Store-Id": auth.storeId
1390
+ },
1391
+ body: form
1392
+ });
1393
+ const body = await response.json().catch(() => ({}));
1394
+ if (!response.ok) {
1395
+ const message = (body && typeof body === "object" && "error" in body ? String(body.error) : "") || `Upload failed (${response.status})`;
1396
+ throw new Error(message);
1397
+ }
1398
+ return body;
1399
+ }
1400
+
1401
+ // src/commands/theme/upload.ts
1402
+ async function runUploadCommand(options) {
1403
+ const themeRoot = await resolveThemeRoot(options.dir);
1404
+ const auth = await resolveAuthConfig({
1405
+ api: options.api,
1406
+ storeId: options.storeId,
1407
+ token: options.token
1408
+ });
1409
+ printHeading("Uploading theme", themeRoot);
1410
+ const zipPath = options.zip || await withSpinner(
1411
+ "Building upload package",
1412
+ async () => runPackCommand({ dir: themeRoot, quiet: true })
1413
+ );
1414
+ const result = await withSpinner(
1415
+ "Uploading zip to Storify API",
1416
+ async () => uploadThemeZip(auth, zipPath, options.updateThemeId)
1417
+ );
1418
+ printDivider();
1419
+ if (options.updateThemeId) {
1420
+ printStep("Theme ID", options.updateThemeId);
1421
+ printSuccess("Theme dist updated successfully.");
1422
+ } else {
1423
+ printStep("Theme ID", result?.id || "unknown");
1424
+ printStep("Name", result?.name || "unknown");
1425
+ if (result?.baseUrl) printStep("Base URL", result.baseUrl);
1426
+ printSuccess("Theme uploaded successfully.");
1427
+ }
1428
+ printHint("Activate the theme from Admin panel if needed.");
1429
+ console.log("");
1430
+ }
1431
+
1432
+ // src/commands/theme/validate.ts
1433
+ import path11 from "path";
1434
+ import { promises as fs11 } from "fs";
1435
+
1436
+ // src/lib/manifest-zod.ts
1437
+ import { z } from "zod";
1438
+ var MAX_SECTIONS = 100;
1439
+ var editorFieldSchema = z.object({
1440
+ type: z.string().min(1),
1441
+ label: z.string().optional(),
1442
+ description: z.string().optional(),
1443
+ group: z.string().min(1).optional(),
1444
+ required: z.boolean().optional(),
1445
+ localizable: z.boolean().optional(),
1446
+ default: z.unknown().optional()
1447
+ }).passthrough();
1448
+ var themePageLayoutItemSchema = z.object({
1449
+ sectionId: z.string().min(1),
1450
+ handle: z.string().min(1),
1451
+ defaultEnabled: z.boolean().optional()
1452
+ });
1453
+ var themePageDefinitionSchema = z.object({
1454
+ id: z.string().min(1),
1455
+ type: z.string().min(1),
1456
+ path: z.string().min(1),
1457
+ layout: z.array(themePageLayoutItemSchema).min(0).max(50),
1458
+ seoSchema: z.record(z.string(), editorFieldSchema).optional()
1459
+ });
1460
+ var themeManifestSectionSchema = z.object({
1461
+ id: z.string().min(1),
1462
+ name: z.string(),
1463
+ component: z.string().min(1),
1464
+ order: z.number().int().nonnegative().optional(),
1465
+ group: z.enum(["header_group", "template_group", "overlay_group", "footer_group"]).optional(),
1466
+ allowedPages: z.array(z.string().min(1)).optional(),
1467
+ contentSchema: z.record(z.string(), editorFieldSchema).optional(),
1468
+ defaultContent: z.record(z.string(), z.unknown()).optional()
1469
+ });
1470
+ var themeManifestDSLSchema = z.object({
1471
+ name: z.string().min(1).transform((s) => s.trim()),
1472
+ version: z.string().min(1).transform((s) => s.trim()),
1473
+ entry: z.string().min(1).transform((s) => s.trim()),
1474
+ sections: z.array(themeManifestSectionSchema).min(1).max(MAX_SECTIONS),
1475
+ pages: z.array(themePageDefinitionSchema).optional(),
1476
+ settingsSchema: z.record(z.string(), z.unknown()).optional(),
1477
+ themeSettingsSchema: z.record(z.string(), editorFieldSchema).optional(),
1478
+ themeSettingsGroups: z.array(z.object({ id: z.string().min(1), label: z.string().min(1), description: z.string().optional(), order: z.number().int().optional() })).optional(),
1479
+ manifestVersion: z.number().int().nonnegative().optional(),
1480
+ languages: z.array(z.string().min(1)).optional(),
1481
+ assets: z.record(z.string(), z.string().min(1)).optional()
1482
+ });
1483
+ function validateThemeManifestDSL(obj) {
1484
+ const result = themeManifestDSLSchema.safeParse(obj);
1485
+ if (result.success) {
1486
+ return { ok: true, manifest: result.data };
1487
+ }
1488
+ const first = result.error.issues[0];
1489
+ const path13 = first?.path?.length ? first.path.join(".") : "manifest";
1490
+ return { ok: false, error: `${path13}: ${first?.message ?? result.error.message}` };
1491
+ }
1492
+ function validateThemeManifestDSLReferences(manifest) {
1493
+ const sectionIds = new Set(manifest.sections.map((s) => s.id));
1494
+ if (!manifest.pages) return { ok: true };
1495
+ for (const page of manifest.pages) {
1496
+ for (const item of page.layout) {
1497
+ if (!sectionIds.has(item.sectionId)) {
1498
+ return { ok: false, error: `Page "${page.id}" layout references unknown section "${item.sectionId}"` };
1499
+ }
1500
+ }
1501
+ }
1502
+ return { ok: true };
1503
+ }
1504
+
1505
+ // src/lib/section-map.ts
1506
+ import path10 from "path";
1507
+ import { promises as fs10 } from "fs";
1508
+ function parseSectionMapKeys(sectionRenderer) {
1509
+ const start = sectionRenderer.indexOf("const SECTION_MAP");
1510
+ if (start < 0) {
1511
+ throw new Error("SECTION_MAP not found in src/SectionRenderer.tsx");
1512
+ }
1513
+ const eqBrace = sectionRenderer.indexOf("= {", start);
1514
+ const open = eqBrace >= 0 ? eqBrace + 2 : sectionRenderer.indexOf("{", start);
1515
+ let depth = 0;
1516
+ let end = -1;
1517
+ for (let i = open; i < sectionRenderer.length; i += 1) {
1518
+ const ch = sectionRenderer[i];
1519
+ if (ch === "{") depth += 1;
1520
+ if (ch === "}") {
1521
+ depth -= 1;
1522
+ if (depth === 0) {
1523
+ end = i;
1524
+ break;
1525
+ }
1526
+ }
1527
+ }
1528
+ if (end < 0) {
1529
+ throw new Error("Could not parse SECTION_MAP block");
1530
+ }
1531
+ const mapBody = sectionRenderer.slice(open + 1, end);
1532
+ return new Set([...mapBody.matchAll(/^\s*([A-Z][A-Z0-9_]*)\s*:/gm)].map((m) => m[1]));
1533
+ }
1534
+ async function validateManifestSectionMap(themeRoot) {
1535
+ const manifestPath = path10.join(themeRoot, "theme-manifest.json");
1536
+ const sectionRendererPath = path10.join(themeRoot, "src", "SectionRenderer.tsx");
1537
+ const [manifestRaw, sectionRenderer] = await Promise.all([
1538
+ fs10.readFile(manifestPath, "utf8"),
1539
+ fs10.readFile(sectionRendererPath, "utf8")
1540
+ ]);
1541
+ const manifest = JSON.parse(manifestRaw);
1542
+ const mapKeys = parseSectionMapKeys(sectionRenderer);
1543
+ const manifestComponents = (manifest.sections || []).map((s) => ({
1544
+ id: String(s.id || ""),
1545
+ component: String(s.component || "").trim().toUpperCase().replace(/-/g, "_")
1546
+ })).filter((s) => Boolean(s.component));
1547
+ const missing = manifestComponents.filter((s) => !mapKeys.has(s.component));
1548
+ if (missing.length > 0) {
1549
+ const message = missing.map((item) => `Manifest section "${item.id}" component "${item.component}" has no SECTION_MAP entry`).join("\n");
1550
+ throw new Error(message);
1551
+ }
1552
+ return { sections: manifestComponents.length, mapKeys: mapKeys.size };
1553
+ }
1554
+
1555
+ // src/commands/theme/validate.ts
1556
+ async function fileExists3(filePath) {
1557
+ try {
1558
+ await fs11.access(filePath);
1559
+ return true;
1560
+ } catch {
1561
+ return false;
1562
+ }
1563
+ }
1564
+ async function runValidateCommand(options) {
1565
+ const themeRoot = await resolveThemeRoot(options.dir);
1566
+ printHeading("Validating theme", themeRoot);
1567
+ const manifestPath = path11.join(themeRoot, "theme-manifest.json");
1568
+ const manifestRaw = await fs11.readFile(manifestPath, "utf8");
1569
+ const manifest = JSON.parse(manifestRaw);
1570
+ let spinner2 = createSpinner("Checking manifest DSL");
1571
+ spinner2.start();
1572
+ const dslValidation = validateThemeManifestDSL(manifest);
1573
+ if (!dslValidation.ok) {
1574
+ spinner2.fail("Manifest DSL validation failed");
1575
+ throw new Error(dslValidation.error);
1576
+ }
1577
+ spinner2.succeed("Manifest DSL is valid");
1578
+ spinner2 = createSpinner("Checking page section references");
1579
+ spinner2.start();
1580
+ const refValidation = validateThemeManifestDSLReferences(dslValidation.manifest);
1581
+ if (!refValidation.ok) {
1582
+ spinner2.fail("Manifest references validation failed");
1583
+ throw new Error(refValidation.error);
1584
+ }
1585
+ spinner2.succeed("Page layout references are valid");
1586
+ const sectionRendererPath = path11.join(themeRoot, "src", "SectionRenderer.tsx");
1587
+ if (await fileExists3(sectionRendererPath)) {
1588
+ spinner2 = createSpinner("Matching SECTION_MAP components");
1589
+ spinner2.start();
1590
+ const sectionMapInfo = await validateManifestSectionMap(themeRoot);
1591
+ spinner2.succeed(`SECTION_MAP matched (${sectionMapInfo.sections} sections, ${sectionMapInfo.mapKeys} keys)`);
1592
+ } else {
1593
+ printWarn("Skipped SECTION_MAP check (src/SectionRenderer.tsx not found).");
1594
+ }
1595
+ spinner2 = createSpinner("Validating config/pages defaults");
1596
+ spinner2.start();
1597
+ const dryRun = await buildThemeManifest({
1598
+ themeRoot,
1599
+ writeToDist: false,
1600
+ syncEntryFromDist: false,
1601
+ quiet: true
1602
+ });
1603
+ spinner2.succeed("Page defaults are consistent");
1604
+ if (!dslValidation.manifest.entry || !dslValidation.manifest.entry.trim()) {
1605
+ throw new Error("Manifest entry is required.");
1606
+ }
1607
+ const indexHtmlPath = path11.join(themeRoot, "index.html");
1608
+ if (!await fileExists3(indexHtmlPath)) {
1609
+ throw new Error("index.html is required at theme root.");
1610
+ }
1611
+ const uniqueSectionIds = new Set(dslValidation.manifest.sections.map((section) => section.id));
1612
+ if (uniqueSectionIds.size !== dslValidation.manifest.sections.length) {
1613
+ throw new Error("Duplicate section ids found in manifest.sections.");
1614
+ }
1615
+ printDivider();
1616
+ printStep("Theme", dslValidation.manifest.name);
1617
+ printStep("Version", dslValidation.manifest.version);
1618
+ printStep("Sections", String(dslValidation.manifest.sections.length));
1619
+ printStep("Manifest size", formatBytes(dryRun.sizeInBytes));
1620
+ printSuccess("Theme is ready to build and pack.");
1621
+ printInfo("Next: `storify theme pack`");
1622
+ console.log("");
1623
+ }
1624
+
1625
+ // src/lib/interactive.ts
1626
+ import * as p3 from "@clack/prompts";
1627
+ import pc2 from "picocolors";
1628
+ async function runInteractiveRoot(version2) {
1629
+ if (!isInteractive()) {
1630
+ printBanner(version2);
1631
+ printQuickStart();
1632
+ return;
1633
+ }
1634
+ p3.intro(`${brand.name} CLI ${pc2.dim(`v${version2}`)}`);
1635
+ const action = await p3.select({
1636
+ message: "What would you like to do?",
1637
+ options: [
1638
+ { value: "preview", label: "Start live theme preview", hint: "Login \u2192 pick store \u2192 storefront" },
1639
+ { value: "validate", label: "Validate theme", hint: "Manifest + sections" },
1640
+ { value: "build", label: "Build theme", hint: "dist/ output" },
1641
+ { value: "pack", label: "Pack zip", hint: "Upload-ready archive" },
1642
+ { value: "upload", label: "Upload theme", hint: "Send zip to Storify" },
1643
+ { value: "new", label: "Create new theme", hint: "From starter template" },
1644
+ { value: "login", label: "Sign in / switch store", hint: "Save credentials" },
1645
+ { value: "status", label: "Check setup", hint: "Config + theme folder" },
1646
+ { value: "help", label: "Show quick start", hint: "Common commands" }
1647
+ ]
1648
+ });
1649
+ if (p3.isCancel(action)) {
1650
+ p3.cancel("Cancelled.");
1651
+ process.exit(0);
1652
+ }
1653
+ try {
1654
+ switch (action) {
1655
+ case "preview":
1656
+ await runDevCommand({ linkStore: true, openStorefront: true, local: true });
1657
+ break;
1658
+ case "validate":
1659
+ await runValidateCommand({});
1660
+ break;
1661
+ case "build":
1662
+ await runBuildCommand({});
1663
+ break;
1664
+ case "pack":
1665
+ await runPackCommand({});
1666
+ break;
1667
+ case "upload":
1668
+ await runUploadCommand({});
1669
+ break;
1670
+ case "new": {
1671
+ const name = await p3.text({
1672
+ message: "Theme folder name",
1673
+ placeholder: "my-theme",
1674
+ validate: (value) => value?.trim() ? void 0 : "Name is required"
1675
+ });
1676
+ if (p3.isCancel(name)) {
1677
+ p3.cancel("Cancelled.");
1678
+ process.exit(0);
1679
+ }
1680
+ await runNewCommand(String(name).trim(), {});
1681
+ break;
1682
+ }
1683
+ case "login":
1684
+ await runLoginCommand({});
1685
+ break;
1686
+ case "status":
1687
+ await runStatusCommand({});
1688
+ break;
1689
+ case "help":
1690
+ printQuickStart();
1691
+ p3.outro("Done.");
1692
+ break;
1693
+ default:
1694
+ break;
1695
+ }
1696
+ } catch (error) {
1697
+ p3.log.error(error instanceof Error ? error.message : String(error));
1698
+ process.exit(1);
1699
+ }
1700
+ }
1701
+
1702
+ // src/cli.ts
1703
+ var __dirname = path12.dirname(fileURLToPath(import.meta.url));
1704
+ var packageJson = JSON.parse(readFileSync(path12.join(__dirname, "../package.json"), "utf8"));
1705
+ var version = packageJson.version || "0.0.0";
1706
+ var program = new Command();
1707
+ program.name("storify").description(`${brand.name} theme development CLI for storefront templates`).version(version, "-V, --version", "Show CLI version");
1708
+ program.command("login").description("Save API credentials to ~/.storify/config.json").option("--api <url>", "API base URL (e.g. http://localhost:3001/api)").option("--store-id <id>", "Store identifier").option("--token <token>", "Auth bearer token").option("--storefront <url>", "Storefront base URL").option("--admin <url>", "Admin base URL").action(async (options) => runLoginCommand(options));
1709
+ program.command("status").description("Show saved credentials and current theme context").option("--dir <path>", "Theme directory (default: current directory)").action(async (options) => runStatusCommand(options));
1710
+ var theme = program.command("theme").description("Develop, validate, build, pack, and upload Storify themes").addHelpText(
1711
+ "after",
1712
+ `
1713
+ Examples:
1714
+ $ storify theme dev
1715
+ $ storify theme dev --no-link-store
1716
+ $ storify theme validate
1717
+ $ storify theme pack
1718
+ $ storify theme upload
1719
+ `
1720
+ );
1721
+ theme.command("dev").description("Run Vite dev server and preview theme on the live storefront").option("--dir <path>", "Theme directory (default: current directory)").option("--port <port>", "Dev server port", "3000").option("--store-id <id>", "Store ID for real data context").option("--api <url>", "API base URL").option("--token <token>", "Auth bearer token").option("--no-link-store", "Local Vite only (skip storefront preview)").option("--unlink-on-exit", "Disable dev link on process exit", true).option("--no-tunnel", "Do not try cloudflared tunnel").option("--local", "Use localhost storefront and theme (no tunnel)").option("--remote", "Use production storefront URL and cloudflared tunnel").option("--public-url <url>", "Public URL to use instead of opening a tunnel").option("--open", "Open local Vite URL in browser").option("--open-storefront", "Open live storefront preview in browser").action(async (options) => runDevCommand(options));
1722
+ theme.command("validate").alias("check").description("Validate theme manifest and section map").option("--dir <path>", "Theme directory (default: current directory)").action(async (options) => runValidateCommand(options));
1723
+ theme.command("build").description("Build theme dist and generated manifest").option("--dir <path>", "Theme directory (default: current directory)").action(async (options) => runBuildCommand(options));
1724
+ theme.command("pack").alias("zip").description("Create upload-ready zip from dist contents").option("--dir <path>", "Theme directory (default: current directory)").option("--out <zipPath>", "Output zip file path").option("--force", "Force rebuild before packaging").action(async (options) => {
1725
+ await runPackCommand(options);
1726
+ });
1727
+ theme.command("new").description("Create a new theme from storify-templatesrap starter").argument("<name>", "Theme folder name").option("--dir <path>", "Base directory for new theme (default: repo themes/)").action(async (name, options) => runNewCommand(name, options));
1728
+ theme.command("upload").description("Upload a theme zip to /api/uploaded-themes").option("--dir <path>", "Theme directory (default: current directory)").option("--zip <path>", "Zip file path (if omitted, runs pack)").option("--api <url>", "API base URL override").option("--store-id <id>", "Store ID override").option("--token <token>", "Bearer token override").option("--update-theme-id <id>", "Use update-dist endpoint for an existing theme id").action(async (options) => runUploadCommand(options));
1729
+ async function main() {
1730
+ const args = process.argv.slice(2);
1731
+ if (args.length === 0) {
1732
+ await runInteractiveRoot(version);
1733
+ return;
1734
+ }
1735
+ await program.parseAsync(process.argv);
1736
+ }
1737
+ main().catch(handleCliError);