sellify-cli 0.0.0-stage → 0.1.1

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Debayan Ghosh
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,61 @@
1
- # Temporary Holding Version
1
+ # sellify-cli
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
+ Command line for Sellify stores: build themes in your own editor with live sync, and call the GraphQL Admin API.
4
+
5
+ ```bash
6
+ npm install -g sellify-cli # or run any command with: npx sellify-cli <command>
7
+ ```
8
+
9
+ Needs Node 20 or newer. No other dependencies.
10
+
11
+ ## Log in
12
+
13
+ ```bash
14
+ sellify login # opens your browser; pick the store and click Allow
15
+ sellify login --store mystore # straight to that store
16
+ sellify login --url https://admin.your-sellify-domain.com
17
+ ```
18
+
19
+ Sign in to Sellify in the browser (password or Google), check the computer name and click **Allow**. The CLI receives its token directly from the browser (a one-off listener on `127.0.0.1` with PKCE), so you never see or copy it. It is saved in `~/.config/sellify/config.json`, readable only by you.
20
+
21
+ A browser login acts as you, can only read and change themes, and needs **Online store** edit access. The store owner sees it under **Apps → Develop apps → Sellify CLI logins** and can sign it out there. Logging in again on the same computer replaces the old token. No browser (SSH)? Add `--no-browser` and open the printed link anywhere.
22
+
23
+ **CI:** create a custom app with `read_themes` + `write_themes` and set `SELLIFY_STORE`, `SELLIFY_TOKEN` and `SELLIFY_URL` (or `sellify login --store <store> --token <sfat_…>`). Custom app tokens can also have other scopes for `sellify graphql`.
24
+
25
+ ## Themes
26
+
27
+ ```bash
28
+ mkdir my-theme && cd my-theme
29
+ sellify theme pull # download the live theme
30
+ sellify theme dev # sync every save to a development theme, prints a preview link
31
+ sellify theme push --unpublished # upload as a new unpublished theme
32
+ sellify theme push --theme <id> # update an existing theme
33
+ sellify theme publish --theme <id> # make it live
34
+ ```
35
+
36
+ | Command | What it does |
37
+ | --- | --- |
38
+ | `theme list` | Themes in the store, live theme first |
39
+ | `theme pull [--theme <id>\|live] [--only <prefix>] [--nodelete]` | Download files; only changed files are written. Local theme files that no longer exist remotely are removed unless `--nodelete` |
40
+ | `theme push [--theme <id>\|live] [--unpublished [--name]] [--publish] [--only <prefix>] [--nodelete]` | Upload changed files (compared by checksum) and delete remote files you removed, unless `--nodelete` |
41
+ | `theme dev [--theme <id>]` | Push once, then watch the folder and upload each save. Uses a theme named `Development (<your computer>)` unless `--theme` is given |
42
+ | `theme open [--theme <id>]` | Preview link and code editor link |
43
+ | `theme publish --theme <id>` | Make a theme live |
44
+ | `theme delete --theme <id> [--force]` | Delete an unpublished theme |
45
+
46
+ Anything that changes the **live** theme asks first. Pass `--allow-live` to skip the question (for scripts).
47
+
48
+ Every file is checked on upload like in the admin code editor (Liquid syntax, section schemas, JSON templates). Files with errors are reported and skipped; the rest are saved. Themes hold text files only: `.liquid`, `.json`, `.css`, `.js` and `.svg` in `layout/`, `templates/`, `sections/`, `snippets/`, `assets/`, `config/` and `locales/`.
49
+
50
+ ## Admin API
51
+
52
+ ```bash
53
+ sellify graphql '{ shop { name } products(first: 5) { nodes { title } } }'
54
+ sellify graphql --file query.graphql --variables '{"id":"gid://sellify/Product/…"}'
55
+ ```
56
+
57
+ Rate limits are handled for you: throttled requests are retried after the `Retry-After` delay.
58
+
59
+ ## License
60
+
61
+ MIT
@@ -0,0 +1,567 @@
1
+ #!/usr/bin/env node
2
+ // Sellify CLI: develop themes locally (pull, push, live-sync with `theme dev`) and call the GraphQL Admin API.
3
+ // No dependencies; needs Node 20+. Authenticates with a custom app access token (read_themes / write_themes).
4
+ import { spawn } from "node:child_process";
5
+ import { createHash, randomBytes } from "node:crypto";
6
+ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, watch, writeFileSync } from "node:fs";
7
+ import { createServer } from "node:http";
8
+ import { homedir, hostname } from "node:os";
9
+ import { dirname, join, relative, resolve, sep } from "node:path";
10
+ import { createInterface } from "node:readline/promises";
11
+ import { parseArgs } from "node:util";
12
+
13
+ const VERSION = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
14
+ const API_VERSION = "2026-10";
15
+ // The Sellify admin host. Point at your own deployment with --url or SELLIFY_URL.
16
+ const DEFAULT_URL = "http://admin.localhost:3000";
17
+ // Same rule as the server (lib/theme-code FILE_PATH_RE): which files a theme can hold. All are text.
18
+ const THEME_PATH_RE =
19
+ /^(layout|templates|sections|snippets)\/[a-z0-9][a-z0-9_.-]{0,60}\.liquid$|^(templates|sections)\/[a-z0-9][a-z0-9_.-]{0,60}\.json$|^assets\/[a-z0-9][a-z0-9_.-]{0,60}\.(css|js|svg|json)$|^config\/settings_(schema|data)\.json$|^locales\/[a-z]{2}(-[a-z]{2})?(\.default)?(\.schema)?\.json$/;
20
+ const THEME_DIRS = ["layout", "templates", "sections", "snippets", "assets", "config", "locales"];
21
+ const BATCH_FILES = 25;
22
+ const BATCH_BYTES = 2 * 1024 * 1024;
23
+
24
+ // ---------- Output ----------
25
+
26
+ const tty = process.stdout.isTTY && !process.env.NO_COLOR;
27
+ const paint = (code) => (s) => (tty ? `\x1b[${code}m${s}\x1b[0m` : String(s));
28
+ const c = { bold: paint(1), dim: paint(2), red: paint(31), green: paint(32), yellow: paint(33), cyan: paint(36) };
29
+ const log = (...a) => console.log(...a);
30
+
31
+ class CliError extends Error {}
32
+ const fail = (msg) => {
33
+ throw new CliError(msg);
34
+ };
35
+
36
+ // ---------- Config (~/.config/sellify/config.json, readable only by you) ----------
37
+
38
+ function configPath() {
39
+ if (process.env.SELLIFY_CONFIG_DIR) return join(process.env.SELLIFY_CONFIG_DIR, "config.json");
40
+ const base = process.platform === "win32" ? process.env.APPDATA || join(homedir(), "AppData", "Roaming") : process.env.XDG_CONFIG_HOME || join(homedir(), ".config");
41
+ return join(base, "sellify", "config.json");
42
+ }
43
+
44
+ function readConfig() {
45
+ try {
46
+ return JSON.parse(readFileSync(configPath(), "utf8"));
47
+ } catch {
48
+ return { current: null, stores: {} };
49
+ }
50
+ }
51
+
52
+ function writeConfig(cfg) {
53
+ const p = configPath();
54
+ mkdirSync(dirname(p), { recursive: true, mode: 0o700 });
55
+ writeFileSync(p, JSON.stringify(cfg, null, 2) + "\n", { mode: 0o600 });
56
+ }
57
+
58
+ /** The store, admin URL and token to use: flags, then SELLIFY_* env vars, then the saved login. */
59
+ function session(flags) {
60
+ const cfg = readConfig();
61
+ const store = flags.store || process.env.SELLIFY_STORE || cfg.current;
62
+ const saved = (store && cfg.stores[store]) || {};
63
+ const token = process.env.SELLIFY_TOKEN || saved.token;
64
+ const url = (flags.url || process.env.SELLIFY_URL || saved.url || DEFAULT_URL).replace(/\/+$/, "");
65
+ if (!store || !token) fail(`Not logged in. Run ${c.bold("sellify login")} (or set SELLIFY_STORE and SELLIFY_TOKEN).`);
66
+ return { store, token, url };
67
+ }
68
+
69
+ // ---------- API ----------
70
+
71
+ async function gql(s, query, variables = {}) {
72
+ for (let attempt = 0; ; attempt++) {
73
+ let res;
74
+ try {
75
+ res = await fetch(`${s.url}/api/admin/${API_VERSION}/graphql`, {
76
+ method: "POST",
77
+ headers: { "Content-Type": "application/json", "X-Sellify-Access-Token": s.token, "User-Agent": `sellify-cli/${VERSION}` },
78
+ body: JSON.stringify({ query, variables }),
79
+ });
80
+ } catch (e) {
81
+ fail(`Can't reach ${s.url} (${e.cause?.code ?? e.message}). Check --url.`);
82
+ }
83
+ if (res.status === 429 && attempt < 5) {
84
+ await new Promise((r) => setTimeout(r, Number(res.headers.get("retry-after") || 2) * 1000));
85
+ continue;
86
+ }
87
+ const body = await res.json().catch(() => null);
88
+ if (!body) fail(`Unexpected response from ${s.url} (HTTP ${res.status}).`);
89
+ if (res.status === 401) {
90
+ const reason = body.errors?.[0]?.message ?? "";
91
+ fail(reason.includes("sellify login") ? reason : "The access token was rejected. Run `sellify login` again.");
92
+ }
93
+ if (body.errors?.length) {
94
+ const e = body.errors[0];
95
+ fail(e.extensions?.code === "ACCESS_DENIED" ? `${e.message} Browser logins can use themes only; for other data log in with a custom app token (sellify login --store <store> --token <sfat_…>).` : e.message);
96
+ }
97
+ return body.data;
98
+ }
99
+ }
100
+
101
+ const userErrors = (payload) => {
102
+ if (payload.userErrors.length) fail(payload.userErrors.map((e) => e.message).join("\n"));
103
+ };
104
+
105
+ const THEME_FIELDS = "id name role previewUrl editorUrl updatedAt";
106
+
107
+ const toGid = (id) => (id.startsWith("gid://") ? id : `gid://sellify/Theme/${id}`);
108
+ const shortId = (gid) => gid.split("/").pop();
109
+
110
+ /** The theme named by --theme (id, or "live"), or the live theme. */
111
+ async function pickTheme(s, flags) {
112
+ if (flags.theme && flags.theme !== "live") {
113
+ const { theme } = await gql(s, `query($id: ID!) { theme(id: $id) { ${THEME_FIELDS} } }`, { id: toGid(flags.theme) });
114
+ if (!theme) fail(`Theme ${flags.theme} not found. Run \`sellify theme list\`.`);
115
+ return theme;
116
+ }
117
+ const { themes } = await gql(s, `{ themes { ${THEME_FIELDS} } }`);
118
+ const live = themes.find((t) => t.role === "MAIN");
119
+ if (!live) fail("This store has no live theme.");
120
+ return live;
121
+ }
122
+
123
+ async function remoteFiles(s, theme, withContent) {
124
+ const { theme: t } = await gql(s, `query($id: ID!) { theme(id: $id) { files { path checksum ${withContent ? "content" : ""} } } }`, { id: theme.id });
125
+ return t.files;
126
+ }
127
+
128
+ // ---------- Local files ----------
129
+
130
+ const md5 = (text) => createHash("md5").update(text).digest("hex");
131
+ const toPosix = (p) => p.split(sep).join("/");
132
+
133
+ /** Theme files in a folder, keyed by theme path. Files a theme can't hold are reported, not uploaded. */
134
+ function localFiles(root) {
135
+ const files = new Map();
136
+ const skipped = [];
137
+ for (const dir of THEME_DIRS) {
138
+ const full = join(root, dir);
139
+ if (!existsSync(full)) continue;
140
+ (function walk(d) {
141
+ for (const name of readdirSync(d)) {
142
+ if (name.startsWith(".")) continue;
143
+ const p = join(d, name);
144
+ if (statSync(p).isDirectory()) walk(p);
145
+ else {
146
+ const path = toPosix(relative(root, p));
147
+ if (THEME_PATH_RE.test(path)) files.set(path, readFileSync(p, "utf8"));
148
+ else skipped.push(path);
149
+ }
150
+ }
151
+ })(full);
152
+ }
153
+ return { files, skipped };
154
+ }
155
+
156
+ const themeRoot = (flags) => resolve(flags.path || ".");
157
+ const looksLikeTheme = (root) => existsSync(join(root, "layout", "theme.liquid"));
158
+ const twinOf = (p) => (p.endsWith(".json") ? p.replace(/\.json$/, ".liquid") : p.replace(/\.liquid$/, ".json"));
159
+
160
+ // ---------- Upload ----------
161
+
162
+ async function upload(s, theme, entries) {
163
+ let batch = [];
164
+ let bytes = 0;
165
+ let done = 0;
166
+ const errors = [];
167
+ const flush = async () => {
168
+ if (!batch.length) return;
169
+ const { themeFilesUpsert: r } = await gql(
170
+ s,
171
+ `mutation($themeId: ID!, $files: [ThemeFileInput!]!) { themeFilesUpsert(themeId: $themeId, files: $files) { upsertedPaths userErrors { message } } }`,
172
+ { themeId: theme.id, files: batch },
173
+ );
174
+ done += r.upsertedPaths.length;
175
+ errors.push(...r.userErrors.map((e) => e.message));
176
+ batch = [];
177
+ bytes = 0;
178
+ };
179
+ // Settings schema and section files first, so templates that use new sections validate.
180
+ const order = (p) => (p.startsWith("config/") ? 0 : p.startsWith("sections/") && p.endsWith(".liquid") ? 1 : p.startsWith("snippets/") ? 2 : 3);
181
+ for (const [path, content] of [...entries].sort((a, b) => order(a[0]) - order(b[0]) || a[0].localeCompare(b[0]))) {
182
+ const size = Buffer.byteLength(content);
183
+ if (batch.length >= BATCH_FILES || (batch.length && bytes + size > BATCH_BYTES)) await flush();
184
+ batch.push({ path, content });
185
+ bytes += size;
186
+ }
187
+ await flush();
188
+ return { done, errors };
189
+ }
190
+
191
+ async function removeRemote(s, theme, paths) {
192
+ if (!paths.length) return [];
193
+ const { themeFilesDelete: r } = await gql(s, `mutation($themeId: ID!, $paths: [String!]!) { themeFilesDelete(themeId: $themeId, paths: $paths) { deletedPaths userErrors { message } } }`, {
194
+ themeId: theme.id,
195
+ paths,
196
+ });
197
+ userErrors(r);
198
+ return r.deletedPaths;
199
+ }
200
+
201
+ async function confirm(question) {
202
+ if (!process.stdin.isTTY) return false;
203
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
204
+ const answer = await rl.question(`${question} ${c.dim("(y/N)")} `);
205
+ rl.close();
206
+ return /^y(es)?$/i.test(answer.trim());
207
+ }
208
+
209
+ async function guardLive(theme, flags, action) {
210
+ if (theme.role !== "MAIN" || flags["allow-live"]) return;
211
+ if (await confirm(`${c.yellow("!")} ${c.bold(theme.name)} is your live theme — shoppers will see this ${action} immediately. Continue?`)) return;
212
+ fail("Stopped. Pass --allow-live to change the live theme without asking.");
213
+ }
214
+
215
+ // ---------- Commands ----------
216
+
217
+ /** Opens the browser to approve this computer; the token comes back to a one-off listener on 127.0.0.1 (never shown). */
218
+ async function browserLogin(url, store, open) {
219
+ const verifier = randomBytes(32).toString("base64url");
220
+ const state = randomBytes(24).toString("base64url");
221
+ let finish;
222
+ const done = new Promise((resolve, reject) => (finish = { resolve, reject }));
223
+ const server = createServer((req, res) => {
224
+ const q = new URL(req.url, "http://127.0.0.1").searchParams;
225
+ if (!req.url.startsWith("/callback") || q.get("state") !== state) {
226
+ res.writeHead(404).end();
227
+ return;
228
+ }
229
+ const ok = !!q.get("code");
230
+ // "connection: close" so the browser doesn't hold the listener open after this page.
231
+ res.writeHead(200, { "content-type": "text/html; charset=utf-8", connection: "close" }).end(
232
+ `<!doctype html><meta charset="utf-8"><title>Sellify CLI</title><body style="font:16px system-ui;display:grid;place-items:center;height:90vh;margin:0;background:#f5f5f5"><div style="background:#fff;padding:32px 40px;border-radius:12px;box-shadow:0 0 0 1px #0001;text-align:center"><h1 style="font-size:20px;margin:0 0 8px">${ok ? "You're logged in to Sellify CLI" : "Login cancelled"}</h1><p style="margin:0;color:#555">You can close this tab and return to your terminal.</p></div>`,
233
+ );
234
+ if (ok) finish.resolve({ code: q.get("code"), store: q.get("store") });
235
+ else finish.reject(new CliError("Login was cancelled in the browser."));
236
+ });
237
+ await new Promise((r) => server.listen(0, "127.0.0.1", r));
238
+ const params = new URLSearchParams({
239
+ port: String(server.address().port),
240
+ state,
241
+ code_challenge: createHash("sha256").update(verifier).digest("base64url"),
242
+ device: hostname().split(".")[0].slice(0, 40),
243
+ ...(store ? { store } : {}),
244
+ });
245
+ const link = `${url}/cli/authorize?${params}`;
246
+ log(`Opening your browser to log in…\n${c.dim("If it doesn't open, visit:")} ${link}`);
247
+ if (open) openBrowser(link);
248
+ const timer = setTimeout(() => finish.reject(new CliError("Timed out waiting for the browser (5 minutes). Run `sellify login` again.")), 5 * 60 * 1000);
249
+ try {
250
+ const { code, store: chosen } = await done;
251
+ let res;
252
+ try {
253
+ res = await fetch(`${url}/api/cli/token`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ code, code_verifier: verifier }) });
254
+ } catch (e) {
255
+ fail(`Can't reach ${url} (${e.cause?.code ?? e.message}). Check --url.`);
256
+ }
257
+ const body = await res.json().catch(() => ({}));
258
+ if (!res.ok || !body.access_token) fail(body.error === "access_denied" ? "Your account needs Online store edit access on this store." : "The login couldn't be completed. Run `sellify login` again.");
259
+ return { store: chosen, token: body.access_token };
260
+ } finally {
261
+ clearTimeout(timer);
262
+ // Browsers keep idle connections (and preconnects) open; drop them so the CLI can exit.
263
+ server.closeAllConnections();
264
+ server.close();
265
+ }
266
+ }
267
+
268
+ function openBrowser(link) {
269
+ const [cmd, args] = process.platform === "darwin" ? ["open", [link]] : process.platform === "win32" ? ["cmd", ["/c", "start", "", link]] : ["xdg-open", [link]];
270
+ try {
271
+ spawn(cmd, args, { stdio: "ignore", detached: true }).on("error", () => {}).unref();
272
+ } catch {
273
+ // The link is printed as well.
274
+ }
275
+ }
276
+
277
+ async function login(flags) {
278
+ const url = (flags.url || process.env.SELLIFY_URL || DEFAULT_URL).replace(/\/+$/, "");
279
+ let store = flags.store || process.env.SELLIFY_STORE;
280
+ let token = flags.token || process.env.SELLIFY_TOKEN;
281
+ if (token) {
282
+ if (!store) fail("Usage: sellify login --store <store> --token <sfat_…> (or just `sellify login` to log in with your browser)");
283
+ if (!token.startsWith("sfat_")) fail("That doesn't look like a Sellify access token (it starts with sfat_).");
284
+ } else {
285
+ ({ store, token } = await browserLogin(url, store, !flags["no-browser"]));
286
+ }
287
+ const { shop } = await gql({ store, token, url }, "{ shop { name myshopifyDomain } }");
288
+ const cfg = readConfig();
289
+ cfg.stores[store] = { url, token };
290
+ cfg.current = store;
291
+ writeConfig(cfg);
292
+ log(`${c.green("✓")} Logged in to ${c.bold(shop.name)} (${shop.myshopifyDomain}). Saved to ${c.dim(configPath())}`);
293
+ }
294
+
295
+ function logout(flags) {
296
+ const cfg = readConfig();
297
+ const store = flags.store || cfg.current;
298
+ if (!store || !cfg.stores[store]) return log("Not logged in.");
299
+ delete cfg.stores[store];
300
+ cfg.current = Object.keys(cfg.stores)[0] ?? null;
301
+ writeConfig(cfg);
302
+ log(`${c.green("✓")} Logged out of ${store}.`);
303
+ }
304
+
305
+ async function whoami(flags) {
306
+ const s = session(flags);
307
+ const { shop } = await gql(s, "{ shop { name myshopifyDomain url } }");
308
+ log(`${c.bold(shop.name)} ${c.dim(`(${s.store})`)}\n Storefront ${shop.url}\n Admin API ${s.url}`);
309
+ }
310
+
311
+ async function themeList(flags) {
312
+ const s = session(flags);
313
+ const { themes } = await gql(s, `{ themes { ${THEME_FIELDS} } }`);
314
+ const width = Math.max(...themes.map((t) => t.name.length), 4);
315
+ for (const t of themes) log(`${t.name.padEnd(width)} ${t.role === "MAIN" ? c.green("[live]".padEnd(13)) : c.dim("[unpublished]")} ${c.dim(shortId(t.id))}`);
316
+ }
317
+
318
+ async function themePull(flags) {
319
+ const s = session(flags);
320
+ const theme = await pickTheme(s, flags);
321
+ const root = themeRoot(flags);
322
+ const remote = await remoteFiles(s, theme, true);
323
+ const local = localFiles(root).files;
324
+ let written = 0;
325
+ for (const f of remote) {
326
+ if (flags.only && !f.path.startsWith(flags.only)) continue;
327
+ if (local.get(f.path) !== undefined && md5(local.get(f.path)) === f.checksum) continue;
328
+ const p = join(root, ...f.path.split("/"));
329
+ mkdirSync(dirname(p), { recursive: true });
330
+ writeFileSync(p, f.content);
331
+ written++;
332
+ }
333
+ const remotePaths = new Set(remote.map((f) => f.path));
334
+ const stale = [...local.keys()].filter((p) => !remotePaths.has(p) && (!flags.only || p.startsWith(flags.only)));
335
+ if (!flags.nodelete) for (const p of stale) rmSync(join(root, ...p.split("/")));
336
+ log(`${c.green("✓")} Pulled ${c.bold(theme.name)} into ${root}: ${written} file${written === 1 ? "" : "s"} updated, ${remote.length - written} unchanged${stale.length ? `, ${stale.length} ${flags.nodelete ? "local-only kept" : "removed"}` : ""}.`);
337
+ }
338
+
339
+ async function themePush(flags) {
340
+ const s = session(flags);
341
+ const root = themeRoot(flags);
342
+ if (!looksLikeTheme(root)) fail(`${root} isn't a theme folder (no layout/theme.liquid). Run \`sellify theme pull\` first or pass --path.`);
343
+ let theme;
344
+ if (flags.unpublished) {
345
+ const name = flags.name || `${root.split(sep).pop()} (pushed ${new Date().toISOString().slice(0, 10)})`;
346
+ const { themeCreate: r } = await gql(s, `mutation($name: String!) { themeCreate(name: $name) { theme { ${THEME_FIELDS} } userErrors { message } } }`, { name });
347
+ userErrors(r);
348
+ theme = r.theme;
349
+ log(`${c.green("✓")} Created unpublished theme ${c.bold(theme.name)}`);
350
+ } else {
351
+ theme = await pickTheme(s, flags);
352
+ await guardLive(theme, flags, "push");
353
+ }
354
+ const { files, skipped } = localFiles(root);
355
+ for (const p of skipped) log(`${c.yellow("skip")} ${p} ${c.dim("(themes can hold .liquid, .json, .css, .js and .svg files in the theme folders)")}`);
356
+ const remote = await remoteFiles(s, theme, false);
357
+ const sums = new Map(remote.map((f) => [f.path, f.checksum]));
358
+ const changed = [...files].filter(([path, content]) => (!flags.only || path.startsWith(flags.only)) && sums.get(path) !== md5(content));
359
+ const gone = flags.nodelete ? [] : remote.map((f) => f.path).filter((p) => !files.has(p) && (!flags.only || p.startsWith(flags.only)));
360
+ // A template is Liquid or JSON, never both: remove the old twin before uploading its replacement.
361
+ const twins = gone.filter((p) => p.startsWith("templates/") && changed.some(([path]) => path === twinOf(p)));
362
+ const deleted = await removeRemote(s, theme, twins);
363
+ const { done, errors } = await upload(s, theme, changed);
364
+ deleted.push(...(await removeRemote(s, theme, gone.filter((p) => !twins.includes(p)))));
365
+ for (const e of errors) log(`${c.red("✗")} ${e}`);
366
+ log(`${errors.length ? c.yellow("!") : c.green("✓")} Pushed to ${c.bold(theme.name)}: ${done} uploaded, ${files.size - changed.length} unchanged${deleted.length ? `, ${deleted.length} deleted` : ""}${errors.length ? `, ${errors.length} failed` : ""}.`);
367
+ if (flags.publish) await publish(s, theme, flags);
368
+ log(` Preview ${c.cyan(theme.previewUrl)}\n Code ${c.dim(theme.editorUrl)}`);
369
+ if (errors.length) process.exitCode = 1;
370
+ }
371
+
372
+ async function publish(s, theme, flags) {
373
+ if (theme.role === "MAIN") return log(`${theme.name} is already live.`);
374
+ if (!flags["allow-live"] && !(await confirm(`Publish ${c.bold(theme.name)}? It replaces your live theme.`))) fail("Stopped. Pass --allow-live to publish without asking.");
375
+ const { themePublish: r } = await gql(s, `mutation($id: ID!) { themePublish(id: $id) { theme { ${THEME_FIELDS} } userErrors { message } } }`, { id: theme.id });
376
+ userErrors(r);
377
+ log(`${c.green("✓")} ${c.bold(theme.name)} is now live.`);
378
+ }
379
+
380
+ async function themePublish(flags) {
381
+ const s = session(flags);
382
+ if (!flags.theme) fail("Usage: sellify theme publish --theme <id>");
383
+ await publish(s, await pickTheme(s, flags), flags);
384
+ }
385
+
386
+ async function themeDelete(flags) {
387
+ const s = session(flags);
388
+ if (!flags.theme || flags.theme === "live") fail("Usage: sellify theme delete --theme <id> (the live theme can't be deleted)");
389
+ const theme = await pickTheme(s, flags);
390
+ if (!flags.force && !(await confirm(`Delete ${c.bold(theme.name)}? This can't be undone.`))) fail("Stopped. Pass --force to delete without asking.");
391
+ const { themeDelete: r } = await gql(s, `mutation($id: ID!) { themeDelete(id: $id) { deletedId userErrors { message } } }`, { id: theme.id });
392
+ userErrors(r);
393
+ log(`${c.green("✓")} Deleted ${theme.name}.`);
394
+ }
395
+
396
+ async function themeOpen(flags) {
397
+ const s = session(flags);
398
+ const theme = await pickTheme(s, flags);
399
+ log(`${c.bold(theme.name)}\n Preview ${c.cyan(theme.previewUrl)}\n Code ${theme.editorUrl}`);
400
+ }
401
+
402
+ /** Uploads every save to a development theme (never the live one unless asked) and prints its preview link. */
403
+ async function themeDev(flags) {
404
+ const s = session(flags);
405
+ const root = themeRoot(flags);
406
+ if (!looksLikeTheme(root)) fail(`${root} isn't a theme folder (no layout/theme.liquid). Run \`sellify theme pull\` first or pass --path.`);
407
+ let theme;
408
+ if (flags.theme) {
409
+ theme = await pickTheme(s, flags);
410
+ await guardLive(theme, flags, "sync");
411
+ } else {
412
+ const name = `Development (${hostname().split(".")[0]})`.slice(0, 60);
413
+ const { themes } = await gql(s, `{ themes { ${THEME_FIELDS} } }`);
414
+ theme = themes.find((t) => t.name === name && t.role !== "MAIN");
415
+ if (!theme) {
416
+ const { themeCreate: r } = await gql(s, `mutation($name: String!) { themeCreate(name: $name) { theme { ${THEME_FIELDS} } userErrors { message } } }`, { name });
417
+ userErrors(r);
418
+ theme = r.theme;
419
+ }
420
+ }
421
+ log(`${c.dim("Syncing")} ${root} ${c.dim("→")} ${c.bold(theme.name)}`);
422
+ await themePush({ ...flags, theme: shortId(theme.id), unpublished: false, publish: false, "allow-live": true });
423
+ log(`\n${c.green("●")} Watching for changes. Refresh the preview after saving. ${c.dim("Ctrl+C to stop.")}`);
424
+
425
+ const pending = new Set();
426
+ let timer = null;
427
+ let running = Promise.resolve();
428
+ const sync = async () => {
429
+ const paths = [...pending];
430
+ pending.clear();
431
+ const writes = [];
432
+ const deletes = [];
433
+ for (const path of paths) {
434
+ const p = join(root, ...path.split("/"));
435
+ if (existsSync(p) && statSync(p).isFile()) writes.push([path, readFileSync(p, "utf8")]);
436
+ else deletes.push(path);
437
+ }
438
+ const time = c.dim(new Date().toLocaleTimeString());
439
+ if (writes.length) {
440
+ const { done, errors } = await upload(s, theme, writes);
441
+ if (done) log(`${time} ${c.green("↑")} ${writes.map(([p]) => p).filter((p) => !errors.some((e) => e.startsWith(`${p}:`))).join(", ")}`);
442
+ for (const e of errors) log(`${time} ${c.red("✗")} ${e}`);
443
+ }
444
+ if (deletes.length) {
445
+ const deleted = await removeRemote(s, theme, deletes);
446
+ if (deleted.length) log(`${time} ${c.yellow("−")} ${deleted.join(", ")}`);
447
+ }
448
+ };
449
+ watch(root, { recursive: true }, (_event, file) => {
450
+ if (!file) return;
451
+ const path = toPosix(file);
452
+ if (!THEME_PATH_RE.test(path)) return;
453
+ pending.add(path);
454
+ clearTimeout(timer);
455
+ // Editors often write a file in several steps; wait for them to finish.
456
+ timer = setTimeout(() => {
457
+ running = running.then(sync).catch((e) => log(`${c.red("✗")} ${e.message}`));
458
+ }, 150);
459
+ });
460
+ await new Promise(() => {});
461
+ }
462
+
463
+ async function graphql(flags, positionals) {
464
+ const s = session(flags);
465
+ const query = flags.file ? readFileSync(flags.file, "utf8") : positionals[0];
466
+ if (!query) fail("Usage: sellify graphql '<query>' [--variables '<json>'] | sellify graphql --file query.graphql");
467
+ let variables = {};
468
+ if (flags.variables) {
469
+ try {
470
+ variables = JSON.parse(flags.variables);
471
+ } catch {
472
+ fail("--variables must be JSON.");
473
+ }
474
+ }
475
+ log(JSON.stringify(await gql(s, query, variables), null, 2));
476
+ }
477
+
478
+ // ---------- Entry ----------
479
+
480
+ const HELP = `${c.bold("sellify")} ${VERSION} — Sellify command line
481
+
482
+ ${c.bold("Account")}
483
+ sellify login [--store <store>] [--url <admin url>] Log in with your browser (no token to copy)
484
+ sellify login --store <store> --token <sfat_…> Log in with a custom app token (CI)
485
+ sellify logout [--store <store>]
486
+ sellify whoami
487
+
488
+ ${c.bold("Themes")} (run inside a theme folder, or pass --path <dir>)
489
+ sellify theme list
490
+ sellify theme pull [--theme <id>|live] [--only <prefix>] [--nodelete]
491
+ sellify theme push [--theme <id>|live] [--unpublished [--name <name>]] [--publish] [--only <prefix>] [--nodelete]
492
+ sellify theme dev [--theme <id>] Sync every save to a development theme and print its preview link
493
+ sellify theme open [--theme <id>] Show preview and code editor links
494
+ sellify theme publish --theme <id>
495
+ sellify theme delete --theme <id> [--force]
496
+
497
+ ${c.bold("Admin API")}
498
+ sellify graphql '<query>' [--variables '<json>']
499
+ sellify graphql --file query.graphql
500
+
501
+ ${c.bold("Options")}
502
+ --store <store> Which logged-in store to use (default: the last one)
503
+ --url <url> Sellify admin URL (default ${DEFAULT_URL}, or SELLIFY_URL)
504
+ --allow-live Change the live theme without asking
505
+ --no-browser Print the login link instead of opening the browser
506
+ -v, --version Print the version
507
+
508
+ Environment: SELLIFY_STORE, SELLIFY_TOKEN and SELLIFY_URL work instead of \`sellify login\` (useful in CI).
509
+ Changing the live theme always asks first unless --allow-live is passed.`;
510
+
511
+ const OPTIONS = {
512
+ store: { type: "string" },
513
+ token: { type: "string" },
514
+ url: { type: "string" },
515
+ theme: { type: "string", short: "t" },
516
+ path: { type: "string" },
517
+ only: { type: "string" },
518
+ name: { type: "string" },
519
+ file: { type: "string", short: "f" },
520
+ variables: { type: "string" },
521
+ unpublished: { type: "boolean", short: "u" },
522
+ publish: { type: "boolean" },
523
+ nodelete: { type: "boolean" },
524
+ force: { type: "boolean" },
525
+ "allow-live": { type: "boolean" },
526
+ "no-browser": { type: "boolean" },
527
+ help: { type: "boolean", short: "h" },
528
+ version: { type: "boolean", short: "v" },
529
+ };
530
+
531
+ const COMMANDS = {
532
+ login: (f) => login(f),
533
+ logout: (f) => logout(f),
534
+ whoami: (f) => whoami(f),
535
+ graphql: (f, p) => graphql(f, p),
536
+ "theme list": (f) => themeList(f),
537
+ "theme pull": (f) => themePull(f),
538
+ "theme push": (f) => themePush(f),
539
+ "theme dev": (f) => themeDev(f),
540
+ "theme open": (f) => themeOpen(f),
541
+ "theme publish": (f) => themePublish(f),
542
+ "theme delete": (f) => themeDelete(f),
543
+ };
544
+
545
+ async function main() {
546
+ let parsed;
547
+ try {
548
+ parsed = parseArgs({ args: process.argv.slice(2), options: OPTIONS, allowPositionals: true });
549
+ } catch (e) {
550
+ fail(`${e.message}\nRun \`sellify --help\`.`);
551
+ }
552
+ const { values: flags, positionals } = parsed;
553
+ if (flags.version) return log(VERSION);
554
+ const [a, b, ...rest] = positionals;
555
+ const key = a === "theme" ? `theme ${b ?? ""}` : a;
556
+ if (flags.help || !a || !COMMANDS[key]) {
557
+ log(HELP);
558
+ if (a && !flags.help && !COMMANDS[key]) process.exitCode = 1;
559
+ return;
560
+ }
561
+ await COMMANDS[key](flags, a === "theme" ? rest : [b, ...rest].filter(Boolean));
562
+ }
563
+
564
+ main().catch((e) => {
565
+ console.error(`${c.red("Error:")} ${e instanceof CliError ? e.message : e.stack}`);
566
+ process.exit(1);
567
+ });
package/package.json CHANGED
@@ -1,6 +1,25 @@
1
1
  {
2
2
  "name": "sellify-cli",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
4
+ "description": "Command line for Sellify: develop themes locally (pull, push, live sync) and call the GraphQL Admin API.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "bin": {
8
+ "sellify": "bin/sellify.mjs"
9
+ },
10
+ "files": [
11
+ "bin",
12
+ "README.md",
13
+ "LICENSE"
14
+ ],
15
+ "engines": {
16
+ "node": ">=20"
17
+ },
18
+ "keywords": [
19
+ "sellify",
20
+ "ecommerce",
21
+ "liquid",
22
+ "theme",
23
+ "cli"
24
+ ]
25
+ }