@shop-on/theme-dev-host 0.0.0-stage → 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 CHANGED
@@ -1,3 +1,77 @@
1
- # Temporary Holding Version
1
+ # @shop-on/theme-dev-host
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ A local theme-editor dev server. It serves the **editor contract** (editor-loader,
4
+ draft read/write, template/settings) over a theme's local `src/`, and
5
+ reverse-proxies everything else to the theme's own dev server — behind **one
6
+ origin** a real admin theme-editor can bind to. So a theme runs and previews
7
+ locally while the platform spends ~zero compute (it never boots a storefront,
8
+ allocates a port, or stores an in-progress theme).
9
+
10
+ Self-contained: no Redis, no orchestrator dependency. This is the nucleus of the
11
+ future `theme` CLI (`theme dev`).
12
+
13
+ See **[docs/theme-dev-host.md](../../docs/theme-dev-host.md)** for the architecture.
14
+
15
+ ## Quick start
16
+
17
+ ```bash
18
+ # One command — starts the theme's dev server AND the gateway, wired together:
19
+ pnpm -C themes/care theme:editor
20
+
21
+ # gateway http://localhost:4101 (open this; ?preview=<sid> shows drafts)
22
+ # theme dev http://localhost:3002
23
+ ```
24
+
25
+ The gateway port is the single origin for both the editor API and the storefront
26
+ render. `Ctrl-C` stops both.
27
+
28
+ Each theme depends on `@shop-on/theme-dev-host` (`workspace:*`) and exposes
29
+ `theme:editor` → the `theme-dev-host` bin, which infers the theme from the directory
30
+ it runs in — so the command is identical for every theme.
31
+
32
+ ## Modes
33
+
34
+ | Command | What it runs |
35
+ | ---------------------------------------------------- | ------------------------------------------------------------- |
36
+ | `pnpm -C themes/<name> theme:editor` | theme dev server **+** gateway (the normal flow) |
37
+ | `pnpm -C packages/theme-dev-host dev --theme <name>` | gateway only (when the theme's `pnpm dev` is already running) |
38
+
39
+ ## Flags (`theme:editor`)
40
+
41
+ | Flag | Default | Purpose |
42
+ | ---------------------- | ------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
43
+ | `<name>` (positional) | inferred from the invoking theme dir | override the theme dir under `themes/` |
44
+ | `--port` | `4101` | gateway port (the preview origin) |
45
+ | `--theme-port` | auto (read from the theme's `vite.config`) | the theme's own dev port |
46
+ | `--slug` / `--install` | `<name>` / `devinstall` | the `(shop-handle, install-id)` the gateway store-pins to — **must match the connected install** (install id is in the admin editor URL `/themes/<install-id>/editor`); defaults won't match a real store and 404 `unknown store` |
47
+ | `--preset <handle>` | the theme's `current.preset` | load that preset's settings into the editor and make it the target **Save** writes back to (load-state / save-state); a missing handle opens empty and Save creates it |
48
+ | `--tunnel [provider]` | off | expose the gateway publicly — `cloudflared` (default, no signup), `ngrok`, `zrok` |
49
+ | `--public-url <url>` | — | with `--tunnel manual`: front the gateway with any tunnel you run yourself |
50
+ | `--env` | `dev` | platform whose public key verifies editor tokens (`dev`/`staging`/`prod`) |
51
+
52
+ Editor write routes require a signature-verified RS256 JWT; pair a hosted-prod
53
+ admin with `--env prod` so the right key verifies it.
54
+
55
+ ## Saving back to source
56
+
57
+ The editor's draft is in-memory — "publish" clears it, it doesn't persist. To
58
+ commit your work, use the editor's **Save** (writes the current templates +
59
+ section groups + settings into the theme's `src/`) or **Save as preset** (writes
60
+ the current settings under a new named preset). Git is the checkpoint: Save →
61
+ `src/` → commit. `--preset <handle>` picks which slot Save targets, so you can
62
+ load one state, tweak, and Save back to it — or branch to a fresh name with Save
63
+ as preset. Settings are **merged** into the preset map, so sibling presets are
64
+ never clobbered.
65
+
66
+ Without `--tunnel` the gateway is localhost-only. With `--tunnel`, paste the
67
+ printed public URL into the **hosted** admin theme-editor (actions menu → "Local
68
+ dev preview") to preview your local theme there — no backend or local admin
69
+ needed. HMR there dials `ws://localhost:<theme-port>`, so it only works in a
70
+ browser on the same machine as the dev server. See
71
+ [docs/theme-dev-host.md](../../docs/theme-dev-host.md).
72
+
73
+ ## Test
74
+
75
+ ```bash
76
+ pnpm -C packages/theme-dev-host test
77
+ ```
@@ -0,0 +1,9 @@
1
+ -----BEGIN PUBLIC KEY-----
2
+ MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAxvrVRevpM9Q4DE8mWony
3
+ MYK2AKBKEXA9sxkerBSAJutggePuPn65xwOC4V90+0SUeXUeobe8Ux0QT3QKy244
4
+ oqE3Ykkonwin3+Ws81ZmI4zzo7d1MXXHkuRko64JJT1LHtOYNsxI209177qxxf6J
5
+ tmprUic5tyq8U2TgcbK4qd38mSLI4QFOieDU0r3uqc8U7S4A9NnHzrba9/lUIouJ
6
+ RvU1j0k4wonnk0idAkRuTWLmQRAcNZQnAVQvQ9t9ShLCC0FSnD2quvV3V1r0YnWY
7
+ +sYRy67zVvJ8DgyhIuJdX8cpWPWTh0DUTspHSPMufmeeq058IIkixJz2poDIdtLl
8
+ /wIDAQAB
9
+ -----END PUBLIC KEY-----
@@ -0,0 +1,9 @@
1
+ -----BEGIN PUBLIC KEY-----
2
+ MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqC3UMe4gql3jXPDATrc3
3
+ PeuwutddgDE1sXhuaX/3yjU0Nket0Rbaho82zSU4FgnXXSwtUWCXt+35+b35zLdO
4
+ WzS24TaE/ZlP1LORm91nSKAvQdzPBIP5paA3/JljByLrWxv6fY6U2+ppGiSpO8fM
5
+ n3ZLlIzk99F+hgNg/8cQy8QhaM7Wz6qiFJ93z3Gfbub3XwKmtdGQlCC86Kvh6GB2
6
+ I5IyKZCfPmEAm15JJzh1zEXZZ+n/PgNcTh6xrJiuQM7/hyFpyKaSwn9NAO6Y1oUC
7
+ tmhMLxuqsJGKQ9ci/83x/OU89cqV8Vt3GAN4M4r/B89cekjxvOuX9V/IzGxTtJti
8
+ ZQIDAQAB
9
+ -----END PUBLIC KEY-----
@@ -0,0 +1,9 @@
1
+ -----BEGIN PUBLIC KEY-----
2
+ MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAu13KN25kdHurKIzWWOf4
3
+ 7nA2zqvEeARCqwOhbBzMjbxCB8wgN0a6LocM0Yqxveq1Hevc3cTnpSSFWhZabv8Q
4
+ 08gxgwkq8Ed1SC2KkjWIGxLJFsoUy0w0lHnWVJisHCcs9r9OLIjwWLVs9mDwRFZV
5
+ DWNn75JZOc5B6fffP1KAvfU9S51I+Yy0GZyoegdcRDelJgeQEFL7U3aoNPpzFsaP
6
+ 2jeK0eGD37cSxI5XcoDZGoQno7YxFM6vA8wkMZSS7tF8Xo89UHgjpKThC+nhriQe
7
+ OVLHiGYXryQojmNc9/3NpQMhlBIEmI+Joe0siSiiH7VWojwV4Mn0J4BeFOIRxTZ/
8
+ ZQIDAQAB
9
+ -----END PUBLIC KEY-----
@@ -0,0 +1,39 @@
1
+ # Cody Theme Development License
2
+
3
+ Copyright (c) 2026 Cody LLC. All rights reserved except as granted below.
4
+
5
+ "The Software" is this package, its source, and its documentation. "The
6
+ Platform" is Cody, the commerce platform operated by Cody LLC.
7
+
8
+ ## 1. Grant
9
+
10
+ You may use, copy and modify the Software, and distribute modified copies, solely
11
+ to develop, test, and distribute themes, apps and other extensions that run on
12
+ the Platform.
13
+
14
+ ## 2. Restrictions
15
+
16
+ You may not:
17
+
18
+ 1. use the Software, or anything derived from it, with any commerce platform,
19
+ storefront or hosting service other than the Platform;
20
+ 2. use the Software to build a product that competes with the Platform;
21
+ 3. sublicense the Software, or distribute it other than as part of a theme,
22
+ app or extension for the Platform;
23
+ 4. remove or alter this license or any copyright notice in the Software.
24
+
25
+ ## 3. Your work
26
+
27
+ Themes, apps and extensions you build with the Software are yours. The parts of
28
+ the Software they include stay under this license.
29
+
30
+ ## 4. Termination
31
+
32
+ This license ends automatically if you break any of its terms. You must then
33
+ stop using the Software and delete your copies of it.
34
+
35
+ ## 5. No warranty
36
+
37
+ The Software is provided "as is", without warranty of any kind. Cody LLC is not
38
+ liable for any claim, damages or other liability arising from the Software or
39
+ its use.
package/dist/auth.mjs ADDED
@@ -0,0 +1,60 @@
1
+ #!/usr/bin/env node
2
+ import { EditorAuthError } from "./verify.mjs";
3
+ //#region src/auth.ts
4
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
5
+ function reject(c, reason) {
6
+ console.warn(`theme-dev-host: 401 ${c.req.method} ${c.req.path} — ${reason}`);
7
+ return c.json({ error: reason }, 401);
8
+ }
9
+ async function applyJwt(c, header, verify) {
10
+ const claims = await verify(header.slice(7));
11
+ const storeSlug = c.req.param("storeSlug") ?? "";
12
+ const installId = c.req.param("installId") ?? "";
13
+ if (claims.shop_handle !== storeSlug) return reject(c, `shop mismatch (token shop_handle=${claims.shop_handle}, path=${storeSlug})`);
14
+ if (claims.install_id !== installId) return reject(c, `install mismatch (token install_id=${claims.install_id}, path=${installId})`);
15
+ c.set("sid", claims.sid);
16
+ c.set("shopHandle", storeSlug);
17
+ c.set("installId", installId);
18
+ return null;
19
+ }
20
+ function createEditorAuth(verify) {
21
+ const editorAuth = async (c, next) => {
22
+ const header = c.req.header("Authorization");
23
+ if (!header?.startsWith("Bearer ")) return reject(c, "missing bearer token");
24
+ try {
25
+ const fail = await applyJwt(c, header, verify);
26
+ if (fail) return fail;
27
+ } catch (err) {
28
+ if (err instanceof EditorAuthError) return reject(c, err.reason);
29
+ throw err;
30
+ }
31
+ await next();
32
+ };
33
+ const editorAuthOrPreview = async (c, next) => {
34
+ const header = c.req.header("Authorization");
35
+ if (header?.startsWith("Bearer ")) {
36
+ try {
37
+ const fail = await applyJwt(c, header, verify);
38
+ if (fail) return fail;
39
+ } catch (err) {
40
+ if (err instanceof EditorAuthError) return reject(c, err.reason);
41
+ throw err;
42
+ }
43
+ await next();
44
+ return;
45
+ }
46
+ if (c.req.method !== "GET") return reject(c, "missing bearer token");
47
+ const sid = c.req.query("sid") ?? c.req.header("X-Editor-Sid") ?? void 0;
48
+ if (!sid || !UUID_RE.test(sid)) return reject(c, "missing or invalid sid");
49
+ c.set("sid", sid);
50
+ c.set("shopHandle", c.req.param("storeSlug") ?? "");
51
+ c.set("installId", c.req.param("installId") ?? "");
52
+ await next();
53
+ };
54
+ return {
55
+ editorAuth,
56
+ editorAuthOrPreview
57
+ };
58
+ }
59
+ //#endregion
60
+ export { createEditorAuth };
@@ -0,0 +1,44 @@
1
+ #!/usr/bin/env node
2
+ //#region src/drafts.ts
3
+ const sessions = /* @__PURE__ */ new Map();
4
+ const MAX_SESSIONS = 100;
5
+ function sessionFor(sid) {
6
+ let s = sessions.get(sid);
7
+ if (!s) {
8
+ if (sessions.size >= MAX_SESSIONS) {
9
+ const oldest = sessions.keys().next().value;
10
+ if (oldest !== void 0) sessions.delete(oldest);
11
+ }
12
+ s = {
13
+ templates: {},
14
+ groups: {}
15
+ };
16
+ sessions.set(sid, s);
17
+ }
18
+ return s;
19
+ }
20
+ const drafts = {
21
+ getSettings(sid) {
22
+ return sessions.get(sid)?.settings;
23
+ },
24
+ setSettings(sid, settings) {
25
+ sessionFor(sid).settings = settings;
26
+ },
27
+ getTemplate(sid, templateId) {
28
+ return sessions.get(sid)?.templates[templateId];
29
+ },
30
+ setTemplate(sid, templateId, template) {
31
+ sessionFor(sid).templates[templateId] = template;
32
+ },
33
+ getGroups(sid) {
34
+ return { ...sessions.get(sid)?.groups };
35
+ },
36
+ setGroup(sid, groupType, group) {
37
+ sessionFor(sid).groups[groupType] = group;
38
+ },
39
+ clear(sid) {
40
+ sessions.delete(sid);
41
+ }
42
+ };
43
+ //#endregion
44
+ export { drafts };
@@ -0,0 +1,228 @@
1
+ #!/usr/bin/env node
2
+ import { LANG_RE, loadLocale, loadStorefrontLocale, parseLang, readJsonDir, resolveDisplayName, resolveTranslations, unwrapSettings } from "./locale.mjs";
3
+ import { drafts } from "./drafts.mjs";
4
+ import { isValidPresetHandle, writeSettingsPreset, writeThemeSourceFiles } from "./save.mjs";
5
+ import "./auth.mjs";
6
+ import path from "node:path";
7
+ import "hono";
8
+ import { readFile, readdir, stat } from "node:fs/promises";
9
+ import { currentPresetHandle } from "@shop-on/theme-contract/preset-listing";
10
+ import { bodyLimit } from "hono/body-limit";
11
+ //#region src/editor-app.ts
12
+ const TEMPLATE_ID_RE = /^[A-Za-z0-9_.-]+$/;
13
+ const invalidTemplateId = (id) => !id || !TEMPLATE_ID_RE.test(id) || /[/\\]|\.\./.test(id);
14
+ const PREFIX = "/api/stores/:storeSlug/:installId";
15
+ function registerEditorRoutes(app, configDir, auth, store, opts) {
16
+ const { editorAuth, editorAuthOrPreview } = auth;
17
+ const themeSrcDir = opts?.themeSrcDir;
18
+ const presetOverride = opts?.preset;
19
+ if (store) app.use(`${PREFIX}/*`, async (c, next) => {
20
+ const slug = c.req.param("storeSlug");
21
+ const install = c.req.param("installId");
22
+ if (slug !== store.slug || install !== store.install) {
23
+ console.warn(`theme-dev-host: rejected /api/stores/${slug}/${install}/* — gateway is pinned to ${store.slug}/${store.install}. Restart with --slug ${slug} --install ${install} (the install id is in the admin editor URL).`);
24
+ return c.json({ error: "unknown store" }, 404);
25
+ }
26
+ await next();
27
+ });
28
+ const writeBodyLimit = bodyLimit({
29
+ maxSize: 8388608,
30
+ onError: (c) => c.json({ error: "draft too large" }, 413)
31
+ });
32
+ async function loadSettingsData() {
33
+ try {
34
+ return JSON.parse(await readFile(path.join(configDir, "settings-data.json"), "utf-8"));
35
+ } catch {
36
+ return {};
37
+ }
38
+ }
39
+ async function liveSettings() {
40
+ return unwrapSettings(await loadSettingsData(), presetOverride);
41
+ }
42
+ async function diskTemplate(id) {
43
+ try {
44
+ return JSON.parse(await readFile(path.join(configDir, "templates", `${id}.json`), "utf-8"));
45
+ } catch {
46
+ return null;
47
+ }
48
+ }
49
+ async function sectionGroupsFor(sid) {
50
+ const groups = drafts.getGroups(sid);
51
+ try {
52
+ for (const file of await readdir(path.join(configDir, "section-groups"))) {
53
+ if (!file.endsWith(".json")) continue;
54
+ const data = JSON.parse(await readFile(path.join(configDir, "section-groups", file), "utf-8"));
55
+ if (data.type && !groups[data.type]) groups[data.type] = {
56
+ sections: data.sections ?? {},
57
+ order: data.order ?? []
58
+ };
59
+ }
60
+ } catch {}
61
+ return groups;
62
+ }
63
+ app.get(`${PREFIX}/editor-loader`, editorAuthOrPreview, async (c) => {
64
+ const sid = c.get("sid");
65
+ const locale = await loadLocale(configDir, parseLang(c));
66
+ const [schemaRaw, sections, blocks, templates, sectionGroups] = await Promise.all([
67
+ readFile(path.join(configDir, "settings-schema.json"), "utf-8").catch(() => null),
68
+ readJsonDir(path.join(configDir, "sections")),
69
+ readJsonDir(path.join(configDir, "blocks")),
70
+ readJsonDir(path.join(configDir, "templates")),
71
+ readJsonDir(path.join(configDir, "section-groups"))
72
+ ]);
73
+ const themeSchema = (schemaRaw ? JSON.parse(schemaRaw) : []).map((s) => resolveTranslations(s, locale));
74
+ const settingsData = await loadSettingsData();
75
+ const settings = drafts.getSettings(sid) ?? unwrapSettings(settingsData, presetOverride);
76
+ const activePreset = presetOverride ?? settingsData.current?.preset ?? "default";
77
+ const initialTemplateData = {
78
+ sections: {},
79
+ order: [],
80
+ ...drafts.getTemplate(sid, "index") ?? await diskTemplate("index")
81
+ };
82
+ initialTemplateData.section_groups = await sectionGroupsFor(sid);
83
+ let layoutSchema = {};
84
+ try {
85
+ layoutSchema = resolveTranslations(JSON.parse(await readFile(path.join(configDir, "layouts", "main.json"), "utf-8")), locale);
86
+ } catch {}
87
+ return c.json({
88
+ themeSchema,
89
+ sectionSchemas: sections.map(({ id, data }) => ({
90
+ type: id,
91
+ ...resolveTranslations(data, locale)
92
+ })),
93
+ blockSchemas: blocks.map(({ id, data }) => ({
94
+ type: id,
95
+ ...resolveTranslations(data, locale)
96
+ })),
97
+ templates: templates.map(({ id, data }) => ({
98
+ id,
99
+ name: resolveDisplayName(data, id, locale)
100
+ })),
101
+ sectionGroupDefaults: sectionGroups.map(({ data }) => resolveTranslations(data, locale)),
102
+ initialTemplateData,
103
+ layoutSchema,
104
+ settings,
105
+ activePreset
106
+ });
107
+ });
108
+ app.put(`${PREFIX}/draft`, editorAuth, writeBodyLimit, async (c) => {
109
+ const body = await c.req.json().catch(() => null);
110
+ if (!body?.settings || typeof body.settings !== "object") return c.json({ error: "settings required" }, 400);
111
+ drafts.setSettings(c.get("sid"), body.settings);
112
+ return c.json({ ok: true });
113
+ });
114
+ app.put(`${PREFIX}/draft/template`, editorAuth, writeBodyLimit, async (c) => {
115
+ const body = await c.req.json().catch(() => null);
116
+ if (invalidTemplateId(body?.templateId) || !body?.template) return c.json({ error: "templateId and template required" }, 400);
117
+ drafts.setTemplate(c.get("sid"), body.templateId, body.template);
118
+ return c.json({ ok: true });
119
+ });
120
+ app.put(`${PREFIX}/draft/section-group`, editorAuth, writeBodyLimit, async (c) => {
121
+ const body = await c.req.json().catch(() => null);
122
+ if (!body?.groupType || !body?.group) return c.json({ error: "groupType and group required" }, 400);
123
+ drafts.setGroup(c.get("sid"), body.groupType, body.group);
124
+ return c.json({ ok: true });
125
+ });
126
+ app.post(`${PREFIX}/publish`, editorAuth, async (c) => {
127
+ drafts.clear(c.get("sid"));
128
+ return c.json({
129
+ ok: true,
130
+ published: true
131
+ });
132
+ });
133
+ async function saveListingPreset() {
134
+ if (!themeSrcDir) return void 0;
135
+ try {
136
+ if (!(await stat(path.join(themeSrcDir, "listings"))).isDirectory()) return void 0;
137
+ } catch {
138
+ return;
139
+ }
140
+ const settingsData = await loadSettingsData();
141
+ return currentPresetHandle(settingsData, presetOverride) ?? "default";
142
+ }
143
+ app.post(`${PREFIX}/save`, editorAuth, writeBodyLimit, async (c) => {
144
+ if (!themeSrcDir) return c.json({ error: "save unavailable (no theme src)" }, 500);
145
+ const body = await c.req.json().catch(() => null);
146
+ if (!body?.files || typeof body.files !== "object") return c.json({ error: "files required" }, 400);
147
+ if (body.settings && !isValidPresetHandle(body.presetHandle)) return c.json({ error: "valid presetHandle required with settings" }, 400);
148
+ try {
149
+ const { written } = await writeThemeSourceFiles(themeSrcDir, body.files, { listingPreset: await saveListingPreset() });
150
+ if (body.settings && isValidPresetHandle(body.presetHandle)) {
151
+ await writeSettingsPreset(themeSrcDir, body.presetHandle, body.settings);
152
+ written.push("config/settings-data.json");
153
+ }
154
+ return c.json({
155
+ ok: true,
156
+ written
157
+ });
158
+ } catch (err) {
159
+ return c.json({ error: err.message }, 400);
160
+ }
161
+ });
162
+ app.post(`${PREFIX}/save-preset`, editorAuth, writeBodyLimit, async (c) => {
163
+ if (!themeSrcDir) return c.json({ error: "save unavailable (no theme src)" }, 500);
164
+ const body = await c.req.json().catch(() => null);
165
+ if (!isValidPresetHandle(body?.handle) || !body?.settings || typeof body.settings !== "object") return c.json({ error: "handle and settings required" }, 400);
166
+ await writeSettingsPreset(themeSrcDir, body.handle, body.settings, { makeCurrent: body.makeCurrent === true });
167
+ return c.json({ ok: true });
168
+ });
169
+ app.get(`${PREFIX}/settings`, editorAuthOrPreview, async (c) => {
170
+ return c.json(drafts.getSettings(c.get("sid")) ?? await liveSettings());
171
+ });
172
+ app.get(`${PREFIX}/template`, editorAuthOrPreview, async (c) => {
173
+ const id = c.req.query("templateId");
174
+ if (invalidTemplateId(id)) return c.json({ error: "invalid or missing templateId" }, 400);
175
+ const draft = drafts.getTemplate(c.get("sid"), id);
176
+ const template = draft ? { ...draft } : await diskTemplate(id);
177
+ if (!template) return c.json({ error: "Template not found" }, 404);
178
+ template.section_groups = await sectionGroupsFor(c.get("sid"));
179
+ return c.json(template);
180
+ });
181
+ app.get(`${PREFIX}/templates`, editorAuthOrPreview, async (c) => {
182
+ const locale = await loadLocale(configDir, parseLang(c));
183
+ const items = await readJsonDir(path.join(configDir, "templates"));
184
+ return c.json(items.map(({ id, data }) => ({
185
+ id,
186
+ name: resolveDisplayName(data, id, locale)
187
+ })));
188
+ });
189
+ app.get(`${PREFIX}/sections`, editorAuthOrPreview, async (c) => {
190
+ const locale = await loadLocale(configDir, parseLang(c));
191
+ const items = await readJsonDir(path.join(configDir, "sections"));
192
+ return c.json(items.map(({ id, data }) => ({
193
+ type: id,
194
+ ...resolveTranslations(data, locale)
195
+ })));
196
+ });
197
+ app.get(`${PREFIX}/blocks`, editorAuthOrPreview, async (c) => {
198
+ const locale = await loadLocale(configDir, parseLang(c));
199
+ const items = await readJsonDir(path.join(configDir, "blocks"));
200
+ return c.json(items.map(({ id, data }) => ({
201
+ type: id,
202
+ ...resolveTranslations(data, locale)
203
+ })));
204
+ });
205
+ app.get(`${PREFIX}/section-groups`, editorAuthOrPreview, async (c) => {
206
+ const locale = await loadLocale(configDir, parseLang(c));
207
+ const items = await readJsonDir(path.join(configDir, "section-groups"));
208
+ return c.json(items.map(({ data }) => resolveTranslations(data, locale)));
209
+ });
210
+ app.get(`${PREFIX}/storefront-strings`, editorAuthOrPreview, async (c) => {
211
+ const lang = c.req.query("lang");
212
+ if (!lang || !LANG_RE.test(lang)) return c.json({ error: "Invalid lang" }, 400);
213
+ return c.json(await loadStorefrontLocale(configDir, lang));
214
+ });
215
+ app.get(`${PREFIX}/layout-schema`, editorAuthOrPreview, async (c) => {
216
+ const layout = c.req.query("layout") ?? "main";
217
+ if (invalidTemplateId(layout)) return c.json({ error: "invalid layout" }, 400);
218
+ const locale = await loadLocale(configDir, parseLang(c));
219
+ try {
220
+ const raw = await readFile(path.join(configDir, "layouts", `${layout}.json`), "utf-8");
221
+ return c.json(resolveTranslations(JSON.parse(raw), locale));
222
+ } catch {
223
+ return c.json({ error: "Layout not found" }, 404);
224
+ }
225
+ });
226
+ }
227
+ //#endregion
228
+ export { registerEditorRoutes };
@@ -0,0 +1,44 @@
1
+ #!/usr/bin/env node
2
+ import { materializeThemeConfig, watchThemeConfig } from "./materialize.mjs";
3
+ import { createEditorVerifier } from "./verify.mjs";
4
+ import { createEditorAuth } from "./auth.mjs";
5
+ import { registerEditorRoutes } from "./editor-app.mjs";
6
+ import { createProxy } from "./proxy.mjs";
7
+ import { mkdtempSync } from "node:fs";
8
+ import path from "node:path";
9
+ import os from "node:os";
10
+ import { Hono } from "hono";
11
+ import { serve } from "@hono/node-server";
12
+ //#region src/gateway.ts
13
+ async function startGateway(opts) {
14
+ const themeSrcDir = path.join(opts.themeDir, "src");
15
+ const storesDir = mkdtempSync(path.join(os.tmpdir(), "theme-dev-host-"));
16
+ const configDir = path.join(storesDir, opts.slug, opts.install, "config");
17
+ await materializeThemeConfig(themeSrcDir, configDir, opts.preset);
18
+ const auth = createEditorAuth(createEditorVerifier(opts.jwtPublicKey));
19
+ const app = new Hono();
20
+ registerEditorRoutes(app, configDir, auth, {
21
+ slug: opts.slug,
22
+ install: opts.install
23
+ }, {
24
+ themeSrcDir,
25
+ preset: opts.preset
26
+ });
27
+ app.all("*", createProxy(`http://localhost:${opts.themePort}`));
28
+ const server = serve({
29
+ fetch: app.fetch,
30
+ port: opts.port
31
+ });
32
+ const watcher = opts.watch === false ? void 0 : watchThemeConfig(themeSrcDir, configDir, { preset: opts.preset });
33
+ return {
34
+ configDir,
35
+ themeSrcDir,
36
+ server,
37
+ close: () => {
38
+ watcher?.close();
39
+ server.close();
40
+ }
41
+ };
42
+ }
43
+ //#endregion
44
+ export { startGateway };
package/dist/keys.mjs ADDED
@@ -0,0 +1,20 @@
1
+ #!/usr/bin/env node
2
+ import { readFileSync } from "node:fs";
3
+ import path from "node:path";
4
+ //#region src/keys.ts
5
+ const PLATFORM_ENVS = [
6
+ "dev",
7
+ "staging",
8
+ "prod"
9
+ ];
10
+ function isPlatformEnv(v) {
11
+ return PLATFORM_ENVS.includes(v);
12
+ }
13
+ function resolveJwtPublicKey(env) {
14
+ const fromEnv = process.env.JWT_PUBLIC_KEY;
15
+ if (fromEnv) return fromEnv.replaceAll("\\n", "\n");
16
+ const certPath = path.resolve(import.meta.dirname, "../certs", `jwtRS256.${env}.pub`);
17
+ return readFileSync(certPath, "utf-8");
18
+ }
19
+ //#endregion
20
+ export { PLATFORM_ENVS, isPlatformEnv, resolveJwtPublicKey };
@@ -0,0 +1,99 @@
1
+ #!/usr/bin/env node
2
+ import { startGateway } from "./gateway.mjs";
3
+ import { startTunnel } from "./tunnel.mjs";
4
+ import { isPlatformEnv, resolveJwtPublicKey } from "./keys.mjs";
5
+ import { spawn } from "node:child_process";
6
+ import { readFileSync } from "node:fs";
7
+ import path from "node:path";
8
+ //#region src/launch.ts
9
+ function flagValue(name) {
10
+ const i = process.argv.indexOf(`--${name}`);
11
+ if (i < 0) return void 0;
12
+ const v = process.argv[i + 1];
13
+ return v && !v.startsWith("--") ? v : void 0;
14
+ }
15
+ const hasFlag = (name) => process.argv.includes(`--${name}`);
16
+ function detectThemePort(themeDir) {
17
+ try {
18
+ const m = readFileSync(path.join(themeDir, "vite.config.ts"), "utf-8").match(/port:\s*(\d+)/);
19
+ if (m) return Number(m[1]);
20
+ } catch {}
21
+ return 3002;
22
+ }
23
+ const themeDir = path.resolve(flagValue("dir") ?? process.cwd());
24
+ const theme = path.basename(themeDir);
25
+ const port = Number(flagValue("port") ?? "4101");
26
+ const themePort = Number(flagValue("theme-port") ?? detectThemePort(themeDir));
27
+ const slug = flagValue("slug") ?? theme;
28
+ const install = flagValue("install") ?? "devinstall";
29
+ const preset = flagValue("preset");
30
+ const env = flagValue("env") ?? "dev";
31
+ if (!isPlatformEnv(env)) {
32
+ console.error(`--env must be one of dev|staging|prod (got "${env}")`);
33
+ process.exit(1);
34
+ }
35
+ const jwtPublicKey = resolveJwtPublicKey(env);
36
+ const tunnelProvider = hasFlag("tunnel") ? flagValue("tunnel") ?? "cloudflared" : "none";
37
+ const publicUrl = flagValue("public-url");
38
+ const pm = process.env.npm_execpath;
39
+ const [cmd, args] = !pm ? ["npm", ["run", "dev"]] : /\.[cm]?js$/.test(pm) ? [process.execPath, [
40
+ pm,
41
+ "run",
42
+ "dev"
43
+ ]] : [pm, ["run", "dev"]];
44
+ const child = spawn(cmd, args, {
45
+ cwd: themeDir,
46
+ stdio: "inherit",
47
+ env: {
48
+ ...process.env,
49
+ ORCHESTRATOR_URL: `http://localhost:${port}`,
50
+ STORE_SLUG: slug,
51
+ INSTALL_ID: install,
52
+ ...preset ? { THEME_PRESET: preset } : {},
53
+ ...tunnelProvider !== "none" ? { THEME_HMR_HOST: "localhost" } : {}
54
+ }
55
+ });
56
+ const gw = await startGateway({
57
+ themeDir,
58
+ port,
59
+ themePort,
60
+ slug,
61
+ install,
62
+ jwtPublicKey,
63
+ preset
64
+ });
65
+ let tunnel = null;
66
+ try {
67
+ tunnel = await startTunnel(tunnelProvider, port, publicUrl);
68
+ } catch (err) {
69
+ console.error(`tunnel: ${err.message}`);
70
+ }
71
+ const origin = tunnel?.url ?? `http://localhost:${port}`;
72
+ console.log("");
73
+ console.log(`theme:editor — ${theme} (slug=${slug}, install=${install})`);
74
+ console.log(` gateway http://localhost:${port} (editor API + storefront proxy)`);
75
+ console.log(` theme dev http://localhost:${themePort} (starting — first load compiles)`);
76
+ console.log(` editor auth verifying editor tokens against the ${env} platform key`);
77
+ if (preset) console.log(` preset editing '${preset}' — Save writes back to this preset`);
78
+ if (tunnel) console.log(` public ${tunnel.url}`);
79
+ console.log("");
80
+ console.log(` preview origin → ${origin}`);
81
+ console.log(tunnel ? " ^ paste into the hosted admin theme-editor: actions menu → \"Local dev preview\"." : " ^ localhost-only; add --tunnel to drive a hosted admin.");
82
+ console.log(" Ctrl-C to stop both.");
83
+ let shuttingDown = false;
84
+ async function shutdown(code) {
85
+ if (shuttingDown) return;
86
+ shuttingDown = true;
87
+ child.kill("SIGINT");
88
+ await tunnel?.stop();
89
+ gw.close();
90
+ process.exit(code);
91
+ }
92
+ process.on("SIGINT", () => void shutdown(0));
93
+ process.on("SIGTERM", () => void shutdown(0));
94
+ child.on("exit", (code) => {
95
+ console.log(`\ntheme dev server exited — shutting down gateway.`);
96
+ shutdown(code ?? 0);
97
+ });
98
+ //#endregion
99
+ export {};
@@ -0,0 +1,103 @@
1
+ #!/usr/bin/env node
2
+ import path from "node:path";
3
+ import { readFile, readdir } from "node:fs/promises";
4
+ //#region src/locale.ts
5
+ async function readJsonDir(dir) {
6
+ let files;
7
+ try {
8
+ files = await readdir(dir);
9
+ } catch {
10
+ return [];
11
+ }
12
+ const results = [];
13
+ for (const file of files) {
14
+ if (!file.endsWith(".json")) continue;
15
+ try {
16
+ const raw = await readFile(path.join(dir, file), "utf-8");
17
+ results.push({
18
+ id: file.replace(".json", ""),
19
+ data: JSON.parse(raw)
20
+ });
21
+ } catch {}
22
+ }
23
+ return results;
24
+ }
25
+ function resolveTranslations(obj, locale) {
26
+ if (typeof obj === "string" && obj.startsWith("t:")) {
27
+ const key = obj.slice(2);
28
+ let cur = locale;
29
+ for (const p of key.split(".")) cur = cur?.[p];
30
+ return typeof cur === "string" ? cur : obj;
31
+ }
32
+ if (Array.isArray(obj)) return obj.map((v) => resolveTranslations(v, locale));
33
+ if (obj && typeof obj === "object") {
34
+ const out = {};
35
+ for (const [k, v] of Object.entries(obj)) out[k] = resolveTranslations(v, locale);
36
+ return out;
37
+ }
38
+ return obj;
39
+ }
40
+ /**
41
+ * Resolve a human-readable display name for a config item. Tries `data.name`
42
+ * through the locale, falls back to the id when the name is missing or its
43
+ * `t:*` key isn't in the locale — never leaks a raw `t:*` token to clients.
44
+ */
45
+ function resolveDisplayName(data, id, locale) {
46
+ const name = data?.name;
47
+ if (typeof name === "string" && name !== "") {
48
+ const resolved = resolveTranslations(name, locale);
49
+ if (typeof resolved === "string" && !resolved.startsWith("t:")) return resolved;
50
+ }
51
+ return id;
52
+ }
53
+ async function loadLocale(baseDir, lang) {
54
+ const localeDir = path.join(baseDir, "locales");
55
+ const read = async (file) => JSON.parse(await readFile(path.join(localeDir, file), "utf-8"));
56
+ try {
57
+ return await read(`${lang}.default.schema.json`);
58
+ } catch {
59
+ try {
60
+ const files = (await readdir(localeDir)).filter((f) => f.endsWith(".default.schema.json")).sort();
61
+ if (files[0]) return await read(files[0]);
62
+ } catch {}
63
+ return {};
64
+ }
65
+ }
66
+ /**
67
+ * Buyer-facing catalog (`<lang>.default.json` — NOT the `.schema.json` editor
68
+ * strings). Returns the catalog PLUS which shipped locale actually served it:
69
+ * the admin Translations page labels its "default value" column with that
70
+ * language, so the fallback must be visible, not silent.
71
+ */
72
+ async function loadStorefrontLocale(baseDir, lang) {
73
+ const localeDir = path.join(baseDir, "locales");
74
+ const read = async (file) => JSON.parse(await readFile(path.join(localeDir, file), "utf-8"));
75
+ try {
76
+ return {
77
+ lang,
78
+ messages: await read(`${lang}.default.json`)
79
+ };
80
+ } catch {
81
+ try {
82
+ const files = (await readdir(localeDir)).filter((f) => f.endsWith(".default.json")).sort();
83
+ if (files[0]) return {
84
+ lang: files[0].replace(".default.json", ""),
85
+ messages: await read(files[0])
86
+ };
87
+ } catch {}
88
+ return {
89
+ lang,
90
+ messages: {}
91
+ };
92
+ }
93
+ }
94
+ const LANG_RE = /^[a-z]{2}(-[A-Z]{2})?$/;
95
+ function parseLang(c) {
96
+ const raw = c.req.header("Accept-Language")?.split(",")[0]?.trim();
97
+ return raw && LANG_RE.test(raw) ? raw : "mn";
98
+ }
99
+ function unwrapSettings(data, preset) {
100
+ return data.presets ? data.presets[preset ?? data.current?.preset]?.settings ?? {} : data;
101
+ }
102
+ //#endregion
103
+ export { LANG_RE, loadLocale, loadStorefrontLocale, parseLang, readJsonDir, resolveDisplayName, resolveTranslations, unwrapSettings };
@@ -0,0 +1,93 @@
1
+ #!/usr/bin/env node
2
+ import { watch } from "node:fs";
3
+ import path from "node:path";
4
+ import { copyFile, mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
5
+ import { LISTING_DIRS, currentPresetHandle, mergePresetJson } from "@shop-on/theme-contract/preset-listing";
6
+ //#region src/materialize.ts
7
+ const JSON_DIRS = [
8
+ "sections",
9
+ "blocks",
10
+ "templates",
11
+ "section-groups",
12
+ "layouts",
13
+ "locales"
14
+ ];
15
+ async function copyJsonDir(srcDir, destDir) {
16
+ let files;
17
+ try {
18
+ files = await readdir(srcDir);
19
+ } catch {
20
+ return;
21
+ }
22
+ await mkdir(destDir, { recursive: true });
23
+ const wanted = new Set(files.filter((f) => f.endsWith(".json") && !f.startsWith("._")));
24
+ try {
25
+ for (const existing of await readdir(destDir)) if (existing.endsWith(".json") && !wanted.has(existing)) await rm(path.join(destDir, existing), { force: true });
26
+ } catch {}
27
+ for (const file of wanted) await copyFile(path.join(srcDir, file), path.join(destDir, file));
28
+ }
29
+ async function dirExists(dir) {
30
+ try {
31
+ return (await stat(dir)).isDirectory();
32
+ } catch {
33
+ return false;
34
+ }
35
+ }
36
+ async function resolveActivePreset(themeSrcDir, presetOverride) {
37
+ let data = null;
38
+ try {
39
+ data = JSON.parse(await readFile(path.join(themeSrcDir, "config", "settings-data.json"), "utf-8"));
40
+ } catch {}
41
+ return currentPresetHandle(data, presetOverride);
42
+ }
43
+ async function overlayListingDir(listingDir, targetDir) {
44
+ let files;
45
+ try {
46
+ files = await readdir(listingDir);
47
+ } catch {
48
+ return;
49
+ }
50
+ await mkdir(targetDir, { recursive: true });
51
+ for (const file of files) {
52
+ if (!file.endsWith(".json") || file.startsWith("._")) continue;
53
+ const override = JSON.parse(await readFile(path.join(listingDir, file), "utf-8"));
54
+ const targetPath = path.join(targetDir, file);
55
+ let base = null;
56
+ try {
57
+ base = JSON.parse(await readFile(targetPath, "utf-8"));
58
+ } catch {}
59
+ const output = base === null ? override : mergePresetJson(base, override);
60
+ await writeFile(targetPath, `${JSON.stringify(output, null, 2)}\n`, "utf-8");
61
+ }
62
+ }
63
+ async function materializeThemeConfig(themeSrcDir, configDir, presetOverride) {
64
+ await mkdir(configDir, { recursive: true });
65
+ for (const dir of JSON_DIRS) await copyJsonDir(path.join(themeSrcDir, dir), path.join(configDir, dir));
66
+ try {
67
+ await copyFile(path.join(themeSrcDir, "config", "settings-schema.json"), path.join(configDir, "settings-schema.json"));
68
+ } catch {}
69
+ const settingsDataPath = path.join(configDir, "settings-data.json");
70
+ try {
71
+ await copyFile(path.join(themeSrcDir, "config", "settings-data.json"), settingsDataPath);
72
+ } catch {
73
+ await writeFile(settingsDataPath, "{}\n", "utf-8");
74
+ }
75
+ const activePreset = await resolveActivePreset(themeSrcDir, presetOverride);
76
+ if (activePreset) {
77
+ const listingDir = path.join(themeSrcDir, "listings", activePreset);
78
+ if (await dirExists(listingDir)) for (const dir of LISTING_DIRS) await overlayListingDir(path.join(listingDir, dir), path.join(configDir, dir));
79
+ }
80
+ }
81
+ function watchThemeConfig(themeSrcDir, configDir, opts = {}) {
82
+ const onError = opts.onError ?? ((err) => console.warn("[theme-dev-host] re-materialize failed:", err));
83
+ let timer;
84
+ return watch(themeSrcDir, { recursive: true }, (_event, filename) => {
85
+ if (!filename || !filename.toString().endsWith(".json")) return;
86
+ clearTimeout(timer);
87
+ timer = setTimeout(() => {
88
+ materializeThemeConfig(themeSrcDir, configDir, opts.preset).catch(onError);
89
+ }, 150);
90
+ });
91
+ }
92
+ //#endregion
93
+ export { materializeThemeConfig, watchThemeConfig };
package/dist/proxy.mjs ADDED
@@ -0,0 +1,35 @@
1
+ #!/usr/bin/env node
2
+ //#region src/proxy.ts
3
+ function createProxy(target) {
4
+ const targetOrigin = new URL(target).origin;
5
+ return async (c) => {
6
+ const incoming = new URL(c.req.url);
7
+ if (incoming.pathname.startsWith("//")) return c.text("bad request", 400);
8
+ const url = new URL(incoming.pathname + incoming.search, target);
9
+ if (url.origin !== targetOrigin) return c.text("bad request", 400);
10
+ const headers = new Headers(c.req.raw.headers);
11
+ headers.delete("host");
12
+ headers.delete("authorization");
13
+ const method = c.req.method;
14
+ const hasBody = method !== "GET" && method !== "HEAD";
15
+ let res;
16
+ try {
17
+ res = await fetch(url, {
18
+ method,
19
+ headers,
20
+ body: hasBody ? c.req.raw.body : void 0,
21
+ redirect: "manual",
22
+ ...hasBody ? { duplex: "half" } : {}
23
+ });
24
+ } catch {
25
+ return c.text(`theme-dev-host: theme dev server not reachable at ${target} — is \`pnpm dev\` running for the theme?`, 502);
26
+ }
27
+ return new Response(res.body, {
28
+ status: res.status,
29
+ statusText: res.statusText,
30
+ headers: res.headers
31
+ });
32
+ };
33
+ }
34
+ //#endregion
35
+ export { createProxy };
package/dist/save.mjs ADDED
@@ -0,0 +1,54 @@
1
+ #!/usr/bin/env node
2
+ import path from "node:path";
3
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
4
+ import { PRESET_HANDLE_RE } from "@shop-on/theme-contract/preset-listing";
5
+ //#region src/save.ts
6
+ function serialize(data) {
7
+ return `${JSON.stringify(data, null, 2)}\n`;
8
+ }
9
+ const WRITABLE_DIRS = /* @__PURE__ */ new Set(["templates", "section-groups"]);
10
+ const FILE_RE = /^[A-Za-z0-9_.-]+\.json$/;
11
+ function isWritableThemePath(rel) {
12
+ if (typeof rel !== "string" || rel.includes("\\")) return false;
13
+ const parts = rel.split("/");
14
+ if (parts.length !== 2) return false;
15
+ const [dir, file] = parts;
16
+ if (!dir || !file) return false;
17
+ return WRITABLE_DIRS.has(dir) && FILE_RE.test(file);
18
+ }
19
+ function isValidPresetHandle(handle) {
20
+ return typeof handle === "string" && /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/.test(handle);
21
+ }
22
+ async function writeThemeSourceFiles(themeSrcDir, files, opts = {}) {
23
+ const root = path.resolve(themeSrcDir);
24
+ const listingPreset = opts.listingPreset;
25
+ if (listingPreset !== void 0 && !PRESET_HANDLE_RE.test(listingPreset)) throw new Error(`invalid listing preset handle: ${listingPreset}`);
26
+ const written = [];
27
+ for (const [rel, data] of Object.entries(files)) {
28
+ if (!isWritableThemePath(rel)) throw new Error(`refusing to write disallowed path: ${rel}`);
29
+ const destRel = listingPreset ? `listings/${listingPreset}/${rel}` : rel;
30
+ const dest = path.resolve(root, destRel);
31
+ if (dest !== root && !dest.startsWith(root + path.sep)) throw new Error(`refusing to write outside theme src: ${rel}`);
32
+ await mkdir(path.dirname(dest), { recursive: true });
33
+ await writeFile(dest, serialize(data), "utf-8");
34
+ written.push(destRel);
35
+ }
36
+ return { written };
37
+ }
38
+ async function writeSettingsPreset(themeSrcDir, handle, settings, opts = {}) {
39
+ const file = path.join(themeSrcDir, "config", "settings-data.json");
40
+ let data = {};
41
+ try {
42
+ data = JSON.parse(await readFile(file, "utf-8"));
43
+ } catch {}
44
+ const presets = { ...data.presets };
45
+ presets[handle] = { settings };
46
+ const next = {
47
+ current: { preset: opts.makeCurrent || !data.current ? handle : data.current.preset },
48
+ presets
49
+ };
50
+ await mkdir(path.dirname(file), { recursive: true });
51
+ await writeFile(file, serialize(next), "utf-8");
52
+ }
53
+ //#endregion
54
+ export { isValidPresetHandle, isWritableThemePath, writeSettingsPreset, writeThemeSourceFiles };
@@ -0,0 +1,93 @@
1
+ #!/usr/bin/env node
2
+ import { spawn } from "node:child_process";
3
+ //#region src/tunnel.ts
4
+ const PROVIDERS = {
5
+ cloudflared: {
6
+ argv: (port) => [
7
+ "cloudflared",
8
+ "tunnel",
9
+ "--url",
10
+ `http://localhost:${port}`
11
+ ],
12
+ urlRe: /https:\/\/[a-z0-9-]+\.trycloudflare\.com/,
13
+ signup: false
14
+ },
15
+ ngrok: {
16
+ argv: (port) => [
17
+ "ngrok",
18
+ "http",
19
+ String(port),
20
+ "--log",
21
+ "stdout"
22
+ ],
23
+ urlRe: /https:\/\/[a-z0-9-]+\.ngrok[-a-z0-9.]*\.(app|dev|io)/,
24
+ signup: true
25
+ },
26
+ zrok: {
27
+ argv: (port) => [
28
+ "zrok",
29
+ "share",
30
+ "public",
31
+ `http://localhost:${port}`
32
+ ],
33
+ urlRe: /https:\/\/[a-z0-9-]+\.share\.zrok\.io/,
34
+ signup: true
35
+ }
36
+ };
37
+ function spawnTunnel(name, argv, urlRe) {
38
+ return new Promise((resolve, reject) => {
39
+ const proc = spawn(argv[0], argv.slice(1), { stdio: [
40
+ "ignore",
41
+ "pipe",
42
+ "pipe"
43
+ ] });
44
+ let settled = false;
45
+ const scan = (buf) => {
46
+ const m = buf.toString().match(urlRe);
47
+ if (m && !settled) {
48
+ settled = true;
49
+ clearTimeout(timer);
50
+ resolve({
51
+ url: m[0],
52
+ stop: async () => {
53
+ proc.kill();
54
+ }
55
+ });
56
+ }
57
+ };
58
+ proc.stdout.on("data", scan);
59
+ proc.stderr.on("data", scan);
60
+ proc.on("error", (err) => {
61
+ if (settled) return;
62
+ settled = true;
63
+ clearTimeout(timer);
64
+ reject(/* @__PURE__ */ new Error(`${name} not found on PATH — install it, or use \`--tunnel manual --public-url <url>\`. (${err.message})`));
65
+ });
66
+ proc.on("exit", (code) => {
67
+ if (settled) return;
68
+ settled = true;
69
+ clearTimeout(timer);
70
+ reject(/* @__PURE__ */ new Error(`${name} exited (code ${code}) before printing a public URL`));
71
+ });
72
+ const timer = setTimeout(() => {
73
+ if (settled) return;
74
+ settled = true;
75
+ proc.kill();
76
+ reject(/* @__PURE__ */ new Error(`${name} timed out before printing a public URL`));
77
+ }, 3e4);
78
+ });
79
+ }
80
+ async function startTunnel(provider, port, publicUrl) {
81
+ if (provider === "none") return null;
82
+ if (provider === "manual") {
83
+ if (!publicUrl) throw new Error("`--tunnel manual` requires `--public-url <url>`");
84
+ return {
85
+ url: publicUrl,
86
+ stop: async () => {}
87
+ };
88
+ }
89
+ const p = PROVIDERS[provider];
90
+ return spawnTunnel(provider, p.argv(port), p.urlRe);
91
+ }
92
+ //#endregion
93
+ export { startTunnel };
@@ -0,0 +1,38 @@
1
+ #!/usr/bin/env node
2
+ import { importSPKI, jwtVerify } from "jose";
3
+ //#region src/verify.ts
4
+ var EditorAuthError = class extends Error {
5
+ reason;
6
+ constructor(reason) {
7
+ super(reason);
8
+ this.reason = reason;
9
+ this.name = "EditorAuthError";
10
+ }
11
+ };
12
+ function createEditorVerifier(pem) {
13
+ let keyPromise;
14
+ return async (token) => {
15
+ if (!pem) throw new EditorAuthError("JWT public key not configured");
16
+ let key;
17
+ try {
18
+ key = await (keyPromise ??= importSPKI(pem, "RS256"));
19
+ } catch (err) {
20
+ throw new EditorAuthError(err.message);
21
+ }
22
+ try {
23
+ const { payload } = await jwtVerify(token, key, {
24
+ algorithms: ["RS256"],
25
+ issuer: "SPN"
26
+ });
27
+ const claims = payload;
28
+ if (!Array.isArray(claims.scp) || !claims.scp.includes("editor")) throw new EditorAuthError("wrong scope");
29
+ if (!claims.sid || !claims.shop_handle || !claims.install_id) throw new EditorAuthError("missing claims");
30
+ return claims;
31
+ } catch (err) {
32
+ if (err instanceof EditorAuthError) throw err;
33
+ throw new EditorAuthError(err.message);
34
+ }
35
+ };
36
+ }
37
+ //#endregion
38
+ export { EditorAuthError, createEditorVerifier };
package/package.json CHANGED
@@ -1,6 +1,32 @@
1
1
  {
2
2
  "name": "@shop-on/theme-dev-host",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.0",
4
+ "description": "Local theme-editor dev server: serves the editor contract + proxies a theme's dev server behind one origin, so a theme runs locally and previews in a real admin theme-editor. Nucleus of the future theme CLI.",
5
+ "license": "SEE LICENSE IN dist/LICENSE.md",
6
+ "bin": {
7
+ "theme-dev-host": "./dist/launch.mjs"
8
+ },
9
+ "files": [
10
+ "dist",
11
+ "certs"
12
+ ],
13
+ "type": "module",
14
+ "dependencies": {
15
+ "@hono/node-server": "^2.1.3",
16
+ "hono": "^4.13.12",
17
+ "jose": "^6.2.12",
18
+ "@shop-on/theme-contract": "0.1.0"
19
+ },
20
+ "devDependencies": {
21
+ "@types/node": "26.6.3",
22
+ "tsx": "^4.23.15",
23
+ "typescript": "7.0.2",
24
+ "vitest": "5.0.3"
25
+ },
26
+ "scripts": {
27
+ "dev": "tsx src/index.ts",
28
+ "launch": "tsx src/launch.ts",
29
+ "check-types": "tsc --noEmit",
30
+ "test": "vitest run"
31
+ }
6
32
  }